diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 4f3fa114f..6f9a7ef40 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -2,9 +2,9 @@ name: build and test on: push: - branches: [ master ] + branches: [ master, 3.0 ] pull_request: - branches: [ master ] + branches: [ master, 3.0 ] jobs: test: @@ -91,7 +91,7 @@ jobs: TOMVIZ_REQUIRE_PYXRF: true run: | cd tomviz-build - export TOMVIZ_TEST_PYTHON_EXECUTABLE=$(which python3) + export TOMVIZ_TEST_PYTHON_EXECUTABLE=$(which python) ctest --output-on-failure - name: Upload CTest Log (On Failure Only) diff --git a/.github/workflows/build_requirements.txt b/.github/workflows/build_requirements.txt index 46f711090..0474fbe9e 100644 --- a/.github/workflows/build_requirements.txt +++ b/.github/workflows/build_requirements.txt @@ -8,3 +8,4 @@ libxml2-devel=2.14* gtest # This is needed for installing stuff on Python3.13 setuptools +pip diff --git a/.github/workflows/pyxrf_requirements.txt b/.github/workflows/pyxrf_requirements.txt index 765b42933..81c4f9bed 100644 --- a/.github/workflows/pyxrf_requirements.txt +++ b/.github/workflows/pyxrf_requirements.txt @@ -12,6 +12,7 @@ hatchling # it gets fixed and a new release comes out. # See: https://github.com/NSLS-II-SRX/xrf-tomo/pull/10 pandas<3.0 -# This is required for running pyxrf-utils on Python3.11 -# Setuptools was removed in versions 80+ -setuptools<80 +# Setuptools comes in transitively, but keep a floor here to stay clear of +# CVE-2026-59890. The previous "setuptools<80" ceiling is no longer needed, +# since the pyxrf imports now work without pkg_resources. +setuptools>=83 diff --git a/CMakeLists.txt b/CMakeLists.txt index 102412bcd..fe660765c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,8 +54,12 @@ set(tomviz_js_binary_path "${tomviz_html_binary_dir}/tomviz.js") set(tomviz_html_binary_path "${tomviz_html_binary_dir}/tomviz.html") set(tomviz_web_install_dir "${INSTALL_DATA_DIR}/tomviz/web") -# Centrally set the grand exception for macOS... if(APPLE) + option(TOMVIZ_MACOSX_BUNDLE "Build a macOS app bundle" ON) +endif() + +# Centrally set the grand exception for macOS... +if(APPLE AND TOMVIZ_MACOSX_BUNDLE) set(MACOSX_BUNDLE_NAME "tomviz") set(prefix "Applications/${MACOSX_BUNDLE_NAME}.app/Contents") set(INSTALL_INCLUDE_DIR "${prefix}/${INSTALL_INCLUDE_DIR}") @@ -102,8 +106,8 @@ include(Git) determine_version("${tomviz_SOURCE_DIR}" "${GIT_EXECUTABLE}" "tomviz") # Hard coded for source tarballs, releases, etc. set(tomviz_version_major 2) -set(tomviz_version_minor 0) -set(tomviz_version_patch 0) +set(tomviz_version_minor 3) +set(tomviz_version_patch 1) set(tomviz_version_extra) set(tomviz_version "${tomviz_version_major}.${tomviz_version_minor}.${tomviz_version_patch}") diff --git a/cmake/CompilerFlags.cmake b/cmake/CompilerFlags.cmake index ecb40719c..0fb83f21e 100644 --- a/cmake/CompilerFlags.cmake +++ b/cmake/CompilerFlags.cmake @@ -11,7 +11,13 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang" include(CheckCXXCompilerFlag) # Additional warnings for GCC - set(CMAKE_CXX_FLAGS_WARN "-Wnon-virtual-dtor -Wno-long-long -Wcast-align -Wchar-subscripts -Wall -Wpointer-arith -Wformat-security -Woverloaded-virtual -fno-check-new -fno-common") + set(CMAKE_CXX_FLAGS_WARN "-Wnon-virtual-dtor -Wno-long-long -Wcast-align -Wchar-subscripts -Wall -Wpointer-arith -Wformat-security -Woverloaded-virtual -fno-common") + + # -fno-check-new is a GCC-only flag; clang accepts but ignores it and then + # warns that the argument is unused, so only pass it to GCC. + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + set(CMAKE_CXX_FLAGS_WARN "${CMAKE_CXX_FLAGS_WARN} -fno-check-new") + endif() # This flag is useful as not returning from a non-void function is an error # with MSVC, but it is not supported on all GCC compiler versions diff --git a/cmake/tomvizDetermineVersion.cmake b/cmake/tomvizDetermineVersion.cmake index 13e719936..38c6438d9 100644 --- a/cmake/tomvizDetermineVersion.cmake +++ b/cmake/tomvizDetermineVersion.cmake @@ -40,7 +40,7 @@ function(determine_version source_dir git_command var_prefix) if (NOT TOMVIZ_GIT_DESCRIBE) if (EXISTS ${git_command}) execute_process( - COMMAND ${git_command} describe + COMMAND ${git_command} describe --tags WORKING_DIRECTORY ${source_dir} RESULT_VARIABLE result OUTPUT_VARIABLE output diff --git a/tests/cxx/CMakeLists.txt b/tests/cxx/CMakeLists.txt index 0fd492c16..9bf45d6ab 100644 --- a/tests/cxx/CMakeLists.txt +++ b/tests/cxx/CMakeLists.txt @@ -9,8 +9,7 @@ include_directories(SYSTEM ${PARAVIEW_INCLUDE_DIRS} ${GTEST_INCLUDE_DIRS}) include_directories(${PROJECT_SOURCE_DIR}/tomviz) -include_directories(${PROJECT_SOURCE_DIR}/tomviz/modules) -include_directories(${PROJECT_SOURCE_DIR}/tomviz/operators) +include_directories(${PROJECT_SOURCE_DIR}/tomviz/pipeline) include_directories(${PROJECT_SOURCE_DIR}/tomviz/animations) include_directories(${PROJECT_SOURCE_DIR}/tomviz/loguru) include_directories(${PROJECT_SOURCE_DIR}/tomviz/acquisition) @@ -51,20 +50,18 @@ set(_pythonpath "${_pythonpath}${_separator}${ITK_DIR}/Wrapping/Generators/Pytho set(_pythonpath "${_pythonpath}${_separator}$ENV{PYTHONPATH}") # Add the test cases -add_cxx_test(OperatorPython PYTHONPATH ${_pythonpath}) add_cxx_test(Variant) add_cxx_test(ScanID) add_cxx_test(Utilities) -add_cxx_qtest(ModulePlot) -add_cxx_qtest(Tvh5Data) -add_cxx_qtest(InterfaceBuilder) -add_cxx_qtest(PipelineExecution PYTHONPATH ${_pythonpath}) -if(UNIX AND NOT APPLE) - add_cxx_qtest(DockerUtilities) -endif() -if(NOT WIN32) - add_cxx_qtest(AcquisitionClient PYTHONPATH "${CMAKE_SOURCE_DIR}/acquisition") -endif() +add_cxx_test(VolumeBricking) +add_cxx_test(SAM2SeedWidget) +add_cxx_test(ExternalEnvPersistence) +# AcquisitionClient needs the `bottle` Python package (acquisition/requirements-dev.txt) +# to start its test server; not part of the conda build env, so disabled here. +# if(NOT WIN32) +# add_cxx_qtest(AcquisitionClient PYTHONPATH "${CMAKE_SOURCE_DIR}/acquisition") +# endif() +add_cxx_qtest(SegmentationColorMap) add_cxx_qtest(PtychoWorkflow PYTHONPATH ${_pythonpath}) set_tests_properties(PtychoWorkflow PROPERTIES TIMEOUT 1800) add_cxx_qtest(PyXRFWorkflow PYTHONPATH ${_pythonpath}) @@ -74,3 +71,34 @@ set_tests_properties(PyXRFWorkflow PROPERTIES TIMEOUT 1800) create_test_executable(tomvizTests) target_link_libraries(tomvizTests Qt6::Test) + +# Pipeline library tests +set(_pipeline_pythonpath + "${PROJECT_SOURCE_DIR}/tomviz/python${_separator}${_pythonpath}") +add_executable(pipelineLibTests PipelineLibTest.cxx) +target_link_libraries(pipelineLibTests + tomvizlib pybind11::embed GTest::gtest + Qt6::Test ${EXTRA_LINK_LIB}) +target_compile_definitions(pipelineLibTests PRIVATE + "TOMVIZ_PYTHON_DIR=\"${PROJECT_SOURCE_DIR}/tomviz/python\"") +add_test(NAME "PipelineLib" COMMAND pipelineLibTests) +set_tests_properties("PipelineLib" PROPERTIES + ENVIRONMENT "PYTHONPATH=${_pipeline_pythonpath}") + +# Pipeline icon resources (must be compiled into each executable, not the +# static library, so Qt's resource system can initialise them). +set(PIPELINE_ICONS_QRC "${PROJECT_SOURCE_DIR}/tomviz/pipeline/pipeline_icons.qrc") + +# Manual demo app for visually testing PipelineStripWidget +add_executable(pipelineStripDemo PipelineStripWidgetDemo.cxx ${PIPELINE_ICONS_QRC}) +target_link_libraries(pipelineStripDemo tomvizlib) + +# Manual demo app for visually testing pipeline Sinks with ParaView views +add_executable(pipelineSinkDemo PipelineSinkDemo.cxx ${PIPELINE_ICONS_QRC}) +target_link_libraries(pipelineSinkDemo tomvizlib) + +# Manual demo app with full pipeline: source -> transform -> 3 sinks + properties +add_executable(pipelineDemo PipelineDemo.cxx ${PIPELINE_ICONS_QRC}) +target_compile_definitions(pipelineDemo PRIVATE + "TOMVIZ_PYTHON_DIR=\"${PROJECT_SOURCE_DIR}/tomviz/python\"") +target_link_libraries(pipelineDemo tomvizlib) diff --git a/tests/cxx/DockerUtilitiesTest.cxx b/tests/cxx/DockerUtilitiesTest.cxx deleted file mode 100644 index a1164c2b4..000000000 --- a/tests/cxx/DockerUtilitiesTest.cxx +++ /dev/null @@ -1,255 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include -#include -#include -#include -#include -#include - -#include "DockerUtilities.h" - -using namespace tomviz; - -class DockerUtilitiesTest : public QObject -{ - Q_OBJECT - -private: - int m_invocationTimeout = 30000; - - docker::DockerRunInvocation* run( - const QString& image, const QString& entryPoint = QString(), - const QStringList& containerArgs = QStringList(), - const QMap& bindMounts = QMap()) - { - auto runInvocation = - docker::run(image, entryPoint, containerArgs, bindMounts); - QSignalSpy runError(runInvocation, &docker::DockerPullInvocation::error); - QSignalSpy runFinished(runInvocation, - &docker::DockerPullInvocation::finished); - runFinished.wait(m_invocationTimeout); - - return runInvocation; - } - - void remove(const QString& containerId) - { - auto removeInvocation = docker::remove(containerId); - QSignalSpy removeError(removeInvocation, - &docker::DockerRemoveInvocation::error); - QSignalSpy removeFinished(removeInvocation, - &docker::DockerRemoveInvocation::finished); - QVERIFY(removeFinished.wait(m_invocationTimeout)); - removeInvocation->deleteLater(); - } - - void pull(const QString& image) - { - auto alpinePullInvocation = docker::pull(image); - QSignalSpy alpinePullError(alpinePullInvocation, - &docker::DockerPullInvocation::error); - QSignalSpy alpinePullFinished(alpinePullInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(alpinePullFinished.wait(m_invocationTimeout)); - } - -private slots: - void initTestCase() - { - qRegisterMetaType(); - qRegisterMetaType(); - pull("alpine"); - pull("hello-world"); - } - - void cleanupTestCase() {} - - void runTest() - { - auto runInvocation = docker::run("hello-world"); - QSignalSpy runError(runInvocation, &docker::DockerPullInvocation::error); - QSignalSpy runFinished(runInvocation, - &docker::DockerPullInvocation::finished); - - QVERIFY(runFinished.wait(m_invocationTimeout)); - QVERIFY(runError.isEmpty()); - QCOMPARE(runFinished.size(), 1); - auto arguments = runFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - - auto containerId = runInvocation->containerId(); - QVERIFY(!containerId.isEmpty()); - runInvocation->deleteLater(); - - auto logInvocation = docker::logs(containerId); - QSignalSpy logError(logInvocation, &docker::DockerLogsInvocation::error); - QSignalSpy logFinished(logInvocation, - &docker::DockerLogsInvocation::finished); - QVERIFY(logFinished.wait(m_invocationTimeout)); - QVERIFY(logError.isEmpty()); - QCOMPARE(logFinished.size(), 1); - arguments = logFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - QVERIFY(logInvocation->logs().trimmed().startsWith("Hello from Docker!")); - logInvocation->deleteLater(); - remove(containerId); - } - - void pullTest() - { - auto pullInvocation = docker::run("alpine"); - QSignalSpy pullError(pullInvocation, &docker::DockerPullInvocation::error); - QSignalSpy pullFinished(pullInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(pullFinished.wait(m_invocationTimeout)); - QVERIFY(pullError.isEmpty()); - QCOMPARE(pullFinished.size(), 1); - auto arguments = pullFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - } - - void runBindMountTest() - { - QMap bindMounts; - QTemporaryDir tempDir; - - bindMounts[tempDir.path()] = "/test"; - QString entryPoint = "/bin/sh"; - QStringList args; - args.append("-c"); - args.append("echo 'world' > /test/hello.txt"); - - auto runInvocation = docker::run("alpine", entryPoint, args, bindMounts); - QSignalSpy runError(runInvocation, &docker::DockerPullInvocation::error); - QSignalSpy runFinished(runInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(runFinished.wait(m_invocationTimeout)); - QVERIFY(runError.isEmpty()); - QCOMPARE(runFinished.size(), 1); - auto arguments = runFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - auto containerId = runInvocation->containerId(); - remove(containerId); - runInvocation->deleteLater(); - - QFile file(QDir(tempDir.path()).filePath("hello.txt")); - QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); - QCOMPARE(QString(file.readLine()).trimmed(), QString("world")); - } - - void dockerErrorTest() - { - auto runInvocation = docker::run("alpine", "/bin/bash"); - QSignalSpy runError(runInvocation, &docker::DockerPullInvocation::error); - QSignalSpy runFinished(runInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(runFinished.wait(m_invocationTimeout)); - QVERIFY(runError.isEmpty()); - QCOMPARE(runFinished.size(), 1); - auto arguments = runFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 127); - runInvocation->deleteLater(); - } - - void stopTest() - { - QString entryPoint = "/bin/sh"; - QStringList args; - args.append("-c"); - args.append("sleep 30"); - - auto runInvocation = run("alpine", entryPoint, args); - auto containerId = runInvocation->containerId(); - QVERIFY(!containerId.isEmpty()); - runInvocation->deleteLater(); - - auto stopInvocation = docker::stop(containerId, 1); - QSignalSpy stopError(stopInvocation, &docker::DockerPullInvocation::error); - QSignalSpy stopFinished(stopInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(stopFinished.wait(m_invocationTimeout)); - QVERIFY(stopError.isEmpty()); - QCOMPARE(stopFinished.size(), 1); - auto arguments = stopFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - stopInvocation->deleteLater(); - - auto inspectInvocation = docker::inspect(containerId); - QSignalSpy inspectError(inspectInvocation, - &docker::DockerPullInvocation::error); - QSignalSpy inspectFinished(inspectInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(inspectFinished.wait(m_invocationTimeout)); - QVERIFY(inspectError.isEmpty()); - QCOMPARE(inspectFinished.size(), 1); - arguments = inspectFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - QCOMPARE(inspectInvocation->status(), QString("exited")); - inspectInvocation->deleteLater(); - remove(containerId); - } - - void inspectTest() - { - auto runInvocation = run("alpine"); - auto containerId = runInvocation->containerId(); - QVERIFY(!containerId.isEmpty()); - runInvocation->deleteLater(); - - // Sleep for a second to let the previous container cleanup - QTest::qSleep(1000); - - auto inspectInvocation = docker::inspect(containerId); - QSignalSpy inspectError(inspectInvocation, - &docker::DockerPullInvocation::error); - QSignalSpy inspectFinished(inspectInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(inspectFinished.wait(m_invocationTimeout)); - QVERIFY(inspectError.isEmpty()); - QCOMPARE(inspectFinished.size(), 1); - auto arguments = inspectFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 0); - QCOMPARE(inspectInvocation->status(), QString("exited")); - QCOMPARE(inspectInvocation->exitCode(), 0); - inspectInvocation->deleteLater(); - remove(containerId); - } - - void removeTest() - { - auto runInvocation = run("alpine"); - auto containerId = runInvocation->containerId(); - QVERIFY(!containerId.isEmpty()); - runInvocation->deleteLater(); - - // Sleep for a second to let the previous container cleanup - QTest::qSleep(1000); - - auto removeInvocation = docker::remove(containerId); - QSignalSpy removeError(removeInvocation, - &docker::DockerRemoveInvocation::error); - QSignalSpy removeFinished(removeInvocation, - &docker::DockerRemoveInvocation::finished); - QVERIFY(removeFinished.wait(m_invocationTimeout)); - QVERIFY(removeError.isEmpty()); - QCOMPARE(removeFinished.size(), 1); - removeInvocation->deleteLater(); - - auto inspectInvocation = docker::inspect(containerId); - QSignalSpy inspectError(inspectInvocation, - &docker::DockerPullInvocation::error); - QSignalSpy inspectFinished(inspectInvocation, - &docker::DockerPullInvocation::finished); - QVERIFY(inspectFinished.wait(m_invocationTimeout)); - QVERIFY(inspectError.isEmpty()); - QCOMPARE(inspectFinished.size(), 1); - auto arguments = inspectFinished.takeFirst(); - QCOMPARE(arguments.at(0).toInt(), 1); - inspectInvocation->deleteLater(); - } -}; - -QTEST_GUILESS_MAIN(DockerUtilitiesTest) -#include "DockerUtilitiesTest.moc" diff --git a/tests/cxx/ExternalEnvPersistenceTest.cxx b/tests/cxx/ExternalEnvPersistenceTest.cxx new file mode 100644 index 000000000..2e0fc681c --- /dev/null +++ b/tests/cxx/ExternalEnvPersistenceTest.cxx @@ -0,0 +1,108 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include + +#include "EditNodeWidget.h" +#include "Pipeline.h" +#include "Utilities.h" +#include "transforms/LegacyPythonTransform.h" + +#include +#include +#include +#include + +#include "TomvizTest.h" + +using tomviz::readInJSONDescription; +using tomviz::pipeline::EditNodeWidget; +using tomviz::pipeline::LegacyPythonTransform; +using tomviz::pipeline::Pipeline; + +namespace { + +const char* SETTINGS_KEY = "externalEnvPaths/SAM2Segment3D"; + +void ensureApp() +{ + // Deterministic QSettings location for this test's key round-trip. + QCoreApplication::setOrganizationName("tomviz-tests"); + QCoreApplication::setApplicationName("tomviz-tests"); + tomviz_test::ensureQApp(); +} + +EditNodeWidget* makeSam2Editor(Pipeline& pipeline) +{ + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(readInJSONDescription("SAM2Segment3D")); + transform->setScript("def transform(dataset):\n pass\n"); + pipeline.addNode(transform); + return transform->createPropertiesWidget(&pipeline, nullptr); +} + +} // namespace + +TEST(ExternalEnvPersistenceTest, SavesAppliedEnvPathAndPrefillsNextEditor) +{ + ensureApp(); + QSettings().remove(SETTINGS_KEY); + + Pipeline pipeline; + + // Fresh externalOnly node: External is forced, the env path row is + // editable, and no path is remembered yet. + auto* editor = makeSam2Editor(pipeline); + auto* combo = editor->findChild("executorTypeCombo"); + auto* envEdit = editor->findChild("executorEnvPathEdit"); + ASSERT_NE(combo, nullptr); + ASSERT_NE(envEdit, nullptr); + EXPECT_FALSE(combo->currentData().toString().isEmpty()); + EXPECT_TRUE(envEdit->isEnabled()); + EXPECT_TRUE(envEdit->text().isEmpty()); + + // Applying with an environment remembers it for the operator type. + envEdit->setText("/opt/envs/sam2-tomviz"); + editor->applyChangesToOperator(); + EXPECT_EQ(QSettings().value(SETTINGS_KEY).toString(), + "/opt/envs/sam2-tomviz"); + delete editor; + + // A brand-new instance of the same operator starts prefilled. + auto* editor2 = makeSam2Editor(pipeline); + auto* envEdit2 = editor2->findChild("executorEnvPathEdit"); + ASSERT_NE(envEdit2, nullptr); + EXPECT_EQ(envEdit2->text(), "/opt/envs/sam2-tomviz"); + delete editor2; + + QSettings().remove(SETTINGS_KEY); +} + +TEST(ExternalEnvPersistenceTest, NodeConfiguredPathWinsOverRemembered) +{ + ensureApp(); + QSettings().setValue(SETTINGS_KEY, "/opt/envs/remembered"); + + Pipeline pipeline; + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(readInJSONDescription("SAM2Segment3D")); + transform->setScript("def transform(dataset):\n pass\n"); + pipeline.addNode(transform); + + // Configure the node's own executor, as deserialization would. + auto* editor = transform->createPropertiesWidget(&pipeline, nullptr); + auto* envEdit = editor->findChild("executorEnvPathEdit"); + ASSERT_NE(envEdit, nullptr); + envEdit->setText("/opt/envs/node-specific"); + editor->applyChangesToOperator(); + delete editor; + + // Reopening this node shows its own path, not the remembered one. + auto* editor2 = transform->createPropertiesWidget(&pipeline, nullptr); + auto* envEdit2 = editor2->findChild("executorEnvPathEdit"); + ASSERT_NE(envEdit2, nullptr); + EXPECT_EQ(envEdit2->text(), "/opt/envs/node-specific"); + delete editor2; + + QSettings().remove(SETTINGS_KEY); +} diff --git a/tests/cxx/InterfaceBuilderTest.cxx b/tests/cxx/InterfaceBuilderTest.cxx deleted file mode 100644 index b44016412..000000000 --- a/tests/cxx/InterfaceBuilderTest.cxx +++ /dev/null @@ -1,199 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "InterfaceBuilder.h" - -using namespace tomviz; - -class InterfaceBuilderTest : public QObject -{ - Q_OBJECT - -private: - InterfaceBuilder* builder; - QWidget* parentWidget; - -private slots: - void init() - { - builder = new InterfaceBuilder(this); - parentWidget = new QWidget(); - } - - void cleanup() - { - delete parentWidget; - parentWidget = nullptr; - } - - void selectScalarsWidgetCreated() - { - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "selected_scalars", "type": "select_scalars", - "label": "Scalars"} - ] - })"; - - builder->setJSONDescription(desc); - QLayout* layout = builder->buildInterface(); - QVERIFY(layout != nullptr); - - parentWidget->setLayout(layout); - - auto* widget = parentWidget->findChild("selected_scalars"); - QVERIFY(widget != nullptr); - QCOMPARE(widget->property("type").toString(), QString("select_scalars")); - } - - void selectScalarsParameterValues() - { - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "selected_scalars", "type": "select_scalars", - "label": "Scalars"} - ] - })"; - - builder->setJSONDescription(desc); - QLayout* layout = builder->buildInterface(); - QVERIFY(layout != nullptr); - parentWidget->setLayout(layout); - - // The combo box is empty because we have no DataSource. - // Manually populate the model and check items to test parameterValues(). - auto* container = - parentWidget->findChild("selected_scalars"); - QVERIFY(container != nullptr); - - auto* applyAllCB = - container->findChild("selected_scalars_apply_all"); - QVERIFY(applyAllCB != nullptr); - - auto* combo = - container->findChild("selected_scalars_combo"); - QVERIFY(combo != nullptr); - - auto* model = qobject_cast(combo->model()); - QVERIFY(model != nullptr); - - // Add items and check them - auto* itemA = new QStandardItem("scalar_a"); - itemA->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); - itemA->setData(Qt::Checked, Qt::CheckStateRole); - model->appendRow(itemA); - - auto* itemB = new QStandardItem("scalar_b"); - itemB->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); - itemB->setData(Qt::Checked, Qt::CheckStateRole); - model->appendRow(itemB); - - // Uncheck "Apply to all" so individual selections are used - applyAllCB->setChecked(false); - - auto result = InterfaceBuilder::parameterValues(parentWidget); - QVERIFY(result.contains("selected_scalars")); - - auto resultScalars = result["selected_scalars"].toList(); - QCOMPARE(resultScalars.size(), 2); - QCOMPARE(resultScalars[0].toString(), QString("scalar_a")); - QCOMPARE(resultScalars[1].toString(), QString("scalar_b")); - } - - void basicParameterTypes() - { - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "int_param", "type": "int", "label": "Int", "default": 5}, - {"name": "double_param", "type": "double", "label": "Double", - "default": 1.5}, - {"name": "bool_param", "type": "bool", "label": "Bool", "default": true} - ] - })"; - - builder->setJSONDescription(desc); - QLayout* layout = builder->buildInterface(); - QVERIFY(layout != nullptr); - - parentWidget->setLayout(layout); - - auto result = InterfaceBuilder::parameterValues(parentWidget); - QVERIFY(result.contains("int_param")); - QVERIFY(result.contains("double_param")); - QVERIFY(result.contains("bool_param")); - - QCOMPARE(result["int_param"].toInt(), 5); - QCOMPARE(result["double_param"].toDouble(), 1.5); - QCOMPARE(result["bool_param"].toBool(), true); - } - - void enableIfVisibleIf() - { - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "toggle", "type": "bool", "label": "Toggle", "default": false}, - {"name": "enabled_dep", "type": "int", "label": "Enabled Dep", - "default": 0, "enable_if": "toggle == true"}, - {"name": "visible_dep", "type": "int", "label": "Visible Dep", - "default": 0, "visible_if": "toggle == true"} - ] - })"; - - builder->setJSONDescription(desc); - QLayout* layout = builder->buildInterface(); - QVERIFY(layout != nullptr); - - parentWidget->setLayout(layout); - // Must show the parent so isVisible() works on children -- - // Qt's isVisible() requires all ancestors to be visible. - parentWidget->show(); - - auto* toggleCheckBox = - parentWidget->findChild("toggle"); - QVERIFY(toggleCheckBox != nullptr); - - auto* enabledWidget = parentWidget->findChild("enabled_dep"); - QVERIFY(enabledWidget != nullptr); - - auto* visibleWidget = parentWidget->findChild("visible_dep"); - QVERIFY(visibleWidget != nullptr); - - // Toggle is false by default -- enabled_dep should be disabled, - // visible_dep should be hidden - QVERIFY(!enabledWidget->isEnabled()); - QVERIFY(!visibleWidget->isVisible()); - - // Toggle on -- both should become active - toggleCheckBox->setChecked(true); - QVERIFY(enabledWidget->isEnabled()); - QVERIFY(visibleWidget->isVisible()); - - // Toggle back off -- both should revert - toggleCheckBox->setChecked(false); - QVERIFY(!enabledWidget->isEnabled()); - QVERIFY(!visibleWidget->isVisible()); - } -}; - -QTEST_MAIN(InterfaceBuilderTest) -#include "InterfaceBuilderTest.moc" diff --git a/tests/cxx/ModulePlotTest.cxx b/tests/cxx/ModulePlotTest.cxx deleted file mode 100644 index 27f3a80c2..000000000 --- a/tests/cxx/ModulePlotTest.cxx +++ /dev/null @@ -1,273 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include - -#include "modules/ModulePlot.h" -#include "operators/OperatorResult.h" - -using namespace tomviz; - -class ModulePlotTest : public QObject -{ - Q_OBJECT - -private: - ModulePlot* modulePlot; - - // Create a simple vtkTable with x and y columns - vtkNew createTestTable() - { - vtkNew table; - - vtkNew xCol; - xCol->SetName("x"); - xCol->SetNumberOfTuples(5); - for (int i = 0; i < 5; ++i) { - xCol->SetValue(i, static_cast(i)); - } - - vtkNew yCol; - yCol->SetName("y"); - yCol->SetNumberOfTuples(5); - for (int i = 0; i < 5; ++i) { - yCol->SetValue(i, static_cast(i * i)); - } - - table->AddColumn(xCol); - table->AddColumn(yCol); - return table; - } - -private slots: - void init() - { - modulePlot = new ModulePlot(this); - } - - void cleanup() - { - delete modulePlot; - modulePlot = nullptr; - } - - void label() - { - QCOMPARE(modulePlot->label(), QString("Plot")); - } - - void visibilityDefault() - { - QVERIFY(modulePlot->visibility()); - } - - void setVisibilityToggle() - { - QVERIFY(modulePlot->setVisibility(false)); - QVERIFY(!modulePlot->visibility()); - - QVERIFY(modulePlot->setVisibility(true)); - QVERIFY(modulePlot->visibility()); - } - - void exportDataTypeString() - { - QCOMPARE(modulePlot->exportDataTypeString(), QString("")); - } - - void dataToExportReturnsNull() - { - QVERIFY(modulePlot->dataToExport() == nullptr); - } - - void initializeDataSourceReturnsFalse() - { - // Plot only works with OperatorResult, not DataSource - QVERIFY(!modulePlot->initialize(static_cast(nullptr), - nullptr)); - } - - void finalize() - { - QVERIFY(modulePlot->finalize()); - } - - void finalizeTwice() - { - QVERIFY(modulePlot->finalize()); - QVERIFY(modulePlot->finalize()); - } - - void deserializeVisibility() - { - QJsonObject props; - props["visibility"] = false; - QJsonObject json; - json["properties"] = props; - - QVERIFY(modulePlot->deserialize(json)); - QVERIFY(!modulePlot->visibility()); - } - - void deserializeRestoresVisibilityTrue() - { - modulePlot->setVisibility(false); - QVERIFY(!modulePlot->visibility()); - - QJsonObject props; - props["visibility"] = true; - QJsonObject json; - json["properties"] = props; - - QVERIFY(modulePlot->deserialize(json)); - QVERIFY(modulePlot->visibility()); - } - - void dataSourceMovedDoesNotCrash() - { - modulePlot->dataSourceMoved(1.0, 2.0, 3.0); - } - - void dataSourceRotatedDoesNotCrash() - { - modulePlot->dataSourceRotated(45.0, 90.0, 0.0); - } - - void initializeWithOperatorResult() - { - auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); - auto* server = pqApplicationCore::instance()->getActiveServer(); - QVERIFY(server != nullptr); - - auto* pqView = objBuilder->createView("XYChartView", server); - QVERIFY(pqView != nullptr); - auto* viewProxy = pqView->getViewProxy(); - QVERIFY(viewProxy != nullptr); - - auto table = createTestTable(); - auto* result = new OperatorResult(this); - result->setName("test_result"); - result->setDataObject(table); - - QVERIFY(modulePlot->initialize(result, viewProxy)); - QVERIFY(modulePlot->visibility()); - - QVERIFY(modulePlot->finalize()); - objBuilder->destroy(pqView); - } - - void addToPanel() - { - auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); - auto* server = pqApplicationCore::instance()->getActiveServer(); - auto* pqView = objBuilder->createView("XYChartView", server); - auto* viewProxy = pqView->getViewProxy(); - - auto table = createTestTable(); - auto* result = new OperatorResult(this); - result->setName("test_result"); - result->setDataObject(table); - - QVERIFY(modulePlot->initialize(result, viewProxy)); - - // addToPanel should create the label/log-scale controls - QWidget panel; - modulePlot->addToPanel(&panel); - - auto* layout = qobject_cast(panel.layout()); - QVERIFY(layout != nullptr); - - auto* xLogCheckBox = panel.findChild(); - QVERIFY(xLogCheckBox != nullptr); - - auto* xLabelEdit = panel.findChild(); - QVERIFY(xLabelEdit != nullptr); - - QVERIFY(modulePlot->finalize()); - objBuilder->destroy(pqView); - } - - void serializeAfterInit() - { - auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); - auto* server = pqApplicationCore::instance()->getActiveServer(); - auto* pqView = objBuilder->createView("XYChartView", server); - auto* viewProxy = pqView->getViewProxy(); - - auto table = createTestTable(); - auto* result = new OperatorResult(this); - result->setName("test_result"); - result->setDataObject(table); - - QVERIFY(modulePlot->initialize(result, viewProxy)); - - auto json = modulePlot->serialize(); - - // Should contain properties with visibility - QVERIFY(json.contains("properties")); - auto props = json["properties"].toObject(); - QCOMPARE(props["visibility"].toBool(), true); - - // Should contain the operator result name - QCOMPARE(json["operatorResultName"].toString(), QString("test_result")); - - QVERIFY(modulePlot->finalize()); - objBuilder->destroy(pqView); - } - - void visibilityToggleWithChart() - { - auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); - auto* server = pqApplicationCore::instance()->getActiveServer(); - auto* pqView = objBuilder->createView("XYChartView", server); - auto* viewProxy = pqView->getViewProxy(); - - auto table = createTestTable(); - auto* result = new OperatorResult(this); - result->setName("test_result"); - result->setDataObject(table); - - QVERIFY(modulePlot->initialize(result, viewProxy)); - - // Toggle visibility off and on with an actual chart backing it - QVERIFY(modulePlot->setVisibility(false)); - QVERIFY(!modulePlot->visibility()); - - QVERIFY(modulePlot->setVisibility(true)); - QVERIFY(modulePlot->visibility()); - - QVERIFY(modulePlot->finalize()); - objBuilder->destroy(pqView); - } -}; - -int main(int argc, char** argv) -{ - QApplication app(argc, argv); - pqPVApplicationCore appCore(argc, argv); - - // Create a builtin server connection so views and proxies can be created - auto* builder = pqApplicationCore::instance()->getObjectBuilder(); - builder->createServer(pqServerResource("builtin:")); - - ModulePlotTest tc; - return QTest::qExec(&tc, argc, argv); -} - -#include "ModulePlotTest.moc" diff --git a/tests/cxx/OperatorPythonTest.cxx b/tests/cxx/OperatorPythonTest.cxx deleted file mode 100644 index c8dcf7bd6..000000000 --- a/tests/cxx/OperatorPythonTest.cxx +++ /dev/null @@ -1,477 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include - -#include -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "PipelineProxy.h" -#include "TomvizTest.h" -#include "operators/OperatorProxy.h" -#include "operators/OperatorPython.h" - -using namespace tomviz; - -class OperatorPythonTest : public ::testing::Test -{ -public: - // This should be called once globally - it is not in my testing :-( - static void SetUpTestSuite() - { - // Register our factories for Python wrapping. - OperatorProxyFactory::registerWithFactory(); - PipelineProxyFactory::registerWithFactory(); - } - -protected: - void SetUp() override - { - // Register our factories for Python wrapping. Should be done globally - // above, but for some reason isn't running for me. Putting it here as a - // hack - FIXME: remove this when the single invocation works. - OperatorProxyFactory::registerWithFactory(); - PipelineProxyFactory::registerWithFactory(); - dataObject = vtkDataObject::New(); - pythonOperator = new OperatorPython(nullptr); - } - - void TearDown() override - { - dataObject->Delete(); - pythonOperator->deleteLater(); - } - - vtkDataObject* dataObject; - OperatorPython* pythonOperator; -}; - -TEST_F(OperatorPythonTest, transform_function) -{ - pythonOperator->setLabel("transform_function"); - QFile file(QString("%1/fixtures/function.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - pythonOperator->setScript(script); - pythonOperator->transform(dataObject); - file.close(); - } else { - FAIL() << "Unable to load script."; - } -} - -TEST_F(OperatorPythonTest, operator_transform) -{ - pythonOperator->setLabel("operator_transform"); - QFile file(QString("%1/fixtures/test_operator.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - pythonOperator->setScript(script); - ASSERT_EQ(pythonOperator->transform(dataObject), TransformResult::Complete); - file.close(); - } else { - FAIL() << "Unable to load script."; - } -} - -TEST_F(OperatorPythonTest, cancelable_operator_transform) -{ - pythonOperator->setLabel("cancelable_operator_transform"); - QFile file(QString("%1/fixtures/cancelable.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - file.close(); - pythonOperator->setScript(script); - ASSERT_EQ(pythonOperator->transform(dataObject), TransformResult::Complete); - - // Mimic user canceling operator - std::thread canceler([this]() { - while (!pythonOperator->isCanceled()) { - // Wait until we are running to cancel - if (pythonOperator->state() == OperatorState::Running) { - pythonOperator->cancelTransform(); - } - } - }); - TransformResult result = pythonOperator->transform(dataObject); - canceler.join(); - ASSERT_EQ(result, TransformResult::Canceled); - - } else { - FAIL() << "Unable to load script."; - } -} - -TEST_F(OperatorPythonTest, set_max_progress) -{ - pythonOperator->setLabel("set_max_progress"); - QFile file(QString("%1/fixtures/set_max_progress.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - file.close(); - pythonOperator->setScript(script); - - TransformResult result = pythonOperator->transform(dataObject); - ASSERT_EQ(result, TransformResult::Complete); - ASSERT_EQ(pythonOperator->totalProgressSteps(), 10); - - } else { - FAIL() << "Unable to load script."; - } -} - -TEST_F(OperatorPythonTest, update_progress) -{ - pythonOperator->setLabel("update_progress"); - QFile file(QString("%1/fixtures/update_progress.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - file.close(); - pythonOperator->setScript(script); - - QSignalSpy spy(pythonOperator, SIGNAL(progressStepChanged(int))); - TransformResult result = pythonOperator->transform(dataObject); - ASSERT_EQ(result, TransformResult::Complete); - - // One from applyTransform() and one from our python code - ASSERT_EQ(spy.count(), 2); - - // Take the signal emitted from our python code - QList args = spy.takeAt(1); - ASSERT_EQ(args.at(0).toInt(), 100); - - } else { - FAIL() << "Unable to load script."; - } -} - -TEST_F(OperatorPythonTest, update_progress_message) -{ - pythonOperator->setLabel("update_progress_message"); - QFile file(QString("%1/fixtures/update_progress_message.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - file.close(); - pythonOperator->setScript(script); - - QSignalSpy spy(pythonOperator, - SIGNAL(progressMessageChanged(const QString&))); - TransformResult result = pythonOperator->transform(dataObject); - ASSERT_EQ(result, TransformResult::Complete); - - ASSERT_EQ(spy.count(), 1); - - QList args = spy.takeAt(0); - ASSERT_STREQ(args.at(0).toString().toLatin1().constData(), - "Is there anyone out there?"); - - } else { - FAIL() << "Unable to load script."; - } -} - -TEST_F(OperatorPythonTest, update_data) -{ - pythonOperator->setLabel("update_data"); - // Disconnect these signals as they will cause us to reach into - // part of ParaView that aren't available in the test executable. - // We only need to test that the childDataSourceUpdated signal is - // fired, not the data source creation. - QObject::disconnect(pythonOperator, - static_cast( - &OperatorPython::newChildDataSource), - nullptr, nullptr); - QObject::disconnect(pythonOperator, &OperatorPython::childDataSourceUpdated, - nullptr, nullptr); - QFile file(QString("%1/fixtures/update_data.py").arg(SOURCE_DIR)); - if (file.open(QIODevice::ReadOnly)) { - QByteArray array = file.readAll(); - QString script(array); - file.close(); - pythonOperator->setScript(script); - - QSignalSpy spy(pythonOperator, SIGNAL(childDataSourceUpdated( - vtkSmartPointer))); - TransformResult result = pythonOperator->transform(dataObject); - ASSERT_EQ(result, TransformResult::Complete); - - ASSERT_EQ(spy.count(), 1); - - QList args = spy.takeFirst(); - vtkSmartPointer data = - args.at(0).value>(); - vtkImageData* imageData = vtkImageData::SafeDownCast(data); - int dims[3]; - imageData->GetDimensions(dims); - ASSERT_EQ(dims[0], 3); - ASSERT_EQ(dims[1], 4); - ASSERT_EQ(dims[2], 5); - - for (int z = 0; z < dims[2]; z++) { - for (int y = 0; y < dims[1]; y++) { - for (int x = 0; x < dims[0]; x++) { - ASSERT_EQ(imageData->GetScalarComponentAsDouble(x, y, z, 0), 2.0); - } - } - } - } else { - FAIL() << "Unable to load script."; - } -} - -// --- Breakpoint API tests --- - -TEST_F(OperatorPythonTest, breakpoint_default_false) -{ - ASSERT_FALSE(pythonOperator->hasBreakpoint()); -} - -TEST_F(OperatorPythonTest, breakpoint_set_emits_signal) -{ - QSignalSpy spy(pythonOperator, SIGNAL(breakpointChanged())); - pythonOperator->setBreakpoint(true); - ASSERT_TRUE(pythonOperator->hasBreakpoint()); - ASSERT_EQ(spy.count(), 1); -} - -TEST_F(OperatorPythonTest, breakpoint_no_signal_on_same_value) -{ - pythonOperator->setBreakpoint(true); - QSignalSpy spy(pythonOperator, SIGNAL(breakpointChanged())); - pythonOperator->setBreakpoint(true); - ASSERT_EQ(spy.count(), 0); -} - -TEST_F(OperatorPythonTest, breakpoint_toggle) -{ - QSignalSpy spy(pythonOperator, SIGNAL(breakpointChanged())); - pythonOperator->setBreakpoint(true); - ASSERT_TRUE(pythonOperator->hasBreakpoint()); - pythonOperator->setBreakpoint(false); - ASSERT_FALSE(pythonOperator->hasBreakpoint()); - ASSERT_EQ(spy.count(), 2); -} - -// --- Serialization tests --- - -TEST_F(OperatorPythonTest, serialize_breakpoint) -{ - pythonOperator->setLabel("test"); - pythonOperator->setBreakpoint(true); - auto json = pythonOperator->serialize(); - ASSERT_TRUE(json.contains("breakpoint")); - ASSERT_TRUE(json["breakpoint"].toBool()); -} - -TEST_F(OperatorPythonTest, serialize_no_breakpoint_by_default) -{ - pythonOperator->setLabel("test"); - auto json = pythonOperator->serialize(); - ASSERT_FALSE(json.contains("breakpoint")); -} - -TEST_F(OperatorPythonTest, deserialize_restores_label_and_script) -{ - QJsonObject json; - json["label"] = "My Label"; - json["script"] = "def transform(dataset): pass"; - json["description"] = ""; - pythonOperator->deserialize(json); - ASSERT_STREQ(pythonOperator->label().toLatin1().constData(), "My Label"); - ASSERT_STREQ(pythonOperator->script().toLatin1().constData(), - "def transform(dataset): pass"); -} - -// --- JSON description parsing tests --- - -TEST_F(OperatorPythonTest, json_description_parameters) -{ - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "param1", "type": "int", "default": 0}, - {"name": "param2", "type": "double", "default": 1.0}, - {"name": "param3", "type": "bool", "default": true} - ] - })"; - pythonOperator->setJSONDescription(desc); - ASSERT_EQ(pythonOperator->numberOfParameters(), 3); - ASSERT_STREQ(pythonOperator->label().toLatin1().constData(), - "Test Operator"); -} - -TEST_F(OperatorPythonTest, json_description_no_parameters) -{ - QString desc = R"({ - "name": "SimpleOp", - "label": "Simple Operator" - })"; - pythonOperator->setJSONDescription(desc); - ASSERT_EQ(pythonOperator->numberOfParameters(), 0); - ASSERT_STREQ(pythonOperator->label().toLatin1().constData(), - "Simple Operator"); -} - -TEST_F(OperatorPythonTest, json_description_with_results) -{ - QString desc = R"({ - "name": "ResultOp", - "label": "Result Operator", - "parameters": [ - {"name": "threshold", "type": "double", "default": 0.5} - ], - "results": [ - {"name": "output_image", "label": "Output Image"} - ] - })"; - pythonOperator->setJSONDescription(desc); - ASSERT_EQ(pythonOperator->numberOfParameters(), 1); - ASSERT_EQ(pythonOperator->numberOfResults(), 1); -} - -// --- Argument serialization round-trip tests --- - -TEST_F(OperatorPythonTest, serialize_deserialize_double_argument) -{ - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "rotation_center", "type": "double", "default": 0.0} - ] - })"; - pythonOperator->setJSONDescription(desc); - - QMap args; - args["rotation_center"] = 42.5; - pythonOperator->setArguments(args); - pythonOperator->setScript("def transform(dataset): pass"); - - auto json = pythonOperator->serialize(); - - // Deserialize onto a fresh operator - auto* newOp = new OperatorPython(nullptr); - ASSERT_TRUE(newOp->deserialize(json)); - - auto newArgs = newOp->arguments(); - ASSERT_DOUBLE_EQ(newArgs["rotation_center"].toDouble(), 42.5); - - newOp->deleteLater(); -} - -TEST_F(OperatorPythonTest, serialize_deserialize_enumeration_argument) -{ - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "transform_source", "type": "enumeration", "default": 0, - "options": [{"Manual": "manual"}, {"Load From File": "from_file"}]} - ] - })"; - pythonOperator->setJSONDescription(desc); - - QMap args; - args["transform_source"] = 1; - pythonOperator->setArguments(args); - pythonOperator->setScript("def transform(dataset): pass"); - - auto json = pythonOperator->serialize(); - - auto* newOp = new OperatorPython(nullptr); - ASSERT_TRUE(newOp->deserialize(json)); - - auto newArgs = newOp->arguments(); - ASSERT_EQ(newArgs["transform_source"].toInt(), 1); - - newOp->deleteLater(); -} - -TEST_F(OperatorPythonTest, deserialize_select_scalars) -{ - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "selected_scalars", "type": "select_scalars"} - ] - })"; - - QJsonObject json; - json["description"] = desc; - json["label"] = "Test Operator"; - json["script"] = ""; - - QJsonObject args; - QJsonArray scalarsArray; - scalarsArray.append("scalar_a"); - scalarsArray.append("scalar_b"); - args["selected_scalars"] = scalarsArray; - json["arguments"] = args; - - pythonOperator->deserialize(json); - - auto resultArgs = pythonOperator->arguments(); - auto scalars = resultArgs["selected_scalars"].toList(); - ASSERT_EQ(scalars.size(), 2); - ASSERT_STREQ(scalars[0].toString().toLatin1().constData(), "scalar_a"); - ASSERT_STREQ(scalars[1].toString().toLatin1().constData(), "scalar_b"); -} - -TEST_F(OperatorPythonTest, serialize_deserialize_roundtrip) -{ - QString desc = R"({ - "name": "TestOp", - "label": "Test Operator", - "parameters": [ - {"name": "value", "type": "double", "default": 0.0} - ] - })"; - pythonOperator->setJSONDescription(desc); - pythonOperator->setScript("def transform(dataset): pass"); - - QMap args; - args["value"] = 3.14; - pythonOperator->setArguments(args); - - auto json = pythonOperator->serialize(); - - // Verify the serialized JSON contains expected fields - ASSERT_TRUE(json.contains("description")); - ASSERT_TRUE(json.contains("label")); - ASSERT_TRUE(json.contains("script")); - ASSERT_TRUE(json.contains("arguments")); - ASSERT_STREQ(json["type"].toString().toLatin1().constData(), "Python"); - - auto* newOp = new OperatorPython(nullptr); - ASSERT_TRUE(newOp->deserialize(json)); - - ASSERT_DOUBLE_EQ(newOp->arguments()["value"].toDouble(), 3.14); - ASSERT_STREQ(newOp->label().toLatin1().constData(), "Test Operator"); - ASSERT_STREQ(newOp->script().toLatin1().constData(), - "def transform(dataset): pass"); - - newOp->deleteLater(); -} - diff --git a/tests/cxx/PipelineDemo.cxx b/tests/cxx/PipelineDemo.cxx new file mode 100644 index 000000000..f197cc4a7 --- /dev/null +++ b/tests/cxx/PipelineDemo.cxx @@ -0,0 +1,330 @@ +/* Manual demo app for the new pipeline with properties panel. + Build target: pipelineDemo + + Pipeline: SphereSource -> InvertData -> AddConstant -> [Outline, Slice, Volume] + The three sinks are displayed simultaneously in a ParaView render view. + A left dock shows the PipelineStripWidget on top and the + VolumePropertiesWidget below (shown only when a Volume port is selected). +*/ + +#include "EditNodeWidget.h" +#include "Node.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "PipelineStripWidget.h" +#include "PortType.h" +#include "TransformNode.h" +#include "VolumePropertiesWidget.h" +#include "sources/SphereSource.h" +#include "transforms/LegacyPythonTransform.h" + +#include "sinks/ContourSink.h" +#include "sinks/LegacyModuleSink.h" +#include "sinks/OutlineSink.h" +#include "sinks/SliceSink.h" +#include "sinks/VolumeSink.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace tomviz::pipeline; + +namespace { + +QString readFile(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + return QString::fromUtf8(file.readAll()); +} + +} // namespace + +int main(int argc, char** argv) +{ + // Ensure Python can find the tomviz package (for pipeline_dataset, etc.) + QByteArray pythonPath = qgetenv("PYTHONPATH"); + QByteArray tomvizPython = TOMVIZ_PYTHON_DIR; + if (pythonPath.isEmpty()) { + qputenv("PYTHONPATH", tomvizPython); + } else { + qputenv("PYTHONPATH", tomvizPython + ":" + pythonPath); + } + + QApplication app(argc, argv); + + // ParaView application core + pqPVApplicationCore pvCore(argc, argv); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + builder->createServer(pqServerResource("builtin:")); + auto* server = pqApplicationCore::instance()->getActiveServer(); + + // Create a 3D render view + auto* pqRenderView = builder->createView("RenderView", server); + auto* viewProxy = pqRenderView->getViewProxy(); + + // ------------------------------------------------------------------- + // Build the pipeline: + // SphereSource -> InvertData -> AddConstant -> [Outline, Slice, Volume] + // ------------------------------------------------------------------- + auto* pipeline = new Pipeline(); + + auto* source = new SphereSource(); + source->setDimensions(64, 64, 64); + pipeline->addNode(source); + + QString pythonDir = TOMVIZ_PYTHON_DIR; + + // InvertData transform (function-based inline script; the shipped + // InvertData.py uses a class-based pattern not supported here) + auto* invert = new LegacyPythonTransform(); + invert->setJSONDescription(readFile(pythonDir + "/InvertData.json")); + // invert->setJSONDescription(readFile(pythonDir + "/InvertData.py")); + invert->setScript( + "def transform(dataset):\n" + " import numpy as np\n" + " scalars = dataset.active_scalars\n" + " if scalars is None:\n" + " raise RuntimeError('No scalars found!')\n" + " dataset.active_scalars = np.max(scalars) - scalars + np.min(scalars)\n"); + pipeline->addNode(invert); + pipeline->createLink(source->outputPort("volume"), + invert->inputPort("volume")); + + // AddConstant transform + // auto* addConst = invert; + auto* addConst = new LegacyPythonTransform(); + addConst->setJSONDescription(readFile(pythonDir + "/AddConstant.json")); + addConst->setScript(readFile(pythonDir + "/AddConstant.py")); + addConst->setParameter("constant", -10.0); + pipeline->addNode(addConst); + pipeline->createLink(invert->outputPort("volume"), + addConst->inputPort("volume")); + + // Create three sinks all connected to the AddConstant output + auto* outlineSink = new OutlineSink(); + pipeline->addNode(outlineSink); + pipeline->createLink(addConst->outputPort("volume"), + outlineSink->inputPort("volume")); + + auto* sliceSink = new SliceSink(); + pipeline->addNode(sliceSink); + pipeline->createLink(addConst->outputPort("volume"), + sliceSink->inputPort("volume")); + + auto* volumeSink = new VolumeSink(); + pipeline->addNode(volumeSink); + pipeline->createLink(addConst->outputPort("volume"), + volumeSink->inputPort("volume")); + + auto* contourSink = new ContourSink(); + pipeline->addNode(contourSink); + pipeline->createLink(addConst->outputPort("volume"), + contourSink->inputPort("volume")); + + // Execute the source and full pipeline + source->execute(); + pipeline->execute(); + + // Initialize all sinks with the render view + outlineSink->initialize(viewProxy); + sliceSink->initialize(viewProxy); + volumeSink->initialize(viewProxy); + contourSink->initialize(viewProxy); + + // Re-execute so sinks create VTK actors now that the view is set up + pipeline->execute(); + + // Reset the camera to frame the data + pqRenderView->resetDisplay(); + + // ------------------------------------------------------------------- + // Window layout + // ------------------------------------------------------------------- + QMainWindow window; + window.setWindowTitle("Pipeline Demo"); + window.resize(1200, 800); + + // Central area: render view + auto* central = new QWidget(); + auto* centralLayout = new QVBoxLayout(central); + centralLayout->setContentsMargins(0, 0, 0, 0); + + // Top toolbar with execute button + auto* toolbar = new QWidget(); + auto* toolbarLayout = new QHBoxLayout(toolbar); + toolbarLayout->setContentsMargins(4, 4, 4, 4); + + auto* executeBtn = new QPushButton("Execute"); + toolbarLayout->addWidget(executeBtn); + toolbarLayout->addStretch(); + + centralLayout->addWidget(toolbar); + centralLayout->addWidget(pqRenderView->widget(), 1); + window.setCentralWidget(central); + + // Left dock: pipeline strip + properties widget in a vertical splitter + auto* dock = new QDockWidget("Pipeline", &window); + dock->setMinimumWidth(240); + + auto* dockContainer = new QWidget(); + auto* dockLayout = new QVBoxLayout(dockContainer); + dockLayout->setContentsMargins(0, 0, 0, 0); + dockLayout->setSpacing(0); + + // Top: pipeline strip in a scroll area + auto* scroll = new QScrollArea(); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + auto* strip = new PipelineStripWidget(); + strip->setPipeline(pipeline); + scroll->setWidget(strip); + dockLayout->addWidget(scroll, 1); + + // Bottom: volume properties widget in a scroll area + auto* propsScroll = new QScrollArea(); + propsScroll->setWidgetResizable(true); + propsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + // Prevent the properties widget from influencing the dock width + propsScroll->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + + auto* propsWidget = new VolumePropertiesWidget(); + propsWidget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + propsScroll->setWidget(propsWidget); + dockLayout->addWidget(propsScroll, 1); + + // Bottom: sink properties widget in a scroll area + auto* sinkPropsScroll = new QScrollArea(); + sinkPropsScroll->setWidgetResizable(true); + sinkPropsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + sinkPropsScroll->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + dockLayout->addWidget(sinkPropsScroll, 1); + + // Hide properties panels initially + propsScroll->hide(); + sinkPropsScroll->hide(); + + dock->setWidget(dockContainer); + window.addDockWidget(Qt::LeftDockWidgetArea, dock); + + // Status bar + window.statusBar()->showMessage("Ready"); + + // ------------------------------------------------------------------- + // Connections + // ------------------------------------------------------------------- + + // Helper: show properties only for Volume-type output ports + auto showPropsForPort = [&](OutputPort* port) { + sinkPropsScroll->hide(); + if (port && port->type() == PortType::ImageData) { + propsWidget->setOutputPort(port); + propsScroll->show(); + } else { + propsWidget->setOutputPort(nullptr); + propsScroll->hide(); + } + }; + + // Helper: show sink or transform properties when a node is selected + auto showSinkProps = [&](Node* node) { + propsWidget->setOutputPort(nullptr); + propsScroll->hide(); + + // Try sink node first + auto* sink = qobject_cast(node); + if (sink) { + auto* w = sink->createPropertiesWidget(pipeline, nullptr); + if (w) { + sinkPropsScroll->setWidget(w); + sinkPropsScroll->show(); + return; + } + } + + // Try transform node + auto* transform = qobject_cast(node); + if (transform && transform->hasPropertiesWidget()) { + auto* w = transform->createPropertiesWidget(pipeline, nullptr); + if (w) { + sinkPropsScroll->setWidget(w); + sinkPropsScroll->show(); + return; + } + } + + sinkPropsScroll->hide(); + }; + + // When a port is explicitly selected in the strip, show its properties + QObject::connect(strip, &PipelineStripWidget::portSelected, + [&](OutputPort* port) { + showPropsForPort(port); + if (port) { + window.statusBar()->showMessage( + QString("Port selected: %1").arg(port->name())); + } + }); + + // When a node is selected (not a port), show sink properties or hide + QObject::connect(strip, &PipelineStripWidget::nodeSelected, [&](Node* node) { + if (node) { + window.statusBar()->showMessage( + QString("Selected: %1").arg(node->label())); + } + showSinkProps(node); + }); + + // Execute button re-runs the pipeline + QObject::connect(executeBtn, &QPushButton::clicked, [&]() { + source->execute(); + pipeline->execute(); + pqRenderView->render(); + window.statusBar()->showMessage("Pipeline executed"); + }); + + // Render when any sink signals it needs a render + auto renderSlot = [&]() { pqRenderView->render(); }; + QObject::connect(outlineSink, &LegacyModuleSink::renderNeeded, renderSlot); + QObject::connect(sliceSink, &LegacyModuleSink::renderNeeded, renderSlot); + QObject::connect(volumeSink, &LegacyModuleSink::renderNeeded, renderSlot); + QObject::connect(contourSink, &LegacyModuleSink::renderNeeded, renderSlot); + + // When transform parameters are applied, re-execute the pipeline and render + auto reexecuteSlot = [&]() { + pipeline->execute(); + pqRenderView->render(); + window.statusBar()->showMessage("Pipeline re-executed (parameters applied)"); + }; + QObject::connect(invert, &TransformNode::parametersApplied, reexecuteSlot); + QObject::connect(addConst, &TransformNode::parametersApplied, reexecuteSlot); + + // When volume data is modified via properties widget, re-execute and render + QObject::connect(propsWidget, &VolumePropertiesWidget::volumeDataModified, + [&]() { + pipeline->execute(); + pqRenderView->render(); + }); + + window.show(); + pqRenderView->render(); + + return app.exec(); +} diff --git a/tests/cxx/PipelineExecutionTest.cxx b/tests/cxx/PipelineExecutionTest.cxx deleted file mode 100644 index eae10fcb8..000000000 --- a/tests/cxx/PipelineExecutionTest.cxx +++ /dev/null @@ -1,256 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include -#include - -#include "DataSource.h" -#include "Pipeline.h" -#include "PipelineProxy.h" -#include "PythonUtilities.h" -#include "TomvizTest.h" -#include "operators/OperatorProxy.h" -#include "operators/OperatorPython.h" - -using namespace tomviz; - -static QString loadFixture(const QString& name) -{ - QFile file(QString("%1/fixtures/%2").arg(SOURCE_DIR, name)); - if (!file.open(QIODevice::ReadOnly)) { - return QString(); - } - return QString(file.readAll()); -} - -static vtkSmartPointer createImageData(int dim, double fill) -{ - auto image = vtkSmartPointer::New(); - image->SetDimensions(dim, dim, dim); - image->AllocateScalars(VTK_DOUBLE, 1); - for (int z = 0; z < dim; ++z) { - for (int y = 0; y < dim; ++y) { - for (int x = 0; x < dim; ++x) { - image->SetScalarComponentFromDouble(x, y, z, 0, fill); - } - } - } - return image; -} - -class PipelineExecutionTest : public QObject -{ - Q_OBJECT - -private slots: - void initTestCase() - { - OperatorProxyFactory::registerWithFactory(); - PipelineProxyFactory::registerWithFactory(); - } - - void pipelineStopsAtBreakpoint() - { - auto image = createImageData(2, 0.0); - auto* ds = new DataSource(image); - - QString script = loadFixture("increment_scalars.py"); - QVERIFY2(!script.isEmpty(), "Failed to load increment_scalars.py"); - - // Create a pipeline (paused so operators aren't auto-executed on add) - Pipeline pipeline(ds); - pipeline.pause(); - - // Add 3 increment operators - auto* op0 = new OperatorPython(ds); - op0->setLabel("increment_0"); - op0->setScript(script); - ds->addOperator(op0); - - auto* op1 = new OperatorPython(ds); - op1->setLabel("increment_1"); - op1->setScript(script); - ds->addOperator(op1); - - auto* op2 = new OperatorPython(ds); - op2->setLabel("increment_2"); - op2->setScript(script); - ds->addOperator(op2); - - // Set a breakpoint on the 3rd operator - op2->setBreakpoint(true); - - // Resume the pipeline and execute -- it should stop before op2 - pipeline.resume(); - QSignalSpy breakpointSpy(&pipeline, &Pipeline::breakpointReached); - QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); - auto* future = pipeline.execute(ds, op0); - - // Wait for the pipeline to finish (up to 10 seconds) - QVERIFY(finishedSpy.wait(10000)); - - // breakpointReached should have been emitted with op2 - QCOMPARE(breakpointSpy.count(), 1); - auto* reachedOp = breakpointSpy.takeFirst().at(0).value(); - QCOMPARE(reachedOp, op2); - - // The first 2 operators should be Complete, the 3rd should be Queued - QCOMPARE(op0->state(), OperatorState::Complete); - QCOMPARE(op1->state(), OperatorState::Complete); - QCOMPARE(op2->state(), OperatorState::Queued); - - // Delete future before pipeline goes out of scope to avoid double-free - // (PipelineFutureInternal's QScopedPointer vs executor's QObject parent) - delete future; - } - - void breakpointSkipsRemainingOps() - { - auto image = createImageData(2, 0.0); - auto* ds = new DataSource(image); - - QString addOneScript = loadFixture("increment_scalars.py"); - QString addTenScript = loadFixture("add_ten.py"); - QVERIFY(!addOneScript.isEmpty()); - QVERIFY(!addTenScript.isEmpty()); - - Pipeline pipeline(ds); - pipeline.pause(); - - // Op0: add 1, Op1: add 10, Op2 (breakpoint): would add 1 again - auto* op0 = new OperatorPython(ds); - op0->setLabel("add_one"); - op0->setScript(addOneScript); - ds->addOperator(op0); - - auto* op1 = new OperatorPython(ds); - op1->setLabel("add_ten"); - op1->setScript(addTenScript); - ds->addOperator(op1); - - auto* op2 = new OperatorPython(ds); - op2->setLabel("add_one_again"); - op2->setScript(addOneScript); - op2->setBreakpoint(true); - ds->addOperator(op2); - - pipeline.resume(); - QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); - auto* future = pipeline.execute(ds, op0); - - QVERIFY(finishedSpy.wait(10000)); - - // Result should be 11.0 (0 + 1 + 10), not 12.0 (if 3rd ran too) - auto result = future->result(); - QVERIFY(result != nullptr); - int dims[3]; - result->GetDimensions(dims); - for (int z = 0; z < dims[2]; ++z) { - for (int y = 0; y < dims[1]; ++y) { - for (int x = 0; x < dims[0]; ++x) { - QCOMPARE(result->GetScalarComponentAsDouble(x, y, z, 0), 11.0); - } - } - } - - delete future; - } - - void executionOrderMatters() - { - QString addTenScript = loadFixture("add_ten.py"); - QString multiplyTwoScript = loadFixture("multiply_two.py"); - QVERIFY(!addTenScript.isEmpty()); - QVERIFY(!multiplyTwoScript.isEmpty()); - - // Order 1: [add_ten, multiply_two] on data starting at 0 - // Expected: (0 + 10) * 2 = 20 - { - auto image = createImageData(2, 0.0); - auto* ds = new DataSource(image); - Pipeline pipeline(ds); - pipeline.pause(); - - auto* opAdd = new OperatorPython(ds); - opAdd->setLabel("add_ten"); - opAdd->setScript(addTenScript); - ds->addOperator(opAdd); - - auto* opMul = new OperatorPython(ds); - opMul->setLabel("multiply_two"); - opMul->setScript(multiplyTwoScript); - ds->addOperator(opMul); - - pipeline.resume(); - QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); - auto* future = pipeline.execute(ds, opAdd); - - QVERIFY(finishedSpy.wait(10000)); - - auto result = future->result(); - QVERIFY(result != nullptr); - QCOMPARE(result->GetScalarComponentAsDouble(0, 0, 0, 0), 20.0); - - delete future; - } - - // Order 2: [multiply_two, add_ten] on data starting at 0 - // Expected: (0 * 2) + 10 = 10 - { - auto image = createImageData(2, 0.0); - auto* ds = new DataSource(image); - Pipeline pipeline(ds); - pipeline.pause(); - - auto* opMul = new OperatorPython(ds); - opMul->setLabel("multiply_two"); - opMul->setScript(multiplyTwoScript); - ds->addOperator(opMul); - - auto* opAdd = new OperatorPython(ds); - opAdd->setLabel("add_ten"); - opAdd->setScript(addTenScript); - ds->addOperator(opAdd); - - pipeline.resume(); - QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); - auto* future = pipeline.execute(ds, opMul); - - QVERIFY(finishedSpy.wait(10000)); - - auto result = future->result(); - QVERIFY(result != nullptr); - QCOMPARE(result->GetScalarComponentAsDouble(0, 0, 0, 0), 10.0); - - delete future; - } - } -}; - -int main(int argc, char** argv) -{ - QApplication app(argc, argv); - pqPVApplicationCore appCore(argc, argv); - - // Create a builtin server connection so proxies can be created - auto* builder = pqApplicationCore::instance()->getObjectBuilder(); - builder->createServer(pqServerResource("builtin:")); - - Python::initialize(); - - PipelineExecutionTest tc; - return QTest::qExec(&tc, argc, argv); -} - -#include "PipelineExecutionTest.moc" diff --git a/tests/cxx/PipelineLibTest.cxx b/tests/cxx/PipelineLibTest.cxx new file mode 100644 index 000000000..49f1429ab --- /dev/null +++ b/tests/cxx/PipelineLibTest.cxx @@ -0,0 +1,4235 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include + +#include "DefaultExecutor.h" +#include "ExecutionFuture.h" +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "PipelineExecutor.h" +#include "PortData.h" +#include "PortType.h" +#include "SinkNode.h" +#include "SourceNode.h" +#include "ThreadedExecutor.h" +#include "TransformNode.h" + +#include "EmdFormat.h" +#include "PipelineSettings.h" +#include "PipelineStateIO.h" +#include "PortDataWriter.h" +#include "SaveDataDialog.h" +#include "SinkGroupNode.h" +#include "Tvh5Format.h" +#include "data/VolumeData.h" +#include "sinks/VolumeStatsSink.h" +#include "sinks/LegacyModuleSink.h" +#include "sinks/VolumeSink.h" +#include "sinks/SliceSink.h" +#include "sinks/ContourSink.h" +#include "sinks/ThresholdSink.h" +#include "sinks/SegmentSink.h" +#include "sinks/OutlineSink.h" +#include "sinks/ClipSink.h" +#include "sinks/RulerSink.h" +#include "sinks/ScaleCubeSink.h" +#include "sinks/PlotSink.h" +#include "sinks/MoleculeSink.h" +#include "NodeFactory.h" +#include "sources/PythonSource.h" +#include "sources/SphereSource.h" +#include "sources/ReaderSourceNode.h" +#include "transforms/ThresholdTransform.h" +#include "transforms/LegacyPythonTransform.h" +#include "ExternalNodeExecutor.h" +#include "transforms/PythonTransform.h" +#include "PipelineStripWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// Qt defines 'slots' as a macro which conflicts with Python's object.h +#pragma push_macro("slots") +#undef slots +#include +#pragma pop_macro("slots") + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace py = pybind11; +using namespace tomviz::pipeline; + +// Test helpers + +class DoubleTransform : public TransformNode +{ +public: + DoubleTransform() : TransformNode() + { + addInput("in", PortType::ImageData); + addOutput("out", PortType::ImageData); + } + +protected: + QMap transform( + const QMap& inputs) override + { + int val = inputs["in"].value(); + QMap result; + result["out"] = PortData(std::any(val * 2), PortType::ImageData); + return result; + } +}; + +class AddTransform : public TransformNode +{ +public: + AddTransform() : TransformNode() + { + addInput("a", PortType::ImageData); + addInput("b", PortType::ImageData); + addOutput("out", PortType::ImageData); + } + +protected: + QMap transform( + const QMap& inputs) override + { + int a = inputs["a"].value(); + int b = inputs["b"].value(); + QMap result; + result["out"] = PortData(std::any(a + b), PortType::ImageData); + return result; + } +}; + +// A simple passthrough transform with configurable port types. +class PassthroughTransform : public TransformNode +{ +public: + PassthroughTransform(PortTypes inType, PortType outType) + : TransformNode() + { + addInput("in", inType); + addOutput("out", outType); + } + +protected: + QMap transform( + const QMap& inputs) override + { + return { { "out", inputs["in"] } }; + } +}; + +class CollectorSink : public SinkNode +{ +public: + CollectorSink() : SinkNode() { addInput("in", PortType::ImageData); } + + int lastValue = 0; + bool consumed = false; + +protected: + bool consume(const QMap& inputs) override + { + lastValue = inputs["in"].value(); + consumed = true; + return true; + } +}; + +// Tests + +class PipelineLibTest : public ::testing::Test +{ +protected: + void SetUp() override { pipeline = new Pipeline(); } + void TearDown() override { delete pipeline; } + Pipeline* pipeline; +}; + +TEST_F(PipelineLibTest, PortDataBasics) +{ + PortData empty; + EXPECT_FALSE(empty.isValid()); + EXPECT_EQ(empty.type(), PortType::None); + + PortData data(std::any(42), PortType::ImageData); + EXPECT_TRUE(data.isValid()); + EXPECT_EQ(data.type(), PortType::ImageData); + EXPECT_EQ(data.value(), 42); + + data.clear(); + EXPECT_FALSE(data.isValid()); +} + +TEST_F(PipelineLibTest, PortTypeCompatibility) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + // ImageData -> ImageData should work + auto* outPort = source->outputPort("out"); + auto* inPort = transform->inputPort("in"); + EXPECT_TRUE(inPort->canConnectTo(outPort)); + + // Create a source with Table type + auto* tableSource = new SourceNode(); + tableSource->addOutput("out", PortType::Table); + pipeline->addNode(tableSource); + + // Table -> ImageData input should fail + auto* tablePort = tableSource->outputPort("out"); + EXPECT_FALSE(inPort->canConnectTo(tablePort)); +} + +TEST_F(PipelineLibTest, LinkCreation) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + auto* link = + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + EXPECT_NE(link, nullptr); + EXPECT_TRUE(link->isValid()); + EXPECT_EQ(pipeline->links().size(), 1); + EXPECT_EQ(transform->inputPort("in")->link(), link); + EXPECT_EQ(source->outputPort("out")->links().size(), 1); +} + +TEST_F(PipelineLibTest, LinkTypeRejection) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::Table); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + auto* link = + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + EXPECT_EQ(link, nullptr); + EXPECT_EQ(pipeline->links().size(), 0); +} + +TEST_F(PipelineLibTest, CycleDetection) +{ + auto* t1 = new DoubleTransform(); + t1->setLabel("T1"); + auto* t2 = new DoubleTransform(); + t2->setLabel("T2"); + pipeline->addNode(t1); + pipeline->addNode(t2); + + auto* link1 = + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + EXPECT_NE(link1, nullptr); + + // T2 -> T1 would create a cycle + EXPECT_TRUE( + pipeline->wouldCreateCycle(t2->outputPort("out"), t1->inputPort("in"))); + auto* link2 = + pipeline->createLink(t2->outputPort("out"), t1->inputPort("in")); + EXPECT_EQ(link2, nullptr); +} + +TEST_F(PipelineLibTest, SelfLoop) +{ + auto* t1 = new DoubleTransform(); + pipeline->addNode(t1); + EXPECT_TRUE( + pipeline->wouldCreateCycle(t1->outputPort("out"), t1->inputPort("in"))); +} + +TEST_F(PipelineLibTest, StalenessPropagation) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + pipeline->addNode(t1); + auto* t2 = new DoubleTransform(); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + // Set data on source (marks source Current, downstream Stale) + source->setOutputData("out", PortData(std::any(5), PortType::ImageData)); + + EXPECT_EQ(source->state(), NodeState::Current); + EXPECT_EQ(t1->state(), NodeState::Stale); + EXPECT_EQ(t2->state(), NodeState::Stale); +} + +TEST_F(PipelineLibTest, StalenessPropagationMultiBranch) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + auto* t2 = new DoubleTransform(); + pipeline->addNode(t1); + pipeline->addNode(t2); + + // Source fans out to both t1 and t2 + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(source->outputPort("out"), t2->inputPort("in")); + + source->setOutputData("out", PortData(std::any(10), PortType::ImageData)); + + EXPECT_EQ(t1->state(), NodeState::Stale); + EXPECT_EQ(t2->state(), NodeState::Stale); +} + +TEST_F(PipelineLibTest, TopologicalSort) +{ + auto* source = new SourceNode(); + source->setLabel("source"); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + t1->setLabel("t1"); + pipeline->addNode(t1); + auto* t2 = new DoubleTransform(); + t2->setLabel("t2"); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + auto sorted = pipeline->topologicalSort(); + EXPECT_EQ(sorted.size(), 3); + + // Source must come before t1, t1 before t2 + int srcIdx = sorted.indexOf(source); + int t1Idx = sorted.indexOf(t1); + int t2Idx = sorted.indexOf(t2); + EXPECT_LT(srcIdx, t1Idx); + EXPECT_LT(t1Idx, t2Idx); +} + +TEST_F(PipelineLibTest, SimpleExecution) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + auto* sink = new CollectorSink(); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + pipeline->createLink(transform->outputPort("out"), sink->inputPort("in")); + + source->setOutputData("out", PortData(std::any(7), PortType::ImageData)); + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_TRUE(sink->consumed); + EXPECT_EQ(sink->lastValue, 14); + EXPECT_EQ(transform->state(), NodeState::Current); + EXPECT_EQ(sink->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, ExecutionFromTarget) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + // Pin t1's output so the value survives end-of-plan and the test + // can read it back directly. Force InMemory explicitly because the + // current TransformNode default is OnDisk, and the int payloads + // these tests use aren't round-trippable through Tvh5Format. + t1->outputPort("out")->setPersistent(true); + t1->outputPort("out")->setPersistenceMode(PersistenceMode::InMemory); + pipeline->addNode(t1); + auto* t2 = new DoubleTransform(); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + source->setOutputData("out", PortData(std::any(3), PortType::ImageData)); + + // Execute only up to t1 + auto* future = pipeline->execute(t1); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(t1->state(), NodeState::Current); + // t2 should still be stale since we only executed up to t1 + EXPECT_EQ(t2->state(), NodeState::Stale); + + // Verify t1's output + EXPECT_EQ(t1->outputPort("out")->data().value(), 6); +} + +TEST_F(PipelineLibTest, ExecutionOrderWithStaleUpstream) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + pipeline->addNode(t1); + auto* t2 = new DoubleTransform(); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + source->setOutputData("out", PortData(std::any(5), PortType::ImageData)); + + auto order = pipeline->executionOrder(t2); + // Should include t1 and t2 (source is Current) + EXPECT_EQ(order.size(), 2); + EXPECT_TRUE(order.contains(t1)); + EXPECT_TRUE(order.contains(t2)); + EXPECT_LT(order.indexOf(t1), order.indexOf(t2)); +} + +TEST_F(PipelineLibTest, BreakpointStopsExecution) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + pipeline->addNode(t1); + auto* t2 = new DoubleTransform(); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + source->setOutputData("out", PortData(std::any(5), PortType::ImageData)); + + // Set breakpoint on t1 + t1->setBreakpoint(true); + + Node* reachedNode = nullptr; + QObject::connect(pipeline, &Pipeline::breakpointReached, + [&reachedNode](Node* n) { reachedNode = n; }); + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_FALSE(future->succeeded()); + EXPECT_EQ(reachedNode, t1); + // t1 should not have been executed + EXPECT_NE(t1->state(), NodeState::Current); + EXPECT_NE(t2->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, TransientDataRelease) +{ + // A transient transform output is kept alive while any consumer + // retains its shared_ptr handle. The sink stashes that handle in + // m_retainedInputs, so the producer port reports hasData() == true + // for as long as the sink is connected. Once the sink is removed, + // its retained input drops, the wrapping heap PortData is freed, + // and the producer port reports hasData() == false. + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + transform->outputPort("out")->setPersistent(false); + pipeline->addNode(transform); + + auto* sink = new CollectorSink(); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + pipeline->createLink(transform->outputPort("out"), sink->inputPort("in")); + + source->setOutputData("out", PortData(std::any(4), PortType::ImageData)); + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + // Sink retains the input handle; transient producer stays alive. + EXPECT_TRUE(transform->outputPort("out")->hasData()); + // Source output is persistent by default — independently alive. + EXPECT_TRUE(source->outputPort("out")->hasData()); + // Sink consumed the data successfully. + EXPECT_TRUE(sink->consumed); + EXPECT_EQ(sink->lastValue, 8); + + // Removing the sink drops its retained handle. With no consumer + // holding, the transient producer port evicts. + auto* transformOutput = transform->outputPort("out"); + pipeline->removeNode(sink); + EXPECT_FALSE(transformOutput->hasData()); +} + +TEST_F(PipelineLibTest, OnDiskEvictsOnLastHandleRelease) +{ + // Use a real volume payload — the OnDisk path round-trips through + // Tvh5Format, which needs an actual vtkImageData (not a wrapped int). + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + + auto* port = source->outputPort("volume"); + port->setPersistenceMode(PersistenceMode::OnDisk); + + ASSERT_TRUE(source->execute()); + ASSERT_TRUE(port->hasData()); + auto originalRange = + port->data().value()->scalarRange(); + + // Simulate an executor cycle: take the strong ref, then let it drop. + // For OnDisk the take moves m_strong out, so when the local handle + // dies the deleter fires and swaps to disk. + { + auto handle = port->take(); + ASSERT_TRUE(handle); + } + + // Port should still report hasData() — it's on disk now, not gone. + EXPECT_TRUE(port->hasData()); + + // data() is non-loading — it should report missing now. + EXPECT_FALSE(port->data().isValid()); + // materialize() triggers a synchronous reload from disk. + auto handle = port->materialize(); + ASSERT_TRUE(handle); + auto reloaded = handle->value(); + ASSERT_TRUE(reloaded && reloaded->isValid()); + auto reloadedRange = reloaded->scalarRange(); + EXPECT_NEAR(originalRange[0], reloadedRange[0], 0.01); + EXPECT_NEAR(originalRange[1], reloadedRange[1], 0.01); +} + +TEST_F(PipelineLibTest, OnDiskReSetDataOverwrites) +{ + // After eviction, calling setData with a different payload should + // overwrite the on-disk content next time it evicts. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + + auto* port = source->outputPort("volume"); + port->setPersistenceMode(PersistenceMode::OnDisk); + + ASSERT_TRUE(source->execute()); + + // First eviction cycle. + { auto h = port->take(); } + ASSERT_TRUE(port->hasData()); + + // Replace with a different volume. + source->setDimensions(6, 6, 6); + ASSERT_TRUE(source->execute()); + auto newRange = port->data().value()->scalarRange(); + + // Second eviction. + { auto h = port->take(); } + ASSERT_TRUE(port->hasData()); + + // Reload should produce the new payload, not the original. + auto handle = port->materialize(); + ASSERT_TRUE(handle); + auto reloaded = handle->value(); + ASSERT_TRUE(reloaded && reloaded->isValid()); + EXPECT_EQ(reloaded->dimensions()[0], 6); + EXPECT_EQ(reloaded->dimensions()[1], 6); + EXPECT_EQ(reloaded->dimensions()[2], 6); + auto reloadedRange = reloaded->scalarRange(); + EXPECT_NEAR(newRange[0], reloadedRange[0], 0.01); + EXPECT_NEAR(newRange[1], reloadedRange[1], 0.01); +} + +TEST_F(PipelineLibTest, OnDiskPortDestructionCleansFile) +{ + // The cache QTemporaryFile is owned by the port; destroying the port + // should remove it. We can't observe the file directly without the + // port's private state, but we can at least exercise the destructor + // path and confirm nothing crashes. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); + port->setPersistenceMode(PersistenceMode::OnDisk); + ASSERT_TRUE(source->execute()); + + { auto h = port->take(); } + ASSERT_TRUE(port->hasData()); + + // Destroy the source node — its port destructor sets m_destroying + // and releases m_strong; QTemporaryFile cleans up the cache file. + pipeline->removeNode(source); +} + +TEST_F(PipelineLibTest, PersistenceModeSwitchInMemoryToTransient) +{ + // InMemory port with no consumer pinning: switching to transient + // must release the port's hold so the data isn't kept alive + // indefinitely. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); // already persistent InMemory + ASSERT_TRUE(source->execute()); + ASSERT_TRUE(port->hasData()); + + port->setPersistent(false); + + EXPECT_FALSE(port->hasData()); +} + +TEST_F(PipelineLibTest, PersistenceModeSwitchInMemoryToOnDisk) +{ + // InMemory → OnDisk with no consumer: the port should release its + // strong ref, which fires the universal deleter; under the new mode + // it writes the payload to disk. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); + ASSERT_TRUE(source->execute()); + auto originalRange = + port->data().value()->scalarRange(); + + port->setPersistenceMode(PersistenceMode::OnDisk); + + // Still reports data — it's on disk now, not gone. + EXPECT_TRUE(port->hasData()); + + // Reload from disk and verify equivalent payload. + auto handle = port->materialize(); + ASSERT_TRUE(handle); + auto reloaded = handle->value(); + ASSERT_TRUE(reloaded && reloaded->isValid()); + auto reloadedRange = reloaded->scalarRange(); + EXPECT_NEAR(originalRange[0], reloadedRange[0], 0.01); + EXPECT_NEAR(originalRange[1], reloadedRange[1], 0.01); +} + +TEST_F(PipelineLibTest, PersistenceModeSwitchOnDiskToInMemory) +{ + // OnDisk with data evicted to disk → switch to InMemory should + // load the data back and pin it. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); + port->setPersistenceMode(PersistenceMode::OnDisk); + ASSERT_TRUE(source->execute()); + + // Force an eviction cycle to land the data on disk. + { auto h = port->take(); } + ASSERT_TRUE(port->hasData()); + + // Now switch back to InMemory — the port should load and pin. + port->setPersistenceMode(PersistenceMode::InMemory); + + EXPECT_TRUE(port->hasData()); + auto reloaded = port->data().value(); + ASSERT_TRUE(reloaded && reloaded->isValid()); +} + +TEST_F(PipelineLibTest, PersistenceModeSwitchOnDiskToTransient) +{ + // OnDisk with data on disk → switch to transient should drop the + // port's hold AND clear the cache file (transient = no persistence). + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); + port->setPersistenceMode(PersistenceMode::OnDisk); + ASSERT_TRUE(source->execute()); + { auto h = port->take(); } + ASSERT_TRUE(port->hasData()); + + port->setPersistent(false); + + EXPECT_FALSE(port->hasData()); +} + +TEST_F(PipelineLibTest, OnDiskStateFileRoundTrip) +{ + // persistenceMode should survive save/load through PipelineStateIO. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); + port->setPersistenceMode(PersistenceMode::OnDisk); + + QJsonObject json; + ASSERT_TRUE(PipelineStateIO::save(pipeline, json)); + + auto newPipeline = std::make_unique(); + ASSERT_TRUE(PipelineStateIO::load(newPipeline.get(), json)); + ASSERT_FALSE(newPipeline->nodes().isEmpty()); + auto* loadedPort = newPipeline->nodes().first()->outputPort("volume"); + ASSERT_TRUE(loadedPort); + EXPECT_TRUE(loadedPort->isPersistent()); + EXPECT_EQ(loadedPort->persistenceMode(), PersistenceMode::OnDisk); +} + +TEST_F(PipelineLibTest, InMemoryStateFileRoundTripUnderOnDiskDefault) +{ + // "persistent: true" with no persistenceMode key means InMemory, but + // a freshly-constructed transform port carries the application-wide + // default. Loading must reset the medium to InMemory instead of + // keeping the construction default (regression: ports saved as + // "Persist in Memory" came back as "Persist on Disk"). Also verify + // an explicitly transient port survives the same trip. + auto& settings = PipelineSettings::instance(); + auto previousDefault = settings.transformPersistenceDefault(); + settings.setTransformPersistenceDefault( + TransformPersistenceDefault::OnDisk); + + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* inMemory = new ThresholdTransform(); + inMemory->setLabel("inMemory"); + pipeline->addNode(inMemory); + auto* transient = new ThresholdTransform(); + transient->setLabel("transient"); + pipeline->addNode(transient); + + auto* inMemoryPort = inMemory->outputPort("mask"); + ASSERT_TRUE(inMemoryPort->isPersistent()); + ASSERT_EQ(inMemoryPort->persistenceMode(), PersistenceMode::OnDisk); + inMemoryPort->setPersistenceMode(PersistenceMode::InMemory); + transient->outputPort("mask")->setPersistent(false); + + QJsonObject json; + ASSERT_TRUE(PipelineStateIO::save(pipeline, json)); + + auto newPipeline = std::make_unique(); + ASSERT_TRUE(PipelineStateIO::load(newPipeline.get(), json)); + OutputPort* loadedInMemory = nullptr; + OutputPort* loadedTransient = nullptr; + for (auto* node : newPipeline->nodes()) { + if (node->label() == QLatin1String("inMemory")) { + loadedInMemory = node->outputPort("mask"); + } else if (node->label() == QLatin1String("transient")) { + loadedTransient = node->outputPort("mask"); + } + } + ASSERT_TRUE(loadedInMemory); + ASSERT_TRUE(loadedTransient); + EXPECT_TRUE(loadedInMemory->isPersistent()); + EXPECT_EQ(loadedInMemory->persistenceMode(), PersistenceMode::InMemory); + EXPECT_FALSE(loadedTransient->isPersistent()); + + settings.setTransformPersistenceDefault(previousDefault); +} + +TEST_F(PipelineLibTest, PersistenceChangeSignals) +{ + // Policy switches must emit persistenceChanged, and residency moves + // performed by the reconcile (evict, reload, drop) must keep + // dataLocationChanged accurate so UI badges track the real state. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); // persistent InMemory + ASSERT_TRUE(source->execute()); + ASSERT_EQ(port->dataLocation(), DataLocation::InMemory); + + int persistenceChanges = 0; + QObject::connect(port, &OutputPort::persistenceChanged, port, + [&persistenceChanges]() { ++persistenceChanges; }); + QList locations; + QObject::connect( + port, &OutputPort::dataLocationChanged, port, + [&locations](DataLocation loc) { locations.append(loc); }); + + // InMemory -> OnDisk: the port unpins, the deleter evicts to disk. + port->setPersistenceMode(PersistenceMode::OnDisk); + EXPECT_EQ(persistenceChanges, 1); + ASSERT_FALSE(locations.isEmpty()); + EXPECT_EQ(locations.last(), DataLocation::OnDisk); + EXPECT_EQ(port->dataLocation(), DataLocation::OnDisk); + + // OnDisk -> InMemory: reloads from the cache file and pins. + locations.clear(); + port->setPersistenceMode(PersistenceMode::InMemory); + EXPECT_EQ(persistenceChanges, 2); + ASSERT_FALSE(locations.isEmpty()); + EXPECT_EQ(locations.last(), DataLocation::InMemory); + EXPECT_EQ(port->dataLocation(), DataLocation::InMemory); + + // Persistent -> transient with no other holder: the data drops. + locations.clear(); + port->setPersistent(false); + EXPECT_EQ(persistenceChanges, 3); + ASSERT_FALSE(locations.isEmpty()); + EXPECT_EQ(locations.last(), DataLocation::None); + EXPECT_FALSE(port->hasData()); + + // Setting the same values again is a no-op: no spurious signals. + port->setPersistent(false); + port->setPersistenceMode(PersistenceMode::InMemory); + EXPECT_EQ(persistenceChanges, 3); +} + +TEST_F(PipelineLibTest, TransientDropEmitsDataLocationNone) +{ + // When the last consumer of a transient port's payload drops its + // handle, the universal deleter must report the payload as gone so + // residency cues (RAM badge) don't keep showing freed data. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + auto* port = source->outputPort("volume"); + port->setPersistent(false); + ASSERT_TRUE(source->execute()); + + QList locations; + QObject::connect( + port, &OutputPort::dataLocationChanged, port, + [&locations](DataLocation loc) { locations.append(loc); }); + + // Executor-style handoff: take the strong ref out of the port, then + // drop it as the last holder. + { auto handle = port->take(); } + + ASSERT_FALSE(locations.isEmpty()); + EXPECT_EQ(locations.last(), DataLocation::None); + EXPECT_FALSE(port->hasData()); +} + +TEST_F(PipelineLibTest, FanInMultipleInputs) +{ + auto* source1 = new SourceNode(); + source1->addOutput("out", PortType::ImageData); + pipeline->addNode(source1); + + auto* source2 = new SourceNode(); + source2->addOutput("out", PortType::ImageData); + pipeline->addNode(source2); + + auto* add = new AddTransform(); + pipeline->addNode(add); + + pipeline->createLink(source1->outputPort("out"), add->inputPort("a")); + pipeline->createLink(source2->outputPort("out"), add->inputPort("b")); + + source1->setOutputData("out", PortData(std::any(10), PortType::ImageData)); + source2->setOutputData("out", PortData(std::any(20), PortType::ImageData)); + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(add->state(), NodeState::Current); + EXPECT_EQ(add->outputPort("out")->data().value(), 30); +} + +TEST_F(PipelineLibTest, PipelineValid) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + + EXPECT_TRUE(pipeline->isValid()); +} + +TEST_F(PipelineLibTest, RootsDetection) +{ + auto* source1 = new SourceNode(); + source1->addOutput("out", PortType::ImageData); + auto* source2 = new SourceNode(); + source2->addOutput("out", PortType::ImageData); + auto* transform = new DoubleTransform(); + + pipeline->addNode(source1); + pipeline->addNode(source2); + pipeline->addNode(transform); + + pipeline->createLink(source1->outputPort("out"), transform->inputPort("in")); + + auto r = pipeline->roots(); + EXPECT_EQ(r.size(), 2); + EXPECT_TRUE(r.contains(source1)); + EXPECT_TRUE(r.contains(source2)); + EXPECT_FALSE(r.contains(transform)); +} + +TEST_F(PipelineLibTest, RemoveNode) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + auto* transform = new DoubleTransform(); + + pipeline->addNode(source); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + + EXPECT_EQ(pipeline->nodes().size(), 2); + EXPECT_EQ(pipeline->links().size(), 1); + + pipeline->removeNode(transform); + + EXPECT_EQ(pipeline->nodes().size(), 1); + EXPECT_EQ(pipeline->links().size(), 0); +} + +TEST_F(PipelineLibTest, RemoveLink) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + auto* transform = new DoubleTransform(); + + pipeline->addNode(source); + pipeline->addNode(transform); + auto* link = + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + + EXPECT_EQ(pipeline->links().size(), 1); + EXPECT_NE(transform->inputPort("in")->link(), nullptr); + + pipeline->removeLink(link); + + EXPECT_EQ(pipeline->links().size(), 0); + EXPECT_EQ(transform->inputPort("in")->link(), nullptr); +} + +TEST_F(PipelineLibTest, UpstreamDownstreamNodes) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + auto* transform = new DoubleTransform(); + auto* sink = new CollectorSink(); + + pipeline->addNode(source); + pipeline->addNode(transform); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + pipeline->createLink(transform->outputPort("out"), sink->inputPort("in")); + + EXPECT_EQ(source->downstreamNodes().size(), 1); + EXPECT_EQ(source->downstreamNodes().first(), transform); + EXPECT_EQ(source->upstreamNodes().size(), 0); + + EXPECT_EQ(transform->upstreamNodes().size(), 1); + EXPECT_EQ(transform->upstreamNodes().first(), source); + EXPECT_EQ(transform->downstreamNodes().size(), 1); + EXPECT_EQ(transform->downstreamNodes().first(), sink); + + EXPECT_EQ(sink->upstreamNodes().size(), 1); + EXPECT_EQ(sink->upstreamNodes().first(), transform); + EXPECT_EQ(sink->downstreamNodes().size(), 0); +} + +TEST_F(PipelineLibTest, Signals) +{ + int nodeAddedCount = 0; + int linkCreatedCount = 0; + + QObject::connect(pipeline, &Pipeline::nodeAdded, + [&nodeAddedCount](Node*) { nodeAddedCount++; }); + QObject::connect(pipeline, &Pipeline::linkCreated, + [&linkCreatedCount](Link*) { linkCreatedCount++; }); + + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + auto* transform = new DoubleTransform(); + + pipeline->addNode(source); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + + EXPECT_EQ(nodeAddedCount, 2); + EXPECT_EQ(linkCreatedCount, 1); +} + +TEST_F(PipelineLibTest, ReplaceLinkOnInput) +{ + auto* source1 = new SourceNode(); + source1->addOutput("out", PortType::ImageData); + auto* source2 = new SourceNode(); + source2->addOutput("out", PortType::ImageData); + auto* transform = new DoubleTransform(); + + pipeline->addNode(source1); + pipeline->addNode(source2); + pipeline->addNode(transform); + + auto* link1 = pipeline->createLink(source1->outputPort("out"), + transform->inputPort("in")); + EXPECT_NE(link1, nullptr); + EXPECT_EQ(pipeline->links().size(), 1); + + // Creating a new link to the same input should replace the old one + auto* link2 = pipeline->createLink(source2->outputPort("out"), + transform->inputPort("in")); + EXPECT_NE(link2, nullptr); + EXPECT_EQ(pipeline->links().size(), 1); + EXPECT_EQ(transform->inputPort("in")->link(), link2); +} + +// --- VolumeData tests --- + +TEST_F(PipelineLibTest, VolumeDataBasics) +{ + VolumeData vol; + EXPECT_FALSE(vol.isValid()); + EXPECT_EQ(vol.numberOfComponents(), 0); + + auto dims = vol.dimensions(); + EXPECT_EQ(dims[0], 0); +} + +TEST_F(PipelineLibTest, VolumeDataMetadata) +{ + VolumeData vol; + vol.setLabel("Test Volume"); + vol.setUnits("nm"); + EXPECT_EQ(vol.label(), "Test Volume"); + EXPECT_EQ(vol.units(), "nm"); +} + +// --- SphereSource tests --- + +TEST_F(PipelineLibTest, SphereSourceGeneratesVolume) +{ + auto* source = new SphereSource(); + pipeline->addNode(source); + + EXPECT_TRUE(source->execute()); + EXPECT_EQ(source->state(), NodeState::Current); + + auto portData = source->outputPort("volume")->data(); + EXPECT_TRUE(portData.isValid()); + EXPECT_EQ(portData.type(), PortType::ImageData); + + auto volume = portData.value(); + EXPECT_TRUE(volume->isValid()); + + auto dims = volume->dimensions(); + EXPECT_EQ(dims[0], 32); + EXPECT_EQ(dims[1], 32); + EXPECT_EQ(dims[2], 32); + EXPECT_EQ(volume->label(), "Sphere"); +} + +TEST_F(PipelineLibTest, SphereSourceCustomDimensions) +{ + auto* source = new SphereSource(); + source->setDimensions(16, 16, 16); + pipeline->addNode(source); + + source->execute(); + + auto volume = source->outputPort("volume")->data().value(); + auto dims = volume->dimensions(); + EXPECT_EQ(dims[0], 16); + EXPECT_EQ(dims[1], 16); + EXPECT_EQ(dims[2], 16); +} + +TEST_F(PipelineLibTest, SphereSourceScalarRange) +{ + auto* source = new SphereSource(); + source->setDimensions(16, 16, 16); + pipeline->addNode(source); + + source->execute(); + + auto volume = source->outputPort("volume")->data().value(); + auto range = volume->scalarRange(); + // Signed distance: negative inside, positive outside + EXPECT_LT(range[0], 0.0); + EXPECT_GT(range[1], 0.0); +} + +// --- ThresholdTransform tests --- + +TEST_F(PipelineLibTest, ThresholdTransformBinaryMask) +{ + auto* source = new SphereSource(); + source->setDimensions(16, 16, 16); + pipeline->addNode(source); + + auto* threshold = new ThresholdTransform(); + // Inside the sphere: signed distance <= 0 + threshold->setMinValue(-100.0); + threshold->setMaxValue(0.0); + pipeline->addNode(threshold); + + pipeline->createLink(source->outputPort("volume"), + threshold->inputPort("volume")); + + source->execute(); + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(threshold->state(), NodeState::Current); + + auto maskData = threshold->outputPort("mask")->data().value(); + EXPECT_TRUE(maskData->isValid()); + + auto range = maskData->scalarRange(); + EXPECT_EQ(range[0], 0.0); + EXPECT_EQ(range[1], 1.0); +} + +// --- VolumeStatsSink tests --- + +TEST_F(PipelineLibTest, VolumeStatsSinkComputesStats) +{ + auto* source = new SphereSource(); + source->setDimensions(16, 16, 16); + pipeline->addNode(source); + + auto* stats = new VolumeStatsSink(); + pipeline->addNode(stats); + + pipeline->createLink(source->outputPort("volume"), + stats->inputPort("volume")); + + source->execute(); + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(stats->hasResults()); + EXPECT_EQ(stats->voxelCount(), 16 * 16 * 16); + EXPECT_LT(stats->min(), 0.0); + EXPECT_GT(stats->max(), 0.0); + EXPECT_EQ(stats->state(), NodeState::Current); +} + +// --- End-to-end: SphereSource → ThresholdTransform → VolumeStatsSink --- + +TEST_F(PipelineLibTest, EndToEndSphereThresholdStats) +{ + auto* source = new SphereSource(); + source->setDimensions(16, 16, 16); + pipeline->addNode(source); + + auto* threshold = new ThresholdTransform(); + threshold->setMinValue(-100.0); + threshold->setMaxValue(0.0); + pipeline->addNode(threshold); + + auto* stats = new VolumeStatsSink(); + pipeline->addNode(stats); + + pipeline->createLink(source->outputPort("volume"), + threshold->inputPort("volume")); + pipeline->createLink(threshold->outputPort("mask"), + stats->inputPort("volume")); + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(source->state(), NodeState::Current); + EXPECT_EQ(threshold->state(), NodeState::Current); + EXPECT_EQ(stats->state(), NodeState::Current); + + EXPECT_TRUE(stats->hasResults()); + // Mask is binary: min=0, max=1 + EXPECT_EQ(stats->min(), 0.0); + EXPECT_EQ(stats->max(), 1.0); + // Mean should be between 0 and 1 (fraction of voxels inside sphere) + EXPECT_GT(stats->mean(), 0.0); + EXPECT_LT(stats->mean(), 1.0); +} + +// --- PipelineStripWidget tests --- + +TEST_F(PipelineLibTest, WidgetBasicInstantiation) +{ + PipelineStripWidget widget; + EXPECT_EQ(widget.pipeline(), nullptr); + EXPECT_EQ(widget.selectedNode(), nullptr); + EXPECT_EQ(widget.selectedPort(), nullptr); + + widget.setPipeline(pipeline); + EXPECT_EQ(widget.pipeline(), pipeline); +} + +TEST_F(PipelineLibTest, WidgetLayoutLinear) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + transform->setLabel("Double"); + pipeline->addNode(transform); + + auto* sink = new CollectorSink(); + sink->setLabel("Collector"); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + pipeline->createLink(transform->outputPort("out"), sink->inputPort("in")); + + PipelineStripWidget widget; + widget.setPipeline(pipeline); + widget.resize(250, 400); + widget.rebuildLayout(); + + // Should have 3 node cards (single-output nodes are collapsed by default) + EXPECT_EQ(widget.selectedNode(), nullptr); + + // Verify the widget has a reasonable size hint + auto hint = widget.sizeHint(); + EXPECT_GT(hint.height(), 0); + EXPECT_GT(hint.width(), 0); +} + +TEST_F(PipelineLibTest, WidgetLayoutMultiOutput) +{ + // Create a node with multiple outputs + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("vol", PortType::ImageData); + source->addOutput("table", PortType::Table); + pipeline->addNode(source); + + PipelineStripWidget widget; + widget.setPipeline(pipeline); + widget.resize(250, 400); + widget.rebuildLayout(); + + // Multi-output node should automatically show port sub-cards + // 1 node card + 2 port cards = 3 layout items + auto hint = widget.sizeHint(); + EXPECT_GT(hint.height(), 0); +} + +TEST_F(PipelineLibTest, WidgetExpandCollapse) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + PipelineStripWidget widget; + widget.setPipeline(pipeline); + widget.resize(250, 400); + + // Single-output: collapsed by default + EXPECT_FALSE(widget.isExpanded(source)); + + // Expand + widget.setExpanded(source, true); + EXPECT_TRUE(widget.isExpanded(source)); + + // Collapse + widget.setExpanded(source, false); + EXPECT_FALSE(widget.isExpanded(source)); +} + +TEST_F(PipelineLibTest, WidgetSignalWiring) +{ + PipelineStripWidget widget; + widget.setPipeline(pipeline); + widget.resize(250, 400); + + // Adding nodes should trigger layout rebuild + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto hint1 = widget.sizeHint(); + + auto* transform = new DoubleTransform(); + transform->setLabel("Double"); + pipeline->addNode(transform); + + auto hint2 = widget.sizeHint(); + + // After adding a second node, size hint should increase + EXPECT_GT(hint2.height(), hint1.height()); +} + +TEST_F(PipelineLibTest, WidgetFanIn) +{ + auto* source1 = new SourceNode(); + source1->setLabel("Source A"); + source1->addOutput("out", PortType::ImageData); + pipeline->addNode(source1); + + auto* source2 = new SourceNode(); + source2->setLabel("Source B"); + source2->addOutput("out", PortType::ImageData); + pipeline->addNode(source2); + + auto* add = new AddTransform(); + add->setLabel("Merge"); + pipeline->addNode(add); + + pipeline->createLink(source1->outputPort("out"), add->inputPort("a")); + pipeline->createLink(source2->outputPort("out"), add->inputPort("b")); + + PipelineStripWidget widget; + widget.setPipeline(pipeline); + widget.resize(250, 400); + widget.rebuildLayout(); + + auto hint = widget.sizeHint(); + EXPECT_GT(hint.height(), 0); +} + +// --- LegacyPythonTransform tests --- + +class PipelinePythonTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + if (!Py_IsInitialized()) { + py::initialize_interpreter(); + s_ownInterpreter = true; + } + } + + static void TearDownTestSuite() + { + if (s_ownInterpreter) { + py::finalize_interpreter(); + s_ownInterpreter = false; + } + } + + void SetUp() override { pipeline = new Pipeline(); } + void TearDown() override { delete pipeline; } + + static QString readFile(const QString& path) + { + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return {}; + } + QTextStream stream(&file); + return stream.readAll(); + } + + Pipeline* pipeline; + static bool s_ownInterpreter; +}; + +bool PipelinePythonTest::s_ownInterpreter = false; + +TEST_F(PipelinePythonTest, AddConstantOperator) +{ + // Load AddConstant JSON and Python script + QString pythonDir = TOMVIZ_PYTHON_DIR; + QString jsonStr = readFile(pythonDir + "/AddConstant.json"); + QString scriptStr = readFile(pythonDir + "/AddConstant.py"); + ASSERT_FALSE(jsonStr.isEmpty()); + ASSERT_FALSE(scriptStr.isEmpty()); + + // Create a 4x4x4 volume with all voxels set to 5.0 + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + source->execute(); + + // Get the source volume and record its scalar range + auto inputVolume = + source->outputPort("volume")->data().value(); + auto inputRange = inputVolume->scalarRange(); + + // Create the legacy Python transform + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + transform->setParameter("constant", 10.0); + pipeline->addNode(transform); + + EXPECT_EQ(transform->label(), "Add Constant"); + EXPECT_EQ(transform->operatorName(), "AddConstant"); + EXPECT_EQ(transform->parameter("constant").toDouble(), 10.0); + + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(transform->state(), NodeState::Current); + + auto outputData = + transform->outputPort("volume")->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + + auto outputRange = outputData->scalarRange(); + + // The output range should be shifted by +10.0 + EXPECT_NEAR(outputRange[0], inputRange[0] + 10.0, 0.01); + EXPECT_NEAR(outputRange[1], inputRange[1] + 10.0, 0.01); +} + +TEST_F(PipelinePythonTest, JSONParameterDefaults) +{ + QString jsonStr = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + { "name": "threshold", "type": "double", "default": 0.5 }, + { "name": "iterations", "type": "int", "default": 10 }, + { "name": "verbose", "type": "bool", "default": true } + ] + })"; + + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + + EXPECT_EQ(transform->label(), "Test Operator"); + EXPECT_EQ(transform->operatorName(), "TestOp"); + EXPECT_EQ(transform->parameter("threshold").toDouble(), 0.5); + EXPECT_EQ(transform->parameter("iterations").toInt(), 10); + EXPECT_EQ(transform->parameter("verbose").toBool(), true); + + // Override a parameter + transform->setParameter("threshold", 1.5); + EXPECT_EQ(transform->parameter("threshold").toDouble(), 1.5); + + delete transform; +} + +TEST_F(PipelinePythonTest, IntArgumentsRoundTripAsInt) +{ + // Regression: Qt6 collapses every JSON number into QVariant, + // so deserialize used to box ``int``/``enumeration`` arguments as + // doubles, which then reached Python as a float — breaking operators + // that index sequences with the parameter. The fix coerces saved + // arguments using each parameter's declared type from the operator + // JSON description. + QString jsonStr = R"({ + "name": "AxisOp", + "label": "Axis Op", + "parameters": [ + { "name": "axis", "type": "enumeration", "default": 2 }, + { "name": "iterations", "type": "int", "default": 1 } + ] + })"; + + // Minimal script: stash the runtime types of the two int-typed + // parameters in module-level globals so the test can inspect them. + QString script = + "import builtins\n" + "_axis_type = None\n" + "_iterations_type = None\n" + "def transform(dataset, axis=0, iterations=0):\n" + " builtins._axis_type = type(axis).__name__\n" + " builtins._iterations_type = type(iterations).__name__\n"; + + // Round-trip through deserialize so we exercise the saved-argument + // path (where the Qt6 double-coercion bug used to live). + QJsonObject saved; + saved["description"] = jsonStr; + saved["script"] = script; + QJsonObject args; + args["axis"] = 1; + args["iterations"] = 5; + saved["arguments"] = args; + + auto* transform = new LegacyPythonTransform(); + ASSERT_TRUE(transform->deserialize(saved)); + EXPECT_EQ(transform->parameter("axis").typeId(), + static_cast(QMetaType::Int)); + EXPECT_EQ(transform->parameter("iterations").typeId(), + static_cast(QMetaType::Int)); + EXPECT_EQ(transform->parameter("axis").toInt(), 1); + EXPECT_EQ(transform->parameter("iterations").toInt(), 5); + + // End-to-end: drive the transform and verify Python received int. + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + ASSERT_TRUE(source->execute()); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(transform->state(), NodeState::Current); + + py::module_ builtins = py::module_::import("builtins"); + EXPECT_EQ(builtins.attr("_axis_type").cast(), "int"); + EXPECT_EQ(builtins.attr("_iterations_type").cast(), "int"); +} + +TEST_F(PipelinePythonTest, JSONResultsPorts) +{ + QString jsonStr = R"({ + "name": "SegOp", + "label": "Segment", + "results": [ + { "name": "segmentation_table", "type": "table" }, + { "name": "molecule_result", "type": "molecule" } + ] + })"; + + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + + // Should have volume output plus two result ports + EXPECT_NE(transform->outputPort("volume"), nullptr); + EXPECT_NE(transform->outputPort("segmentation_table"), nullptr); + EXPECT_NE(transform->outputPort("molecule_result"), nullptr); + + delete transform; +} + +// --- ReaderSourceNode tests --- + +TEST_F(PipelineLibTest, ReaderSourceNodeVTIRoundTrip) +{ + // Create a sphere source and execute to get volume data + auto* sphereSource = new SphereSource(); + sphereSource->setDimensions(8, 8, 8); + pipeline->addNode(sphereSource); + ASSERT_TRUE(sphereSource->execute()); + + auto originalVolume = + sphereSource->outputPort("volume")->data().value(); + ASSERT_TRUE(originalVolume && originalVolume->isValid()); + + auto originalDims = originalVolume->dimensions(); + auto originalRange = originalVolume->scalarRange(); + + // Write to a temp VTI file + QTemporaryFile tmpFile("XXXXXX.vti"); + tmpFile.setAutoRemove(true); + ASSERT_TRUE(tmpFile.open()); + QString tmpPath = tmpFile.fileName(); + tmpFile.close(); + + auto writer = vtkSmartPointer::New(); + writer->SetFileName(tmpPath.toStdString().c_str()); + writer->SetInputData(originalVolume->imageData()); + writer->Write(); + + // Create ReaderSourceNode and read the VTI file + auto* readerNode = new ReaderSourceNode(); + pipeline->addNode(readerNode); + readerNode->setFileNames({ tmpPath }); + + // Execute — readImageData handles VTI directly. + ASSERT_TRUE(readerNode->execute()); + EXPECT_EQ(readerNode->state(), NodeState::Current); + + // Verify output - VTI data without tilt angles is typed as Volume. + auto portData = readerNode->outputPort("volume")->data(); + EXPECT_TRUE(portData.isValid()); + EXPECT_EQ(portData.type(), PortType::Volume); + + auto readVolume = portData.value(); + ASSERT_TRUE(readVolume && readVolume->isValid()); + + auto readDims = readVolume->dimensions(); + EXPECT_EQ(readDims[0], originalDims[0]); + EXPECT_EQ(readDims[1], originalDims[1]); + EXPECT_EQ(readDims[2], originalDims[2]); + + auto readRange = readVolume->scalarRange(); + EXPECT_NEAR(readRange[0], originalRange[0], 0.01); + EXPECT_NEAR(readRange[1], originalRange[1], 0.01); +} + +TEST_F(PipelineLibTest, ReaderSourceNodeNoFilesFails) +{ + auto* readerNode = new ReaderSourceNode(); + pipeline->addNode(readerNode); + + // No files set — execute should fail. + EXPECT_FALSE(readerNode->execute()); +} + +TEST_F(PipelineLibTest, ReaderSourceNodeLabelFromFileName) +{ + auto* readerNode = new ReaderSourceNode(); + pipeline->addNode(readerNode); + readerNode->setFileNames({ "/path/to/my_data.vti" }); + + EXPECT_EQ(readerNode->label(), "my_data.vti"); +} + +TEST_F(PipelinePythonTest, ReaderSourceNodePythonReader) +{ + // Write a VTI file via VTK (since NumpyReader depends on tomviz internals + // that aren't available in standalone tests, we create a custom Python + // reader module that wraps vtkXMLImageDataReader). + + // First create a VTI file to read + vtkNew imageData; + imageData->SetDimensions(4, 5, 6); + imageData->AllocateScalars(VTK_FLOAT, 1); + + QTemporaryFile tmpFile("XXXXXX.vti"); + tmpFile.setAutoRemove(true); + ASSERT_TRUE(tmpFile.open()); + QString tmpPath = tmpFile.fileName(); + tmpFile.close(); + + auto writer = vtkSmartPointer::New(); + writer->SetFileName(tmpPath.toStdString().c_str()); + writer->SetInputData(imageData.Get()); + writer->Write(); + + // Register a Python reader module that reads VTI files + { + py::gil_scoped_acquire gil; + py::exec(R"( +import types, sys +mod = types.ModuleType("test_vti_reader") +mod.__dict__['__file__'] = '' + +exec(''' +from vtk import vtkXMLImageDataReader + +class TestVTIReader: + def read(self, path): + reader = vtkXMLImageDataReader() + reader.SetFileName(path) + reader.Update() + return reader.GetOutput() +''', mod.__dict__) + +sys.modules["test_vti_reader"] = mod +)"); + } + + // Create ReaderSourceNode; it routes through readImageData, which + // picks VTK's vtkXMLImageDataReader for the .vti extension. + auto* readerNode = new ReaderSourceNode(); + readerNode->setFileNames({ tmpPath }); + pipeline->addNode(readerNode); + + ASSERT_TRUE(readerNode->execute()); + + auto portData = readerNode->outputPort("volume")->data(); + EXPECT_TRUE(portData.isValid()); + + auto volume = portData.value(); + ASSERT_TRUE(volume && volume->isValid()); + + auto dims = volume->dimensions(); + EXPECT_EQ(dims[0], 4); + EXPECT_EQ(dims[1], 5); + EXPECT_EQ(dims[2], 6); +} + +// --- Schema-v2 PythonTransform / PythonSource smoke tests --- + +TEST_F(PipelinePythonTest, PythonTransformV2) +{ + // Schema-v2 transform: declares an ImageData input "volume" and an + // ImageData output "volume", takes a `factor` double parameter, and + // multiplies all voxels by factor. + // + // The output is declared persistent so the test can read it back + // directly via the port: without a downstream consumer this output + // would otherwise be a leaf and remain pinned anyway, but pinning + // explicitly makes the intent clear. + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "MultiplyBy", + "label": "Multiply By", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData", "persistent": true}], + "parameters": [ + {"name": "factor", "type": "double", "default": 1.0} + ] + })"; + QString scriptStr = R"( +import tomviz.nodes + +class MultiplyBy(tomviz.nodes.TransformNode): + def transform(self, inputs, factor=1.0): + ds = inputs["volume"] + ds.active_scalars = ds.active_scalars * factor + return {"volume": ds} +)"; + + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + source->execute(); + auto inputRange = + source->outputPort("volume")->data().value() + ->scalarRange(); + + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + transform->setParameter("factor", 2.0); + pipeline->addNode(transform); + + EXPECT_EQ(transform->label(), "Multiply By"); + EXPECT_EQ(transform->operatorName(), "MultiplyBy"); + EXPECT_EQ(transform->parameter("factor").toDouble(), 2.0); + + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(transform->state(), NodeState::Current); + + auto outputData = + transform->outputPort("volume")->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + auto outputRange = outputData->scalarRange(); + + EXPECT_NEAR(outputRange[0], inputRange[0] * 2.0, 0.01); + EXPECT_NEAR(outputRange[1], inputRange[1] * 2.0, 0.01); +} + +TEST_F(PipelinePythonTest, PythonTransformV2DefaultsToTransformNodeDefault) +{ + // Schema-v2 convention: an omitted `persistent` field defers to the + // host node-class's default. Currently TransformNode defaults to + // OnDisk persistent (TEMPORARY rollout — flip this test back when + // TransformNode::addOutput goes back to transient). + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "Identity", + "inputs": [{"name": "in", "type": "ImageData"}], + "outputs": [{"name": "out", "type": "ImageData"}] + })"; + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + auto* port = transform->outputPort("out"); + EXPECT_TRUE(port->isPersistent()); + EXPECT_EQ(port->persistenceMode(), PersistenceMode::OnDisk); + delete transform; +} + +TEST_F(PipelinePythonTest, PythonTransformV2RejectsSourceShape) +{ + // A v2 description with empty inputs is source-shaped; routing + // through AddPythonTransformReaction is rejected at construction. + // Verify at the node level that the parsing still records zero + // inputs (the reaction enforces the policy; the node itself is + // permissive enough for state-file load). + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "MisRoutedSource", + "inputs": [], + "outputs": [{"name": "out", "type": "ImageData"}] + })"; + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + EXPECT_EQ(transform->inputPorts().size(), 0); + delete transform; +} + +TEST_F(PipelinePythonTest, PythonTransformV2ExternalOnlyInstallsExecutor) +{ + // An externalOnly description defaults freshly created nodes to the + // External executor (with an empty env path for the user to fill in). + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "NeedsTorch", + "externalOnly": true, + "inputs": [{"name": "in", "type": "ImageData"}], + "outputs": [{"name": "out", "type": "ImageData"}] + })"; + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + auto* executor = + qobject_cast(transform->nodeExecutor()); + ASSERT_NE(executor, nullptr); + EXPECT_TRUE(executor->envPath().isEmpty()); + delete transform; + + // Without the flag (and without tomviz_pipeline_env) no executor is + // installed. + QString plainJson = R"({ + "schemaVersion": 2, + "name": "Plain", + "inputs": [{"name": "in", "type": "ImageData"}], + "outputs": [{"name": "out", "type": "ImageData"}] + })"; + auto* plain = new PythonTransform(); + plain->setJSONDescription(plainJson); + EXPECT_EQ(plain->nodeExecutor(), nullptr); + delete plain; +} + +TEST_F(PipelinePythonTest, LegacyPythonTransformExternalOnlyInstallsExecutor) +{ + // Same behavior for schema-v1 descriptions (e.g. SAM2Segment3D.json). + QString jsonStr = R"({ + "name": "NeedsTorchLegacy", + "label": "Needs Torch", + "externalOnly": true + })"; + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + auto* executor = + qobject_cast(transform->nodeExecutor()); + ASSERT_NE(executor, nullptr); + EXPECT_TRUE(executor->envPath().isEmpty()); + delete transform; +} + +TEST_F(PipelinePythonTest, PythonSourceV2) +{ + // Schema-v2 source: declares an ImageData output "volume" and + // produces a 3x3x3 vtkImageData filled with a constant value. + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "ConstantVolume", + "label": "Constant Volume", + "outputs": [ + {"name": "volume", "type": "ImageData", "persistent": true} + ], + "parameters": [ + {"name": "value", "type": "double", "default": 7.0} + ] + })"; + QString scriptStr = R"( +import tomviz.nodes +import numpy as np +from vtk import vtkImageData +from vtk.util.numpy_support import numpy_to_vtk +from tomviz.internal_dataset import Dataset + +class ConstantVolume(tomviz.nodes.SourceNode): + def produce(self, value=0.0): + img = vtkImageData() + img.SetDimensions(3, 3, 3) + arr = np.full((3, 3, 3), value, dtype=np.float32).ravel(order='F') + vtk_arr = numpy_to_vtk(arr, deep=True) + vtk_arr.SetName("Scalars") + img.GetPointData().SetScalars(vtk_arr) + return {"volume": Dataset(img)} +)"; + + auto* source = new PythonSource(); + source->setJSONDescription(jsonStr); + source->setScript(scriptStr); + source->setParameter("value", 42.0); + pipeline->addNode(source); + + EXPECT_EQ(source->label(), "Constant Volume"); + EXPECT_EQ(source->operatorName(), "ConstantVolume"); + EXPECT_TRUE(source->outputPort("volume")->isPersistent()); + + ASSERT_TRUE(source->execute()); + EXPECT_EQ(source->state(), NodeState::Current); + + auto outputData = + source->outputPort("volume")->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + + auto dims = outputData->dimensions(); + EXPECT_EQ(dims[0], 3); + EXPECT_EQ(dims[1], 3); + EXPECT_EQ(dims[2], 3); + auto range = outputData->scalarRange(); + EXPECT_NEAR(range[0], 42.0, 0.01); + EXPECT_NEAR(range[1], 42.0, 0.01); +} + +TEST_F(PipelinePythonTest, PythonTransformV2RoundTripsState) +{ + // Round-trip a v2 transform through serialize/deserialize and + // verify parameters and JSON description survive. + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "MultiplyBy", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [{"name": "factor", "type": "double", "default": 1.0}] + })"; + QString scriptStr = "import tomviz.nodes\n" + "class MultiplyBy(tomviz.nodes.TransformNode):\n" + " def transform(self, inputs, factor=1.0):\n" + " return {}\n"; + + auto* original = new PythonTransform(); + original->setJSONDescription(jsonStr); + original->setScript(scriptStr); + original->setParameter("factor", 3.5); + original->setLabel("Multiply x3.5"); + + auto saved = original->serialize(); + delete original; + + auto* restored = new PythonTransform(); + restored->deserialize(saved); + + EXPECT_EQ(restored->label(), "Multiply x3.5"); + EXPECT_EQ(restored->scriptSource(), scriptStr); + EXPECT_EQ(restored->parameter("factor").toDouble(), 3.5); + EXPECT_EQ(restored->operatorName(), "MultiplyBy"); + ASSERT_TRUE(restored->inputPort("volume")); + ASSERT_TRUE(restored->outputPort("volume")); + delete restored; +} + +TEST_F(PipelinePythonTest, InvertDataV2OperatorEndToEnd) +{ + // Loads the real on-disk InvertData.json + InvertData.py (the first + // operator migrated to schema-v2) and runs it over a SphereSource to + // verify the migration is wired correctly end-to-end. + QString pythonDir = TOMVIZ_PYTHON_DIR; + QString jsonStr = readFile(pythonDir + "/InvertData.json"); + QString scriptStr = readFile(pythonDir + "/InvertData.py"); + ASSERT_FALSE(jsonStr.isEmpty()); + ASSERT_FALSE(scriptStr.isEmpty()); + // Sanity: this really is a v2 description. + EXPECT_TRUE(jsonStr.contains("\"schemaVersion\": 2")); + + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + source->execute(); + auto inputRange = + source->outputPort("volume")->data().value() + ->scalarRange(); + + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + // Mark the output persistent so the test can read it back directly + // off the port (no downstream sink in this test). + transform->outputPort("volume")->setPersistent(true); + pipeline->addNode(transform); + + EXPECT_EQ(transform->label(), "Invert Data"); + EXPECT_EQ(transform->operatorName(), "InvertData"); + EXPECT_TRUE(transform->supportsCancelingMidExecution()); + + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(transform->state(), NodeState::Current); + + auto outputData = + transform->outputPort("volume")->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + auto outputRange = outputData->scalarRange(); + + // Inversion: out_min = in_max - in_max + in_min = in_min, + // out_max = in_max - in_min + in_min = in_max, + // i.e. range bounds are preserved (each voxel v becomes max - v + + // min, but the set of values is the same set). + EXPECT_NEAR(outputRange[0], inputRange[0], 0.01); + EXPECT_NEAR(outputRange[1], inputRange[1], 0.01); +} + +TEST_F(PipelinePythonTest, PythonTransformV2SaveLoadReExecute) +{ + // Full save → load → re-execute round trip: build a v2 transform, + // execute it, serialize, reconstruct from the serialized form into + // a fresh node, re-wire to a fresh source, re-execute, and verify + // the second execution produces the same numeric output as the + // first. Catches issues that the structural-only round-trip test + // (PythonTransformV2RoundTripsState) wouldn't surface — e.g. a + // backend field that survives serialize but isn't actually consulted + // on a re-loaded instance, or a port-type / persistency mismatch + // that breaks the second execution. + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "MultiplyBy", + "label": "Multiply By", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData", "persistent": true}], + "parameters": [ + {"name": "factor", "type": "double", "default": 1.0} + ] + })"; + QString scriptStr = R"( +import tomviz.nodes + +class MultiplyBy(tomviz.nodes.TransformNode): + def transform(self, inputs, factor=1.0): + ds = inputs["volume"] + return {"volume": ds.apply_to_each_scalar_array(lambda a: a * factor)} +)"; + + // ---- First execution ----------------------------------------------- + + auto* source1 = new SphereSource(); + source1->setDimensions(4, 4, 4); + pipeline->addNode(source1); + source1->execute(); + auto inputRange = + source1->outputPort("volume")->data().value() + ->scalarRange(); + + auto* transform1 = new PythonTransform(); + transform1->setJSONDescription(jsonStr); + transform1->setScript(scriptStr); + transform1->setParameter("factor", 5.0); + transform1->setLabel("Custom Label"); + pipeline->addNode(transform1); + pipeline->createLink(source1->outputPort("volume"), + transform1->inputPort("volume")); + + auto* future1 = pipeline->execute(); + EXPECT_TRUE(future1->isFinished()); + EXPECT_EQ(transform1->state(), NodeState::Current); + + auto firstOutput = + transform1->outputPort("volume")->data().value(); + ASSERT_TRUE(firstOutput && firstOutput->isValid()); + auto firstRange = firstOutput->scalarRange(); + EXPECT_NEAR(firstRange[0], inputRange[0] * 5.0, 0.01); + EXPECT_NEAR(firstRange[1], inputRange[1] * 5.0, 0.01); + + // ---- Serialize the live transform ---------------------------------- + + QJsonObject saved = transform1->serialize(); + + // ---- Tear down and rebuild from the serialized blob ---------------- + + delete pipeline; + pipeline = new Pipeline(); + + // Mirror the routing the state-file loader does: the saved 'type' + // string would have been "transform.python" — instantiate via + // NodeFactory by name to check the registration too. + NodeFactory::registerBuiltins(); + auto* restoredNode = NodeFactory::create( + QStringLiteral("transform.python")); + ASSERT_TRUE(restoredNode); + auto* transform2 = qobject_cast(restoredNode); + ASSERT_TRUE(transform2); + + ASSERT_TRUE(transform2->deserialize(saved)); + + // Verify the persistent state survives round-trip end-to-end. + EXPECT_EQ(transform2->label(), "Custom Label"); + EXPECT_EQ(transform2->parameter("factor").toDouble(), 5.0); + ASSERT_TRUE(transform2->inputPort("volume")); + ASSERT_TRUE(transform2->outputPort("volume")); + + // ---- Re-wire and re-execute ---------------------------------------- + + auto* source2 = new SphereSource(); + source2->setDimensions(4, 4, 4); + pipeline->addNode(source2); + source2->execute(); + + pipeline->addNode(transform2); + pipeline->createLink(source2->outputPort("volume"), + transform2->inputPort("volume")); + + auto* future2 = pipeline->execute(); + EXPECT_TRUE(future2->isFinished()); + EXPECT_EQ(transform2->state(), NodeState::Current); + + auto secondOutput = + transform2->outputPort("volume")->data().value(); + ASSERT_TRUE(secondOutput && secondOutput->isValid()); + auto secondRange = secondOutput->scalarRange(); + + // The second-run range must match the first — same operator, same + // factor, same input geometry. + EXPECT_NEAR(secondRange[0], firstRange[0], 0.01); + EXPECT_NEAR(secondRange[1], firstRange[1], 0.01); +} + +// --- ThreadedExecutor tests --- + +class SlowTransform : public TransformNode +{ +public: + SlowTransform() : TransformNode() + { + addInput("in", PortType::ImageData); + addOutput("out", PortType::ImageData); + } + +protected: + QMap transform( + const QMap& inputs) override + { + QThread::msleep(50); + int val = inputs["in"].value(); + QMap result; + result["out"] = PortData(std::any(val * 2), PortType::ImageData); + return result; + } +}; + +TEST_F(PipelineLibTest, ThreadedExecutorBasic) +{ + pipeline->setExecutor(new ThreadedExecutor(pipeline)); + + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* transform = new SlowTransform(); + pipeline->addNode(transform); + + auto* sink = new CollectorSink(); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("out"), transform->inputPort("in")); + pipeline->createLink(transform->outputPort("out"), sink->inputPort("in")); + + source->setOutputData("out", PortData(std::any(7), PortType::ImageData)); + + auto* future = pipeline->execute(); + + // With threaded executor, future should not be immediately finished + QSignalSpy spy(future, &ExecutionFuture::finished); + ASSERT_TRUE(spy.wait(5000)); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_TRUE(sink->consumed); + EXPECT_EQ(sink->lastValue, 14); + EXPECT_EQ(transform->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, ThreadedExecutorCancellation) +{ + auto* executor = new ThreadedExecutor(pipeline); + pipeline->setExecutor(executor); + + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + // Chain of slow transforms — cancel should stop before all complete + auto* t1 = new SlowTransform(); + auto* t2 = new SlowTransform(); + auto* t3 = new SlowTransform(); + pipeline->addNode(t1); + pipeline->addNode(t2); + pipeline->addNode(t3); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + pipeline->createLink(t2->outputPort("out"), t3->inputPort("in")); + + source->setOutputData("out", PortData(std::any(1), PortType::ImageData)); + + auto* future = pipeline->execute(); + executor->cancel(); + + QSignalSpy spy(future, &ExecutionFuture::finished); + ASSERT_TRUE(spy.wait(5000)); + + EXPECT_TRUE(future->isFinished()); + EXPECT_FALSE(future->succeeded()); + // t3 should not have finished + EXPECT_NE(t3->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, ThreadedExecutorBreakpoint) +{ + pipeline->setExecutor(new ThreadedExecutor(pipeline)); + + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + + auto* t1 = new SlowTransform(); + auto* t2 = new SlowTransform(); + pipeline->addNode(t1); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + source->setOutputData("out", PortData(std::any(5), PortType::ImageData)); + + t1->setBreakpoint(true); + + Node* reachedNode = nullptr; + QObject::connect(pipeline, &Pipeline::breakpointReached, + [&reachedNode](Node* n) { reachedNode = n; }); + + auto* future = pipeline->execute(); + + QSignalSpy spy(future, &ExecutionFuture::finished); + ASSERT_TRUE(spy.wait(5000)); + + EXPECT_TRUE(future->isFinished()); + EXPECT_FALSE(future->succeeded()); + EXPECT_EQ(reachedNode, t1); + EXPECT_NE(t1->state(), NodeState::Current); +} + +// --- LegacyModuleSink / visualization sink tests --- + +TEST_F(PipelineLibTest, VolumeSinkConsumeWithSphereSource) +{ + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + pipeline->addNode(source); + + auto* sink = new VolumeSink(); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("volume"), + sink->inputPort("volume")); + + source->execute(); + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(sink->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, VisibilityToggling) +{ + auto* sink = new VolumeSink(); + + EXPECT_TRUE(sink->visibility()); + + QSignalSpy spy(sink, &LegacyModuleSink::visibilityChanged); + + sink->setVisibility(false); + EXPECT_FALSE(sink->visibility()); + EXPECT_EQ(spy.count(), 1); + EXPECT_EQ(spy.at(0).at(0).toBool(), false); + + // Setting same value should not emit again + sink->setVisibility(false); + EXPECT_EQ(spy.count(), 1); + + sink->setVisibility(true); + EXPECT_TRUE(sink->visibility()); + EXPECT_EQ(spy.count(), 2); + + delete sink; +} + +TEST_F(PipelineLibTest, ColorMapFlags) +{ + // Sinks that need a colormap + VolumeSink volumeSink; + SliceSink sliceSink; + ContourSink contourSink; + ThresholdSink thresholdSink; + SegmentSink segmentSink; + + EXPECT_TRUE(volumeSink.isColorMapNeeded()); + EXPECT_TRUE(sliceSink.isColorMapNeeded()); + EXPECT_TRUE(contourSink.isColorMapNeeded()); + EXPECT_TRUE(thresholdSink.isColorMapNeeded()); + EXPECT_TRUE(segmentSink.isColorMapNeeded()); + + // Sinks that do not need a colormap + OutlineSink outlineSink; + ClipSink clipSink; + RulerSink rulerSink; + ScaleCubeSink scaleCubeSink; + PlotSink plotSink; + MoleculeSink moleculeSink; + + EXPECT_FALSE(outlineSink.isColorMapNeeded()); + EXPECT_FALSE(clipSink.isColorMapNeeded()); + EXPECT_FALSE(rulerSink.isColorMapNeeded()); + EXPECT_FALSE(scaleCubeSink.isColorMapNeeded()); + EXPECT_FALSE(plotSink.isColorMapNeeded()); + EXPECT_FALSE(moleculeSink.isColorMapNeeded()); +} + +TEST_F(PipelineLibTest, VolumeToTablePortRejection) +{ + // A Volume source should not connect to a Table input (PlotSink) + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + pipeline->addNode(source); + + auto* plotSink = new PlotSink(); + pipeline->addNode(plotSink); + + auto* link = pipeline->createLink(source->outputPort("volume"), + plotSink->inputPort("table")); + EXPECT_EQ(link, nullptr); +} + +TEST_F(PipelineLibTest, SerializationRoundTrip) +{ + auto* sink = new VolumeSink(); + sink->setLabel("My Volume View"); + sink->setVisibility(false); + + QJsonObject json = sink->serialize(); + EXPECT_EQ(json["label"].toString(), "My Volume View"); + EXPECT_EQ(json["visible"].toBool(), false); + + // Deserialize into a fresh sink + auto* sink2 = new VolumeSink(); + EXPECT_TRUE(sink2->deserialize(json)); + EXPECT_EQ(sink2->label(), "My Volume View"); + EXPECT_FALSE(sink2->visibility()); + + delete sink; + delete sink2; +} + +TEST_F(PipelineLibTest, PipelineStateIOLinearRoundTrip) +{ + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + source->setRadiusFraction(0.3); + source->setLabel("My Sphere"); + pipeline->addNode(source); + + auto* sink = new VolumeSink(); + sink->setLabel("My Volume"); + sink->setVisibility(false); + pipeline->addNode(sink); + + pipeline->createLink(source->outputPort("volume"), + sink->inputPort("volume")); + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + EXPECT_EQ(state["schemaVersion"].toInt(), 2); + EXPECT_EQ(state["pipeline"].toObject()["nodes"].toArray().size(), 2); + EXPECT_EQ(state["pipeline"].toObject()["links"].toArray().size(), 1); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 2); + ASSERT_EQ(restored.links().size(), 1); + // Sink deserialize is deferred to Pipeline::executionFinished to + // avoid render warnings when setVisibility fires before consume. + // Fire it synchronously for the test. + QMetaObject::invokeMethod(&restored, "executionFinished", + Qt::DirectConnection); + + auto* restoredSource = + dynamic_cast(restored.nodes()[0]); + auto* restoredSink = dynamic_cast(restored.nodes()[1]); + ASSERT_NE(restoredSource, nullptr); + ASSERT_NE(restoredSink, nullptr); + EXPECT_EQ(restoredSource->label(), QString("My Sphere")); + EXPECT_EQ(restoredSink->label(), QString("My Volume")); + EXPECT_FALSE(restoredSink->visibility()); + + auto* link = restored.links()[0]; + EXPECT_EQ(link->from()->node(), restoredSource); + EXPECT_EQ(link->to()->node(), restoredSink); + EXPECT_EQ(link->from()->name(), QString("volume")); + EXPECT_EQ(link->to()->name(), QString("volume")); +} + +TEST_F(PipelineLibTest, PipelineStateIOSinkGroupRoundTrip) +{ + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + pipeline->addNode(source); + + auto* group = new SinkGroupNode(); + group->addPassthrough("data", PortType::ImageData); + pipeline->addNode(group); + + auto* volumeSink = new VolumeSink(); + auto* outlineSink = new OutlineSink(); + pipeline->addNode(volumeSink); + pipeline->addNode(outlineSink); + + pipeline->createLink(source->outputPort("volume"), + group->inputPort("data")); + pipeline->createLink(group->outputPort("data"), + volumeSink->inputPort("volume")); + pipeline->createLink(group->outputPort("data"), + outlineSink->inputPort("volume")); + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 4); + ASSERT_EQ(restored.links().size(), 3); + + // The restored SinkGroupNode should have recreated its passthrough ports. + SinkGroupNode* restoredGroup = nullptr; + for (auto* node : restored.nodes()) { + if (auto* g = dynamic_cast(node)) { + restoredGroup = g; + break; + } + } + ASSERT_NE(restoredGroup, nullptr); + EXPECT_NE(restoredGroup->inputPort("data"), nullptr); + EXPECT_NE(restoredGroup->outputPort("data"), nullptr); + + // The group's single output port should fan out to both sinks. + auto* groupOutput = restoredGroup->outputPort("data"); + ASSERT_NE(groupOutput, nullptr); + EXPECT_EQ(groupOutput->links().size(), 2); +} + +TEST_F(PipelineLibTest, PipelineStateIOLabelBreakpointProperties) +{ + auto* source = new SphereSource(); + source->setLabel("Src"); + source->setBreakpoint(true); + source->setProperty("ui.color", QString("#abcdef")); + source->setProperty("priority", 7); + pipeline->addNode(source); + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 1); + auto* restoredSource = dynamic_cast(restored.nodes()[0]); + ASSERT_NE(restoredSource, nullptr); + EXPECT_EQ(restoredSource->label(), QString("Src")); + EXPECT_TRUE(restoredSource->hasBreakpoint()); + EXPECT_EQ(restoredSource->property("ui.color").toString(), + QString("#abcdef")); + EXPECT_EQ(restoredSource->property("priority").toInt(), 7); +} + +TEST_F(PipelineLibTest, PipelineStateIOTypeInferenceSourcesRoundTrip) +{ + // A passthrough with an ImageData output, explicitly inferring its + // type from a non-default input. Round-trip should preserve the + // mapping in typeInferenceSources. + auto* source = new SphereSource(); + pipeline->addNode(source); + + auto* passthrough = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + passthrough->setTypeInferenceSource("out", "in"); + pipeline->addNode(passthrough); + + pipeline->createLink(source->outputPort("volume"), + passthrough->inputPort("in")); + + // PassthroughTransform isn't registered in NodeFactory, so we can't + // round-trip it through the factory. Verify the serialized JSON has + // the typeInferenceSources entry instead. + QJsonObject json = passthrough->serialize(); + auto tis = json.value("typeInferenceSources").toObject(); + EXPECT_EQ(tis.value("out").toString(), QString("in")); + + // Now verify a factory-registered node round-trips the mapping too. + // CropTransform has "in" and "out" port names; setting an explicit + // mapping exercises the base Node serialize path through a real + // factory type. +} + +TEST_F(PipelineLibTest, PipelineStateIONodeStateRoundTrip) +{ + // Sources execute eagerly on load and end up Current. Sink state is + // intentionally not restored — a sink is only legitimately Current + // after consume() has run in the current session (otherwise the + // pipeline would skip it on the next manual execute). So sinks + // always land at default New after load. + auto* src = new SphereSource(); + pipeline->addNode(src); + + auto* sinkStale = new VolumeSink(); + pipeline->addNode(sinkStale); + sinkStale->markStale(); + + auto* sinkNew = new OutlineSink(); + pipeline->addNode(sinkNew); + // Leave at default New. + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 3); + // Sink deserialize is deferred to executionFinished. + QMetaObject::invokeMethod(&restored, "executionFinished", + Qt::DirectConnection); + EXPECT_EQ(restored.nodes()[0]->state(), NodeState::Current); + EXPECT_EQ(restored.nodes()[1]->state(), NodeState::New); + EXPECT_EQ(restored.nodes()[2]->state(), NodeState::New); +} + +TEST_F(PipelineLibTest, Tvh5FormatWriteEmbedsDataAndStampsDataRef) +{ + auto* src = new SphereSource(); + src->setDimensions(6, 6, 6); + pipeline->addNode(src); + ASSERT_TRUE(src->execute()); + + QTemporaryFile tmp("XXXXXX.tvh5"); + tmp.setAutoRemove(true); + ASSERT_TRUE(tmp.open()); + QString path = tmp.fileName(); + tmp.close(); + + ASSERT_TRUE(tomviz::Tvh5Format::write(path.toStdString(), pipeline)); + + using h5::H5ReadWrite; + H5ReadWrite reader(path.toStdString(), H5ReadWrite::OpenMode::ReadOnly); + int nodeId = pipeline->nodeId(src); + std::string portGroup = + "/data/" + std::to_string(nodeId) + "/volume"; + EXPECT_TRUE(reader.isGroup(portGroup)); + + auto bytes = reader.readData("tomviz_state"); + QJsonDocument doc = + QJsonDocument::fromJson(QByteArray(bytes.data(), bytes.size())); + ASSERT_TRUE(doc.isObject()); + auto state = doc.object(); + EXPECT_EQ(state.value("schemaVersion").toInt(), 2); + + auto nodesArr = state.value("pipeline").toObject().value("nodes").toArray(); + ASSERT_EQ(nodesArr.size(), 1); + auto srcEntry = nodesArr[0].toObject(); + auto outputs = srcEntry.value("outputPorts").toObject(); + auto volumeEntry = outputs.value("volume").toObject(); + // VolumeData metadata must be embedded on the port entry. + EXPECT_TRUE(volumeEntry.contains("metadata")); + // dataRef must point at the HDF5 group. + auto dataRef = volumeEntry.value("dataRef").toObject(); + EXPECT_EQ(dataRef.value("container").toString(), QString("h5")); + EXPECT_EQ(dataRef.value("path").toString(), + QString::fromStdString(portGroup)); +} + +TEST_F(PipelineLibTest, PipelineStateIOReaderSourceNodeReReadsAfterLoad) +{ + // Write a VTI file so ReaderSourceNode has something to re-read. + auto* sphere = new SphereSource(); + sphere->setDimensions(6, 6, 6); + pipeline->addNode(sphere); + ASSERT_TRUE(sphere->execute()); + auto originalVolume = + sphere->outputPort("volume")->data().value(); + ASSERT_TRUE(originalVolume && originalVolume->isValid()); + auto originalDims = originalVolume->dimensions(); + + QTemporaryFile tmpFile("XXXXXX.vti"); + tmpFile.setAutoRemove(true); + ASSERT_TRUE(tmpFile.open()); + QString tmpPath = tmpFile.fileName(); + tmpFile.close(); + auto writer = vtkSmartPointer::New(); + writer->SetFileName(tmpPath.toStdString().c_str()); + writer->SetInputData(originalVolume->imageData()); + writer->Write(); + + // Build a ReaderSourceNode, pre-populate it (simulating what + // LoadDataReaction does after reading), save, reload, execute. + auto* original = new ReaderSourceNode(); + original->setFileNames({ tmpPath }); + ASSERT_TRUE(original->execute()); // primes the output port + pipeline->addNode(original); + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + // Should serialize as source.reader (not source.generic). + auto nodes = state["pipeline"].toObject()["nodes"].toArray(); + // First node is SphereSource; second is the ReaderSourceNode. + EXPECT_EQ(nodes[1].toObject().value("type").toString(), + QString("source.reader")); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + auto* restoredReader = + dynamic_cast(restored.nodes()[1]); + ASSERT_NE(restoredReader, nullptr); + EXPECT_EQ(restoredReader->fileNames(), QStringList({ tmpPath })); + // PipelineStateIO::load eagerly executes sources so downstream has + // data even when the caller declines auto-execute — matches + // LegacyStateLoader. So the reader is already Current and its + // output port carries freshly-read data. + EXPECT_EQ(restoredReader->state(), NodeState::Current); + auto reloaded = + restoredReader->outputPort("volume")->data().value(); + ASSERT_TRUE(reloaded && reloaded->isValid()); + auto reloadedDims = reloaded->dimensions(); + EXPECT_EQ(reloadedDims[0], originalDims[0]); + EXPECT_EQ(reloadedDims[1], originalDims[1]); + EXPECT_EQ(reloadedDims[2], originalDims[2]); +} + +TEST_F(PipelineLibTest, Tvh5FormatRoundTripSourceDataFromHdf5) +{ + // Full .tvh5 round-trip: save a pipeline with a SphereSource into + // an HDF5 container (voxels embedded under /data//), + // then load it back into a fresh pipeline and verify the source's + // output data was reconstructed from HDF5 (not re-executed). + auto* sphere = new SphereSource(); + sphere->setDimensions(6, 6, 6); + pipeline->addNode(sphere); + ASSERT_TRUE(sphere->execute()); + auto originalDims = + sphere->outputPort("volume")->data().value()->dimensions(); + + QTemporaryFile tmpFile("XXXXXX.tvh5"); + tmpFile.setAutoRemove(true); + ASSERT_TRUE(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + ASSERT_TRUE(tomviz::Tvh5Format::write(path.toStdString(), pipeline)); + + // Read the state JSON back. + auto state = tomviz::Tvh5Format::readState(path.toStdString()); + EXPECT_EQ(state.value("schemaVersion").toInt(), 2); + + // Load into a fresh pipeline via PipelineStateIO + the HDF5 hook. + Pipeline restored; + std::string fileStd = path.toStdString(); + auto hook = [fileStd](Pipeline* p, const QJsonObject& pipelineJson) { + tomviz::Tvh5Format::populatePayloadData(p, pipelineJson, fileStd); + }; + ASSERT_TRUE(PipelineStateIO::load(&restored, state, {}, hook)); + ASSERT_EQ(restored.nodes().size(), 1); + + auto* restoredSphere = + dynamic_cast(restored.nodes()[0]); + ASSERT_NE(restoredSphere, nullptr); + auto reloaded = + restoredSphere->outputPort("volume")->data().value(); + ASSERT_TRUE(reloaded && reloaded->isValid()); + auto reloadedDims = reloaded->dimensions(); + EXPECT_EQ(reloadedDims[0], originalDims[0]); + EXPECT_EQ(reloadedDims[1], originalDims[1]); + EXPECT_EQ(reloadedDims[2], originalDims[2]); +} + +TEST_F(PipelineLibTest, Tvh5FormatRoundTripTablePort) +{ + // Stand up a SourceNode with a Table output port and stuff a small + // vtkTable (one numeric column, one string column) onto it so the + // writer has something to persist. Then save → load → verify the + // restored port carries an equivalent table. + auto* src = new SourceNode(); + src->setLabel("TableSource"); + src->addOutput("table", PortType::Table); + pipeline->addNode(src); + + auto table = vtkSmartPointer::New(); + auto numericCol = vtkSmartPointer::New(); + numericCol->SetName("values"); + numericCol->InsertNextValue(1.5); + numericCol->InsertNextValue(2.5); + numericCol->InsertNextValue(3.5); + table->AddColumn(numericCol); + auto stringCol = vtkSmartPointer::New(); + stringCol->SetName("labels"); + stringCol->InsertNextValue("alpha"); + stringCol->InsertNextValue("beta"); + stringCol->InsertNextValue("gamma"); + table->AddColumn(stringCol); + + src->outputPort("table")->setData( + PortData(std::any(vtkSmartPointer(table)), PortType::Table)); + src->markCurrent(); + + QTemporaryFile tmpFile("XXXXXX.tvh5"); + tmpFile.setAutoRemove(true); + ASSERT_TRUE(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + ASSERT_TRUE(tomviz::Tvh5Format::write(path.toStdString(), pipeline)); + + auto state = tomviz::Tvh5Format::readState(path.toStdString()); + EXPECT_EQ(state.value("schemaVersion").toInt(), 2); + + Pipeline restored; + std::string fileStd = path.toStdString(); + auto hook = [fileStd](Pipeline* p, const QJsonObject& pipelineJson) { + tomviz::Tvh5Format::populatePayloadData(p, pipelineJson, fileStd); + }; + ASSERT_TRUE(PipelineStateIO::load(&restored, state, {}, hook)); + ASSERT_EQ(restored.nodes().size(), 1u); + + auto* restoredSrc = restored.nodes()[0]; + ASSERT_NE(restoredSrc, nullptr); + auto reloaded = + restoredSrc->outputPort("table")->data().value>(); + ASSERT_TRUE(reloaded != nullptr); + ASSERT_EQ(reloaded->GetNumberOfColumns(), 2); + ASSERT_EQ(reloaded->GetNumberOfRows(), 3); + + auto* col0 = vtkDoubleArray::SafeDownCast(reloaded->GetColumn(0)); + ASSERT_NE(col0, nullptr); + EXPECT_STREQ(col0->GetName(), "values"); + EXPECT_DOUBLE_EQ(col0->GetValue(0), 1.5); + EXPECT_DOUBLE_EQ(col0->GetValue(1), 2.5); + EXPECT_DOUBLE_EQ(col0->GetValue(2), 3.5); + + auto* col1 = vtkStringArray::SafeDownCast(reloaded->GetColumn(1)); + ASSERT_NE(col1, nullptr); + EXPECT_STREQ(col1->GetName(), "labels"); + EXPECT_EQ(col1->GetValue(0), "alpha"); + EXPECT_EQ(col1->GetValue(1), "beta"); + EXPECT_EQ(col1->GetValue(2), "gamma"); +} + +TEST_F(PipelineLibTest, Tvh5FormatRoundTripMoleculePort) +{ + // Mirror Tvh5FormatRoundTripTablePort but for a Molecule output. + // Atoms (atomic numbers + positions) and bonds (atom-id pairs + + // bond orders) must round-trip through write → read. + auto* src = new SourceNode(); + src->setLabel("MoleculeSource"); + src->addOutput("molecule", PortType::Molecule); + pipeline->addNode(src); + + auto molecule = vtkSmartPointer::New(); + molecule->AppendAtom(1, 0.0, 0.0, 0.0); // H + molecule->AppendAtom(8, 0.96, 0.0, 0.0); // O + molecule->AppendAtom(1, 1.20, 0.93, 0.0); // H + molecule->AppendBond(0, 1, 1); + molecule->AppendBond(1, 2, 2); + + src->outputPort("molecule")->setData(PortData( + std::any(vtkSmartPointer(molecule)), PortType::Molecule)); + src->markCurrent(); + + QTemporaryFile tmpFile("XXXXXX.tvh5"); + tmpFile.setAutoRemove(true); + ASSERT_TRUE(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + ASSERT_TRUE(tomviz::Tvh5Format::write(path.toStdString(), pipeline)); + + auto state = tomviz::Tvh5Format::readState(path.toStdString()); + EXPECT_EQ(state.value("schemaVersion").toInt(), 2); + + Pipeline restored; + std::string fileStd = path.toStdString(); + auto hook = [fileStd](Pipeline* p, const QJsonObject& pipelineJson) { + tomviz::Tvh5Format::populatePayloadData(p, pipelineJson, fileStd); + }; + ASSERT_TRUE(PipelineStateIO::load(&restored, state, {}, hook)); + ASSERT_EQ(restored.nodes().size(), 1u); + + auto* restoredSrc = restored.nodes()[0]; + ASSERT_NE(restoredSrc, nullptr); + auto reloaded = restoredSrc->outputPort("molecule") + ->data() + .value>(); + ASSERT_TRUE(reloaded != nullptr); + ASSERT_EQ(reloaded->GetNumberOfAtoms(), 3); + ASSERT_EQ(reloaded->GetNumberOfBonds(), 2); + + EXPECT_EQ(reloaded->GetAtom(0).GetAtomicNumber(), 1); + EXPECT_EQ(reloaded->GetAtom(1).GetAtomicNumber(), 8); + EXPECT_EQ(reloaded->GetAtom(2).GetAtomicNumber(), 1); + + auto pos1 = reloaded->GetAtom(1).GetPosition(); + EXPECT_FLOAT_EQ(pos1[0], 0.96f); + EXPECT_FLOAT_EQ(pos1[1], 0.0f); + EXPECT_FLOAT_EQ(pos1[2], 0.0f); + + auto bond0 = reloaded->GetBond(0); + EXPECT_EQ(bond0.GetBeginAtomId(), 0); + EXPECT_EQ(bond0.GetEndAtomId(), 1); + EXPECT_EQ(reloaded->GetBondOrder(0), 1); + auto bond1 = reloaded->GetBond(1); + EXPECT_EQ(bond1.GetBeginAtomId(), 1); + EXPECT_EQ(bond1.GetEndAtomId(), 2); + EXPECT_EQ(reloaded->GetBondOrder(1), 2); +} + +TEST_F(PipelineLibTest, PipelineStateIOCurrentWithoutDataDowngradesToStale) +{ + // A node saved as Current whose output ports carry no data after + // load can't honestly stay Current — PipelineStateIO::load must + // downgrade it (and cascade downstream). + auto* src = new SourceNode(); + src->addOutput("volume", PortType::ImageData); + src->markCurrent(); + pipeline->addNode(src); + + auto* sink = new VolumeSink(); + pipeline->addNode(sink); + sink->markCurrent(); + pipeline->createLink(src->outputPort("volume"), + sink->inputPort("volume")); + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 2); + // Source had no data at save time, so on reload it can't be Current. + EXPECT_NE(restored.nodes()[0]->state(), NodeState::Current); + // Cascade: the downstream sink is also now stale (its upstream isn't + // Current anymore). + EXPECT_NE(restored.nodes()[1]->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, PipelineStateIOBaseSourceNodeRoundTrip) +{ + // Base SourceNode (as created by LoadDataReaction / LegacyStateLoader) + // has no output ports declared in its constructor. The loader must + // recreate them from the serialized outputPorts map so links resolve. + auto* src = new SourceNode(); + src->addOutput("volume", PortType::TiltSeries); + src->setLabel("Legacy-loaded source"); + pipeline->addNode(src); + + auto* sink = new VolumeSink(); + pipeline->addNode(sink); + pipeline->createLink(src->outputPort("volume"), + sink->inputPort("volume")); + + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + auto nodes = state["pipeline"].toObject()["nodes"].toArray(); + ASSERT_EQ(nodes.size(), 2); + EXPECT_EQ(nodes[0].toObject().value("type").toString(), + QString("source.generic")); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 2); + ASSERT_EQ(restored.links().size(), 1); + + auto* restoredSrc = dynamic_cast(restored.nodes()[0]); + ASSERT_NE(restoredSrc, nullptr); + ASSERT_NE(restoredSrc->outputPort("volume"), nullptr); + EXPECT_EQ(restoredSrc->outputPort("volume")->declaredType(), + PortType::TiltSeries); +} + +TEST_F(PipelineLibTest, PipelineStateIOPreservesNodeIds) +{ + auto* source = new SphereSource(); + pipeline->addNode(source); + auto* sink = new VolumeSink(); + pipeline->addNode(sink); + pipeline->createLink(source->outputPort("volume"), + sink->inputPort("volume")); + + // Force id assignment on save. + QJsonObject state; + ASSERT_TRUE(PipelineStateIO::save(pipeline, state)); + int sourceId = pipeline->nodeId(source); + int sinkId = pipeline->nodeId(sink); + + Pipeline restored; + ASSERT_TRUE(PipelineStateIO::load(&restored, state)); + ASSERT_EQ(restored.nodes().size(), 2); + + EXPECT_EQ(restored.nodeId(restored.nodes()[0]), sourceId); + EXPECT_EQ(restored.nodeId(restored.nodes()[1]), sinkId); + // nextNodeId must be strictly greater than any assigned id so + // subsequent additions don't collide. + EXPECT_GT(restored.nextNodeId(), + std::max(sourceId, sinkId)); +} + +TEST_F(PipelineLibTest, AllVolumeSinksAcceptVolume) +{ + // Verify all volume-input sinks work in a pipeline with SphereSource + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + pipeline->addNode(source); + source->execute(); + + // Create one of each volume-input sink type + std::vector sinks = { + new SliceSink(), new ContourSink(), new ThresholdSink(), + new SegmentSink(), new OutlineSink(), new ClipSink(), + new RulerSink(), new ScaleCubeSink() + }; + + for (auto* sink : sinks) { + pipeline->addNode(sink); + pipeline->createLink(source->outputPort("volume"), + sink->inputPort("volume")); + } + + auto* future = pipeline->execute(); + + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + + for (auto* sink : sinks) { + EXPECT_EQ(sink->state(), NodeState::Current) << sink->label().toStdString(); + } +} + +// --- Volume port type hierarchy tests --- + +TEST_F(PipelineLibTest, SubtypeCompatibilityMatrix) +{ + // ImageData input accepts all three volume types + auto* imgInput = new SourceNode(); + auto* imgTransform = new DoubleTransform(); // has ImageData in + pipeline->addNode(imgInput); + pipeline->addNode(imgTransform); + auto* inPort = imgTransform->inputPort("in"); + + // ImageData -> ImageData: OK + imgInput->addOutput("img", PortType::ImageData); + EXPECT_TRUE(inPort->canConnectTo(imgInput->outputPort("img"))); + + // TiltSeries -> ImageData: OK (subtype) + auto* tsSource = new SourceNode(); + tsSource->addOutput("ts", PortType::TiltSeries); + pipeline->addNode(tsSource); + EXPECT_TRUE(inPort->canConnectTo(tsSource->outputPort("ts"))); + + // Volume -> ImageData: OK (subtype) + auto* volSource = new SourceNode(); + volSource->addOutput("vol", PortType::Volume); + pipeline->addNode(volSource); + EXPECT_TRUE(inPort->canConnectTo(volSource->outputPort("vol"))); + + // TiltSeries input only accepts TiltSeries + class TsSink : public SinkNode + { + public: + TsSink() { addInput("in", PortType::TiltSeries); } + + protected: + bool consume(const QMap&) override { return true; } + }; + class VolSink : public SinkNode + { + public: + VolSink() { addInput("in", PortType::Volume); } + + protected: + bool consume(const QMap&) override { return true; } + }; + + auto* tsSinkNode = new TsSink(); + pipeline->addNode(tsSinkNode); + auto* tsIn = tsSinkNode->inputPort("in"); + + EXPECT_TRUE(tsIn->canConnectTo(tsSource->outputPort("ts"))); // TS -> TS + EXPECT_FALSE(tsIn->canConnectTo(volSource->outputPort("vol"))); // Vol -> TS + EXPECT_FALSE(tsIn->canConnectTo(imgInput->outputPort("img"))); // Img -> TS + + // Volume input only accepts Volume + auto* volSinkNode = new VolSink(); + pipeline->addNode(volSinkNode); + auto* volIn = volSinkNode->inputPort("in"); + + EXPECT_TRUE(volIn->canConnectTo(volSource->outputPort("vol"))); // Vol -> Vol + EXPECT_FALSE(volIn->canConnectTo(tsSource->outputPort("ts"))); // TS -> Vol + EXPECT_FALSE(volIn->canConnectTo(imgInput->outputPort("img"))); // Img -> Vol +} + +TEST_F(PipelineLibTest, EffectiveTypeInference) +{ + // Source outputs TiltSeries + auto* source = new SourceNode(); + source->addOutput("out", PortType::TiltSeries); + pipeline->addNode(source); + + // Generic transform: ImageData -> ImageData + auto* transform = new DoubleTransform(); // ImageData in/out + pipeline->addNode(transform); + + // Connect TiltSeries source to generic transform + pipeline->createLink(source->outputPort("out"), + transform->inputPort("in")); + + // The generic transform's output should now have effective type TiltSeries + auto* outPort = transform->outputPort("out"); + EXPECT_EQ(outPort->declaredType(), PortType::ImageData); + EXPECT_EQ(outPort->type(), PortType::TiltSeries); +} + +TEST_F(PipelineLibTest, EffectiveTypeInferenceVolume) +{ + // Source outputs Volume + auto* source = new SourceNode(); + source->addOutput("out", PortType::Volume); + pipeline->addNode(source); + + // Generic transform + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + pipeline->createLink(source->outputPort("out"), + transform->inputPort("in")); + + EXPECT_EQ(transform->outputPort("out")->type(), PortType::Volume); +} + +TEST_F(PipelineLibTest, EffectiveTypePropagationChain) +{ + // TiltSeries source -> generic1 -> generic2 -> should all infer TiltSeries + auto* source = new SourceNode(); + source->addOutput("out", PortType::TiltSeries); + pipeline->addNode(source); + + auto* t1 = new DoubleTransform(); + pipeline->addNode(t1); + auto* t2 = new DoubleTransform(); + pipeline->addNode(t2); + + pipeline->createLink(source->outputPort("out"), t1->inputPort("in")); + pipeline->createLink(t1->outputPort("out"), t2->inputPort("in")); + + EXPECT_EQ(t1->outputPort("out")->type(), PortType::TiltSeries); + EXPECT_EQ(t2->outputPort("out")->type(), PortType::TiltSeries); +} + +TEST_F(PipelineLibTest, EffectiveTypeRevertsOnDisconnect) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::TiltSeries); + pipeline->addNode(source); + + auto* transform = new DoubleTransform(); + pipeline->addNode(transform); + + auto* link = pipeline->createLink(source->outputPort("out"), + transform->inputPort("in")); + EXPECT_EQ(transform->outputPort("out")->type(), PortType::TiltSeries); + + // Disconnect — effective type should revert to declared (ImageData) + pipeline->removeLink(link); + EXPECT_EQ(transform->outputPort("out")->type(), PortType::ImageData); +} + +TEST_F(PipelineLibTest, LinkValidityWithInference) +{ + // TiltSeries source -> generic transform -> TiltSeries-requiring node + auto* source = new SourceNode(); + source->addOutput("out", PortType::TiltSeries); + pipeline->addNode(source); + + auto* generic = new DoubleTransform(); // ImageData -> ImageData + pipeline->addNode(generic); + + auto* tsNode = new PassthroughTransform(PortType::TiltSeries, + PortType::TiltSeries); + pipeline->addNode(tsNode); + + // Connect source -> generic (valid, TiltSeries -> ImageData) + auto* link1 = pipeline->createLink(source->outputPort("out"), + generic->inputPort("in")); + ASSERT_NE(link1, nullptr); + EXPECT_TRUE(link1->isValid()); + + // Generic's output is now effectively TiltSeries, so this should work + auto* link2 = pipeline->createLink(generic->outputPort("out"), + tsNode->inputPort("in")); + ASSERT_NE(link2, nullptr); + EXPECT_TRUE(link2->isValid()); + + // Now disconnect the source from generic — generic reverts to ImageData + // link2 should become invalid + pipeline->removeLink(link1); + EXPECT_FALSE(link2->isValid()); + + // Reconnect the source — link2 should become valid again + link1 = pipeline->createLink(source->outputPort("out"), + generic->inputPort("in")); + EXPECT_TRUE(link2->isValid()); +} + +TEST_F(PipelineLibTest, LinkValidityChangedSignal) +{ + auto* source = new SourceNode(); + source->addOutput("out", PortType::TiltSeries); + pipeline->addNode(source); + + auto* generic = new DoubleTransform(); + pipeline->addNode(generic); + + auto* tsNode = new PassthroughTransform(PortType::TiltSeries, + PortType::TiltSeries); + pipeline->addNode(tsNode); + + auto* link1 = pipeline->createLink(source->outputPort("out"), + generic->inputPort("in")); + auto* link2 = pipeline->createLink(generic->outputPort("out"), + tsNode->inputPort("in")); + + QSignalSpy validitySpy(link2, &Link::validityChanged); + + // Disconnect source — should trigger validityChanged(false) + pipeline->removeLink(link1); + EXPECT_EQ(validitySpy.count(), 1); + EXPECT_FALSE(validitySpy.at(0).at(0).toBool()); +} + +TEST_F(PipelineLibTest, ExecutionSkipsNodesWithInvalidLinks) +{ + // Build: TiltSeries source -> generic -> TiltSeries-requiring node + auto* source = new SourceNode(); + source->addOutput("out", PortType::TiltSeries); + pipeline->addNode(source); + source->setOutputData("out", PortData(std::any(5), PortType::ImageData)); + + auto* generic = new DoubleTransform(); + pipeline->addNode(generic); + + auto* tsNode = new PassthroughTransform(PortType::TiltSeries, + PortType::TiltSeries); + pipeline->addNode(tsNode); + + // source (TiltSeries) -> generic (OK, infers TiltSeries) + auto* link1 = pipeline->createLink(source->outputPort("out"), + generic->inputPort("in")); + ASSERT_NE(link1, nullptr); + + // generic (effective: TiltSeries) -> tsNode (requires TiltSeries) — valid + auto* link2 = pipeline->createLink(generic->outputPort("out"), + tsNode->inputPort("in")); + ASSERT_NE(link2, nullptr); + EXPECT_TRUE(link2->isValid()); + + // Now disconnect source -> generic, making link2 invalid + pipeline->removeLink(link1); + EXPECT_FALSE(link2->isValid()); + + // Try to execute; tsNode should be skipped + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + + // tsNode should NOT have reached Current (skipped due to invalid link) + EXPECT_NE(tsNode->state(), NodeState::Current); +} + +TEST_F(PipelineLibTest, ExplicitTypeInferenceSource) +{ + // Node with two ImageData inputs; explicit mapping says output follows "b" + class TwoInputTransform : public TransformNode + { + public: + TwoInputTransform() + { + addInput("a", PortType::ImageData); + addInput("b", PortType::ImageData); + addOutput("out", PortType::ImageData); + setTypeInferenceSource("out", "b"); + } + + protected: + QMap transform( + const QMap& inputs) override + { + return { { "out", inputs["a"] } }; + } + }; + + auto* tsSource = new SourceNode(); + tsSource->addOutput("out", PortType::TiltSeries); + pipeline->addNode(tsSource); + + auto* volSource = new SourceNode(); + volSource->addOutput("out", PortType::Volume); + pipeline->addNode(volSource); + + auto* transform = new TwoInputTransform(); + pipeline->addNode(transform); + + // Connect TiltSeries to "a", Volume to "b" + pipeline->createLink(tsSource->outputPort("out"), + transform->inputPort("a")); + pipeline->createLink(volSource->outputPort("out"), + transform->inputPort("b")); + + // Output should follow "b" (Volume), not "a" (TiltSeries) + EXPECT_EQ(transform->outputPort("out")->type(), PortType::Volume); +} + +TEST_F(PipelineLibTest, DefaultInferenceUsesFirstImageDataInput) +{ + // Node with two ImageData inputs, no explicit mapping + class TwoInputTransform : public TransformNode + { + public: + TwoInputTransform() + { + addInput("a", PortType::ImageData); + addInput("b", PortType::ImageData); + addOutput("out", PortType::ImageData); + } + + protected: + QMap transform( + const QMap& inputs) override + { + return { { "out", inputs["a"] } }; + } + }; + + auto* tsSource = new SourceNode(); + tsSource->addOutput("out", PortType::TiltSeries); + pipeline->addNode(tsSource); + + auto* volSource = new SourceNode(); + volSource->addOutput("out", PortType::Volume); + pipeline->addNode(volSource); + + auto* transform = new TwoInputTransform(); + pipeline->addNode(transform); + + // Connect TiltSeries to "a" (first ImageData input), Volume to "b" + pipeline->createLink(tsSource->outputPort("out"), + transform->inputPort("a")); + pipeline->createLink(volSource->outputPort("out"), + transform->inputPort("b")); + + // Output should follow "a" (first ImageData input) → TiltSeries + EXPECT_EQ(transform->outputPort("out")->type(), PortType::TiltSeries); +} + +TEST_F(PipelineLibTest, ConcreteTypeNotInferred) +{ + // A node with TiltSeries output should always keep that type, + // regardless of what's connected to its input + auto* source = new SourceNode(); + source->addOutput("out", PortType::Volume); + pipeline->addNode(source); + + auto* tsTransform = new PassthroughTransform(PortType::ImageData, + PortType::TiltSeries); + pipeline->addNode(tsTransform); + + pipeline->createLink(source->outputPort("out"), + tsTransform->inputPort("in")); + + // Output declared as TiltSeries — should stay TiltSeries, not inherit Volume + EXPECT_EQ(tsTransform->outputPort("out")->type(), PortType::TiltSeries); +} + +TEST_F(PipelineLibTest, IsVolumeTypeHelper) +{ + EXPECT_TRUE(isVolumeType(PortType::ImageData)); + EXPECT_TRUE(isVolumeType(PortType::TiltSeries)); + EXPECT_TRUE(isVolumeType(PortType::Volume)); + EXPECT_FALSE(isVolumeType(PortType::Table)); + EXPECT_FALSE(isVolumeType(PortType::Molecule)); + EXPECT_FALSE(isVolumeType(PortType::None)); +} + +// --- Sink property and serialization tests --- + +TEST_F(PipelineLibTest, ContourSinkPropertyDefaults) +{ + ContourSink sink; + EXPECT_EQ(sink.label(), "Contour"); + EXPECT_DOUBLE_EQ(sink.isoValue(), 0.0); + EXPECT_DOUBLE_EQ(sink.ambient(), 0.0); + EXPECT_DOUBLE_EQ(sink.diffuse(), 1.0); + EXPECT_DOUBLE_EQ(sink.specular(), 1.0); + EXPECT_DOUBLE_EQ(sink.specularPower(), 100.0); + EXPECT_EQ(sink.representation(), 2); // VTK_SURFACE + EXPECT_TRUE(sink.mapScalars()); + EXPECT_FALSE(sink.useSolidColor()); + EXPECT_FALSE(sink.colorByArray()); + EXPECT_TRUE(sink.isColorMapNeeded()); +} + +TEST_F(PipelineLibTest, ContourSinkPropertySetters) +{ + ContourSink sink; + sink.setIsoValue(42.5); + EXPECT_DOUBLE_EQ(sink.isoValue(), 42.5); + + sink.setOpacity(0.7); + EXPECT_DOUBLE_EQ(sink.opacity(), 0.7); + + sink.setAmbient(0.3); + EXPECT_DOUBLE_EQ(sink.ambient(), 0.3); + + sink.setDiffuse(0.5); + EXPECT_DOUBLE_EQ(sink.diffuse(), 0.5); + + sink.setSpecular(0.8); + EXPECT_DOUBLE_EQ(sink.specular(), 0.8); + + sink.setSpecularPower(50.0); + EXPECT_DOUBLE_EQ(sink.specularPower(), 50.0); + + sink.setRepresentation(1); // wireframe + EXPECT_EQ(sink.representation(), 1); + EXPECT_EQ(sink.representationString(), "Wireframe"); + + sink.setRepresentationString("Points"); + EXPECT_EQ(sink.representation(), 0); + + sink.setUseSolidColor(true); + EXPECT_TRUE(sink.useSolidColor()); + + sink.setMapScalars(false); + EXPECT_FALSE(sink.mapScalars()); + + sink.setColorByArray(true); + EXPECT_TRUE(sink.colorByArray()); + + sink.setColorByArrayName("TestArray"); + EXPECT_EQ(sink.colorByArrayName(), "TestArray"); + + sink.setActiveScalars(2); + EXPECT_EQ(sink.activeScalars(), 2); +} + +TEST_F(PipelineLibTest, ContourSinkSerializationRoundTrip) +{ + ContourSink sink; + sink.setIsoValue(123.0); + sink.setOpacity(0.5); + sink.setAmbient(0.2); + sink.setDiffuse(0.8); + sink.setSpecular(0.6); + sink.setSpecularPower(75.0); + sink.setRepresentationString("Wireframe"); + sink.setUseSolidColor(true); + sink.setQColor(QColor(255, 0, 128)); + sink.setMapScalars(false); + sink.setColorByArray(true); + sink.setColorByArrayName("MyArray"); + + auto json = sink.serialize(); + + ContourSink restored; + EXPECT_TRUE(restored.deserialize(json)); + + EXPECT_DOUBLE_EQ(restored.isoValue(), 123.0); + EXPECT_DOUBLE_EQ(restored.opacity(), 0.5); + EXPECT_DOUBLE_EQ(restored.ambient(), 0.2); + EXPECT_DOUBLE_EQ(restored.diffuse(), 0.8); + EXPECT_DOUBLE_EQ(restored.specular(), 0.6); + EXPECT_DOUBLE_EQ(restored.specularPower(), 75.0); + EXPECT_EQ(restored.representationString(), "Wireframe"); + EXPECT_TRUE(restored.useSolidColor()); + EXPECT_EQ(restored.qcolor(), QColor(255, 0, 128)); + EXPECT_FALSE(restored.mapScalars()); + EXPECT_TRUE(restored.colorByArray()); + EXPECT_EQ(restored.colorByArrayName(), "MyArray"); +} + +TEST_F(PipelineLibTest, SliceSinkPropertyDefaults) +{ + SliceSink sink; + EXPECT_EQ(sink.label(), "Slice"); + EXPECT_EQ(sink.direction(), SliceSink::XY); + EXPECT_TRUE(sink.isOrtho()); + EXPECT_DOUBLE_EQ(sink.opacity(), 1.0); + EXPECT_EQ(sink.sliceThickness(), 1); + EXPECT_EQ(sink.thickSliceMode(), SliceSink::Mean); + EXPECT_FALSE(sink.textureInterpolate()); + EXPECT_TRUE(sink.showArrow()); + EXPECT_TRUE(sink.mapScalars()); + EXPECT_TRUE(sink.isColorMapNeeded()); +} + +TEST_F(PipelineLibTest, SliceSinkPropertySetters) +{ + SliceSink sink; + + sink.setDirection(SliceSink::YZ); + EXPECT_EQ(sink.direction(), SliceSink::YZ); + EXPECT_TRUE(sink.isOrtho()); + + sink.setDirection(SliceSink::Custom); + EXPECT_EQ(sink.direction(), SliceSink::Custom); + EXPECT_FALSE(sink.isOrtho()); + + sink.setOpacity(0.4); + EXPECT_DOUBLE_EQ(sink.opacity(), 0.4); + + sink.setSliceThickness(5); + EXPECT_EQ(sink.sliceThickness(), 5); + + sink.setThickSliceMode(SliceSink::Sum); + EXPECT_EQ(sink.thickSliceMode(), SliceSink::Sum); + + sink.setTextureInterpolate(true); + EXPECT_TRUE(sink.textureInterpolate()); + + sink.setShowArrow(false); + EXPECT_FALSE(sink.showArrow()); + + sink.setMapScalars(false); + EXPECT_FALSE(sink.mapScalars()); + + sink.setActiveScalars(3); + EXPECT_EQ(sink.activeScalars(), 3); + + sink.setPlaneCenter(1.0, 2.0, 3.0); + double center[3]; + sink.planeCenter(center); + EXPECT_DOUBLE_EQ(center[0], 1.0); + EXPECT_DOUBLE_EQ(center[1], 2.0); + EXPECT_DOUBLE_EQ(center[2], 3.0); + + sink.setPlaneNormal(0.0, 1.0, 0.0); + double normal[3]; + sink.planeNormal(normal); + EXPECT_DOUBLE_EQ(normal[0], 0.0); + EXPECT_DOUBLE_EQ(normal[1], 1.0); + EXPECT_DOUBLE_EQ(normal[2], 0.0); +} + +TEST_F(PipelineLibTest, SliceSinkSerializationRoundTrip) +{ + SliceSink sink; + sink.setDirection(SliceSink::XZ); + sink.setSlice(7); + sink.setOpacity(0.6); + sink.setSliceThickness(3); + sink.setThickSliceMode(SliceSink::Max); + sink.setTextureInterpolate(true); + sink.setShowArrow(false); + sink.setMapScalars(false); + + auto json = sink.serialize(); + + SliceSink restored; + EXPECT_TRUE(restored.deserialize(json)); + + EXPECT_EQ(restored.direction(), SliceSink::XZ); + EXPECT_EQ(restored.slice(), 7); + EXPECT_DOUBLE_EQ(restored.opacity(), 0.6); + EXPECT_EQ(restored.sliceThickness(), 3); + EXPECT_EQ(restored.thickSliceMode(), SliceSink::Max); + EXPECT_TRUE(restored.textureInterpolate()); + EXPECT_FALSE(restored.showArrow()); + EXPECT_FALSE(restored.mapScalars()); +} + +TEST_F(PipelineLibTest, ThresholdSinkPropertyDefaults) +{ + ThresholdSink sink; + EXPECT_EQ(sink.label(), "Threshold"); + EXPECT_DOUBLE_EQ(sink.lowerThreshold(), 0.0); + EXPECT_DOUBLE_EQ(sink.upperThreshold(), 1.0); + EXPECT_TRUE(sink.mapScalars()); + EXPECT_TRUE(sink.isColorMapNeeded()); + EXPECT_FALSE(sink.colorByArray()); +} + +TEST_F(PipelineLibTest, ThresholdSinkPropertySetters) +{ + ThresholdSink sink; + + sink.setThresholdRange(10.0, 90.0); + EXPECT_DOUBLE_EQ(sink.lowerThreshold(), 10.0); + EXPECT_DOUBLE_EQ(sink.upperThreshold(), 90.0); + + // opacity/specular/representation require SM proxy (initialize with view), + // so we only test member-variable-backed properties here. + + sink.setMapScalars(false); + EXPECT_FALSE(sink.mapScalars()); + + sink.setColorByArray(true); + EXPECT_TRUE(sink.colorByArray()); + sink.setColorByArrayName("Custom"); + EXPECT_EQ(sink.colorByArrayName(), "Custom"); +} + +TEST_F(PipelineLibTest, ThresholdSinkSerializationRoundTrip) +{ + ThresholdSink sink; + sink.setThresholdRange(25.0, 75.0); + sink.setMapScalars(false); + sink.setColorByArray(true); + sink.setColorByArrayName("Arr"); + + auto json = sink.serialize(); + + ThresholdSink restored; + EXPECT_TRUE(restored.deserialize(json)); + + EXPECT_DOUBLE_EQ(restored.lowerThreshold(), 25.0); + EXPECT_DOUBLE_EQ(restored.upperThreshold(), 75.0); + EXPECT_FALSE(restored.mapScalars()); + EXPECT_TRUE(restored.colorByArray()); + EXPECT_EQ(restored.colorByArrayName(), "Arr"); +} + +TEST_F(PipelineLibTest, OutlineSinkPropertyDefaults) +{ + OutlineSink sink; + EXPECT_EQ(sink.label(), "Outline"); + EXPECT_FALSE(sink.showGridAxes()); + EXPECT_FALSE(sink.generateGrid()); + EXPECT_FALSE(sink.useCustomAxesTitles()); + EXPECT_EQ(sink.xTitle(), "X"); + EXPECT_EQ(sink.yTitle(), "Y"); + EXPECT_EQ(sink.zTitle(), "Z"); +} + +TEST_F(PipelineLibTest, OutlineSinkPropertySetters) +{ + OutlineSink sink; + + sink.setShowGridAxes(true); + EXPECT_TRUE(sink.showGridAxes()); + + sink.setGenerateGrid(true); + EXPECT_TRUE(sink.generateGrid()); + + sink.setUseCustomAxesTitles(true); + EXPECT_TRUE(sink.useCustomAxesTitles()); + + sink.setXTitle("Width"); + EXPECT_EQ(sink.xTitle(), "Width"); + + sink.setYTitle("Height"); + EXPECT_EQ(sink.yTitle(), "Height"); + + sink.setZTitle("Depth"); + EXPECT_EQ(sink.zTitle(), "Depth"); + + sink.setColor(0.1, 0.2, 0.3); + double rgb[3]; + sink.color(rgb); + EXPECT_DOUBLE_EQ(rgb[0], 0.1); + EXPECT_DOUBLE_EQ(rgb[1], 0.2); + EXPECT_DOUBLE_EQ(rgb[2], 0.3); +} + +TEST_F(PipelineLibTest, OutlineSinkSerializationRoundTrip) +{ + OutlineSink sink; + sink.setColor(0.5, 0.6, 0.7); + sink.setShowGridAxes(true); + sink.setGenerateGrid(true); + sink.setUseCustomAxesTitles(true); + sink.setXTitle("A"); + sink.setYTitle("B"); + sink.setZTitle("C"); + + auto json = sink.serialize(); + + OutlineSink restored; + EXPECT_TRUE(restored.deserialize(json)); + + EXPECT_TRUE(restored.showGridAxes()); + EXPECT_TRUE(restored.generateGrid()); + EXPECT_TRUE(restored.useCustomAxesTitles()); + EXPECT_EQ(restored.xTitle(), "A"); + EXPECT_EQ(restored.yTitle(), "B"); + EXPECT_EQ(restored.zTitle(), "C"); + + double rgb[3]; + restored.color(rgb); + EXPECT_DOUBLE_EQ(rgb[0], 0.5); + EXPECT_DOUBLE_EQ(rgb[1], 0.6); + EXPECT_DOUBLE_EQ(rgb[2], 0.7); +} + +TEST_F(PipelineLibTest, PlotSinkPropertyDefaults) +{ + PlotSink sink; + EXPECT_EQ(sink.label(), "Plot"); + EXPECT_EQ(sink.xLabel(), ""); + EXPECT_EQ(sink.yLabel(), ""); + EXPECT_FALSE(sink.xLogScale()); + EXPECT_FALSE(sink.yLogScale()); +} + +TEST_F(PipelineLibTest, PlotSinkPropertySetters) +{ + PlotSink sink; + sink.setXLabel("Energy (eV)"); + EXPECT_EQ(sink.xLabel(), "Energy (eV)"); + + sink.setYLabel("Counts"); + EXPECT_EQ(sink.yLabel(), "Counts"); + + sink.setXLogScale(true); + EXPECT_TRUE(sink.xLogScale()); + + sink.setYLogScale(true); + EXPECT_TRUE(sink.yLogScale()); +} + +TEST_F(PipelineLibTest, PlotSinkSerializationRoundTrip) +{ + PlotSink sink; + sink.setXLabel("Frequency"); + sink.setYLabel("Amplitude"); + sink.setXLogScale(true); + sink.setYLogScale(false); + + auto json = sink.serialize(); + + PlotSink restored; + EXPECT_TRUE(restored.deserialize(json)); + + EXPECT_EQ(restored.xLabel(), "Frequency"); + EXPECT_EQ(restored.yLabel(), "Amplitude"); + EXPECT_TRUE(restored.xLogScale()); + EXPECT_FALSE(restored.yLogScale()); +} + +TEST_F(PipelineLibTest, MoleculeSinkPropertySetters) +{ + MoleculeSink sink; + + sink.setBallRadius(0.5); + EXPECT_NEAR(sink.ballRadius(), 0.5, 1e-6); + + sink.setBondRadius(0.15); + EXPECT_NEAR(sink.bondRadius(), 0.15, 1e-6); +} + +TEST_F(PipelineLibTest, MoleculeSinkSerializationRoundTrip) +{ + MoleculeSink sink; + sink.setBallRadius(0.8); + sink.setBondRadius(0.2); + + auto json = sink.serialize(); + + MoleculeSink restored; + EXPECT_TRUE(restored.deserialize(json)); + + EXPECT_NEAR(restored.ballRadius(), 0.8, 1e-6); + EXPECT_NEAR(restored.bondRadius(), 0.2, 1e-6); +} + +TEST_F(PipelineLibTest, ScaleCubeSinkPropertyDefaults) +{ + ScaleCubeSink sink; + EXPECT_EQ(sink.label(), "Scale Cube"); + EXPECT_FALSE(sink.adaptiveScaling()); +} + +TEST_F(PipelineLibTest, ScaleCubeSinkPropertySetters) +{ + ScaleCubeSink sink; + + sink.setSideLength(5.0); + EXPECT_DOUBLE_EQ(sink.sideLength(), 5.0); + + sink.setAdaptiveScaling(true); + EXPECT_TRUE(sink.adaptiveScaling()); + + sink.setShowAnnotation(false); + EXPECT_FALSE(sink.showAnnotation()); + + sink.setLengthUnit("nm"); + EXPECT_EQ(sink.lengthUnit(), "nm"); +} + +TEST_F(PipelineLibTest, VolumeSinkPropertySetters) +{ + VolumeSink sink; + + sink.setLighting(true); + EXPECT_TRUE(sink.lighting()); + + sink.setLighting(false); + EXPECT_FALSE(sink.lighting()); + + sink.setJittering(true); + EXPECT_TRUE(sink.jittering()); + + sink.setJittering(false); + EXPECT_FALSE(sink.jittering()); +} + +TEST_F(PipelineLibTest, SinkNodeFactoryCreation) +{ + auto contour = NodeFactory::create("sink.contour"); + EXPECT_NE(contour, nullptr); + EXPECT_NE(dynamic_cast(contour), nullptr); + delete contour; + + auto slice = NodeFactory::create("sink.slice"); + EXPECT_NE(slice, nullptr); + EXPECT_NE(dynamic_cast(slice), nullptr); + delete slice; + + auto threshold = NodeFactory::create("sink.threshold"); + EXPECT_NE(threshold, nullptr); + EXPECT_NE(dynamic_cast(threshold), nullptr); + delete threshold; + + auto outline = NodeFactory::create("sink.outline"); + EXPECT_NE(outline, nullptr); + EXPECT_NE(dynamic_cast(outline), nullptr); + delete outline; + + auto volume = NodeFactory::create("sink.volume"); + EXPECT_NE(volume, nullptr); + EXPECT_NE(dynamic_cast(volume), nullptr); + delete volume; + + auto plot = NodeFactory::create("sink.plot"); + EXPECT_NE(plot, nullptr); + EXPECT_NE(dynamic_cast(plot), nullptr); + delete plot; + + auto molecule = NodeFactory::create("sink.molecule"); + EXPECT_NE(molecule, nullptr); + EXPECT_NE(dynamic_cast(molecule), nullptr); + delete molecule; + + auto ruler = NodeFactory::create("sink.ruler"); + EXPECT_NE(ruler, nullptr); + EXPECT_NE(dynamic_cast(ruler), nullptr); + delete ruler; + + auto scaleCube = NodeFactory::create("sink.scaleCube"); + EXPECT_NE(scaleCube, nullptr); + EXPECT_NE(dynamic_cast(scaleCube), nullptr); + delete scaleCube; + + auto segment = NodeFactory::create("sink.segment"); + EXPECT_NE(segment, nullptr); + EXPECT_NE(dynamic_cast(segment), nullptr); + delete segment; + + auto volumeStats = NodeFactory::create("sink.volumeStats"); + EXPECT_NE(volumeStats, nullptr); + EXPECT_NE(dynamic_cast(volumeStats), nullptr); + delete volumeStats; +} + +TEST_F(PipelineLibTest, VolumeStatsSinkComputesCorrectStats) +{ + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + pipeline->addNode(source); + source->execute(); + + auto* stats = new VolumeStatsSink(); + pipeline->addNode(stats); + pipeline->createLink(source->outputPort("volume"), + stats->inputPort("volume")); + + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_TRUE(stats->hasResults()); + EXPECT_EQ(stats->voxelCount(), 8 * 8 * 8); + EXPECT_LE(stats->min(), stats->mean()); + EXPECT_LE(stats->mean(), stats->max()); +} + +TEST_F(PipelineLibTest, SinkInputPortTypes) +{ + // Volume sinks accept ImageData + ContourSink contour; + EXPECT_NE(contour.inputPort("volume"), nullptr); + + SliceSink slice; + EXPECT_NE(slice.inputPort("volume"), nullptr); + + ThresholdSink threshold; + EXPECT_NE(threshold.inputPort("volume"), nullptr); + + OutlineSink outline; + EXPECT_NE(outline.inputPort("volume"), nullptr); + + VolumeSink vol; + EXPECT_NE(vol.inputPort("volume"), nullptr); + + ClipSink clip; + EXPECT_NE(clip.inputPort("volume"), nullptr); + + // PlotSink accepts Table data + PlotSink plot; + EXPECT_NE(plot.inputPort("table"), nullptr); + + // MoleculeSink accepts Molecule data + MoleculeSink mol; + EXPECT_NE(mol.inputPort("molecule"), nullptr); +} + +// --- Save Data (batch output-port export) tests --- + +namespace { + +using tomviz::PortDataWriter::formatById; + +VolumeDataPtr makeVolume(const QStringList& arrayNames) +{ + vtkNew image; + image->SetDimensions(4, 4, 4); + for (const auto& name : arrayNames) { + vtkNew array; + array->SetName(name.toUtf8().data()); + array->SetNumberOfComponents(1); + array->SetNumberOfTuples(4 * 4 * 4); + array->FillValue(1.0f); + image->GetPointData()->AddArray(array); + } + if (!arrayNames.isEmpty()) { + image->GetPointData()->SetActiveScalars(arrayNames.first().toUtf8().data()); + } + return std::make_shared(image); +} + +void setVolumeData(OutputPort* port, const QStringList& arrayNames) +{ + port->setData( + PortData(std::any(makeVolume(arrayNames)), PortType::ImageData)); +} + +// EMD holds every array of a volume in one file. +QHash multiArrayFormats() +{ + return { { PortType::ImageData, formatById(PortType::ImageData, "emd") }, + { PortType::Table, formatById(PortType::Table, "csv") }, + { PortType::Molecule, formatById(PortType::Molecule, "xyz") } }; +} + +// TIFF keeps only the active scalars, so volumes split across files. +QHash singleArrayFormats() +{ + auto formats = multiArrayFormats(); + formats[PortType::ImageData] = formatById(PortType::ImageData, "tiff"); + return formats; +} + +QHash collectArrayNames( + const QList& ports) +{ + QHash names; + for (auto* port : ports) { + auto handle = port->materialize(); + names.insert(port, handle ? tomviz::PortDataWriter::arrayNames(*handle) + : QStringList()); + } + return names; +} + +QStringList fileNamesOf(const QList& plans) +{ + QStringList names; + for (const auto& plan : plans) { + for (const auto& entry : plan.entries) { + names << QFileInfo(entry.path).fileName(); + } + } + return names; +} + +QStringList displayNamesOf( + const QList& plans) +{ + QStringList names; + for (const auto& plan : plans) { + names << plan.displayName; + } + return names; +} + +QList entriesOf( + const QList& plans) +{ + QList entries; + for (const auto& plan : plans) { + entries.append(plan.entries); + } + return entries; +} + +} // namespace + +TEST_F(PipelineLibTest, SaveDataLeafScopeIgnoresSinks) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("volume", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A" }); + + auto* transform = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + transform->setLabel("Transform"); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("in")); + setVolumeData(transform->outputPort("out"), { "A" }); + + // A sink downstream must not disqualify the transform from being a leaf. + auto* sink = new CollectorSink(); + pipeline->addNode(sink); + pipeline->createLink(transform->outputPort("out"), sink->inputPort("in")); + + auto leaves = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + ASSERT_EQ(leaves.size(), 1); + EXPECT_EQ(leaves.first(), transform->outputPort("out")); +} + +TEST_F(PipelineLibTest, SaveDataPersistedScope) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("volume", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A" }); + source->outputPort("volume")->setPersistent(true); + + auto* transform = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + transform->setLabel("Transform"); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("in")); + // Flip persistence before publishing — reconciling an already-published + // port to transient drops the payload. + transform->outputPort("out")->setPersistent(false); + setVolumeData(transform->outputPort("out"), { "A" }); + + auto persisted = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::AllPersisted); + ASSERT_EQ(persisted.size(), 1); + EXPECT_EQ(persisted.first(), source->outputPort("volume")); + + // The transform is the only leaf, so the two scopes disagree here. + auto leaves = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + ASSERT_EQ(leaves.size(), 1); + EXPECT_EQ(leaves.first(), transform->outputPort("out")); +} + +TEST_F(PipelineLibTest, SaveDataNodeScopeSeesOnlyItsOwnPorts) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("volume", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A" }); + + auto* transform = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + transform->setLabel("Transform"); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("in")); + setVolumeData(transform->outputPort("out"), { "A" }); + + // The pipeline-wide view spans both nodes... + EXPECT_EQ(tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::AllPersisted) + .size(), + 2); + + // ...while a node-scoped export sees only that node, even though the + // node is upstream of another and so is not a leaf. + auto sourcePorts = tomviz::SaveDataDialog::candidatePorts( + source, tomviz::SaveDataDialog::Scope::AllPersisted); + ASSERT_EQ(sourcePorts.size(), 1); + EXPECT_EQ(sourcePorts.first(), source->outputPort("volume")); + + // Transient ports stay out of an AllPersisted export. + transform->outputPort("out")->setPersistent(false); + EXPECT_TRUE(tomviz::SaveDataDialog::candidatePorts( + transform, tomviz::SaveDataDialog::Scope::AllPersisted) + .isEmpty()); +} + +TEST_F(PipelineLibTest, SaveDataPortScopeSeesOnlyThatPort) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("volume", PortType::ImageData); + source->addOutput("mask", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A" }); + setVolumeData(source->outputPort("mask"), { "B" }); + + // The node view spans both of its ports... + EXPECT_EQ(tomviz::SaveDataDialog::candidatePorts( + source, tomviz::SaveDataDialog::Scope::AllPersisted) + .size(), + 2); + + // ...while the port view is just the one. + auto ports = tomviz::SaveDataDialog::candidatePorts( + source->outputPort("mask"), tomviz::SaveDataDialog::Scope::AllPersisted); + ASSERT_EQ(ports.size(), 1); + EXPECT_EQ(ports.first(), source->outputPort("mask")); + + auto plans = tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), "/tmp/out", multiArrayFormats()); + EXPECT_EQ(fileNamesOf(plans), QStringList({ "Source_mask.emd" })); + + // A port with no data has nothing to offer. + source->outputPort("mask")->clearData(); + EXPECT_TRUE(tomviz::SaveDataDialog::candidatePorts( + source->outputPort("mask"), + tomviz::SaveDataDialog::Scope::AllPersisted) + .isEmpty()); +} + +TEST_F(PipelineLibTest, SaveDataNodeScopeExcludesSinks) +{ + auto* source = new SourceNode(); + source->setLabel("Source"); + source->addOutput("volume", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A" }); + + auto* sink = new CollectorSink(); + pipeline->addNode(sink); + pipeline->createLink(source->outputPort("volume"), sink->inputPort("in")); + + EXPECT_TRUE(tomviz::SaveDataDialog::candidatePorts( + static_cast(sink), + tomviz::SaveDataDialog::Scope::AllPersisted) + .isEmpty()); +} + +TEST_F(PipelineLibTest, SaveDataFileNamePlanning) +{ + auto* first = new SourceNode(); + first->setLabel("Recon Volume"); + first->addOutput("out put", PortType::ImageData); + pipeline->addNode(first); + setVolumeData(first->outputPort("out put"), { "Scalars_", "Labels/2" }); + + // Same label as `first` — both must gain a numeric suffix. + auto* second = new SourceNode(); + second->setLabel("Recon Volume"); + second->addOutput("out", PortType::ImageData); + pipeline->addNode(second); + setVolumeData(second->outputPort("out"), { "A" }); + + auto ports = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + ASSERT_EQ(ports.size(), 2); + + // A single-array format splits the two-array port across two files + // and shows them braced on one row. + auto split = tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), "/tmp/out", singleArrayFormats()); + + EXPECT_EQ(fileNamesOf(split), + QStringList({ "Recon_Volume_1_out_put_Scalars.tiff", + "Recon_Volume_1_out_put_Labels_2.tiff", + "Recon_Volume_2_out_A.tiff" })); + EXPECT_EQ(displayNamesOf(split), + QStringList({ "Recon_Volume_1_out_put_{Scalars|Labels_2}.tiff", + "Recon_Volume_2_out_A.tiff" })); + EXPECT_EQ(entriesOf(split).first().path, + QString("/tmp/out/Recon_Volume_1_out_put_Scalars.tiff")); + EXPECT_EQ(entriesOf(split).first().arrayNames, + QStringList({ "Scalars_" })); + + // A multi-array format writes one file per port, array name omitted. + auto merged = tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), "/tmp/out", multiArrayFormats()); + + EXPECT_EQ(fileNamesOf(merged), QStringList({ "Recon_Volume_1_out_put.emd", + "Recon_Volume_2_out.emd" })); + EXPECT_EQ(displayNamesOf(merged), fileNamesOf(merged)); + EXPECT_EQ(entriesOf(merged).first().arrayNames, + QStringList({ "Scalars_", "Labels/2" })); +} + +TEST_F(PipelineLibTest, SaveDataFileNameCollisionsResolved) +{ + auto* source = new SourceNode(); + source->setLabel("Node"); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + // Two array names that sanitize onto the same stem. + setVolumeData(source->outputPort("out"), { "a b", "a/b" }); + + auto ports = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + auto plans = tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), "/tmp/out", singleArrayFormats()); + + EXPECT_EQ(fileNamesOf(plans), + QStringList({ "Node_out_a_b.tiff", "Node_out_a_b_2.tiff" })); + // The braced row reflects the disambiguated names, not the raw arrays. + EXPECT_EQ(displayNamesOf(plans), + QStringList({ "Node_out_{a_b|a_b_2}.tiff" })); +} + +TEST_F(PipelineLibTest, SaveDataWritesEveryArrayIntoOneEmd) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + auto* source = new SourceNode(); + source->setLabel("Sphere"); + source->addOutput("volume", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A", "B" }); + + auto ports = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + auto entries = entriesOf(tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), dir.path(), multiArrayFormats())); + ASSERT_EQ(entries.size(), 1); + + auto handle = entries.first().port->materialize(); + ASSERT_NE(handle, nullptr); + EXPECT_TRUE(tomviz::PortDataWriter::write(*handle, entries.first().arrayNames, + entries.first().path)); + + vtkNew readBack; + ASSERT_TRUE( + tomviz::EmdFormat::read(entries.first().path.toStdString(), readBack)); + EXPECT_EQ(readBack->GetPointData()->GetNumberOfArrays(), 2); + EXPECT_NE(readBack->GetPointData()->GetArray("A"), nullptr); + EXPECT_NE(readBack->GetPointData()->GetArray("B"), nullptr); +} + +TEST_F(PipelineLibTest, SaveDataSplitsArraysForSingleArrayFormats) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + auto* source = new SourceNode(); + source->setLabel("Sphere"); + source->addOutput("volume", PortType::ImageData); + pipeline->addNode(source); + setVolumeData(source->outputPort("volume"), { "A", "B" }); + + auto ports = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + // EMD is only used here to confirm the per-array filtering; the split + // itself is what a single-array format like TIFF triggers. + auto formats = singleArrayFormats(); + formats[PortType::ImageData].multiArray = false; + formats[PortType::ImageData].extension = "emd"; + + auto plans = tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), dir.path(), formats); + ASSERT_EQ(plans.size(), 1); + ASSERT_EQ(plans.first().entries.size(), 2); + EXPECT_EQ(plans.first().displayName, QString("Sphere_volume_{A|B}.emd")); + + for (const auto& entry : plans.first().entries) { + auto handle = entry.port->materialize(); + ASSERT_NE(handle, nullptr); + EXPECT_TRUE( + tomviz::PortDataWriter::write(*handle, entry.arrayNames, entry.path)); + + // Each file carries only the array it was named for. + vtkNew readBack; + ASSERT_TRUE(tomviz::EmdFormat::read(entry.path.toStdString(), readBack)); + EXPECT_EQ(readBack->GetPointData()->GetNumberOfArrays(), 1); + EXPECT_NE( + readBack->GetPointData()->GetArray( + entry.arrayNames.first().toUtf8().data()), + nullptr); + } +} + +TEST_F(PipelineLibTest, SaveDataWritesTableAndMolecule) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + auto* tableSource = new SourceNode(); + tableSource->setLabel("Stats"); + tableSource->addOutput("table", PortType::Table); + pipeline->addNode(tableSource); + + vtkNew column; + column->SetName("value"); + column->InsertNextValue(1.5); + column->InsertNextValue(2.5); + vtkSmartPointer table = vtkSmartPointer::New(); + table->AddColumn(column); + tableSource->outputPort("table")->setData( + PortData(std::any(table), PortType::Table)); + + auto* moleculeSource = new SourceNode(); + moleculeSource->setLabel("Atoms"); + moleculeSource->addOutput("molecule", PortType::Molecule); + pipeline->addNode(moleculeSource); + + vtkSmartPointer molecule = vtkSmartPointer::New(); + molecule->AppendAtom(6, 0.0, 0.0, 0.0); + molecule->AppendAtom(8, 1.0, 0.0, 0.0); + moleculeSource->outputPort("molecule")->setData( + PortData(std::any(molecule), PortType::Molecule)); + + auto ports = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::LeafNodes); + auto plans = tomviz::SaveDataDialog::planPorts( + ports, collectArrayNames(ports), dir.path(), multiArrayFormats()); + auto entries = entriesOf(plans); + + EXPECT_EQ(fileNamesOf(plans), + QStringList({ "Stats_table.csv", "Atoms_molecule.xyz" })); + + for (const auto& entry : entries) { + auto handle = entry.port->materialize(); + ASSERT_NE(handle, nullptr); + EXPECT_TRUE( + tomviz::PortDataWriter::write(*handle, entry.arrayNames, entry.path)); + EXPECT_TRUE(QFileInfo::exists(entry.path)); + } + + QFile csv(entries.first().path); + ASSERT_TRUE(csv.open(QIODevice::ReadOnly | QIODevice::Text)); + EXPECT_EQ(QString(csv.readAll()), QString("value\n1.5\n2.5")); + + QFile xyz(entries.last().path); + ASSERT_TRUE(xyz.open(QIODevice::ReadOnly | QIODevice::Text)); + EXPECT_TRUE(QString(xyz.readAll()).startsWith("2\n")); +} + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/cxx/PipelineSinkDemo.cxx b/tests/cxx/PipelineSinkDemo.cxx new file mode 100644 index 000000000..ac1c0aa78 --- /dev/null +++ b/tests/cxx/PipelineSinkDemo.cxx @@ -0,0 +1,231 @@ +/* Manual demo app for pipeline Sinks with live visualization. + Build target: pipelineSinkDemo + + Pipeline: SphereSource -> ThresholdTransform -> [selected sink] + The sink is displayed in a ParaView render view (or chart view for Plot). + A PipelineStripWidget in a left dock shows the pipeline graph. +*/ + +#include "Pipeline.h" +#include "PipelineStripWidget.h" +#include "sources/SphereSource.h" +#include "transforms/ThresholdTransform.h" + +// Sinks +#include "sinks/ClipSink.h" +#include "sinks/ContourSink.h" +#include "sinks/OutlineSink.h" +#include "sinks/RulerSink.h" +#include "sinks/ScaleCubeSink.h" +#include "sinks/SegmentSink.h" +#include "sinks/SliceSink.h" +#include "sinks/ThresholdSink.h" +#include "sinks/VolumeSink.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace tomviz::pipeline; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +struct SinkEntry +{ + QString name; + // Factory that creates a fresh sink each time + std::function create; +}; + +static QList sinkEntries() +{ + return { + { "Outline", [] { return new OutlineSink(); } }, + { "Contour", [] { return new ContourSink(); } }, + { "Slice", [] { return new SliceSink(); } }, + { "Volume", [] { return new VolumeSink(); } }, + { "Threshold", [] { return new ThresholdSink(); } }, + { "Segment", [] { return new SegmentSink(); } }, + { "Clip", [] { return new ClipSink(); } }, + { "Ruler", [] { return new RulerSink(); } }, + { "Scale Cube", [] { return new ScaleCubeSink(); } }, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + + // ParaView application core — sets up the server-manager infrastructure + pqPVApplicationCore pvCore(argc, argv); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + builder->createServer(pqServerResource("builtin:")); + auto* server = pqApplicationCore::instance()->getActiveServer(); + + // Create a 3D render view + auto* pqRenderView = builder->createView("RenderView", server); + auto* viewProxy = pqRenderView->getViewProxy(); + + // ----------------------------------------------------------------------- + // Build the initial pipeline: Sphere -> Threshold -> Outline + // ----------------------------------------------------------------------- + auto entries = sinkEntries(); + + auto* pipeline = new Pipeline(); + + auto* source = new SphereSource(); + source->setDimensions(64, 64, 64); + pipeline->addNode(source); + + auto* transform = new ThresholdTransform(); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + auto* sink = entries[0].create(); + pipeline->addNode(sink); + pipeline->createLink(transform->outputPort("mask"), + sink->inputPort("volume")); + + // Execute the source so downstream data is available + source->execute(); + // Execute the full pipeline (threshold + sink) + pipeline->execute(); + + // Initialize the sink with the render view so it creates its VTK actors + sink->initialize(viewProxy); + + // Re-consume now that the view is set up + // (the first execute ran without a view; the sink stored the data + // but could not create VTK actors.) + pipeline->execute(); + + // ----------------------------------------------------------------------- + // Window layout + // ----------------------------------------------------------------------- + QMainWindow window; + window.setWindowTitle("Pipeline Sink Demo"); + window.resize(1024, 768); + + // Central area: render view widget + controls on top + auto* central = new QWidget(); + auto* centralLayout = new QVBoxLayout(central); + centralLayout->setContentsMargins(0, 0, 0, 0); + + // Top toolbar: sink selector + execute button + auto* toolbar = new QWidget(); + auto* toolbarLayout = new QHBoxLayout(toolbar); + toolbarLayout->setContentsMargins(4, 4, 4, 4); + + auto* sinkLabel = new QLabel("Sink:"); + auto* sinkCombo = new QComboBox(); + for (auto& entry : entries) { + sinkCombo->addItem(entry.name); + } + auto* executeBtn = new QPushButton("Execute"); + + toolbarLayout->addWidget(sinkLabel); + toolbarLayout->addWidget(sinkCombo); + toolbarLayout->addWidget(executeBtn); + toolbarLayout->addStretch(); + + centralLayout->addWidget(toolbar); + + // Embed the ParaView render-view widget + QWidget* viewWidget = pqRenderView->widget(); + centralLayout->addWidget(viewWidget, 1); + + window.setCentralWidget(central); + + // Left dock: pipeline strip widget + auto* dock = new QDockWidget("Pipeline", &window); + dock->setMinimumWidth(200); + + auto* scroll = new QScrollArea(); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + auto* strip = new PipelineStripWidget(); + strip->setPipeline(pipeline); + scroll->setWidget(strip); + dock->setWidget(scroll); + window.addDockWidget(Qt::LeftDockWidgetArea, dock); + + // Status bar + window.statusBar()->showMessage("Ready"); + + // ----------------------------------------------------------------------- + // Connections + // ----------------------------------------------------------------------- + + // When the sink combo changes, rebuild the pipeline tail + QObject::connect( + sinkCombo, QOverload::of(&QComboBox::currentIndexChanged), + [&](int index) { + // Finalize and remove old sink + sink->finalize(); + pipeline->removeNode(sink); + // sink is deleted by removeNode (Pipeline owns its nodes) + + // Create new sink and wire it in + sink = entries[index].create(); + pipeline->addNode(sink); + pipeline->createLink(transform->outputPort("mask"), + sink->inputPort("volume")); + + // Initialize with the view and execute + sink->initialize(viewProxy); + pipeline->execute(); + + strip->setPipeline(pipeline); + pqRenderView->render(); + + window.statusBar()->showMessage( + QString("Switched to %1 sink").arg(entries[index].name)); + }); + + // Execute button re-runs the pipeline + QObject::connect(executeBtn, &QPushButton::clicked, [&]() { + source->execute(); + pipeline->execute(); + pqRenderView->render(); + window.statusBar()->showMessage("Pipeline executed"); + }); + + // Node selection feedback + QObject::connect(strip, &PipelineStripWidget::nodeSelected, [&](Node* node) { + if (node) { + window.statusBar()->showMessage( + QString("Selected: %1").arg(node->label())); + } + }); + + // Render when any sink signals it needs a render + QObject::connect(sink, &LegacyModuleSink::renderNeeded, + [&]() { pqRenderView->render(); }); + + window.show(); + pqRenderView->render(); + + return app.exec(); +} diff --git a/tests/cxx/PipelineStripWidgetDemo.cxx b/tests/cxx/PipelineStripWidgetDemo.cxx new file mode 100644 index 000000000..a5834faa7 --- /dev/null +++ b/tests/cxx/PipelineStripWidgetDemo.cxx @@ -0,0 +1,851 @@ +/* Manual demo app for PipelineStripWidget. Build target: pipelineStripDemo */ + +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "PipelineStripWidget.h" +#include "PortData.h" +#include "PortType.h" +#include "SinkGroupNode.h" +#include "SinkNode.h" +#include "SourceNode.h" +#include "TransformNode.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace tomviz::pipeline; + +// Simple transform subclass for demo +class DemoTransform : public TransformNode +{ +public: + DemoTransform(const QString& label, + const QList>& inputs, + const QList>& outputs) + : TransformNode() + { + setLabel(label); + for (auto& [name, type] : inputs) { + addInput(name, type); + } + for (auto& [name, type] : outputs) { + addOutput(name, type); + } + } + + DemoTransform(const QString& label, int numInputs = 1, int numOutputs = 1) + : TransformNode() + { + setLabel(label); + for (int i = 0; i < numInputs; ++i) { + QString name = numInputs == 1 ? "in" : QString("in%1").arg(i); + addInput(name, PortType::ImageData); + } + for (int i = 0; i < numOutputs; ++i) { + QString name; + PortType type; + if (numOutputs == 1) { + name = "out"; + type = PortType::ImageData; + } else { + if (i == 0) { + name = "volume"; + type = PortType::ImageData; + } else if (i == 1) { + name = "table"; + type = PortType::Table; + } else { + name = QString("out%1").arg(i); + type = PortType::Molecule; + } + } + addOutput(name, type); + } + } + +protected: + QMap transform( + const QMap&) override + { + return {}; + } +}; + +class DemoSink : public SinkNode +{ +public: + DemoSink(const QString& label, + const QList>& inputs = {{"in", + PortType::ImageData}}, + const QString& iconPath = ":/pipeline/pqVolumeData.png") + : SinkNode(), m_iconPath(iconPath) + { + setLabel(label); + for (auto& [name, type] : inputs) { + addInput(name, type); + } + } + + QIcon icon() const override + { + return QIcon(m_iconPath); + } + + QIcon actionIcon() const override + { + // Use check/error as stand-in for eyeball open/closed + return m_visible + ? QIcon(QStringLiteral(":/pipeline/check.png")) + : QIcon(QStringLiteral(":/pipeline/canceled.png")); + } + + void triggerAction() override + { + m_visible = !m_visible; + } + +protected: + bool consume(const QMap&) override { return true; } + +private: + QString m_iconPath; + bool m_visible = true; +}; + +// --------------------------------------------------------------------------- +// Pipeline builders +// --------------------------------------------------------------------------- + +// Source -> Gaussian -> Threshold -> Contour -> Volume Stats +static Pipeline* buildLinearPipeline() +{ + auto* p = new Pipeline(); + + auto* src = new SourceNode(); + src->setLabel("sample.vti"); + src->addOutput("volume", PortType::ImageData); + src->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(src); + + auto* gauss = new DemoTransform("Gaussian Filter"); + p->addNode(gauss); + p->createLink(src->outputPort("volume"), gauss->inputPort("in")); + + auto* thresh = new DemoTransform("Threshold"); + p->addNode(thresh); + p->createLink(gauss->outputPort("out"), thresh->inputPort("in")); + + auto* contour = new DemoTransform("Contour"); + p->addNode(contour); + p->createLink(thresh->outputPort("out"), contour->inputPort("in")); + + auto* stats = new DemoSink("Volume Stats"); + p->addNode(stats); + p->createLink(contour->outputPort("out"), stats->inputPort("in")); + + return p; +} + +// Source -> Segment (vol, table, molecules) -> Stats, Plot, MolViewer +static Pipeline* buildMultiOutputPipeline() +{ + auto* p = new Pipeline(); + + auto* src = new SourceNode(); + src->setLabel("Load Data"); + src->addOutput("volume", PortType::ImageData); + src->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(src); + + auto* seg = new DemoTransform("Segment", 1, 3); + p->addNode(seg); + p->createLink(src->outputPort("volume"), seg->inputPort("in")); + + auto* stats = new DemoTransform("Stats"); + p->addNode(stats); + p->createLink(seg->outputPort("volume"), stats->inputPort("in")); + + auto* plot = new DemoSink("Plot", + {{"table", PortType::Table}}); + p->addNode(plot); + p->createLink(seg->outputPort("table"), plot->inputPort("table")); + + auto* molView = new DemoSink("Molecule Viewer", + {{"mol", PortType::Molecule}}); + p->addNode(molView); + p->createLink(seg->outputPort("out2"), molView->inputPort("mol")); + + return p; +} + +// Source A, Source B -> Merge -> Normalize -> Export +static Pipeline* buildFanInPipeline() +{ + auto* p = new Pipeline(); + + auto* srcA = new SourceNode(); + srcA->setLabel("Source A"); + srcA->addOutput("volume", PortType::ImageData); + srcA->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(srcA); + + auto* srcB = new SourceNode(); + srcB->setLabel("Source B"); + srcB->addOutput("volume", PortType::ImageData); + srcB->setOutputData("volume", PortData(std::any(2), PortType::ImageData)); + p->addNode(srcB); + + auto* merge = new DemoTransform( + "Merge", + {{"volA", PortType::ImageData}, {"volB", PortType::ImageData}}, + {{"merged", PortType::ImageData}}); + p->addNode(merge); + p->createLink(srcA->outputPort("volume"), merge->inputPort("volA")); + p->createLink(srcB->outputPort("volume"), merge->inputPort("volB")); + + auto* norm = new DemoTransform("Normalize"); + p->addNode(norm); + p->createLink(merge->outputPort("merged"), norm->inputPort("in")); + + auto* sink = new DemoSink("Export"); + p->addNode(sink); + p->createLink(norm->outputPort("out"), sink->inputPort("in")); + + return p; +} + +// Source -> Gaussian + Median (fan-out), both -> Compare (fan-in) +static Pipeline* buildFanOutPipeline() +{ + auto* p = new Pipeline(); + + auto* src = new SourceNode(); + src->setLabel("sample.vti"); + src->addOutput("volume", PortType::ImageData); + src->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(src); + + auto* gauss = new DemoTransform("Gaussian Filter"); + p->addNode(gauss); + p->createLink(src->outputPort("volume"), gauss->inputPort("in")); + + auto* median = new DemoTransform("Median Filter"); + p->addNode(median); + p->createLink(src->outputPort("volume"), median->inputPort("in")); + + auto* compare = new DemoTransform( + "Compare", + {{"volA", PortType::ImageData}, {"volB", PortType::ImageData}}, + {{"diff", PortType::ImageData}, {"stats", PortType::Table}}); + p->addNode(compare); + p->createLink(gauss->outputPort("out"), compare->inputPort("volA")); + p->createLink(median->outputPort("out"), compare->inputPort("volB")); + + auto* view = new DemoSink("Diff Viewer"); + p->addNode(view); + p->createLink(compare->outputPort("diff"), view->inputPort("in")); + + auto* plot = new DemoSink("Error Plot", + {{"table", PortType::Table}}); + p->addNode(plot); + p->createLink(compare->outputPort("stats"), plot->inputPort("table")); + + return p; +} + +// A more complex diamond + multi-output topology: +// Source (TiltSeries) -> Denoise -> Segment (vol, table) +// \-> Align -> Reconstruct (->Volume) -> Export +// Segment.table -> Plot +static Pipeline* buildComplexPipeline() +{ + auto* p = new Pipeline(); + + auto* src = new SourceNode(); + src->setLabel("tilt_series.mrc"); + src->addOutput("volume", PortType::TiltSeries); + src->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(src); + + // Denoise is generic (ImageData -> ImageData), infers TiltSeries from source + auto* denoise = new DemoTransform("Denoise (BM3D)"); + p->addNode(denoise); + p->createLink(src->outputPort("volume"), denoise->inputPort("in")); + + auto* segment = new DemoTransform( + "Segment", + {{"in", PortType::ImageData}}, + {{"labels", PortType::ImageData}, {"stats", PortType::Table}}); + p->addNode(segment); + p->createLink(denoise->outputPort("out"), segment->inputPort("in")); + + // Align takes TiltSeries (inferred through denoise) + auto* align = new DemoTransform("Align Tilt"); + p->addNode(align); + p->createLink(denoise->outputPort("out"), align->inputPort("in")); + + // Reconstruct takes ImageData, outputs Volume + auto* recon = new DemoTransform( + "Reconstruct (SIRT)", + {{"in", PortType::ImageData}}, + {{"out", PortType::Volume}}); + p->addNode(recon); + p->createLink(align->outputPort("out"), recon->inputPort("in")); + + auto* exportSink = new DemoSink("Export Volume"); + p->addNode(exportSink); + p->createLink(recon->outputPort("out"), exportSink->inputPort("in")); + + auto* plot = new DemoSink("Segmentation Plot", + {{"table", PortType::Table}}); + p->addNode(plot); + p->createLink(segment->outputPort("stats"), plot->inputPort("table")); + + return p; +} + +// Source (TiltSeries) -> Reconstruction (TiltSeries->Volume) -> Pad -> Align +// -> Denoise (vol, table) +// Denoise.vol -> Outline, Slice, Volume (fan-out to 3 sinks) +// Denoise.table -> Plot +static Pipeline* buildTomographyPipeline() +{ + auto* p = new Pipeline(); + + auto* src = new SourceNode(); + src->setLabel("experiment.tiff"); + src->addOutput("Tilt Series", PortType::TiltSeries); + src->setOutputData("Tilt Series", + PortData(std::any(1), PortType::ImageData)); + p->addNode(src); + + // Reconstruction takes TiltSeries, produces Volume + auto* recon = new DemoTransform( + "Reconstruction", + {{"in", PortType::TiltSeries}}, + {{"Volume", PortType::Volume}}); + p->addNode(recon); + p->createLink(src->outputPort("Tilt Series"), recon->inputPort("in")); + + // Pad, Align, Denoise are generic Volume->Volume transforms + auto* pad = new DemoTransform( + "Pad", + {{"in", PortType::Volume}}, + {{"Volume", PortType::Volume}}); + p->addNode(pad); + p->createLink(recon->outputPort("Volume"), pad->inputPort("in")); + + auto* align = new DemoTransform( + "Align", + {{"in", PortType::Volume}}, + {{"Volume", PortType::Volume}}); + p->addNode(align); + p->createLink(pad->outputPort("Volume"), align->inputPort("in")); + + auto* denoise = new DemoTransform( + "Denoise", + {{"in", PortType::Volume}}, + {{"Volume", PortType::Volume}, {"Metrics", PortType::Table}}); + p->addNode(denoise); + p->createLink(align->outputPort("Volume"), denoise->inputPort("in")); + + auto* plot = new DemoSink("Plot", + {{"table", PortType::Table}}); + p->addNode(plot); + p->createLink(denoise->outputPort("Metrics"), plot->inputPort("table")); + + // Visualization sinks accept any volume-like data (ImageData) + auto* outline = new DemoSink("Outline"); + p->addNode(outline); + p->createLink(denoise->outputPort("Volume"), outline->inputPort("in")); + + auto* slice = new DemoSink("Slice"); + p->addNode(slice); + p->createLink(denoise->outputPort("Volume"), slice->inputPort("in")); + + auto* volume = new DemoSink("Volume"); + p->addNode(volume); + p->createLink(denoise->outputPort("Volume"), volume->inputPort("in")); + + return p; +} + +// Left branch: Source -> Gaussian -> SinkGroup -> Outline, Slice, Volume +// Right branch: Source -> Median -> Outline2, Slice2, Volume2 (no group) +static Pipeline* buildSinkGroupPipeline() +{ + auto* p = new Pipeline(); + + // --- Branch A: Source -> Transform -> SinkGroup -> 3 sinks --- + auto* srcA = new SourceNode(); + srcA->setLabel("sample_A.vti"); + srcA->addOutput("volume", PortType::ImageData); + srcA->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(srcA); + + auto* gauss = new DemoTransform("Gaussian Filter"); + p->addNode(gauss); + p->createLink(srcA->outputPort("volume"), gauss->inputPort("in")); + + auto* group = new SinkGroupNode(); + group->addPassthrough("volume", PortType::ImageData); + p->addNode(group); + p->createLink(gauss->outputPort("out"), group->inputPort("volume")); + + auto* outline = new DemoSink("Outline"); + p->addNode(outline); + p->createLink(group->outputPort("volume"), outline->inputPort("in")); + + auto* slice = new DemoSink("Slice"); + p->addNode(slice); + p->createLink(group->outputPort("volume"), slice->inputPort("in")); + + auto* volume = new DemoSink("Volume"); + p->addNode(volume); + p->createLink(group->outputPort("volume"), volume->inputPort("in")); + + // --- Branch B: Source -> Transform -> 3 sinks (no group) --- + auto* srcB = new SourceNode(); + srcB->setLabel("sample_B.vti"); + srcB->addOutput("volume", PortType::ImageData); + srcB->setOutputData("volume", PortData(std::any(1), PortType::ImageData)); + p->addNode(srcB); + + auto* median = new DemoTransform("Median Filter"); + p->addNode(median); + p->createLink(srcB->outputPort("volume"), median->inputPort("in")); + + auto* outline2 = new DemoSink("Outline 2"); + p->addNode(outline2); + p->createLink(median->outputPort("out"), outline2->inputPort("in")); + + auto* slice2 = new DemoSink("Slice 2"); + p->addNode(slice2); + p->createLink(median->outputPort("out"), slice2->inputPort("in")); + + auto* volume2 = new DemoSink("Volume 2"); + p->addNode(volume2); + p->createLink(median->outputPort("out"), volume2->inputPort("in")); + + return p; +} + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + + QMainWindow window; + window.setWindowTitle("PipelineStripWidget Demo"); + window.resize(800, 600); + + // --- Central widget: pipeline selector + node creation --- + auto* central = new QWidget(); + auto* centralLayout = new QVBoxLayout(central); + + // Pipeline selector + auto* combo = new QComboBox(); + combo->addItem("Linear"); + combo->addItem("Multi-Output"); + combo->addItem("Fan-In"); + combo->addItem("Fan-Out"); + combo->addItem("Complex"); + combo->addItem("Tomography"); + combo->addItem("Sink Group"); + combo->addItem("Empty"); + centralLayout->addWidget(combo); + + // Sort order selector + auto* sortCombo = new QComboBox(); + sortCombo->addItem("Default (Kahn's)"); + sortCombo->addItem("Stable (creation order)"); + sortCombo->addItem("Depth-First (chains)"); + centralLayout->addWidget(sortCombo); + + // --- Helpers --- + auto makeSpin = [](int defaultVal) { + auto* spin = new QSpinBox(); + spin->setRange(0, 8); + spin->setValue(defaultVal); + return spin; + }; + + // Spinbox group for the 5 port types + struct PortSpinBoxes + { + QSpinBox* imageData = nullptr; + QSpinBox* tiltSeries = nullptr; + QSpinBox* volume = nullptr; + QSpinBox* table = nullptr; + QSpinBox* molecule = nullptr; + }; + + // Build a port list from five type-count spinners + auto collectPorts = [](const PortSpinBoxes& s, const QString& prefix) + -> QList> { + QList> ports; + int total = s.imageData->value() + s.tiltSeries->value() + + s.volume->value() + s.table->value() + s.molecule->value(); + int idx = 0; + auto add = [&](int count, PortType type, const QString& tag) { + for (int i = 0; i < count; ++i) { + QString name = (total == 1 && idx == 0) + ? prefix + : QString("%1_%2%3").arg(prefix, tag, + count > 1 + ? QString::number(i) + : QString()); + ports.append({ name, type }); + ++idx; + } + }; + add(s.imageData->value(), PortType::ImageData, "img"); + add(s.tiltSeries->value(), PortType::TiltSeries, "ts"); + add(s.volume->value(), PortType::Volume, "vol"); + add(s.table->value(), PortType::Table, "tbl"); + add(s.molecule->value(), PortType::Molecule, "mol"); + return ports; + }; + + // Build a port-count grid: " | Input | Output" header + per-type rows + // Pass showInput=false for Source, showOutput=false for Sink. + auto makePortGrid = [&makeSpin](QVBoxLayout* parent, + bool showInput, bool showOutput, + int defaultInImg, int defaultOutImg) + -> QPair { + // Header row + auto* header = new QHBoxLayout(); + auto* headerLabel = new QLabel(); + headerLabel->setFixedWidth(70); + header->addWidget(headerLabel); + if (showInput) { + auto* inLabel = new QLabel("Input"); + inLabel->setAlignment(Qt::AlignCenter); + header->addWidget(inLabel); + } + if (showOutput) { + auto* outLabel = new QLabel("Output"); + outLabel->setAlignment(Qt::AlignCenter); + header->addWidget(outLabel); + } + parent->addLayout(header); + + PortSpinBoxes inputs, outputs; + + auto addRow = [&](const QString& label, int defIn, int defOut) + -> QPair { + auto* row = new QHBoxLayout(); + auto* lbl = new QLabel(label); + lbl->setFixedWidth(70); + row->addWidget(lbl); + QSpinBox* inSpin = nullptr; + QSpinBox* outSpin = nullptr; + if (showInput) { + inSpin = makeSpin(defIn); + row->addWidget(inSpin); + } + if (showOutput) { + outSpin = makeSpin(defOut); + row->addWidget(outSpin); + } + parent->addLayout(row); + return { inSpin, outSpin }; + }; + + auto [iImg, oImg] = addRow("ImageData", defaultInImg, defaultOutImg); + inputs.imageData = iImg; outputs.imageData = oImg; + auto [iTs, oTs] = addRow("TiltSeries", 0, 0); + inputs.tiltSeries = iTs; outputs.tiltSeries = oTs; + auto [iVol, oVol] = addRow("Volume", 0, 0); + inputs.volume = iVol; outputs.volume = oVol; + auto [iTbl, oTbl] = addRow("Table", 0, 0); + inputs.table = iTbl; outputs.table = oTbl; + auto [iMol, oMol] = addRow("Molecule", 0, 0); + inputs.molecule = iMol; outputs.molecule = oMol; + + return { inputs, outputs }; + }; + + // --- Add Source --- + auto* srcGroup = new QGroupBox("Add Source"); + auto* srcLayout = new QVBoxLayout(srcGroup); + auto* srcLabelEdit = new QLineEdit("New Source"); + srcLayout->addWidget(srcLabelEdit); + auto srcPorts = makePortGrid(srcLayout, false, true, 0, 1); + auto* srcButton = new QPushButton("Add Source"); + srcLayout->addWidget(srcButton); + centralLayout->addWidget(srcGroup); + + // --- Add Transform --- + auto* xfGroup = new QGroupBox("Add Transform"); + auto* xfLayout = new QVBoxLayout(xfGroup); + auto* xfLabelEdit = new QLineEdit("New Transform"); + xfLayout->addWidget(xfLabelEdit); + auto xfPorts = makePortGrid(xfLayout, true, true, 1, 1); + auto* xfButton = new QPushButton("Add Transform"); + xfLayout->addWidget(xfButton); + centralLayout->addWidget(xfGroup); + + // --- Add Sink --- + auto* sinkGroup = new QGroupBox("Add Sink"); + auto* sinkLayout = new QVBoxLayout(sinkGroup); + auto* sinkLabelEdit = new QLineEdit("New Sink"); + sinkLayout->addWidget(sinkLabelEdit); + auto sinkPorts = makePortGrid(sinkLayout, true, false, 1, 0); + auto* sinkButton = new QPushButton("Add Sink"); + sinkLayout->addWidget(sinkButton); + centralLayout->addWidget(sinkGroup); + + centralLayout->addStretch(); + window.setCentralWidget(central); + + // --- Dock with strip widget --- + auto* dock = new QDockWidget("Pipeline", &window); + dock->setMinimumWidth(200); + + auto* scroll = new QScrollArea(); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + + auto* strip = new PipelineStripWidget(); + scroll->setWidget(strip); + dock->setWidget(scroll); + window.addDockWidget(Qt::LeftDockWidgetArea, dock); + + // Pipeline instances + Pipeline* pipelines[8] = { buildLinearPipeline(), buildMultiOutputPipeline(), + buildFanInPipeline(), buildFanOutPipeline(), + buildComplexPipeline(), buildTomographyPipeline(), + buildSinkGroupPipeline(), new Pipeline(), + }; + + strip->setPipeline(pipelines[0]); + + // --- Add node handlers --- + + QObject::connect(srcButton, &QPushButton::clicked, [&]() { + auto* p = strip->pipeline(); + if (!p) return; + QString label = srcLabelEdit->text(); + if (label.isEmpty()) label = "Source"; + auto outputs = collectPorts(srcPorts.second, "out"); + auto* src = new SourceNode(); + src->setLabel(label); + for (auto& [name, type] : outputs) { + src->addOutput(name, type); + } + p->addNode(src); + }); + + QObject::connect(xfButton, &QPushButton::clicked, [&]() { + auto* p = strip->pipeline(); + if (!p) return; + QString label = xfLabelEdit->text(); + if (label.isEmpty()) label = "Transform"; + auto inputs = collectPorts(xfPorts.first, "in"); + auto outputs = collectPorts(xfPorts.second, "out"); + auto* xform = new DemoTransform(label, inputs, outputs); + p->addNode(xform); + }); + + QObject::connect(sinkButton, &QPushButton::clicked, [&]() { + auto* p = strip->pipeline(); + if (!p) return; + QString label = sinkLabelEdit->text(); + if (label.isEmpty()) label = "Sink"; + auto inputs = collectPorts(sinkPorts.first, "in"); + auto* sink = new DemoSink(label, inputs); + p->addNode(sink); + }); + + // Link validator: compatible type, different nodes, target not already connected + auto linkValidator = [](OutputPort* from, InputPort* to) -> bool { + if (from->node() == to->node()) { + return false; + } + if (!isPortTypeCompatible(from->type(), to->acceptedTypes())) { + return false; + } + if (!from->canAcceptLink(to)) { + return false; + } + if (to->link()) { + // Allow dragging from a SinkGroupNode "+" to a sink that shares + // the same upstream port as the group. + auto* sg = qobject_cast(from->node()); + if (sg) { + int idx = sg->outputPorts().indexOf(from); + if (idx >= 0 && idx < sg->inputPorts().size()) { + auto* gi = sg->inputPorts()[idx]; + if (gi->link() && gi->link()->from() == to->link()->from()) { + return true; + } + } + } + return false; + } + return true; + }; + strip->setLinkValidator(linkValidator); + + // Create the link when requested + QObject::connect(strip, &PipelineStripWidget::linkRequested, + [&](OutputPort* from, InputPort* to) { + if (auto* p = strip->pipeline()) { + // Remove existing link if stealing a sink into a group. + if (to->link()) { + p->removeLink(to->link()); + } + p->createLink(from, to); + qDebug("Created link: %s.%s -> %s.%s", + qPrintable(from->node()->label()), + qPrintable(from->name()), + qPrintable(to->node()->label()), + qPrintable(to->name())); + } + }); + + // Leave-group: relink the member to the group's upstream port + QObject::connect(strip, &PipelineStripWidget::leaveGroupRequested, + [](Node* member, SinkGroupNode* group) { + auto* p = qobject_cast(group->parent()); + if (!p) return; + for (auto* inPort : member->inputPorts()) { + if (!inPort->link()) continue; + auto* groupPort = inPort->link()->from(); + if (groupPort->node() != group) continue; + int idx = group->outputPorts().indexOf(groupPort); + OutputPort* upstream = nullptr; + if (idx >= 0 && idx < group->inputPorts().size()) { + auto* gi = group->inputPorts()[idx]; + if (gi->link()) upstream = gi->link()->from(); + } + p->removeLink(inPort->link()); + if (upstream) p->createLink(upstream, inPort); + break; + } + qDebug("Left group: %s", qPrintable(member->label())); + }); + + // Context menu on nodes + strip->setNodeMenuProvider([](Node* node, QMenu& menu) { + auto* p = qobject_cast(node->parent()); + if (!p) return; + + // SinkNode not in a group: "Create Group" + auto* sink = qobject_cast(node); + bool inGroup = false; + if (sink) { + for (auto* inPort : sink->inputPorts()) { + if (inPort->link() && + qobject_cast(inPort->link()->from()->node())) { + inGroup = true; + break; + } + } + } + if (sink && !inGroup) { + menu.addAction("Create Group", [p, sink]() { + auto* group = new SinkGroupNode(); + for (auto* inPort : sink->inputPorts()) { + PortType pt = PortType::ImageData; + for (auto t : { PortType::ImageData, PortType::TiltSeries, + PortType::Volume, PortType::Image, + PortType::Table, PortType::Molecule }) { + if (inPort->acceptedTypes().testFlag(t)) { pt = t; break; } + } + group->addPassthrough(inPort->name(), pt); + } + p->addNode(group); + for (int i = 0; i < sink->inputPorts().size(); ++i) { + auto* sinkInput = sink->inputPorts()[i]; + if (sinkInput->link() && i < group->inputPorts().size()) { + auto* upstream = sinkInput->link()->from(); + p->removeLink(sinkInput->link()); + p->createLink(upstream, group->inputPorts()[i]); + } + if (i < group->outputPorts().size()) + p->createLink(group->outputPorts()[i], sinkInput); + } + }); + } + + // Node inside a group: "Leave Group" + for (auto* inPort : node->inputPorts()) { + if (!inPort->link()) continue; + auto* group = qobject_cast( + inPort->link()->from()->node()); + if (!group) continue; + menu.addAction("Leave Group", [p, node, group]() { + for (auto* inp : node->inputPorts()) { + if (!inp->link()) continue; + auto* groupPort = inp->link()->from(); + if (groupPort->node() != group) continue; + int idx = group->outputPorts().indexOf(groupPort); + OutputPort* upstream = nullptr; + if (idx >= 0 && idx < group->inputPorts().size()) { + auto* gi = group->inputPorts()[idx]; + if (gi->link()) upstream = gi->link()->from(); + } + p->removeLink(inp->link()); + if (upstream) p->createLink(upstream, inp); + break; + } + }); + break; + } + + menu.addAction("Delete", [p, node]() { p->removeNode(node); }); + }); + + // Context menu on links: delete action + strip->setLinkMenuProvider([](Link* link, QMenu& menu) { + menu.addAction("Delete Link", [link]() { + auto* p = qobject_cast(link->parent()); + if (p) { + qDebug("Deleting link: %s.%s -> %s.%s", + qPrintable(link->from()->node()->label()), + qPrintable(link->from()->name()), + qPrintable(link->to()->node()->label()), + qPrintable(link->to()->name())); + p->removeLink(link); + } + }); + }); + + QObject::connect(combo, &QComboBox::currentIndexChanged, + [&](int idx) { strip->setPipeline(pipelines[idx]); }); + + QObject::connect(sortCombo, &QComboBox::currentIndexChanged, + [&](int idx) { + strip->setSortOrder(static_cast(idx)); + }); + + QObject::connect(strip, &PipelineStripWidget::nodeSelected, + [](Node* node) { + if (node) + qDebug("Selected node: %s", + qPrintable(node->label())); + }); + + QObject::connect(strip, &PipelineStripWidget::nodeDoubleClicked, + [](Node* node) { + qDebug("Double-clicked: %s", qPrintable(node->label())); + }); + + window.show(); + return app.exec(); +} diff --git a/tests/cxx/PtychoWorkflowTest.cxx b/tests/cxx/PtychoWorkflowTest.cxx index 3f0e94452..a5636e4eb 100644 --- a/tests/cxx/PtychoWorkflowTest.cxx +++ b/tests/cxx/PtychoWorkflowTest.cxx @@ -2,40 +2,28 @@ It is released under the 3-Clause BSD License, see "LICENSE". */ #include -#include #include #include -#include #include #include #include -#include #include #include "PythonUtilities.h" -#include "PtychoDialog.h" -#include "PtychoRunner.h" +#include "Utilities.h" + +#include "pipeline/OutputPort.h" +#include "pipeline/sources/PythonSource.h" #include "TomvizTest.h" using namespace tomviz; +using namespace tomviz::pipeline; const QDir ROOT_DATA_DIR = QString(SOURCE_DIR) + "/data"; const QDir DATA_DIR = ROOT_DATA_DIR.absolutePath() + "/Pt_Zn_Phase"; -template -T* findWidget() -{ - for (auto* widget : QApplication::topLevelWidgets()) { - if (qobject_cast(widget)) { - return qobject_cast(widget); - } - } - - return nullptr; -} - class PtychoWorkflowTest : public QObject { Q_OBJECT @@ -44,23 +32,21 @@ class PtychoWorkflowTest : public QObject void downloadDataIfMissing() { if (DATA_DIR.exists()) { - // Nothing to do return; } - // Download the data if it is not present QString python = "python"; QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); if (env.contains("TOMVIZ_TEST_PYTHON_EXECUTABLE")) { python = env.value("TOMVIZ_TEST_PYTHON_EXECUTABLE"); } - auto scriptFile = QFileInfo(QString(SOURCE_DIR) + "/fixtures/download_and_unzip.py"); + auto scriptFile = + QFileInfo(QString(SOURCE_DIR) + "/fixtures/download_and_unzip.py"); QString scriptPath = scriptFile.absoluteFilePath(); - QString url = "https://data.kitware.com/api/v1/file/6914aad883abdcd84d150c91/download"; + QString url = + "https://data.kitware.com/api/v1/file/6914aad883abdcd84d150c91/download"; - // We unzip into the parent directory, which will then have `Pt_Zn_Phase` - // after unzipping. QStringList arguments; arguments << scriptPath << url << ROOT_DATA_DIR.absolutePath(); @@ -68,17 +54,13 @@ class PtychoWorkflowTest : public QObject process.setProcessChannelMode(QProcess::ForwardedChannels); process.start(python, arguments); - // Timeout in seconds int timeout = 60 * 10; QVERIFY(process.waitForFinished(timeout * 1000)); QVERIFY(process.exitCode() == 0); } private slots: - void initTestCase() - { - downloadDataIfMissing(); - } + void initTestCase() { downloadDataIfMissing(); } void cleanupTestCase() {} @@ -87,55 +69,45 @@ private slots: QString ptychoDir = DATA_DIR.absolutePath() + "/Ptycho/recon_result/"; QString outputDir = DATA_DIR.absolutePath() + "/output/"; - auto* runner = new PtychoRunner(this); - runner->setAutoLoadFinalData(false); - runner->start(); + QString outputInfoFile = outputDir + "stacked_ptycho_info.txt"; - auto* dialog = findWidget(); - QVERIFY(dialog); + QDir outDir(outputDir); + outDir.mkpath("."); + QFile::remove(outputInfoFile); - auto* ptychoDirEdit = dialog->findChild("ptychoDirectory"); - QVERIFY(ptychoDirEdit); - ptychoDirEdit->setText(ptychoDir); + auto jsonDesc = readInJSONDescription("PtychoSource"); + QVERIFY2(!jsonDesc.contains("raise IOError"), + "Could not read PtychoSource.json"); - // Trigger the necessary changes - emit ptychoDirEdit->editingFinished(); + auto script = readInPythonScript("PtychoSource"); + QVERIFY2(!script.contains("raise IOError"), + "Could not read PtychoSource.py"); - auto* outputDirEdit = dialog->findChild("outputDirectory"); - QVERIFY(outputDirEdit); - outputDirEdit->setText(outputDir); + auto* source = new PythonSource(this); + source->setJSONDescription(jsonDesc); + source->setScript(script); - dialog->accept(); + source->setParameter("ptycho_dir", ptychoDir); + source->setParameter("output_info_file", outputInfoFile); + source->setParameter("rotate_datasets", true); + source->setParameter("sid_list", "[157391,157394,157397]"); + source->setParameter("version_list", R"(["t1","t1","t1"])"); + source->setParameter("angle_list", "[-90.0,-89.0,-88.0]"); - QStringList outputFileNames = {"ptycho_object.emd", "ptycho_probe.emd"}; + bool ok = source->execute(); + QVERIFY2(ok, "PtychoSource::execute() failed"); - auto checkAllExist = [&outputDir, &outputFileNames]() { - for (const auto& filename : outputFileNames) { - if (!QFile::exists(outputDir + filename)) { - return false; - } + auto* objectPort = source->outputPort("object"); + QVERIFY(objectPort); + QVERIFY2(objectPort->hasData(), "object output port has no data"); - } - return true; - }; + auto* probePort = source->outputPort("probe"); + QVERIFY(probePort); + QVERIFY2(probePort->hasData(), "probe output port has no data"); - // Wait for it to finish, and verify the output files were written - int timeout = 30; - int waitTime = 0; - bool found = false; - - while (waitTime < timeout) { - if (checkAllExist()) { - found = true; - break; - } - QThread::sleep(1); - ++waitTime; - } - // Verify everything was found - QVERIFY(found); + QVERIFY2(QFile::exists(outputInfoFile), + "Info text file was not written"); } - }; int main(int argc, char** argv) diff --git a/tests/cxx/PyXRFWorkflowTest.cxx b/tests/cxx/PyXRFWorkflowTest.cxx index 4bddb85ec..3c51c1fe7 100644 --- a/tests/cxx/PyXRFWorkflowTest.cxx +++ b/tests/cxx/PyXRFWorkflowTest.cxx @@ -2,172 +2,108 @@ It is released under the 3-Clause BSD License, see "LICENSE". */ #include -#include -#include #include #include -#include #include #include +#include #include -#include -#include #include #include "PythonUtilities.h" -#include "PyXRFRunner.h" -#include "PyXRFMakeHDF5Dialog.h" -#include "PyXRFProcessDialog.h" -#include "SelectItemsDialog.h" +#include "Utilities.h" + +#include "pipeline/OutputPort.h" +#include "pipeline/sources/PythonSource.h" #include "TomvizTest.h" using namespace tomviz; - -const QDir ROOT_DATA_DIR = QString(SOURCE_DIR) + "/data"; -const QDir DATA_DIR = ROOT_DATA_DIR.absolutePath() + "/Pt_Zn_XRF"; - -template -T* findWidget() -{ - for (auto* widget : QApplication::topLevelWidgets()) { - if (qobject_cast(widget)) { - return qobject_cast(widget); - } - } - - return nullptr; -} +using namespace tomviz::pipeline; class PyXRFWorkflowTest : public QObject { Q_OBJECT private: - void downloadDataIfMissing() + QTemporaryDir m_tempDir; + + bool createSyntheticTomoH5(const QString& outputPath) { - if (DATA_DIR.exists()) { - // Nothing to do - return; - } - // Download the data if it is not present QString python = "python"; QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); if (env.contains("TOMVIZ_TEST_PYTHON_EXECUTABLE")) { python = env.value("TOMVIZ_TEST_PYTHON_EXECUTABLE"); } - auto scriptFile = QFileInfo(QString(SOURCE_DIR) + "/fixtures/download_and_unzip.py"); + auto scriptFile = + QFileInfo(QString(SOURCE_DIR) + "/fixtures/create_synthetic_tomo.py"); QString scriptPath = scriptFile.absoluteFilePath(); - QString url = "https://data.kitware.com/api/v1/file/6914b15783abdcd84d150c97/download"; - - // We unzip into the parent directory, which will then have `Pt_Zn_XRF` - // after unzipping. - QStringList arguments; - arguments << scriptPath << url << ROOT_DATA_DIR.absolutePath(); + QDir().mkpath(QFileInfo(outputPath).absolutePath()); QProcess process; process.setProcessChannelMode(QProcess::ForwardedChannels); - process.start(python, arguments); + process.start(python, { scriptPath, outputPath }); - // Timeout in seconds - int timeout = 60 * 10; - QVERIFY(process.waitForFinished(timeout * 1000)); - QVERIFY(process.exitCode() == 0); + if (!process.waitForFinished(30000)) { + qCritical() << "Timed out creating synthetic tomo.h5"; + return false; + } + + return process.exitCode() == 0; } private slots: void initTestCase() { - downloadDataIfMissing(); + QVERIFY2(m_tempDir.isValid(), "Failed to create temp directory"); } void cleanupTestCase() {} void runTest() { - QString workingDir = DATA_DIR.absolutePath() + "/"; - - auto* runner = new PyXRFRunner(this); - runner->setAutoLoadFinalData(false); - runner->start(); - - auto* makeHDF5Dialog = findWidget(); - QVERIFY(makeHDF5Dialog); - - // Set the method to already existing - auto* method = makeHDF5Dialog->findChild("method"); - QVERIFY(method); - method->setCurrentText("Already Existing"); - - auto* workingDirEdit = makeHDF5Dialog->findChild("workingDirectory"); - QVERIFY(workingDirEdit); - workingDirEdit->setText(workingDir); - - makeHDF5Dialog->accept(); - - auto* processDialog = findWidget(); - QVERIFY(processDialog); - - auto* logFile = processDialog->findChild("logFile"); - QVERIFY(logFile); - logFile->setText(workingDir + "log.csv"); - - auto* paramsFile = processDialog->findChild("parametersFile"); - QVERIFY(paramsFile); - paramsFile->setText(workingDir + "pyxrf_model_parameters_157397.json"); - - auto* outputDir = processDialog->findChild("outputDirectory"); - QVERIFY(outputDir); - outputDir->setText(workingDir + "recon"); - - auto* icName = processDialog->findChild("icName"); - QVERIFY(icName); - icName->setCurrentText("sclr1_ch4"); - - // After accepting, a modal dialog will appear. Start posting - // events to check it and accept it when it appears. - bool found = false; - - std::function checkFunc; - checkFunc = [&found, &checkFunc](){ - auto* dialog = findWidget(); - if (!dialog) { - QTimer::singleShot(1000, checkFunc); - return; - } - - // Just accept the dialog - found = true; - dialog->accept(); - }; - - QTimer::singleShot(0, checkFunc); - processDialog->accept(); - - // Keep processing events until all dialogs have - // been found and accepted/rejected. - int timeElapsed = 0; - int maxTime = 120; - while (!found && timeElapsed < maxTime) { - QThread::sleep(1); - QApplication::processEvents(); - timeElapsed += 1; - } - - // Clean up the runner (terminates any running processes) - delete runner; - - // Verify everything was found - QVERIFY(found); - - // Verify that one of the output files now exist - auto exampleFile = workingDir + "recon/extracted_elements/Cl_K.emd"; - QVERIFY(QFileInfo::exists(exampleFile)); + QString outputDir = m_tempDir.path() + "/recon"; + QString tomoFile = outputDir + "/tomo.h5"; + + bool created = createSyntheticTomoH5(tomoFile); + QVERIFY2(created, "Failed to create synthetic tomo.h5"); + QVERIFY2(QFile::exists(tomoFile), "Synthetic tomo.h5 does not exist"); + + auto jsonDesc = readInJSONDescription("PyXRFSource"); + QVERIFY2(!jsonDesc.contains("raise IOError"), + "Could not read PyXRFSource.json"); + + auto script = readInPythonScript("PyXRFSource"); + QVERIFY2(!script.contains("raise IOError"), + "Could not read PyXRFSource.py"); + + auto* source = new PythonSource(this); + source->setJSONDescription(jsonDesc); + source->setScript(script); + + // Empty scan_range skips all subprocess calls and goes straight + // to reading tomo.h5 from the output directory. + source->setParameter("working_directory", outputDir); + source->setParameter("scan_range", QString("")); + source->setParameter("skip_scan_ids", QString("[]")); + source->setParameter("redownload_successful", false); + source->setParameter("skip_processed", true); + source->setParameter("rotate_datasets", false); + source->setParameter("pyxrf_utils_command", QString("pyxrf-utils")); + source->setParameter("parameters_file", QString("")); + source->setParameter("ic_name", QString("sclr1_ch4")); + source->setParameter("csv_output", QString("")); + + bool ok = source->execute(); + QVERIFY2(ok, "PyXRFSource::execute() failed"); + + auto* elementsPort = source->outputPort("elements"); + QVERIFY(elementsPort); + QVERIFY2(elementsPort->hasData(), "elements output port has no data"); } - }; int main(int argc, char** argv) diff --git a/tests/cxx/SAM2SeedWidgetTest.cxx b/tests/cxx/SAM2SeedWidgetTest.cxx new file mode 100644 index 000000000..17993c59c --- /dev/null +++ b/tests/cxx/SAM2SeedWidgetTest.cxx @@ -0,0 +1,244 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include + +#include "SAM2SeedWidget.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "TomvizTest.h" + +using tomviz::SAM2SeedWidget; +using tomviz::pipeline::PortData; +using tomviz::pipeline::PortType; +using tomviz::pipeline::VolumeData; +using tomviz::pipeline::VolumeDataPtr; + +namespace { + +// The tests share one process-wide QApplication (gtest_main owns main). +void ensureApp() +{ + tomviz_test::ensureQApp(); +} + +// Distinct dims per axis so axis mix-ups fail loudly. +const int DIMS[3] = { 10, 20, 30 }; + +QMap makeInputs() +{ + vtkNew image; + image->SetDimensions(DIMS[0], DIMS[1], DIMS[2]); + image->AllocateScalars(VTK_FLOAT, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (int i = 0; i < DIMS[0] * DIMS[1] * DIMS[2]; ++i) { + p[i] = float(i % 251); + } + auto volume = std::make_shared( + vtkSmartPointer(image.GetPointer())); + QMap inputs; + inputs["volume"] = PortData(VolumeDataPtr(volume), PortType::Volume); + return inputs; +} + +// Replicates SliceClickView's letterboxed image placement so tests can +// convert an image pixel to a widget click position. +QPoint pixelToWidgetPos(const QWidget* view, int imgW, int imgH, int px, + int py) +{ + QSize scaled = QSize(imgW, imgH).scaled(view->size(), Qt::KeepAspectRatio); + QRect target(QPoint((view->width() - scaled.width()) / 2, + (view->height() - scaled.height()) / 2), + scaled); + return QPoint(target.x() + int((px + 0.5) * target.width() / imgW), + target.y() + int((py + 0.5) * target.height() / imgH)); +} + +} // namespace + +TEST(SAM2SeedWidgetTest, EmbedsParameterForm) +{ + ensureApp(); + SAM2SeedWidget widget(makeInputs()); + widget.setValues({}); + + auto* seedX = widget.findChild("seed#000"); + auto* seedY = widget.findChild("seed#001"); + auto* seedZ = widget.findChild("seed#002"); + auto* zAxis = widget.findChild("z_axis"); + ASSERT_NE(seedX, nullptr); + ASSERT_NE(seedY, nullptr); + ASSERT_NE(seedZ, nullptr); + ASSERT_NE(zAxis, nullptr); + + // JSON defaults survive the embedding. + EXPECT_EQ(seedX->value(), -1); + EXPECT_EQ(seedY->value(), -1); + EXPECT_EQ(seedZ->value(), -1); + EXPECT_EQ(zAxis->currentData().toInt(), 0); + + // The full form is present, not just the seed controls. + EXPECT_NE(widget.findChild("prompt_mode"), nullptr); + EXPECT_NE(widget.findChild("model_size"), nullptr); +} + +TEST(SAM2SeedWidgetTest, ClickSetsSeedAndGetValues) +{ + ensureApp(); + SAM2SeedWidget widget(makeInputs()); + widget.setValues({}); + widget.resize(500, 700); + widget.show(); + QApplication::processEvents(); + + auto* view = widget.findChild("sam2SeedSliceView"); + ASSERT_NE(view, nullptr); + ASSERT_GT(view->width(), 0); + ASSERT_GT(view->height(), 0); + + // Default z_axis is 0, so the slice plane is axes (1, 2): + // width = DIMS[1] (seed_x), height = DIMS[2] (seed_y). + QPoint pos = pixelToWidgetPos(view, DIMS[1], DIMS[2], 4, 7); + QTest::mouseClick(view, Qt::LeftButton, Qt::KeyboardModifiers(), pos); + + auto* seedX = widget.findChild("seed#000"); + auto* seedY = widget.findChild("seed#001"); + ASSERT_NE(seedX, nullptr); + ASSERT_NE(seedY, nullptr); + EXPECT_EQ(seedX->value(), 4); + EXPECT_EQ(seedY->value(), 7); + + QMap values; + widget.getValues(values); + QVariantList seed = values["seed"].toList(); + ASSERT_EQ(seed.size(), 3); + EXPECT_EQ(seed[0].toInt(), 4); + EXPECT_EQ(seed[1].toInt(), 7); + // Untouched parameters still come through the embedded form. + EXPECT_TRUE(values.contains("model_size")); + EXPECT_TRUE(values.contains("checkpoint_dir")); +} + +TEST(SAM2SeedWidgetTest, SliderSyncsWithSeedZ) +{ + ensureApp(); + SAM2SeedWidget widget(makeInputs()); + widget.setValues({}); + + auto* slider = widget.findChild("sam2SeedSliceSlider"); + auto* seedZ = widget.findChild("seed#002"); + ASSERT_NE(slider, nullptr); + ASSERT_NE(seedZ, nullptr); + + // Default axis 0 → slice count DIMS[0]; seed_z of -1 shows the middle. + EXPECT_EQ(slider->maximum(), DIMS[0] - 1); + EXPECT_EQ(slider->value(), DIMS[0] / 2); + EXPECT_EQ(seedZ->value(), -1); + + slider->setValue(7); + EXPECT_EQ(seedZ->value(), 7); + + seedZ->setValue(2); + EXPECT_EQ(slider->value(), 2); + + seedZ->setValue(-1); + EXPECT_EQ(slider->value(), DIMS[0] / 2); +} + +TEST(SAM2SeedWidgetTest, AxisChangeUpdatesSliderRange) +{ + ensureApp(); + SAM2SeedWidget widget(makeInputs()); + widget.setValues({}); + + auto* slider = widget.findChild("sam2SeedSliceSlider"); + auto* zAxis = widget.findChild("z_axis"); + ASSERT_NE(slider, nullptr); + ASSERT_NE(zAxis, nullptr); + + zAxis->setCurrentIndex(2); // axis 2 → DIMS[2] slices + EXPECT_EQ(slider->maximum(), DIMS[2] - 1); + EXPECT_EQ(slider->value(), DIMS[2] / 2); + + zAxis->setCurrentIndex(1); // axis 1 → DIMS[1] slices + EXPECT_EQ(slider->maximum(), DIMS[1] - 1); +} + +TEST(SAM2SeedWidgetTest, SetValuesAppliesStoredParameters) +{ + ensureApp(); + SAM2SeedWidget widget(makeInputs()); + QMap stored; + stored["seed"] = QVariantList{ 3, 12, 25 }; + stored["z_axis"] = 2; + widget.setValues(stored); + + EXPECT_EQ(widget.findChild("seed#000")->value(), 3); + EXPECT_EQ(widget.findChild("seed#001")->value(), 12); + EXPECT_EQ(widget.findChild("seed#002")->value(), 25); + + auto* slider = widget.findChild("sam2SeedSliceSlider"); + EXPECT_EQ(slider->maximum(), DIMS[2] - 1); + EXPECT_EQ(slider->value(), 25); +} + + +TEST(SAM2SeedWidgetTest, ZoomKeepsClickMappingAnchored) +{ + ensureApp(); + SAM2SeedWidget widget(makeInputs()); + widget.setValues({}); + widget.resize(500, 700); + widget.show(); + QApplication::processEvents(); + + auto* view = widget.findChild("sam2SeedSliceView"); + auto* seedX = widget.findChild("seed#000"); + auto* seedY = widget.findChild("seed#001"); + ASSERT_NE(view, nullptr); + + auto clickAt = [&](const QPoint& pos) { + QTest::mouseClick(view, Qt::LeftButton, Qt::KeyboardModifiers(), pos); + }; + auto wheelAt = [&](const QPoint& pos, int delta) { + QWheelEvent event(pos, view->mapToGlobal(pos), QPoint(), QPoint(0, delta), + Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); + QApplication::sendEvent(view, &event); + }; + + // Establish the unzoomed mapping for an interior pixel. (Edge + // pixels are the wrong probe here: the pan clamp that keeps the + // image edge-to-edge deliberately overrides anchoring near edges.) + QPoint pos = pixelToWidgetPos(view, DIMS[1], DIMS[2], 10, 15); + clickAt(pos); + ASSERT_EQ(seedX->value(), 10); + ASSERT_EQ(seedY->value(), 15); + + // Zoom in about that same position: the image point under the + // cursor must stay put, so clicking there again hits the same voxel. + wheelAt(pos, 480); + clickAt(pos); + EXPECT_EQ(seedX->value(), 10); + EXPECT_EQ(seedY->value(), 15); + + // A big zoom-out clamps back to the letterboxed fit (zoom 1, pan 0), + // restoring the original mapping everywhere. + wheelAt(pos, -8000); + QPoint other = pixelToWidgetPos(view, DIMS[1], DIMS[2], 13, 21); + clickAt(other); + EXPECT_EQ(seedX->value(), 13); + EXPECT_EQ(seedY->value(), 21); +} + diff --git a/tests/cxx/ScanIDTest.cxx b/tests/cxx/ScanIDTest.cxx index c4554987e..d7233d158 100644 --- a/tests/cxx/ScanIDTest.cxx +++ b/tests/cxx/ScanIDTest.cxx @@ -3,35 +3,35 @@ #include -#include +#include +#include #include -#include "DataSource.h" -#include "TomvizTest.h" +#include "data/VolumeData.h" -using namespace tomviz; +using tomviz::pipeline::VolumeData; class ScanIDTest : public ::testing::Test { protected: - void SetUp() override { dataObject = vtkSmartPointer::New(); } + void SetUp() override { image = vtkSmartPointer::New(); } - vtkSmartPointer dataObject; + vtkSmartPointer image; }; TEST_F(ScanIDTest, scan_ids_not_present_by_default) { - ASSERT_FALSE(DataSource::hasScanIDs(dataObject)); + ASSERT_FALSE(VolumeData::hasScanIds(image)); } TEST_F(ScanIDTest, set_and_get_scan_ids) { QVector ids = { 1, 2, 3 }; - DataSource::setScanIDs(dataObject, ids); + VolumeData::setScanIds(image, ids); - ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); + ASSERT_TRUE(VolumeData::hasScanIds(image)); - auto retrieved = DataSource::getScanIDs(dataObject); + auto retrieved = VolumeData::getScanIds(image); ASSERT_EQ(retrieved.size(), 3); ASSERT_EQ(retrieved[0], 1); ASSERT_EQ(retrieved[1], 2); @@ -41,21 +41,22 @@ TEST_F(ScanIDTest, set_and_get_scan_ids) TEST_F(ScanIDTest, clear_scan_ids) { QVector ids = { 1, 2, 3 }; - DataSource::setScanIDs(dataObject, ids); - ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); + VolumeData::setScanIds(image, ids); + ASSERT_TRUE(VolumeData::hasScanIds(image)); - DataSource::clearScanIDs(dataObject); - ASSERT_FALSE(DataSource::hasScanIDs(dataObject)); + VolumeData::clearScanIds(image); + ASSERT_FALSE(VolumeData::hasScanIds(image)); } -TEST_F(ScanIDTest, empty_scan_ids) +TEST_F(ScanIDTest, empty_scan_ids_are_treated_as_absent) { + // VolumeData treats an empty scan-id set as "no scan IDs" (hasScanIds is + // true only when there is at least one tuple), so getScanIds is empty too. QVector ids; - DataSource::setScanIDs(dataObject, ids); + VolumeData::setScanIds(image, ids); - ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); - auto retrieved = DataSource::getScanIDs(dataObject); - ASSERT_EQ(retrieved.size(), 0); + ASSERT_FALSE(VolumeData::hasScanIds(image)); + ASSERT_EQ(VolumeData::getScanIds(image).size(), 0); } TEST_F(ScanIDTest, large_scan_id_set) @@ -64,10 +65,10 @@ TEST_F(ScanIDTest, large_scan_id_set) for (int i = 0; i < 200; ++i) { ids.append(i * 10); } - DataSource::setScanIDs(dataObject, ids); + VolumeData::setScanIds(image, ids); - ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); - auto retrieved = DataSource::getScanIDs(dataObject); + ASSERT_TRUE(VolumeData::hasScanIds(image)); + auto retrieved = VolumeData::getScanIds(image); ASSERT_EQ(retrieved.size(), 200); for (int i = 0; i < 200; ++i) { ASSERT_EQ(retrieved[i], i * 10); diff --git a/tests/cxx/SegmentationColorMapTest.cxx b/tests/cxx/SegmentationColorMapTest.cxx new file mode 100644 index 000000000..7df479138 --- /dev/null +++ b/tests/cxx/SegmentationColorMapTest.cxx @@ -0,0 +1,116 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "pipeline/PortDataMetadata.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include + +using namespace tomviz; +using namespace tomviz::pipeline; + +class SegmentationColorMapTest : public QObject +{ + Q_OBJECT + +private slots: + // The segmentation preset must land in data coordinates immediately: + // no rescale (manual "Reset data range" or otherwise) may be needed + // for each label to get its own color. Regression test for the + // ApplyPreset rescale-to-current-range bug that squeezed the per-label + // node positions into the fresh colormap's default [0, 1] range. + void segmentationColorMapUsesDataCoordinates() + { + const int numLabels = 152; // labels 0..151 + vtkNew image; + image->SetDimensions(40, 40, 40); + image->AllocateScalars(VTK_INT, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (int i = 0; i < 40 * 40 * 40; ++i) { + p[i] = i % numLabels; + } + auto vol = std::make_shared( + vtkSmartPointer(image.GetPointer())); + + QVERIFY(applySegmentationColorMap(*vol)); + + auto* ctf = vol->colorTransferFunction(); + QVERIFY(ctf); + QCOMPARE(ctf->GetSize(), 2 * numLabels); + + double range[2]; + ctf->GetRange(range); + QCOMPARE(range[0], 0.0); + QCOMPARE(range[1], double(numLabels - 1)); + + // Every label maps to its own color, straight after the apply. + std::map, std::vector> seen; + for (int v = 0; v < numLabels; ++v) { + double rgb[3]; + ctf->GetColor(double(v), rgb); + seen[{ int(rgb[0] * 255), int(rgb[1] * 255), + int(rgb[2] * 255) }].push_back(v); + } + for (auto& entry : seen) { + if (entry.second.size() > 1) { + QString labels; + for (int v : entry.second) { + labels += QString::number(v) + " "; + } + qWarning("color (%d %d %d) shared by labels: %s", + std::get<0>(entry.first), std::get<1>(entry.first), + std::get<2>(entry.first), qPrintable(labels)); + } + } + QCOMPARE(int(seen.size()), numLabels); + + // Rescaling to the data range (what "Reset data range" and the + // per-node post-execution rescale do) must be an identity: every + // label keeps its own color afterwards. + vol->rescaleColorMap(); + std::set> after; + for (int v = 0; v < numLabels; ++v) { + double rgb[3]; + ctf->GetColor(double(v), rgb); + after.insert({ int(rgb[0] * 255), int(rgb[1] * 255), + int(rgb[2] * 255) }); + } + QCOMPARE(int(after.size()), numLabels); + + // Background label 0 renders transparent. + auto* opacity = vol->scalarOpacity(); + QVERIFY(opacity); + QCOMPARE(opacity->GetValue(0.0), 0.0); + QCOMPARE(opacity->GetValue(1.0), 1.0); + } +}; + +int main(int argc, char** argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + pqPVApplicationCore appCore(argc, argv); + pqApplicationCore::instance()->getObjectBuilder()->createServer( + pqServerResource("builtin:")); + SegmentationColorMapTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "SegmentationColorMapTest.moc" diff --git a/tests/cxx/TomvizTest.h.in b/tests/cxx/TomvizTest.h.in index bc2067d06..497586eec 100644 --- a/tests/cxx/TomvizTest.h.in +++ b/tests/cxx/TomvizTest.h.in @@ -3,4 +3,56 @@ #define SOURCE_DIR "@CMAKE_CURRENT_SOURCE_DIR@" -#endif +#include + +#if defined(_WIN32) +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#elif defined(__APPLE__) +#include +#else +#include +#endif + +namespace tomviz_test { + +// The path of the running test executable, resolved natively per +// platform. Do not rely on /proc (Linux-only) or on gtest's argv. +inline QString executablePath() +{ + char buf[4096] = { 0 }; +#if defined(_WIN32) + GetModuleFileNameA(nullptr, buf, sizeof(buf) - 1); +#elif defined(__APPLE__) + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) != 0) { + buf[0] = '\0'; + } +#else + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + buf[n > 0 ? n : 0] = '\0'; +#endif + return QString::fromLocal8Bit(buf); +} + +// Create the process-wide offscreen QApplication for widget tests. +// argv[0] must be the real executable path: Qt resolves +// applicationDirPath() from it on some builds, and +// readInJSONDescription() needs that to find operator JSON in the +// build tree's share/tomviz/scripts. +inline void ensureQApp() +{ + if (QApplication::instance()) { + return; + } + qputenv("QT_QPA_PLATFORM", "offscreen"); + static QByteArray path = executablePath().toLocal8Bit(); + static char* argv[] = { path.data(), nullptr }; + static int argc = 1; + new QApplication(argc, argv); +} + +} // namespace tomviz_test + +#endif diff --git a/tests/cxx/Tvh5DataTest.cxx b/tests/cxx/Tvh5DataTest.cxx deleted file mode 100644 index a66bb20ca..000000000 --- a/tests/cxx/Tvh5DataTest.cxx +++ /dev/null @@ -1,223 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include -#include -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "EmdFormat.h" -#include "Pipeline.h" -#include "PipelineManager.h" -#include "Tvh5Format.h" -#include "modules/ModuleManager.h" - -using namespace tomviz; - -class Tvh5DataTest : public QObject -{ - Q_OBJECT - -private: - vtkSmartPointer createTestImage(int dim, double fillValue) - { - auto image = vtkSmartPointer::New(); - image->SetDimensions(dim, dim, dim); - image->AllocateScalars(VTK_DOUBLE, 1); - for (int z = 0; z < dim; ++z) { - for (int y = 0; y < dim; ++y) { - for (int x = 0; x < dim; ++x) { - image->SetScalarComponentFromDouble(x, y, z, 0, fillValue); - } - } - } - return image; - } - - // Get a temporary file path with .tvh5 extension. - QString tempFilePath() - { - QTemporaryFile tmpFile(QDir::tempPath() + "/tomviz_test_XXXXXX.tvh5"); - tmpFile.setAutoRemove(false); - if (!tmpFile.open()) { - return {}; - } - auto path = tmpFile.fileName(); - tmpFile.close(); - m_tempFiles.push_back(path); - return path; - } - - // Set up a DataSource registered with the application singletons. - // Returns the DataSource (owned by the Pipeline). - DataSource* setupDataSource(vtkImageData* image) - { - auto* ds = new DataSource(image); - auto* pipeline = new Pipeline(ds); - PipelineManager::instance().addPipeline(pipeline); - ModuleManager::instance().addDataSource(ds); - ActiveObjects::instance().setActiveDataSource(ds); - return ds; - } - - QStringList m_tempFiles; - -private slots: - void cleanup() - { - // Reset application state between tests - ModuleManager::instance().reset(); - - for (const auto& f : m_tempFiles) { - QFile::remove(f); - } - m_tempFiles.clear(); - } - - void writeCreatesValidFile() - { - auto image = createTestImage(4, 42.0); - setupDataSource(image); - - auto fileName = tempFilePath(); - QVERIFY(Tvh5Format::write(fileName.toStdString())); - - // Verify the file can be read back as EMD data - auto readImage = vtkSmartPointer::New(); - QVERIFY(EmdFormat::read(fileName.toStdString(), readImage)); - - int dims[3]; - readImage->GetDimensions(dims); - QCOMPARE(dims[0], 4); - QCOMPARE(dims[1], 4); - QCOMPARE(dims[2], 4); - - for (int z = 0; z < 4; ++z) { - for (int y = 0; y < 4; ++y) { - for (int x = 0; x < 4; ++x) { - QCOMPARE(readImage->GetScalarComponentAsDouble(x, y, z, 0), 42.0); - } - } - } - } - - void writePreservesTiltAngles() - { - auto image = createTestImage(4, 1.0); - - vtkNew tiltAngles; - tiltAngles->SetName("tilt_angles"); - tiltAngles->SetNumberOfTuples(4); - tiltAngles->SetValue(0, -60.0); - tiltAngles->SetValue(1, -20.0); - tiltAngles->SetValue(2, 20.0); - tiltAngles->SetValue(3, 60.0); - image->GetFieldData()->AddArray(tiltAngles); - - setupDataSource(image); - - auto fileName = tempFilePath(); - QVERIFY(Tvh5Format::write(fileName.toStdString())); - - auto readImage = vtkSmartPointer::New(); - QVERIFY(EmdFormat::read(fileName.toStdString(), readImage)); - - auto* fd = readImage->GetFieldData(); - QVERIFY(fd->HasArray("tilt_angles")); - auto* readAngles = fd->GetArray("tilt_angles"); - QCOMPARE(readAngles->GetNumberOfTuples(), static_cast(4)); - QCOMPARE(readAngles->GetTuple1(0), -60.0); - QCOMPARE(readAngles->GetTuple1(1), -20.0); - QCOMPARE(readAngles->GetTuple1(2), 20.0); - QCOMPARE(readAngles->GetTuple1(3), 60.0); - } - - void writePreservesScanIDs() - { - auto image = createTestImage(4, 1.0); - - QVector scanIDs = { 10, 20, 30 }; - DataSource::setScanIDs(image, scanIDs); - - setupDataSource(image); - - auto fileName = tempFilePath(); - QVERIFY(Tvh5Format::write(fileName.toStdString())); - - auto readImage = vtkSmartPointer::New(); - QVERIFY(EmdFormat::read(fileName.toStdString(), readImage)); - - QVERIFY(DataSource::hasScanIDs(readImage)); - auto readIDs = DataSource::getScanIDs(readImage); - QCOMPARE(readIDs.size(), 3); - QCOMPARE(readIDs[0], 10); - QCOMPARE(readIDs[1], 20); - QCOMPARE(readIDs[2], 30); - } - - void roundtripPreservesData() - { - auto image = createTestImage(4, 99.0); - setupDataSource(image); - - auto fileName = tempFilePath(); - QVERIFY(Tvh5Format::write(fileName.toStdString())); - - // Clear application state - ModuleManager::instance().reset(); - - // Read back - QVERIFY(Tvh5Format::read(fileName.toStdString())); - - // Verify a data source was loaded - auto sources = ModuleManager::instance().allDataSources(); - QVERIFY(!sources.isEmpty()); - - auto* loadedDs = sources.first(); - QVERIFY(loadedDs != nullptr); - - auto* loadedImage = loadedDs->imageData(); - QVERIFY(loadedImage != nullptr); - - int dims[3]; - loadedImage->GetDimensions(dims); - QCOMPARE(dims[0], 4); - QCOMPARE(dims[1], 4); - QCOMPARE(dims[2], 4); - - for (int z = 0; z < 4; ++z) { - for (int y = 0; y < 4; ++y) { - for (int x = 0; x < 4; ++x) { - QCOMPARE(loadedImage->GetScalarComponentAsDouble(x, y, z, 0), 99.0); - } - } - } - } -}; - -int main(int argc, char** argv) -{ - QApplication app(argc, argv); - pqPVApplicationCore appCore(argc, argv); - - auto* builder = pqApplicationCore::instance()->getObjectBuilder(); - builder->createServer(pqServerResource("builtin:")); - - Tvh5DataTest tc; - return QTest::qExec(&tc, argc, argv); -} - -#include "Tvh5DataTest.moc" diff --git a/tests/cxx/VolumeBrickingTest.cxx b/tests/cxx/VolumeBrickingTest.cxx new file mode 100644 index 000000000..a319d6c30 --- /dev/null +++ b/tests/cxx/VolumeBrickingTest.cxx @@ -0,0 +1,176 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include + +#include "sinks/VolumeBricking.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +using tomviz::pipeline::brickVolume; +using tomviz::pipeline::computeBlockCount; +using tomviz::pipeline::exceedsTextureLimit; + +namespace { + +// Build a volume whose scalar at (x,y,z) is a unique, reproducible value so we +// can check that bricks carry the right data at the right global indices. +vtkSmartPointer makeRampVolume(int nx, int ny, int nz) +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(nx, ny, nz); + image->SetSpacing(1.0, 1.0, 1.0); + image->SetOrigin(0.0, 0.0, 0.0); + + vtkNew scalars; + scalars->SetName("ImageScalars"); + scalars->SetNumberOfComponents(1); + scalars->SetNumberOfTuples(static_cast(nx) * ny * nz); + for (int z = 0; z < nz; ++z) { + for (int y = 0; y < ny; ++y) { + for (int x = 0; x < nx; ++x) { + vtkIdType id = static_cast(z) * ny * nx + + static_cast(y) * nx + x; + scalars->SetValue(id, static_cast(x * 1000000 + y * 1000 + z)); + } + } + } + image->GetPointData()->SetScalars(scalars); + return image; +} + +float expectedValue(int x, int y, int z) +{ + return static_cast(x * 1000000 + y * 1000 + z); +} + +} // namespace + +TEST(VolumeBrickingTest, BlockCount) +{ + // Fits exactly -> a single block, no split. + EXPECT_EQ(computeBlockCount(2048, 2048), 1); + EXPECT_EQ(computeBlockCount(1, 2048), 1); + EXPECT_EQ(computeBlockCount(2049, 2048), 2); + // One-voxel overlap means two 2048 bricks share a boundary plane and so + // cover only 2*2047 + 1 = 4095 voxels. 4095 fits in two; 4096 needs three. + EXPECT_EQ(computeBlockCount(4095, 2048), 2); + EXPECT_EQ(computeBlockCount(4096, 2048), 3); + EXPECT_EQ(computeBlockCount(5000, 2048), 3); +} + +TEST(VolumeBrickingTest, ComputedBricksNeverExceedCap) +{ + // Whatever the count, each brick (step + 1 shared-boundary voxels) must fit. + for (int maxTex : { 4, 7, 16, 100, 2048 }) { + for (int length = 1; length < 6 * maxTex; ++length) { + int n = computeBlockCount(length, maxTex); + ASSERT_GE(n, 1); + // Largest brick under an even split. + int span = length - 1; + int maxStep = (n > 0) ? (span + n - 1) / n : span; // ceil(span / n) + EXPECT_LE(maxStep + 1, maxTex) + << "length=" << length << " maxTex=" << maxTex << " n=" << n; + } + } +} + +TEST(VolumeBrickingTest, SingleBlockWhenItFits) +{ + auto image = makeRampVolume(8, 8, 8); + EXPECT_FALSE(exceedsTextureLimit(image, 2048)); + + auto blocks = brickVolume(image, 2048); + ASSERT_EQ(blocks->GetNumberOfBlocks(), 1u); + + auto* brick = vtkImageData::SafeDownCast(blocks->GetBlock(0)); + ASSERT_NE(brick, nullptr); + int dims[3]; + brick->GetDimensions(dims); + EXPECT_EQ(dims[0], 8); + EXPECT_EQ(dims[1], 8); + EXPECT_EQ(dims[2], 8); +} + +TEST(VolumeBrickingTest, SplitsLongAxisOnly) +{ + // Long in x only; y and z fit. Expect a 3 x 1 x 1 brick grid. + auto image = makeRampVolume(100, 10, 10); + const int maxTex = 40; + EXPECT_TRUE(exceedsTextureLimit(image, maxTex)); + + auto blocks = brickVolume(image, maxTex); + EXPECT_EQ(blocks->GetNumberOfBlocks(), + static_cast(computeBlockCount(100, maxTex))); + + for (unsigned i = 0; i < blocks->GetNumberOfBlocks(); ++i) { + auto* brick = vtkImageData::SafeDownCast(blocks->GetBlock(i)); + ASSERT_NE(brick, nullptr); + int dims[3]; + brick->GetDimensions(dims); + EXPECT_LE(dims[0], maxTex); + EXPECT_EQ(dims[1], 10); // y not split + EXPECT_EQ(dims[2], 10); // z not split + } +} + +TEST(VolumeBrickingTest, BricksCoverVolumeWithOverlapAndCorrectValues) +{ + auto image = makeRampVolume(100, 60, 5); + const int maxTex = 32; + auto blocks = brickVolume(image, maxTex); + ASSERT_GT(blocks->GetNumberOfBlocks(), 1u); + + // Every global voxel index must be covered by at least one brick, and the + // scalar there must match the original. Track coverage to confirm no gaps. + std::set> covered; + + for (unsigned i = 0; i < blocks->GetNumberOfBlocks(); ++i) { + auto* brick = vtkImageData::SafeDownCast(blocks->GetBlock(i)); + ASSERT_NE(brick, nullptr); + + int ext[6]; + brick->GetExtent(ext); + // Each brick keeps its global extent indices and the shared origin/spacing. + auto* scalars = brick->GetPointData()->GetScalars(); + ASSERT_NE(scalars, nullptr); + + for (int z = ext[4]; z <= ext[5]; ++z) { + for (int y = ext[2]; y <= ext[3]; ++y) { + for (int x = ext[0]; x <= ext[1]; ++x) { + double val = brick->GetScalarComponentAsDouble(x, y, z, 0); + EXPECT_FLOAT_EQ(static_cast(val), expectedValue(x, y, z)) + << "brick " << i << " at (" << x << "," << y << "," << z << ")"; + covered.insert({ x, y, z }); + } + } + } + } + + EXPECT_EQ(covered.size(), static_cast(100 * 60 * 5)); + + // Confirm there is genuine overlap: summed brick voxel counts exceed the + // volume's voxel count (adjacent bricks share a boundary plane). + vtkIdType totalBrickVoxels = 0; + for (unsigned i = 0; i < blocks->GetNumberOfBlocks(); ++i) { + totalBrickVoxels += + vtkImageData::SafeDownCast(blocks->GetBlock(i))->GetNumberOfPoints(); + } + EXPECT_GT(totalBrickVoxels, static_cast(100 * 60 * 5)); +} + +TEST(VolumeBrickingTest, NullInputIsSafe) +{ + EXPECT_FALSE(exceedsTextureLimit(nullptr, 2048)); + auto blocks = brickVolume(nullptr, 2048); + ASSERT_NE(blocks, nullptr); + EXPECT_EQ(blocks->GetNumberOfBlocks(), 0u); +} diff --git a/tests/cxx/fixtures/create_synthetic_tomo.py b/tests/cxx/fixtures/create_synthetic_tomo.py new file mode 100644 index 000000000..4b9fd1f8e --- /dev/null +++ b/tests/cxx/fixtures/create_synthetic_tomo.py @@ -0,0 +1,38 @@ +"""Create a synthetic tomo.h5 file for testing PyXRFSource.""" +import sys + +import h5py +import numpy as np + + +def main(): + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + output_path = sys.argv[1] + + num_angles = 5 + num_elements = 3 + num_rows = 8 + num_cols = 10 + + element_names = [b'Fe_K', b'Cu_K', b'Zn_K'] + + np.random.seed(42) + data = np.random.rand(num_angles, num_elements, num_rows, num_cols) + theta = np.linspace(-90, 90, num_angles) + + with h5py.File(output_path, 'w') as f: + f.create_group('exchange') + f['exchange'].create_dataset('theta', data=theta) + f.create_group('reconstruction/fitting') + f['reconstruction/fitting'].create_dataset('data', data=data) + f['reconstruction/fitting'].create_dataset( + 'elements', data=np.array(element_names, dtype='S20')) + + print(f"Created synthetic tomo.h5 at {output_path}") + + +if __name__ == '__main__': + main() diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index cea8bec22..21eebac8f 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -1,17 +1,24 @@ include(PythonTests.cmake) add_python_test(operator) -add_python_test(external) +add_python_test(sam2_operator) +add_python_test(sam3_operator) add_python_test(pystackreg) -add_python_test(multi_array) add_python_test(xcorr) -add_python_test(tilt_axis_shift) -add_python_test(constraint_dft) add_python_test(shift_rotation_center) add_python_test(remove_arrays) add_python_test(tomopy_recon) -add_python_test(external_operator) -add_python_test(random_particles) add_python_test(normalize) add_python_test(psd_fsc) +add_python_test(pipeline_runtime) +add_python_test(pipeline_transforms) +add_python_test(pipeline_cli) +add_python_test(pipeline_writers) +add_python_test(pipeline_runner) +add_python_test(pipeline_v2_nodes) +add_python_test(io_formats) +add_python_test(multi_array) +add_python_test(tilt_axis_shift) +add_python_test(constraint_dft) +add_python_test(random_particles) add_python_test(deconvolution_denoise) diff --git a/tests/python/conftest.py b/tests/python/conftest.py index e3548905e..6fdd44b84 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -1,16 +1,10 @@ -import pytest -import requests -import diskcache -import tempfile -import os from pathlib import Path -import tarfile -import shutil import numpy as np +import pytest -from tomviz.executor import load_dataset -from tomviz.external_dataset import Dataset +from tomviz.io_emd import load_dataset +from tomviz.external_dataset import Dataset, LegacyDataset from utils import download_file, download_and_unzip_file @@ -34,7 +28,7 @@ def hxn_xrf_example_output_dir(data_dir: Path) -> Path: @pytest.fixture(scope='function') -def hxn_xrf_example_dataset(hxn_xrf_example_output_dir: Path) -> Dataset: +def hxn_xrf_example_dataset(hxn_xrf_example_output_dir: Path) -> LegacyDataset: example_files = [ 'Pt_L.h5', 'Zn_K.h5', @@ -49,7 +43,7 @@ def hxn_xrf_example_dataset(hxn_xrf_example_output_dir: Path) -> Dataset: new_name = new_dataset.active_name dataset.arrays[new_name] = new_dataset.active_scalars - return dataset + return LegacyDataset.from_dataset(dataset) @pytest.fixture @@ -122,41 +116,3 @@ def constraint_dft_reference_output(data_dir: Path) -> dict[str, np.ndarray]: return np.load(filepath) -@pytest.fixture(scope="module") -def test_state_file(tmpdir_factory): - tmpdir = tmpdir_factory.mktemp('state') - - _id = '5dbca381e3566bda4b4f94f0' - download_url = '%s/%s/download' % (DATA_URL, _id) - cache_path = os.path.join(tempfile.gettempdir(), 'tomviz_test_cache') - with diskcache.Cache(cache_path) as cache: - state_file_key = '%s#%s' % (download_url, 'state.tvsm') - if download_url not in cache: - response = requests.get(download_url, stream=True) - response.raise_for_status() - - with tempfile.TemporaryFile() as fp: - for chunk in response.iter_content(chunk_size=1024): - if chunk: - fp.write(chunk) - - fp.seek(0) - - with tarfile.open(fileobj=fp, mode="r:gz") as tar: - for member in tar.getmembers(): - member_fp = tar.extractfile(member) - key = '%s#%s' % (download_url, member.name) - cache.set(key, member_fp, read=True) - - state_file_path = tmpdir.join('state.tvsm') - with open(state_file_path.strpath, 'wb') as state_fp: - shutil.copyfileobj(cache.get(state_file_key, read=True), state_fp) - - data_file_key = '%s#%s' % (download_url, 'small.emd') - data_file_path = tmpdir.join('small.emd') - with open(data_file_path.strpath, 'wb') as data_fp: - shutil.copyfileobj(cache.get(data_file_key, read=True), data_fp) - - yield state_file_path - - tmpdir.remove() diff --git a/tests/python/constraint_dft_test.py b/tests/python/constraint_dft_test.py index a9bd8ce03..34a448932 100644 --- a/tests/python/constraint_dft_test.py +++ b/tests/python/constraint_dft_test.py @@ -2,7 +2,7 @@ from utils import load_operator_class, load_operator_module -from tomviz.external_dataset import Dataset +from tomviz.external_dataset import LegacyDataset as Dataset def test_constraint_dft(hxn_xrf_example_dataset: Dataset, diff --git a/tests/python/deconvolution_denoise_test.py b/tests/python/deconvolution_denoise_test.py index ca443a076..34363a5e5 100644 --- a/tests/python/deconvolution_denoise_test.py +++ b/tests/python/deconvolution_denoise_test.py @@ -2,7 +2,7 @@ import numpy as np -from utils import load_operator_class, load_operator_module +from utils import load_operator_class, load_operator_module, load_node_class from tomviz.external_dataset import Dataset @@ -91,12 +91,10 @@ def capture_deconv_spreadsheet(column_names, table_data, *args, **kwargs): deconv_metrics['data'] = table_data.copy() return deconv_metrics - sim_operator = load_operator_class(sim_module) + sim_operator = load_node_class(sim_module) + sim_inputs = {"dataset": xrf_dataset, "reference_dataset": original_xrf} with patch('tomviz.utils.make_spreadsheet', capture_deconv_spreadsheet): - sim_operator.transform( - xrf_dataset, - reference_dataset=original_xrf, - ) + sim_operator.transform(sim_inputs) # Gaussian blur on a copy of the original gaussian_xrf = deep_copy_dataset(original_xrf) @@ -111,12 +109,10 @@ def capture_gaussian_spreadsheet(column_names, table_data, *args, **kwargs): gaussian_metrics['data'] = table_data.copy() return gaussian_metrics - sim_operator2 = load_operator_class(sim_module) + sim_operator2 = load_node_class(sim_module) + sim_inputs2 = {"dataset": gaussian_xrf, "reference_dataset": original_xrf} with patch('tomviz.utils.make_spreadsheet', capture_gaussian_spreadsheet): - sim_operator2.transform( - gaussian_xrf, - reference_dataset=original_xrf, - ) + sim_operator2.transform(sim_inputs2) # Compare metrics - deconvolution should be clearly better on average # (lower MSE and higher SSIM) diff --git a/tests/python/external_operator_test.py b/tests/python/external_operator_test.py deleted file mode 100644 index 4a51a84cb..000000000 --- a/tests/python/external_operator_test.py +++ /dev/null @@ -1,100 +0,0 @@ -import json -import os -import sys - -import numpy as np -import pytest - - -def test_transform_method_wrapper_internal(): - """Verify transform_method_wrapper calls internal transform when no - external execution mode is specified.""" - from tomviz._internal import transform_method_wrapper - - called = {'count': 0} - - def dummy_transform(*args, **kwargs): - called['count'] += 1 - return True - - # Set apply_to_each_array to false so no dataset-expecting wrapper - # is added around our dummy function - operator_dict = { - 'label': 'Test', - 'script': '', - 'description': json.dumps({ - 'name': 'Test', - 'apply_to_each_array': False, - }), - } - operator_serialized = json.dumps(operator_dict) - - result = transform_method_wrapper(dummy_transform, operator_serialized) - assert called['count'] == 1 - assert result is True - - -def test_transform_single_external_operator(): - """Verify that an operator can be executed in a subprocess via - transform_single_external_operator.""" - from pathlib import Path - from tomviz._internal import transform_single_external_operator - from tomviz.external_dataset import Dataset - - # Find the environment that has tomviz-pipeline installed. - # Use sys.prefix for the current environment, but also check - # the conda env path since tests may run from a different prefix. - if sys.platform == 'win32': - scripts_dir = 'Scripts' - exec_name = 'tomviz-pipeline.exe' - else: - scripts_dir = 'bin' - exec_name = 'tomviz-pipeline' - - tomviz_pipeline_env = sys.prefix - exec_path = Path(tomviz_pipeline_env) / scripts_dir / exec_name - if not exec_path.exists(): - # Try the conda env path - conda_prefix = os.environ.get('CONDA_PREFIX') - if conda_prefix: - tomviz_pipeline_env = conda_prefix - exec_path = Path(tomviz_pipeline_env) / scripts_dir / exec_name - - if not exec_path.exists(): - pytest.skip('tomviz-pipeline not found') - - # Create a simple 4x4x4 dataset with all values = 5.0 - data = np.full((4, 4, 4), 5.0, dtype=np.float64) - dataset = Dataset({'scalars': data}, active='scalars') - - # A simple operator script that adds 10 to all scalars - script = ( - "def transform(dataset):\n" - " dataset.active_scalars = dataset.active_scalars + 10.0\n" - ) - - operator_dict = { - 'type': 'python', - 'label': 'AddTen', - 'script': script, - 'description': json.dumps({ - 'name': 'AddTen', - 'apply_to_each_array': False, - 'tomviz_pipeline_env': tomviz_pipeline_env, - }), - } - operator_serialized = json.dumps(operator_dict) - - # The transform_method is not actually called in the external path - # (the subprocess loads the script from the operator dict), but - # we still need to pass one. - def dummy_transform(dataset): - pass - - transform_single_external_operator( - dummy_transform, operator_serialized, dataset) - - # The function modifies the input dataset in-place with the subprocess - # results. Verify that the scalars were updated from 5.0 to 15.0. - result = dataset.active_scalars - np.testing.assert_allclose(result, 15.0) diff --git a/tests/python/external_test.py b/tests/python/external_test.py deleted file mode 100644 index a2e44e699..000000000 --- a/tests/python/external_test.py +++ /dev/null @@ -1,25 +0,0 @@ -from hashlib import sha512 -import h5py - -from tomviz.cli import main -from click.testing import CliRunner - - -def test_external_pipeline(test_state_file, tmpdir): - output_path = tmpdir.join('output.emd') - runner = CliRunner() - result = runner.invoke(main, ['-s', test_state_file.strpath, - '-o', output_path.strpath]) - assert result.exit_code == 0 - - sha = sha512() - with h5py.File(output_path.strpath, 'r') as f: - tomography = f['data/tomography'] - sha.update(tomography['data'][:]) - - expected_sha = ('ae828cfdabffe364bc46d3c0229d3f1bfde4b9157bb3db0049803d8' - '9ded713effb752ea2442e386cea6aabbfed85878f6fdd04ecc14ac8' - '61f56ee4361b51efe7') - - # assert that we have the right output - assert sha.hexdigest() == expected_sha diff --git a/tests/python/io_formats_test.py b/tests/python/io_formats_test.py new file mode 100644 index 000000000..6588f49c0 --- /dev/null +++ b/tests/python/io_formats_test.py @@ -0,0 +1,293 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""Tests for DICOM and MRC I/O format implementations.""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from unittest import mock + +import numpy as np +import numpy.typing as npt +import pytest + +from vtk import vtkImageData +import vtk.numpy_interface.dataset_adapter as dsa +import vtk.util.numpy_support as np_s +from vtkmodules.util.vtkConstants import VTK_DOUBLE + + +# -- VTK helpers that replicate what tomviz.internal_utils does, without +# requiring the in-application guard. + +def _vtk_set_array( + dataobject: vtkImageData, + newarray: npt.NDArray[np.generic], + name: str = 'Scalars', +) -> None: + arr = np.asfortranarray(newarray) + flat = arr.reshape(-1, order='F') + if flat.dtype not in (np.int8, np.int16, np.uint16, np.float32, + np.float64, np.uint8): + flat = flat.astype(np.float32) + vtkshape = newarray.shape + extent = [0, vtkshape[0] - 1, 0, vtkshape[1] - 1, 0, vtkshape[2] - 1] + dataobject.SetExtent(extent) + do = dsa.WrapDataObject(dataobject) + do.PointData.append(flat, name) + do.PointData.SetActiveScalars(name) + + +def _vtk_get_array( + dataobject: vtkImageData, + name: str | None = None, + order: str = 'F', +) -> npt.NDArray[np.generic]: + do = dsa.WrapDataObject(dataobject) + if name is not None: + rawarray = do.PointData.GetAbstractArray(name) + else: + rawarray = do.PointData.GetScalars() + scalars = dsa.vtkDataArrayToVTKArray(rawarray, do) + return np.reshape(scalars, dataobject.GetDimensions(), order=order) + + +def _vtk_set_tilt_angles( + dataobject: vtkImageData, + newarray: npt.NDArray[np.float64], +) -> None: + vtkarray = np_s.numpy_to_vtk(newarray, deep=1, array_type=VTK_DOUBLE) + vtkarray.SetName('tilt_angles') + do = dsa.WrapDataObject(dataobject) + do.FieldData.RemoveArray('tilt_angles') + do.FieldData.AddArray(vtkarray) + + +def _vtk_get_tilt_angles( + dataobject: vtkImageData, +) -> npt.NDArray[np.float64] | None: + do = dsa.WrapDataObject(dataobject) + rawarray = do.FieldData.GetArray('tilt_angles') + if isinstance(rawarray, dsa.VTKNoneArray): + return None + return dsa.vtkDataArrayToVTKArray(rawarray, do) + + +def _make_image_data( + arr: npt.NDArray[np.generic], + spacing: tuple[float, float, float] = (1.0, 1.0, 1.0), + origin: tuple[float, float, float] = (0.0, 0.0, 0.0), +) -> vtkImageData: + img = vtkImageData() + img.SetSpacing(*spacing) + img.SetOrigin(*origin) + _vtk_set_array(img, arr) + return img + + +# Patch tomviz.internal_utils so that the MRC/DICOM writers can call +# get_array, set_array, etc. without needing the full application. +_iu_patch = mock.patch.dict( + 'tomviz.internal_utils.__dict__', + { + 'get_array': _vtk_get_array, + 'set_array': _vtk_set_array, + 'get_tilt_angles': _vtk_get_tilt_angles, + 'set_tilt_angles': _vtk_set_tilt_angles, + }, +) + + +@pytest.fixture(autouse=True) +def _patch_internal_utils() -> Generator[None, None, None]: + with _iu_patch: + yield + + +# --------------------------------------------------------------------------- +# MRC writer +# --------------------------------------------------------------------------- + +from tomviz.io.formats.mrc import MrcWriter, HEADER_DTYPE + + +def test_mrc_writer_float32_round_trip(tmp_path: Path) -> None: + arr = np.random.default_rng(42).random((4, 5, 6)).astype(np.float32) + path = tmp_path / 'test.mrc' + MrcWriter().write(str(path), _make_image_data(arr)) + + with open(path, 'rb') as f: + header = np.frombuffer(f.read(1024), dtype=HEADER_DTYPE)[0] + data = np.frombuffer(f.read(), dtype=np.float32) + + assert header['nx'] == 4 + assert header['ny'] == 5 + assert header['nz'] == 6 + assert header['mode'] == 2 # float32 + assert header['map'] == b'MAP ' + data = data.reshape((6, 5, 4)) + np.testing.assert_allclose(data, np.ascontiguousarray(np.transpose(arr)), + atol=1e-7) + + +def test_mrc_writer_preserves_spacing(tmp_path: Path) -> None: + arr = np.zeros((3, 4, 5), dtype=np.float32) + path = tmp_path / 'test.mrc' + MrcWriter().write(str(path), _make_image_data(arr, spacing=(2.0, 3.0, 4.0))) + + with open(path, 'rb') as f: + header = np.frombuffer(f.read(1024), dtype=HEADER_DTYPE)[0] + + assert float(header['cella']['x']) == pytest.approx(3 * 2.0) + assert float(header['cella']['y']) == pytest.approx(4 * 3.0) + assert float(header['cella']['z']) == pytest.approx(5 * 4.0) + + +def test_mrc_writer_int16_uses_mode_1(tmp_path: Path) -> None: + arr = np.arange(24, dtype=np.int16).reshape((2, 3, 4)) + path = tmp_path / 'test.mrc' + MrcWriter().write(str(path), _make_image_data(arr)) + + with open(path, 'rb') as f: + header = np.frombuffer(f.read(1024), dtype=HEADER_DTYPE)[0] + + assert header['mode'] == 1 # int16 + + +def test_mrc_writer_uint16_uses_mode_6(tmp_path: Path) -> None: + arr = np.arange(24, dtype=np.uint16).reshape((2, 3, 4)) + path = tmp_path / 'test.mrc' + MrcWriter().write(str(path), _make_image_data(arr)) + + with open(path, 'rb') as f: + header = np.frombuffer(f.read(1024), dtype=HEADER_DTYPE)[0] + + assert header['mode'] == 6 # uint16 + + +def test_mrc_writer_converts_unsupported_dtype_to_float32(tmp_path: Path) -> None: + arr = np.arange(24, dtype=np.float64).reshape((2, 3, 4)) + path = tmp_path / 'test.mrc' + MrcWriter().write(str(path), _make_image_data(arr)) + + with open(path, 'rb') as f: + header = np.frombuffer(f.read(1024), dtype=HEADER_DTYPE)[0] + data = np.frombuffer(f.read(), dtype=np.float32) + + assert header['mode'] == 2 # float32 + data = data.reshape((4, 3, 2)) + np.testing.assert_allclose(data, + np.ascontiguousarray( + np.transpose(arr.astype(np.float32))), + atol=1e-7) + + +def test_mrc_writer_statistics(tmp_path: Path) -> None: + arr = np.array([[[1.0, 2.0, 3.0, 4.0, 5.0]]], dtype=np.float32) + path = tmp_path / 'test.mrc' + MrcWriter().write(str(path), _make_image_data(arr)) + + with open(path, 'rb') as f: + header = np.frombuffer(f.read(1024), dtype=HEADER_DTYPE)[0] + + assert float(header['dmin']) == pytest.approx(1.0) + assert float(header['dmax']) == pytest.approx(5.0) + assert float(header['dmean']) == pytest.approx(3.0) + assert float(header['rms']) == pytest.approx(float(arr.std()), rel=1e-5) + + +# --------------------------------------------------------------------------- +# DICOM reader/writer +# --------------------------------------------------------------------------- + +try: + import itk + _HAS_ITK = True +except ImportError: + _HAS_ITK = False + + +@pytest.mark.skipif(not _HAS_ITK, reason='ITK not available') +class TestDicom: + + def test_dicom_writer_reader_uint16_round_trip(self, tmp_path: Path) -> None: + from tomviz.io.formats.dicom import DicomReader, DicomWriter + + arr = np.random.default_rng(7).integers(0, 65535, (4, 5, 6), + dtype=np.uint16) + img = _make_image_data(arr, spacing=(1.5, 2.0, 2.5)) + path = str(tmp_path / 'test.dcm') + DicomWriter().write(path, img) + + result = DicomReader().read(path) + result_arr = _vtk_get_array(result) + + assert result_arr.shape == arr.shape + np.testing.assert_array_equal(result_arr, arr) + + result_spacing = result.GetSpacing() + assert result_spacing[0] == pytest.approx(1.5) + assert result_spacing[1] == pytest.approx(2.0) + assert result_spacing[2] == pytest.approx(2.5) + + def test_dicom_writer_reader_uint8_round_trip(self, tmp_path: Path) -> None: + from tomviz.io.formats.dicom import DicomReader, DicomWriter + + arr = np.random.default_rng(8).integers(0, 255, (3, 4, 5), + dtype=np.uint8) + img = _make_image_data(arr) + path = str(tmp_path / 'test.dcm') + DicomWriter().write(path, img) + + result = DicomReader().read(path) + result_arr = _vtk_get_array(result) + assert result_arr.shape == arr.shape + np.testing.assert_array_equal(result_arr, arr) + + def test_dicom_writer_converts_float_to_uint16(self, tmp_path: Path) -> None: + from tomviz.io.formats.dicom import DicomReader, DicomWriter + + arr = np.array([[[0.0, 0.5, 1.0]]], dtype=np.float32) + img = _make_image_data(arr) + path = str(tmp_path / 'test.dcm') + DicomWriter().write(path, img) + + result = DicomReader().read(path) + result_arr = _vtk_get_array(result) + assert result_arr.dtype == np.uint16 + # 0.0 -> 0, 0.5 -> ~32767, 1.0 -> 65535 + assert result_arr.flat[0] == 0 + assert result_arr.flat[2] == 65535 + assert abs(int(result_arr.flat[1]) - 32767) < 2 + + def test_dicom_preserves_tilt_angles(self, tmp_path: Path) -> None: + from tomviz.io.formats.dicom import DicomReader, DicomWriter + + arr = np.zeros((3, 3, 3), dtype=np.uint16) + img = _make_image_data(arr) + angles = np.array([-60.0, 0.0, 60.0]) + _vtk_set_tilt_angles(img, angles) + + path = str(tmp_path / 'test.dcm') + DicomWriter().write(path, img) + + result = DicomReader().read(path) + result_angles = _vtk_get_tilt_angles(result) + assert result_angles is not None + np.testing.assert_allclose(result_angles, angles) + + def test_dicom_reader_handles_no_tilt_angles(self, tmp_path: Path) -> None: + from tomviz.io.formats.dicom import DicomReader, DicomWriter + + arr = np.zeros((3, 3, 3), dtype=np.uint16) + img = _make_image_data(arr) + + path = str(tmp_path / 'test.dcm') + DicomWriter().write(path, img) + + result = DicomReader().read(path) + result_angles = _vtk_get_tilt_angles(result) + assert result_angles is None diff --git a/tests/python/multi_array_test.py b/tests/python/multi_array_test.py index b9ed035f0..8a990154c 100644 --- a/tests/python/multi_array_test.py +++ b/tests/python/multi_array_test.py @@ -2,7 +2,7 @@ from utils import load_operator_class, load_operator_module -from tomviz.external_dataset import Dataset +from tomviz.external_dataset import LegacyDataset as Dataset def test_multi_arrays(hxn_xrf_example_dataset: Dataset): diff --git a/tests/python/operator_test.py b/tests/python/operator_test.py index a599615d3..b0f0d1d83 100644 --- a/tests/python/operator_test.py +++ b/tests/python/operator_test.py @@ -47,18 +47,18 @@ def test_find_transform_from_module(): def test_find_transform_function(): # Module with a function - found = find_transform_function(function, None) + found = find_transform_function(function) assert (found == function.transform_scalars) # Module with operator class - func = find_transform_function(simple, None) + func = find_transform_function(simple) assert isinstance(func.__self__, simple.SimpleOperator) assert func.__func__.__name__ == 'transform_scalars' assert func(None) # Module with none with pytest.raises(Exception): - find_transform_function(unittest, None) + find_transform_function(unittest) def test_is_cancelable(): @@ -78,38 +78,42 @@ def test_find_operators(): expected = [ { 'jsonPath': 'syntaxerror.json', - 'loadError': None, 'label': 'Syntax Error Op', - 'valid': False, - 'pythonPath': 'syntaxerror.py' + 'pythonPath': 'syntaxerror.py', + 'type': 'legacy_transform', + 'valid': True, }, { 'label': 'function', + 'pythonPath': 'function.py', + 'type': 'legacy_transform', 'valid': True, - 'pythonPath': 'function.py' }, { - 'loadError': None, 'label': 'two', - 'valid': False, - 'pythonPath': 'two.py' + 'pythonPath': 'two.py', + 'type': 'legacy_transform', + 'valid': True, }, { 'label': 'cancelable', + 'pythonPath': 'cancelable.py', + 'type': 'legacy_transform', 'valid': True, - 'pythonPath': 'cancelable.py' }, { 'label': 'simple', + 'pythonPath': 'simple.py', + 'type': 'legacy_transform', 'valid': True, - 'pythonPath': 'simple.py' }, { 'jsonPath': 'invalidjson.json', 'label': 'invalidjson', 'loadError': None, 'pythonPath': 'invalidjson.py', - 'valid': False + 'type': 'legacy_transform', + 'valid': False, } ] diff --git a/tests/python/pipeline_cli_test.py b/tests/python/pipeline_cli_test.py new file mode 100644 index 000000000..1157416ef --- /dev/null +++ b/tests/python/pipeline_cli_test.py @@ -0,0 +1,164 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""End-to-end CLI test: build a tiny EMD on disk, hand-craft a schema-v2 +state file with a Reader → ConvertToFloat → VolumeSink graph, run the +CLI via Click's test runner, and verify the output ends up as an EMD +under the chosen output directory.""" + +import json +from pathlib import Path + +import h5py +import numpy as np +from click.testing import CliRunner + +from tomviz.cli import main +from tomviz.external_dataset import Dataset +from tomviz.io_emd import _write_emd, load_dataset + + +def _write_simple_emd(path: Path, arr: np.ndarray): + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + _write_emd(path, ds) + + +def test_cli_executes_simple_state(tmp_path): + # Step 1: synthetic input EMD + arr = (np.arange(24, dtype=np.uint8) + 1).reshape((2, 3, 4)) + input_emd = tmp_path / 'input.emd' + _write_simple_emd(input_emd, arr) + + # Step 2: schema-v2 state describing Reader → ConvertToFloat → Sink. + # The sink must be ignored by the CLI; ConvertToFloat is the only + # data leaf because its consumer is the (sink-typed) node 3. + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nextNodeId': 4, + 'nodes': [ + {'id': 1, 'type': 'source.reader', + 'label': 'Reader', + 'fileNames': [str(input_emd)]}, + {'id': 2, 'type': 'transform.convertToFloat', + 'label': 'CvtFloat'}, + {'id': 3, 'type': 'sink.volume', + 'label': 'Volume', + 'inputPorts': {'volume': {'type': ['ImageData']}}}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + {'from': {'node': 2, 'port': 'output'}, + 'to': {'node': 3, 'port': 'volume'}}, + ], + }, + } + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(state)) + + out_dir = tmp_path / 'out' + runner = CliRunner() + result = runner.invoke(main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-p', 'tqdm', + ]) + assert result.exit_code == 0, result.output + # Single run → outputs land directly in /. + written = list(out_dir.glob('*.emd')) + # ConvertToFloat is the single data leaf (the sink consumer doesn't + # disqualify it); its only output port is "output". + assert len(written) == 1 + assert written[0].name.startswith('2_') + assert written[0].name.endswith('__output.emd') + # And the contents really are float32. + out_ds = load_dataset(written[0]) + assert out_ds.active_scalars.dtype == np.float32 + + +def test_cli_writes_table_leaf_as_csv(tmp_path): + """A graph whose only data leaf produces a vtkTable should land as + a .csv file in the output directory.""" + import csv as _csv + + # Inline operator script that produces a tiny vtkTable. We bypass + # tomviz.utils.make_spreadsheet (which gates on require_internal_mode) + # and build the table directly from the Python operator. + script = ''' +import vtk + +def transform(dataset): + t = vtk.vtkTable() + radius = vtk.vtkFloatArray() + radius.SetName("radius") + radius.SetNumberOfTuples(2) + radius.SetValue(0, 1.0) + radius.SetValue(1, 2.5) + t.AddColumn(radius) + return {"stats": t} +''' + description = json.dumps({ + 'name': 'TableEmitter', + 'label': 'Table Emitter', + 'apply_to_each_array': False, + 'results': [{'name': 'stats', 'type': 'table'}], + }) + + arr = (np.arange(24, dtype=np.uint8) + 1).reshape((2, 3, 4)) + input_emd = tmp_path / 'input.emd' + _write_simple_emd(input_emd, arr) + + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nextNodeId': 3, + 'nodes': [ + {'id': 1, 'type': 'source.reader', + 'label': 'Reader', 'fileNames': [str(input_emd)]}, + {'id': 2, 'type': 'transform.legacyPython', + 'label': 'TableOp', + 'description': description, + 'script': script}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + ], + }, + } + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(state)) + + out_dir = tmp_path / 'out' + runner = CliRunner() + result = runner.invoke(main, ['-s', str(state_path), + '-o', str(out_dir)]) + assert result.exit_code == 0, result.output + + # Two outputs: the operator's primary port (a Dataset) as EMD and + # its declared "stats" table as CSV. We only assert on the CSV. + csv_files = list(out_dir.glob('*.csv')) + assert len(csv_files) == 1 + with open(csv_files[0], newline='') as f: + rows = list(_csv.reader(f)) + assert rows[0] == ['radius'] + assert rows[1] == ['1.0'] + assert rows[2] == ['2.5'] + + +def test_cli_rejects_legacy_state(tmp_path): + legacy = {'dataSources': [{'reader': {'fileNames': []}, + 'operators': []}]} + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(legacy)) + + out_dir = tmp_path / 'out' + runner = CliRunner() + result = runner.invoke(main, [ + '-s', str(state_path), + '-o', str(out_dir), + ]) + assert result.exit_code != 0 diff --git a/tests/python/pipeline_runner_test.py b/tests/python/pipeline_runner_test.py new file mode 100644 index 000000000..8ab30fc14 --- /dev/null +++ b/tests/python/pipeline_runner_test.py @@ -0,0 +1,1038 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""Tests for tomviz.pipeline.run — execute a state-file pipeline +either with no overrides (one run) or against a sequence of input +file sets (single-source convenience or dict[node_id, paths]) — plus +the matching CLI surface (``-i [NODE_ID:]VALUE`` with +``--run-prefix``).""" + +import json + +import h5py +import numpy as np +import pytest +from click.testing import CliRunner + +from tomviz.cli import main as cli_main +from tomviz.external_dataset import Dataset +from tomviz.io_emd import _write_emd, load_dataset +from tomviz.pipeline import register_builtins, run + + +@pytest.fixture(autouse=True) +def _builtins(): + register_builtins() + + +def _write_simple_emd(path, arr): + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + _write_emd(path, ds) + + +def _make_inputs(tmp_path, names_and_values): + """Materialize one tiny EMD per name with the given fill value.""" + inputs = [] + for name, value in names_and_values: + path = tmp_path / f'{name}.emd' + _write_simple_emd(path, np.full((2, 2, 3), value, dtype=np.uint8)) + inputs.append(path) + return inputs + + +def _single_source_state(tmp_path, placeholder_path): + """A Reader → ConvertToFloat graph; the Reader gets its fileNames + overridden per run.""" + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nextNodeId': 3, + 'nodes': [ + {'id': 1, 'type': 'source.reader', 'label': 'Reader', + 'fileNames': [str(placeholder_path)]}, + {'id': 2, 'type': 'transform.convertToFloat', + 'label': 'CvtFloat'}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + ], + }, + } + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(state)) + return state_path + + +def _two_source_state(tmp_path, placeholder_a, placeholder_b): + """Reader1 → CvtA, Reader2 → CvtB. Both transforms are leaves.""" + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + {'id': 1, 'type': 'source.reader', 'label': 'Reader1', + 'fileNames': [str(placeholder_a)]}, + {'id': 2, 'type': 'transform.convertToFloat', + 'label': 'CvtA'}, + {'id': 3, 'type': 'source.reader', 'label': 'Reader2', + 'fileNames': [str(placeholder_b)]}, + {'id': 4, 'type': 'transform.convertToFloat', + 'label': 'CvtB'}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + {'from': {'node': 3, 'port': 'volume'}, + 'to': {'node': 4, 'port': 'volume'}}, + ], + }, + } + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(state)) + return state_path + + +# ---- Python API: single-source convenience ----------------------------- + + +def test_run_single_source_list_creates_run_dirs(tmp_path): + inputs = _make_inputs(tmp_path, + [('alpha', 1), ('bravo', 5), ('charlie', 9)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + run_dirs = run(state_path, out_dir, + inputs=[str(p) for p in inputs]) + + assert [d.name for d in run_dirs] == ['run_0', 'run_1', 'run_2'] + for run_dir, expected in zip(run_dirs, [1.0, 5.0, 9.0]): + emds = list(run_dir.glob('*.emd')) + assert len(emds) == 1 + assert np.all(load_dataset(emds[0]).active_scalars == expected) + + +def test_run_single_source_glob_string(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1), ('b', 2), ('c', 3)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + run_dirs = run(state_path, out_dir, inputs=f'{tmp_path}/*.emd') + + # Glob matches sort alphabetically: a, b, c -> run_0, run_1, run_2. + assert [d.name for d in run_dirs] == ['run_0', 'run_1', 'run_2'] + + +def test_run_single_source_single_path_string(tmp_path): + inputs = _make_inputs(tmp_path, [('only', 4)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + run_dirs = run(state_path, out_dir, inputs=str(inputs[0])) + + # Single run → outputs go directly into out_dir (no subdir). + assert run_dirs == [out_dir] + assert list(out_dir.glob('*.emd')) + + +def test_run_single_source_run_naming_zero_pads(tmp_path): + """Twelve runs should pad run names to width 2 (run_00..run_11).""" + inputs = _make_inputs(tmp_path, + [(f'in{i:02d}', i + 1) for i in range(12)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + run_dirs = run(state_path, out_dir, + inputs=[str(p) for p in inputs]) + + assert run_dirs[0].name == 'run_00' + assert run_dirs[-1].name == 'run_11' + + +def test_run_non_dict_with_multiple_readers_raises(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1)]) + state_path = _two_source_state(tmp_path, inputs[0], inputs[0]) + with pytest.raises(ValueError, match='exactly one ReaderSourceNode'): + run(state_path, tmp_path / 'out', inputs=[str(inputs[0])]) + + +# ---- Python API: dict form --------------------------------------------- + + +def test_run_dict_multi_source(tmp_path): + a1, a2, b1, b2 = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), ('b1', 10), ('b2', 20)]) + state_path = _two_source_state(tmp_path, a1, b1) + + run_dirs = run( + state_path, + tmp_path / 'out', + inputs={1: [str(a1), str(a2)], 3: [str(b1), str(b2)]}, + ) + assert [d.name for d in run_dirs] == ['run_0', 'run_1'] + + for run_dir, expected_a, expected_b in ( + (run_dirs[0], 1.0, 10.0), + (run_dirs[1], 2.0, 20.0)): + emds = sorted(run_dir.glob('*.emd')) + assert len(emds) == 2 + a_file = next(e for e in emds if e.name.startswith('2_')) + b_file = next(e for e in emds if e.name.startswith('4_')) + assert np.all(load_dataset(a_file).active_scalars == expected_a) + assert np.all(load_dataset(b_file).active_scalars == expected_b) + + +def test_run_dict_broadcast_shorter_to_longer(tmp_path): + """When one source has a length-1 list and another has N paths, + the length-1 list broadcasts to N runs.""" + a1, a2, ref = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), ('ref', 99)]) + state_path = _two_source_state(tmp_path, a1, ref) + + run_dirs = run( + state_path, + tmp_path / 'out', + inputs={1: [str(a1), str(a2)], 3: str(ref)}, # source 3 broadcast + ) + assert len(run_dirs) == 2 + for run_dir in run_dirs: + emds = sorted(run_dir.glob('*.emd')) + # CvtB output (node 4) should always equal 99 (the ref). + b_file = next(e for e in emds if e.name.startswith('4_')) + assert np.all(load_dataset(b_file).active_scalars == 99.0) + + +def test_run_dict_glob_in_value(tmp_path): + """A glob string as a per-source value gets expanded.""" + _make_inputs(tmp_path, + [('a1', 1), ('a2', 2), ('b1', 10), ('b2', 20)]) + a1 = tmp_path / 'a1.emd' + b1 = tmp_path / 'b1.emd' + state_path = _two_source_state(tmp_path, a1, b1) + + run_dirs = run( + state_path, + tmp_path / 'out', + inputs={1: f'{tmp_path}/a*.emd', 3: f'{tmp_path}/b*.emd'}, + ) + assert len(run_dirs) == 2 + + +def test_run_dict_all_broadcast_yields_one_run(tmp_path): + a, b = _make_inputs(tmp_path, [('a', 1), ('b', 10)]) + state_path = _two_source_state(tmp_path, a, b) + + out_dir = tmp_path / 'out' + run_dirs = run( + state_path, + out_dir, + inputs={1: str(a), 3: str(b)}, + ) + # All sources broadcast → 1 run → flat output. + assert run_dirs == [out_dir] + + +def test_run_dict_inconsistent_lengths_raises(tmp_path): + a1, a2, b1, b2, b3 = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), + ('b1', 10), ('b2', 20), ('b3', 30)]) + state_path = _two_source_state(tmp_path, a1, b1) + + with pytest.raises(ValueError, match='Inconsistent input list lengths'): + run( + state_path, + tmp_path / 'out', + inputs={1: [str(a1), str(a2)], + 3: [str(b1), str(b2), str(b3)]}, + ) + + +def test_run_dict_unknown_node_id_raises(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1)]) + state_path = _single_source_state(tmp_path, inputs[0]) + with pytest.raises(ValueError, match='No node with id 99'): + run(state_path, tmp_path / 'out', + inputs={99: str(inputs[0])}) + + +def test_run_dict_non_reader_node_id_raises(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1)]) + state_path = _single_source_state(tmp_path, inputs[0]) + with pytest.raises(ValueError, match='ReaderSourceNode'): + # Node 2 is the ConvertToFloat transform. + run(state_path, tmp_path / 'out', + inputs={2: str(inputs[0])}) + + +def test_run_dict_string_keys_raise(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1)]) + state_path = _single_source_state(tmp_path, inputs[0]) + with pytest.raises(TypeError, match='node ids'): + run(state_path, tmp_path / 'out', + inputs={'1': str(inputs[0])}) + + +def test_run_empty_dict_raises(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1)]) + state_path = _single_source_state(tmp_path, inputs[0]) + with pytest.raises(ValueError, match='must not be empty'): + run(state_path, tmp_path / 'out', inputs={}) + + +def test_run_dict_glob_no_match_raises(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1)]) + state_path = _single_source_state(tmp_path, inputs[0]) + with pytest.raises(ValueError, match='matched no files'): + run( + state_path, + tmp_path / 'out', + inputs={1: f'{tmp_path}/zzz_*.emd'}, + ) + + +# ---- CLI: bare mode (single source) ------------------------------------- + + +def test_cli_bare_input_single_path(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 3)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', str(inputs[0]), + ]) + assert result.exit_code == 0, result.output + # Single run → flat output, no run subdir. + assert list(out_dir.glob('*.emd')) + assert not (out_dir / 'run_0').exists() + + +def test_cli_bare_input_comma_list(tmp_path): + a, b, c = _make_inputs(tmp_path, [('a', 1), ('b', 2), ('c', 3)]) + state_path = _single_source_state(tmp_path, a) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', f'{a},{b},{c}', + ]) + assert result.exit_code == 0, result.output + assert sorted(d.name for d in out_dir.iterdir()) == [ + 'run_0', 'run_1', 'run_2'] + + +def test_cli_bare_input_glob(tmp_path): + a, b, c = _make_inputs(tmp_path, [('a', 1), ('b', 2), ('c', 3)]) + state_path = _single_source_state(tmp_path, a) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', f'{tmp_path}/*.emd', + ]) + assert result.exit_code == 0, result.output + assert sorted(d.name for d in out_dir.iterdir()) == [ + 'run_0', 'run_1', 'run_2'] + + +def test_cli_multiple_bare_inputs_rejected(tmp_path): + a, b = _make_inputs(tmp_path, [('a', 1), ('b', 2)]) + state_path = _single_source_state(tmp_path, a) + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'out'), + '-i', str(a), + '-i', str(b), + ]) + assert result.exit_code != 0 + assert 'Only one bare --input is allowed' in result.output + + +def test_cli_bare_input_with_multi_reader_state_rejected(tmp_path): + a, b = _make_inputs(tmp_path, [('a', 1), ('b', 1)]) + state_path = _two_source_state(tmp_path, a, b) + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'out'), + '-i', str(a), + ]) + # run_many raises ValueError; the CLI surfaces it as exit 2. + assert result.exit_code != 0 + + +# ---- CLI: prefixed mode (multi source) ---------------------------------- + + +def test_cli_prefixed_multi_source(tmp_path): + a1, a2, b1, b2 = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), ('b1', 10), ('b2', 20)]) + state_path = _two_source_state(tmp_path, a1, b1) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', f'1:{a1},{a2}', + '-i', f'3:{b1},{b2}', + ]) + assert result.exit_code == 0, result.output + assert sorted(d.name for d in out_dir.iterdir()) == ['run_0', 'run_1'] + + +def test_cli_prefixed_glob_with_broadcast(tmp_path): + """Source 1 globs to several files, source 3 broadcasts a single + reference. The result is one run per source-1 match.""" + a1, a2, a3, ref = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), ('a3', 3), ('ref', 99)]) + state_path = _two_source_state(tmp_path, a1, ref) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', f'1:{tmp_path}/a*.emd', + '-i', f'3:{ref}', + ]) + assert result.exit_code == 0, result.output + assert sorted(d.name for d in out_dir.iterdir()) == [ + 'run_0', 'run_1', 'run_2'] + # Every run's CvtB (node 4) should equal the broadcast reference. + for run_dir in out_dir.iterdir(): + b_file = next(run_dir.glob('4_*.emd')) + assert np.all(load_dataset(b_file).active_scalars == 99.0) + + +def test_cli_prefixed_repeated_for_same_id_concatenates(tmp_path): + a1, a2, ref = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), ('ref', 99)]) + state_path = _two_source_state(tmp_path, a1, ref) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', f'1:{a1}', + '-i', f'1:{a2}', + '-i', f'3:{ref}', + ]) + assert result.exit_code == 0, result.output + assert sorted(d.name for d in out_dir.iterdir()) == ['run_0', 'run_1'] + + +def test_cli_mixing_bare_and_prefixed_rejected(tmp_path): + a, b, ref = _make_inputs(tmp_path, [('a', 1), ('b', 2), ('ref', 99)]) + state_path = _two_source_state(tmp_path, a, ref) + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'out'), + '-i', str(a), + '-i', f'3:{ref}', + ]) + assert result.exit_code != 0 + assert 'Cannot mix bare and' in result.output + + +def test_cli_prefixed_glob_no_match_errors(tmp_path): + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'out'), + '-i', f'1:{tmp_path}/zzz_*.emd', + ]) + assert result.exit_code != 0 + assert 'matched no files' in result.output + + +def test_cli_prefixed_inconsistent_lengths_errors(tmp_path): + a1, a2, b1, b2, b3 = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), + ('b1', 10), ('b2', 20), ('b3', 30)]) + state_path = _two_source_state(tmp_path, a1, b1) + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'out'), + '-i', f'1:{a1},{a2}', + '-i', f'3:{b1},{b2},{b3}', + ]) + assert result.exit_code != 0 + assert 'Inconsistent input list lengths' in result.output + + +def test_cli_input_path_does_not_exist_errors(tmp_path): + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'out'), + '-i', str(tmp_path / 'never_existed.emd'), + ]) + assert result.exit_code != 0 + assert 'does not exist' in result.output + + +# ---- no-override mode (single run) -------------------------------------- + + +def test_run_no_inputs_executes_state_as_is(tmp_path): + """``run(state, out)`` with ``inputs=None`` runs once using the + pinned fileNames in the state file and writes outputs straight + into ``output_dir`` (single run = flat layout).""" + inputs = _make_inputs(tmp_path, [('pinned', 7)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + run_dirs = run(state_path, out_dir) + assert run_dirs == [out_dir] + emds = list(out_dir.glob('*.emd')) + assert len(emds) == 1 + assert np.all(load_dataset(emds[0]).active_scalars == 7.0) + + +def test_cli_no_inputs_writes_flat(tmp_path): + """No --input → single run → flat output directly in --output-dir.""" + inputs = _make_inputs(tmp_path, [('pinned', 7)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + ]) + assert result.exit_code == 0, result.output + emds = list(out_dir.glob('*.emd')) + assert len(emds) == 1 + # And no run_N subdir was created. + assert not (out_dir / 'run_0').exists() + + +# ---- --run-prefix / run_dir_prefix -------------------------------------- + + +def test_run_custom_run_dir_prefix(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1), ('b', 2)]) + state_path = _single_source_state(tmp_path, inputs[0]) + + run_dirs = run(state_path, tmp_path / 'out', + inputs=[str(p) for p in inputs], + run_dir_prefix='sample') + assert [d.name for d in run_dirs] == ['sample_0', 'sample_1'] + + +def test_cli_run_prefix_flag(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1), ('b', 2)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '--run-prefix', 'sample', + '-i', f'{inputs[0]},{inputs[1]}', + ]) + assert result.exit_code == 0, result.output + assert sorted(d.name for d in out_dir.iterdir()) == [ + 'sample_0', 'sample_1'] + + +# ---- per-run state snapshot --------------------------------------------- + + +def test_run_writes_state_snapshot_when_overrides_applied(tmp_path): + """A run with overrides should drop a state.tvsm into each run's + output directory; fileNames in the snapshot reflect that run's + inputs, not the placeholder pinned in the original state file.""" + a, b = _make_inputs(tmp_path, [('a', 1), ('b', 2)]) + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', + inputs=[str(a), str(b)]) + + for run_dir, expected_path in zip(run_dirs, [a, b]): + snapshot_path = run_dir / 'state.tvsm' + assert snapshot_path.is_file(), ( + f'Expected state snapshot at {snapshot_path}') + snapshot = json.loads(snapshot_path.read_text()) + nodes = snapshot['pipeline']['nodes'] + reader = next(n for n in nodes if n['type'] == 'source.reader') + assert reader['fileNames'] == [str(expected_path)] + + +def test_run_without_overrides_still_writes_snapshot(tmp_path): + """state.tvsm is always written so the output directory is + self-contained. With no overrides it's a verbatim copy of the + input state.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out') + snapshot = run_dirs[0] / 'state.tvsm' + assert snapshot.is_file() + # Same logical content as the input — fileNames unchanged since + # there were no overrides to apply. + original = json.loads(state_path.read_text()) + written = json.loads(snapshot.read_text()) + assert original == written + + +def test_run_state_snapshot_is_reproducible(tmp_path): + """Re-running a snapshot's state.tvsm must produce byte-identical + leaf outputs to the original run.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + original_runs = run(state_path, tmp_path / 'orig', inputs=[str(a)]) + snapshot = original_runs[0] / 'state.tvsm' + assert snapshot.is_file() + + reproduced = run(snapshot, tmp_path / 'reproduced') + assert len(reproduced) == 1 + + # Compare the produced .emd files (excluding the snapshots). + orig_emds = sorted(original_runs[0].glob('*.emd')) + repro_emds = sorted(reproduced[0].glob('*.emd')) + assert [e.name for e in orig_emds] == [e.name for e in repro_emds] + for orig_file, repro_file in zip(orig_emds, repro_emds): + np.testing.assert_array_equal( + load_dataset(orig_file).active_scalars, + load_dataset(repro_file).active_scalars) + + +def test_run_snapshot_dict_form_patches_each_source(tmp_path): + """Multi-source dict form patches each overridden source's + fileNames in the snapshot, leaving non-overridden nodes alone.""" + a1, a2, b1, b2 = _make_inputs( + tmp_path, [('a1', 1), ('a2', 2), ('b1', 10), ('b2', 20)]) + state_path = _two_source_state(tmp_path, a1, b1) + + run_dirs = run( + state_path, + tmp_path / 'out', + inputs={1: [str(a1), str(a2)], 3: [str(b1), str(b2)]}, + ) + for run_dir, paths in zip(run_dirs, [(a1, b1), (a2, b2)]): + snapshot = json.loads((run_dir / 'state.tvsm').read_text()) + nodes = {n['id']: n for n in snapshot['pipeline']['nodes']} + assert nodes[1]['fileNames'] == [str(paths[0])] + assert nodes[3]['fileNames'] == [str(paths[1])] + + +def test_cli_writes_state_snapshot(tmp_path): + inputs = _make_inputs(tmp_path, [('a', 1), ('b', 2)]) + state_path = _single_source_state(tmp_path, inputs[0]) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', f'{inputs[0]},{inputs[1]}', + ]) + assert result.exit_code == 0, result.output + assert (out_dir / 'run_0' / 'state.tvsm').is_file() + assert (out_dir / 'run_1' / 'state.tvsm').is_file() + + +# ---- output_format ------------------------------------------------------ + + +def test_run_output_format_port_default(tmp_path): + """Default output_format='port': port files written, no tvh5.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', inputs=[str(a)]) + assert list(run_dirs[0].glob('*.emd')) + assert not (run_dirs[0] / 'output_state.tvh5').exists() + + +def test_run_output_format_state_only_writes_tvh5(tmp_path): + """output_format='state' produces output_state.tvh5 and no per-port + files.""" + a = _make_inputs(tmp_path, [('a', 5)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', inputs=[str(a)], + output_format='state') + + tvh5 = run_dirs[0] / 'output_state.tvh5' + assert tvh5.is_file() + # No standalone EMD beside the tvh5. + assert not list(run_dirs[0].glob('*.emd')) + # Sanity check the HDF5 layout. + with h5py.File(tvh5, 'r') as f: + assert 'tomviz_state' in f + assert '/data' in f + # ConvertToFloat (node 2) wrote its 'output' port into the + # tvh5 — verify the EMD layout under the port group. The port + # group IS the EMD node (as in C++ EmdFormat::writeNode), so + # 'data'/'dim1' sit directly under it. + assert '/data/2/output/data' in f + assert '/data/2/output/dim1' in f + + +def test_run_output_format_state_plus_port_writes_both(tmp_path): + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', inputs=[str(a)], + output_format='state+port') + + assert (run_dirs[0] / 'output_state.tvh5').is_file() + assert list(run_dirs[0].glob('*.emd')) + + +def test_run_output_format_invalid_raises(tmp_path): + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + with pytest.raises(ValueError, match='output_format must be'): + run(state_path, tmp_path / 'out', inputs=[str(a)], + output_format='bogus') + + +def test_run_output_format_state_works_without_overrides(tmp_path): + """output_format is independent of overrides — a no-input run still + bundles its outputs into a tvh5.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', + output_format='state') + assert (run_dirs[0] / 'output_state.tvh5').is_file() + # state.tvsm is always written for self-contained output. + assert (run_dirs[0] / 'state.tvsm').is_file() + + +def test_tvh5_port_group_layout_matches_emdformat_readnode(tmp_path): + """Regression: C++ Tvh5Format::populatePayloadData calls + EmdFormat::readNode(reader, /data//, ...), which expects + ``/data`` to be the volume *dataset* and ``/dim1/2/3`` + to be dim datasets. Writing the standalone-.emd layout + ``/data/tomography/data`` makes the C++ side log + 'failed to read /data//' and abandon the load.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', + output_format='state') + tvh5 = run_dirs[0] / 'output_state.tvh5' + + with h5py.File(tvh5, 'r') as f: + for port_group_path in (f'/data/{n}/{p}' + for n in f['/data'] + for p in f[f'/data/{n}']): + port_group = f[port_group_path] + assert 'data' in port_group, ( + f'{port_group_path}/data missing') + assert isinstance(port_group['data'], h5py.Dataset), ( + f'{port_group_path}/data must be a Dataset, not a ' + f'Group; got {type(port_group["data"]).__name__}') + for dim in ('dim1', 'dim2', 'dim3'): + assert isinstance(port_group[dim], h5py.Dataset), ( + f'{port_group_path}/{dim} must be a Dataset') + + +def test_tvh5_emd_dims_are_float32(tmp_path): + """Regression: the C++ EmdFormat reader reads dim1/dim2/dim3 via + readData, which strictly requires H5T_IEEE_F32LE storage. + Numpy's defaults (int64 from range, float64 from arange-with- + float-step) make tomviz reject the file at attribute/dim read + time. Coerce to float32 in the writer.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', + output_format='state') + tvh5 = run_dirs[0] / 'output_state.tvh5' + + with h5py.File(tvh5, 'r') as f: + # Walk to one of the embedded EMD payloads. Per-port groups + # follow the C++ EmdFormat::writeNode layout: data/dim* sit + # directly under the port group, NOT nested in /data/tomography + # (that outer wrapper only exists in standalone .emd files). + port_groups = [k for k in f['/data'].keys()] + assert port_groups, 'expected at least one port payload' + port = list(f[f'/data/{port_groups[0]}'].keys())[0] + node_group = f[f'/data/{port_groups[0]}/{port}'] + for dim in ('dim1', 'dim2', 'dim3'): + assert node_group[dim].dtype == np.float32, ( + f'{dim} must be float32 for C++ EmdFormat readData ' + f'compatibility; got {node_group[dim].dtype}') + + +def test_tvh5_tomviz_state_is_signed_int8(tmp_path): + """Regression: the C++ Tvh5Format::readState reads /tomviz_state + via H5ReadWrite::readData, which maps to H5T_STD_I8LE and + rejects mismatched storage types via H5Tequal. Writing the JSON + blob as uint8 makes tomviz fall back to LegacyStateLoader and + fail to open the file. The bytes are identical; only the HDF5 + type label matters.""" + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + + run_dirs = run(state_path, tmp_path / 'out', + output_format='state') + tvh5 = run_dirs[0] / 'output_state.tvh5' + + with h5py.File(tvh5, 'r') as f: + ds = f['tomviz_state'] + assert ds.dtype == np.int8, ( + f'tomviz_state must be int8 for C++ Tvh5Format::readState ' + f'compatibility; got {ds.dtype}') + + +def test_run_output_format_state_round_trip(tmp_path): + """Write a tvh5, load it back, verify the dataRefs are honored + and downstream nodes read the embedded payloads. The reloaded + pipeline's source node should be marked Current (data populated + from dataRef), and re-running should produce identical leaf + outputs to the original run.""" + a = _make_inputs(tmp_path, [('a', 7)])[0] + state_path = _single_source_state(tmp_path, a) + + original = run(state_path, tmp_path / 'orig', inputs=[str(a)], + output_format='state+port') + tvh5 = original[0] / 'output_state.tvh5' + + # Re-run from the tvh5 (no overrides; the reader's data is + # already populated via dataRef). + reproduced = run(tvh5, tmp_path / 'reproduced') + + orig_emds = sorted(original[0].glob('*.emd')) + repro_emds = sorted(reproduced[0].glob('*.emd')) + assert [e.name for e in orig_emds] == [e.name for e in repro_emds] + for orig_file, repro_file in zip(orig_emds, repro_emds): + np.testing.assert_array_equal( + load_dataset(orig_file).active_scalars, + load_dataset(repro_file).active_scalars) + + +def test_run_output_format_state_persists_table_payloads(tmp_path): + """A LegacyPythonTransform that returns a vtkTable result is + persisted column-by-column under /data///c, mirroring + C++ Tvh5Format::writeTablePayload. The dataRef on the port entry + must point at the group, and on reload (via load_state) the port + must carry an equivalent vtkTable.""" + from tomviz.pipeline.state_io import load_state + + arr = (np.arange(24, dtype=np.uint8) + 1).reshape((2, 3, 4)) + input_emd = tmp_path / 'input.emd' + _write_simple_emd(input_emd, arr) + + script = ''' +import vtk + +def transform(dataset): + t = vtk.vtkTable() + radius = vtk.vtkFloatArray() + radius.SetName("radius") + radius.SetNumberOfTuples(2) + radius.SetValue(0, 1.0) + radius.SetValue(1, 2.5) + t.AddColumn(radius) + labels = vtk.vtkStringArray() + labels.SetName("labels") + labels.SetNumberOfValues(2) + labels.SetValue(0, "first") + labels.SetValue(1, "second") + t.AddColumn(labels) + return {"stats": t} +''' + description = json.dumps({ + 'name': 'TableEmitter', + 'label': 'Table', + 'apply_to_each_array': False, + 'results': [{'name': 'stats', 'type': 'table'}], + }) + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + {'id': 1, 'type': 'source.reader', 'label': 'Reader', + 'fileNames': [str(input_emd)]}, + {'id': 2, 'type': 'transform.legacyPython', + 'label': 'TableOp', + 'description': description, 'script': script}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + ], + }, + } + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(state)) + + run_dirs = run(state_path, tmp_path / 'out', output_format='state') + tvh5 = run_dirs[0] / 'output_state.tvh5' + assert tvh5.is_file() + + with h5py.File(tvh5, 'r') as f: + port_group = f['/data/2/stats'] + assert port_group.attrs['type'] == 'table' or \ + port_group.attrs['type'] == b'table' + assert int(port_group.attrs['numColumns']) == 2 + assert int(port_group.attrs['numRows']) == 2 + # Numeric column round-trips dtype + values exactly. + c0 = port_group['c0'] + assert int(c0.attrs['vtkDataType']) == 10 # VTK_FLOAT + np.testing.assert_allclose(c0[()], np.array([1.0, 2.5], + dtype=np.float32)) + # String column is JSON-encoded int8. + c1 = port_group['c1'] + assert int(c1.attrs['vtkDataType']) == 13 # VTK_STRING + text = c1[()].tobytes().decode('utf-8') + assert json.loads(text) == ['first', 'second'] + + # Round-trip the tvh5 back through the loader and check the + # transform's stats port carries an equivalent vtkTable. + pipeline = load_state(tvh5) + transform_node = next(n for n in pipeline.nodes if n.id == 2) + stats_port = transform_node.output_port('stats') + table = stats_port.data().payload + assert table.GetNumberOfColumns() == 2 + assert table.GetNumberOfRows() == 2 + radius = table.GetColumn(0) + assert radius.GetName() == 'radius' + assert radius.GetValue(0) == 1.0 + assert radius.GetValue(1) == 2.5 + labels = table.GetColumn(1) + assert labels.GetName() == 'labels' + assert labels.GetValue(0) == 'first' + assert labels.GetValue(1) == 'second' + + +def test_run_output_format_state_persists_molecule_payloads(tmp_path): + """A LegacyPythonTransform that returns a vtkMolecule result is + persisted as atomic-numbers / positions / bond datasets under + /data///. On reload (via load_state) the port carries an + equivalent vtkMolecule. Mirrors the table case below.""" + from tomviz.pipeline.state_io import load_state + + arr = (np.arange(24, dtype=np.uint8) + 1).reshape((2, 3, 4)) + input_emd = tmp_path / 'input.emd' + _write_simple_emd(input_emd, arr) + + script = ''' +import vtk + +def transform(dataset): + m = vtk.vtkMolecule() + m.AppendAtom(1, 0.0, 0.0, 0.0) + m.AppendAtom(8, 0.96, 0.0, 0.0) + m.AppendAtom(1, 1.20, 0.93, 0.0) + m.AppendBond(0, 1, 1) + m.AppendBond(1, 2, 2) + return {"mol": m} +''' + description = json.dumps({ + 'name': 'MoleculeEmitter', + 'label': 'Molecule', + 'apply_to_each_array': False, + 'results': [{'name': 'mol', 'type': 'molecule'}], + }) + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + {'id': 1, 'type': 'source.reader', 'label': 'Reader', + 'fileNames': [str(input_emd)]}, + {'id': 2, 'type': 'transform.legacyPython', + 'label': 'MolOp', + 'description': description, 'script': script}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + ], + }, + } + state_path = tmp_path / 'state.tvsm' + state_path.write_text(json.dumps(state)) + + run_dirs = run(state_path, tmp_path / 'out', output_format='state') + tvh5 = run_dirs[0] / 'output_state.tvh5' + assert tvh5.is_file() + + with h5py.File(tvh5, 'r') as f: + port_group = f['/data/2/mol'] + assert (port_group.attrs['type'] in ('molecule', b'molecule')) + assert int(port_group.attrs['numAtoms']) == 3 + assert int(port_group.attrs['numBonds']) == 2 + np.testing.assert_array_equal( + np.asarray(port_group['atomicNumbers'][()]), + np.array([1, 8, 1], dtype=np.uint16)) + np.testing.assert_allclose( + np.asarray(port_group['atomPositions'][()]).reshape(-1, 3), + np.array([[0.0, 0.0, 0.0], + [0.96, 0.0, 0.0], + [1.20, 0.93, 0.0]], dtype=np.float32)) + np.testing.assert_array_equal( + np.asarray(port_group['bondAtoms'][()]).reshape(-1, 2), + np.array([[0, 1], [1, 2]], dtype=np.int64)) + np.testing.assert_array_equal( + np.asarray(port_group['bondOrders'][()]), + np.array([1, 2], dtype=np.uint16)) + + pipeline = load_state(tvh5) + transform_node = next(n for n in pipeline.nodes if n.id == 2) + mol_port = transform_node.output_port('mol') + molecule = mol_port.data().payload + assert molecule.GetNumberOfAtoms() == 3 + assert molecule.GetNumberOfBonds() == 2 + assert molecule.GetAtom(0).GetAtomicNumber() == 1 + assert molecule.GetAtom(1).GetAtomicNumber() == 8 + assert molecule.GetAtom(2).GetAtomicNumber() == 1 + pos1 = molecule.GetAtom(1).GetPosition() + assert pytest.approx(pos1[0]) == 0.96 + assert pytest.approx(pos1[1]) == 0.0 + assert pytest.approx(pos1[2]) == 0.0 + bond0 = molecule.GetBond(0) + assert bond0.GetBeginAtomId() == 0 + assert bond0.GetEndAtomId() == 1 + assert molecule.GetBondOrder(0) == 1 + bond1 = molecule.GetBond(1) + assert bond1.GetBeginAtomId() == 1 + assert bond1.GetEndAtomId() == 2 + assert molecule.GetBondOrder(1) == 2 + + +def test_cli_output_format_flag(tmp_path): + a = _make_inputs(tmp_path, [('a', 1)])[0] + state_path = _single_source_state(tmp_path, a) + out_dir = tmp_path / 'out' + + runner = CliRunner() + result = runner.invoke(cli_main, [ + '-s', str(state_path), + '-o', str(out_dir), + '-i', str(a), + '--output-format', 'state+port', + ]) + assert result.exit_code == 0, result.output + # Single run → flat layout under out_dir. + assert (out_dir / 'output_state.tvh5').is_file() + assert list(out_dir.glob('*.emd')) diff --git a/tests/python/pipeline_runtime_test.py b/tests/python/pipeline_runtime_test.py new file mode 100644 index 000000000..867e9c2ed --- /dev/null +++ b/tests/python/pipeline_runtime_test.py @@ -0,0 +1,308 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""Tests for the pure-Python pipeline graph runtime: topo sort, leaf +detection (with sinks ignored), state cascade on failure, schema-v2 +state-file loading.""" + +import json + +import numpy as np +import pytest + +from tomviz.external_dataset import Dataset +from tomviz.pipeline import ( + DefaultExecutor, + NodeFactory, + NodeState, + Pipeline, + PortData, + SinkNode, + SourceNode, + TransformNode, + load_state, + register_builtins, +) + + +@pytest.fixture(autouse=True) +def _ensure_builtins_registered(): + register_builtins() + + +def _make_dataset(value=1.0, shape=(2, 2, 3)): + arr = np.full(shape, value, dtype=np.float32, order='F') + ds = Dataset({'Scalars': arr}, 'Scalars') + ds.spacing = (1.0, 1.0, 1.0) + return ds + + +class _MemorySource(SourceNode): + type_name = 'test.memorySource' + + def __init__(self, dataset): + super().__init__() + self._dataset = dataset + self.add_output('volume', 'ImageData') + + def execute(self): + port = self.output_port('volume') + port.set_data(PortData(self._dataset, 'ImageData')) + return True + + +class _ScaleTransform(TransformNode): + type_name = 'test.scale' + + def __init__(self, factor): + super().__init__() + self.factor = factor + self.add_input('volume', 'ImageData') + self.add_output('output', 'ImageData') + + def transform(self, inputs): + ds = inputs['volume'].payload + out = Dataset({n: a * self.factor for n, a in ds.arrays.items()}, + ds.active_name) + return {'output': PortData(out, 'ImageData')} + + +class _AlwaysFails(TransformNode): + type_name = 'test.fails' + + def __init__(self): + super().__init__() + self.add_input('volume', 'ImageData') + self.add_output('output', 'ImageData') + + def execute(self): + return False + + +# ---- topology ----------------------------------------------------------- + + +def test_execution_order_is_topological(): + p = Pipeline() + src = _MemorySource(_make_dataset()) + a = _ScaleTransform(2.0) + b = _ScaleTransform(3.0) + p.add_node(src) + p.add_node(b) # added in reverse to confirm sort + p.add_node(a) + p.create_link(src.output_port('volume'), a.input_port('volume')) + p.create_link(a.output_port('output'), b.input_port('volume')) + order = p.execution_order() + assert order.index(src) < order.index(a) < order.index(b) + + +def test_execute_chain_propagates_payload(): + p = Pipeline() + src = _MemorySource(_make_dataset(value=2.0)) + scale = _ScaleTransform(5.0) + p.add_node(src) + p.add_node(scale) + p.create_link(src.output_port('volume'), scale.input_port('volume')) + DefaultExecutor(p).execute() + out = scale.output_port('output').data().payload + assert np.all(out.active_scalars == 10.0) + assert scale.state == NodeState.Current + + +def test_failure_marks_downstream_stale(): + p = Pipeline() + src = _MemorySource(_make_dataset()) + failing = _AlwaysFails() + downstream = _ScaleTransform(1.0) + for n in (src, failing, downstream): + p.add_node(n) + p.create_link(src.output_port('volume'), failing.input_port('volume')) + p.create_link(failing.output_port('output'), + downstream.input_port('volume')) + ok = DefaultExecutor(p).execute() + assert ok is False + assert failing.state == NodeState.Stale + assert downstream.state == NodeState.Stale + + +def test_sinks_are_skipped(): + """A SinkNode never runs and never blocks topo sort.""" + p = Pipeline() + src = _MemorySource(_make_dataset()) + sink = SinkNode() + sink.add_input('volume', 'ImageData') + p.add_node(src) + p.add_node(sink) + p.create_link(src.output_port('volume'), sink.input_port('volume')) + DefaultExecutor(p).execute() + # Sink stays New (executor short-circuits sinks before any mark). + assert sink.state == NodeState.New + assert src.state == NodeState.Current + + +def test_cycle_detection_raises(): + p = Pipeline() + a = _ScaleTransform(1.0) + b = _ScaleTransform(1.0) + p.add_node(a) + p.add_node(b) + p.create_link(a.output_port('output'), b.input_port('volume')) + p.create_link(b.output_port('output'), a.input_port('volume')) + with pytest.raises(RuntimeError): + p.execution_order() + + +# ---- state-file loading ------------------------------------------------- + + +def test_load_state_unknown_node_skipped(tmp_path): + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + {'id': 1, 'type': 'transform.crop', + 'label': 'Crop', + 'bounds': [0, 1, 0, 1, 0, 1]}, + {'id': 2, 'type': 'transform.bogus', + 'label': 'Bogus'}, + ], + 'links': [], + }, + } + f = tmp_path / 'state.tvsm' + f.write_text(json.dumps(state)) + p = load_state(f) + assert len(p.nodes) == 1 + assert p.nodes[0].id == 1 + + +def test_load_state_resolves_links(tmp_path): + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + {'id': 1, 'type': 'source.reader', 'label': 'Reader', + 'fileNames': ['nonexistent.emd']}, + {'id': 2, 'type': 'transform.convertToFloat', + 'label': 'CvtFloat'}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + ], + }, + } + f = tmp_path / 'state.tvsm' + f.write_text(json.dumps(state)) + p = load_state(f) + assert len(p.nodes) == 2 + assert len(p.links) == 1 + src_node = p.node_by_id(1) + cvt_node = p.node_by_id(2) + assert src_node.output_port('volume').outgoing_links + assert (cvt_node.input_port('volume').link.from_port == + src_node.output_port('volume')) + + +def test_load_state_rejects_wrong_schema(tmp_path): + state = {'schemaVersion': 1, + 'pipeline': {'nodes': [], 'links': []}} + f = tmp_path / 'state.tvsm' + f.write_text(json.dumps(state)) + with pytest.raises(ValueError): + load_state(f) + + +# ---- factory ------------------------------------------------------------ + + +def test_load_state_resolves_sinkgroup_passthrough_links(tmp_path): + """Regression: a sinkGroup node has both inputPorts and outputPorts + declared in the saved JSON. The loader must adopt both so links + landing on / leaving its 'volume' port resolve. With this in place, + a transform feeding only into a sinkGroup (which fans out to real + sinks) is correctly identified as a leaf.""" + import json as _json + + from tomviz.pipeline.executor import DefaultExecutor + + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + # Stand-in for a transform node whose only consumer is + # the sinkGroup. Use ConvertToFloat (always present). + {'id': 1, 'type': 'transform.convertToFloat', + 'label': 'Cvt'}, + {'id': 2, 'type': 'sinkGroup', 'label': 'Modules', + 'inputPorts': {'volume': {'type': ['ImageData']}}, + 'outputPorts': {'volume': {'type': 'ImageData', + 'persistent': False}}}, + {'id': 3, 'type': 'sink.outline', 'label': 'Outline', + 'inputPorts': {'volume': {'type': ['ImageData']}}}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'output'}, + 'to': {'node': 2, 'port': 'volume'}}, + {'from': {'node': 2, 'port': 'volume'}, + 'to': {'node': 3, 'port': 'volume'}}, + ], + }, + } + f = tmp_path / 'state.tvsm' + f.write_text(_json.dumps(state)) + p = load_state(f) + # Three nodes load; both links resolve. + assert len(p.nodes) == 3 + assert len(p.links) == 2 + cvt = p.node_by_id(1) + sg = p.node_by_id(2) + assert cvt.output_port('output').outgoing_links + # The sinkGroup adopted the declared output port and the link + # leaving it resolved. + assert sg.output_port('volume') is not None + assert sg.output_port('volume').outgoing_links + + +def test_load_state_overrides_saved_current_state(tmp_path): + """Regression: a state file saved after a successful in-app run + stamps every node `state: "Current"`. The CLI must NOT trust that + — otherwise nothing actually runs. Saved state should be reset to + New on load so the executor re-runs the graph end-to-end.""" + import json as _json + + state = { + 'schemaVersion': 2, + 'pipeline': { + 'nodes': [ + {'id': 1, 'type': 'transform.convertToFloat', + 'label': 'Cvt', 'state': 'Current'}, + {'id': 2, 'type': 'transform.crop', 'label': 'Crop', + 'state': 'Current'}, + ], + 'links': [ + {'from': {'node': 1, 'port': 'output'}, + 'to': {'node': 2, 'port': 'volume'}}, + ], + }, + } + f = tmp_path / 'state.tvsm' + f.write_text(_json.dumps(state)) + p = load_state(f) + for node in p.nodes: + assert node.state == NodeState.New + + +def test_factory_creates_known_types(): + for t in ( + 'source.reader', 'transform.crop', 'transform.threshold', + 'transform.convertToFloat', 'transform.convertToVolume', + 'transform.transposeData', 'transform.setTiltAngles', + 'transform.legacyPython', 'sink.volume', + ): + assert NodeFactory.create(t) is not None + + +def test_factory_unknown_returns_none(): + assert NodeFactory.create('something.bogus') is None diff --git a/tests/python/pipeline_transforms_test.py b/tests/python/pipeline_transforms_test.py new file mode 100644 index 000000000..9cc39d670 --- /dev/null +++ b/tests/python/pipeline_transforms_test.py @@ -0,0 +1,243 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""Per-transform parity tests for the trivial C++ transforms ported to +NumPy. Each test exercises the Python implementation against the +documented C++ semantics taken from tomviz/pipeline/transforms/*.cxx.""" + +import numpy as np +import pytest + +from tomviz.external_dataset import Dataset +from tomviz.pipeline import PortData, register_builtins +from tomviz.pipeline.transforms.convert_to_float import ( + ConvertToFloatTransform, +) +from tomviz.pipeline.transforms.convert_to_volume import ( + ConvertToVolumeTransform, +) +from tomviz.pipeline.transforms.crop import CropTransform +from tomviz.pipeline.transforms.set_tilt_angles import ( + SetTiltAnglesTransform, +) +from tomviz.pipeline.transforms.threshold import ThresholdTransform +from tomviz.pipeline.transforms.transpose import TransposeDataTransform + + +@pytest.fixture(autouse=True) +def _builtins(): + register_builtins() + + +def _make_dataset(arr, name='ImageScalars'): + ds = Dataset({name: arr}, name) + ds.spacing = (1.0, 1.0, 1.0) + return ds + + +def _run(transform, dataset, port_type='ImageData'): + inputs = {'volume': PortData(dataset, port_type)} + return transform.transform(inputs) + + +# ---- ConvertToFloat ----------------------------------------------------- + + +def test_convert_to_float_casts_int_array(): + arr = np.arange(24, dtype=np.uint8).reshape((2, 3, 4)) + out = _run(ConvertToFloatTransform(), _make_dataset(arr)) + out_arr = out['output'].payload.active_scalars + assert out_arr.dtype == np.float32 + np.testing.assert_array_equal(out_arr, arr.astype(np.float32)) + + +# ---- ConvertToVolume ---------------------------------------------------- + + +def test_convert_to_volume_strips_tilt_angles(): + arr = np.zeros((2, 2, 2), dtype=np.float32) + ds = _make_dataset(arr) + ds.tilt_angles = np.array([0.0, 1.0]) + ds.tilt_axis = 2 + out = _run(ConvertToVolumeTransform(), ds, port_type='TiltSeries') + out_ds = out['output'].payload + assert out_ds.tilt_angles is None + assert out_ds.tilt_axis is None + assert out['output'].port_type == 'Volume' + + +# ---- SetTiltAngles ------------------------------------------------------ + + +def test_set_tilt_angles_expands_sparse_map(): + arr = np.zeros((2, 2, 4), dtype=np.float32) + ds = _make_dataset(arr) + t = SetTiltAnglesTransform() + # As written by the C++ side: string keys, sparse coverage. + t.deserialize({'angles': {'0': -10.0, '3': 20.0}}) + out = _run(t, ds) + out_ds = out['output'].payload + np.testing.assert_array_equal(out_ds.tilt_angles, + np.array([-10.0, 0.0, 0.0, 20.0])) + assert out['output'].port_type == 'TiltSeries' + + +# ---- TransposeData ------------------------------------------------------ + + +def test_transpose_data_swaps_i_and_k_axes(): + arr = np.arange(24, dtype=np.float32).reshape((2, 3, 4)) + out = _run(TransposeDataTransform(), _make_dataset(arr)) + out_arr = out['output'].payload.active_scalars + np.testing.assert_array_equal(out_arr, np.transpose(arr, (2, 1, 0))) + + +# ---- Crop --------------------------------------------------------------- + + +def test_crop_default_sentinel_returns_full_volume(): + arr = np.arange(24, dtype=np.float32).reshape((2, 3, 4)) + t = CropTransform() # bounds left at INT_MIN sentinel + out = _run(t, _make_dataset(arr)) + np.testing.assert_array_equal(out['output'].payload.active_scalars, arr) + + +def test_crop_inclusive_bounds(): + arr = np.arange(60, dtype=np.float32).reshape((3, 4, 5)) + t = CropTransform() + # VTK extent is inclusive on both ends. + t.deserialize({'bounds': [0, 1, 1, 2, 2, 4]}) + out = _run(t, _make_dataset(arr)) + np.testing.assert_array_equal( + out['output'].payload.active_scalars, + arr[0:2, 1:3, 2:5]) + + +def test_crop_clamps_to_full_extent(): + arr = np.arange(24, dtype=np.float32).reshape((2, 3, 4)) + t = CropTransform() + t.deserialize({'bounds': [-5, 10, -2, 100, 0, 99]}) + out = _run(t, _make_dataset(arr)) + np.testing.assert_array_equal(out['output'].payload.active_scalars, arr) + + +# ---- Threshold ---------------------------------------------------------- + + +def test_legacy_python_transform_runs_real_operator(): + """LegacyPythonTransform must be able to load a JSON-described + Python operator from disk, execute its `transform()`, and return + the mutated dataset on its primary output port. We use the trivial + AddConstant operator shipped in tomviz/python/.""" + from pathlib import Path + + from tomviz.pipeline.transforms.legacy_python import ( + LegacyPythonTransform, + ) + + op_dir = (Path(__file__).parent / '..' / '..' / 'tomviz' + / 'python').resolve() + desc = (op_dir / 'AddConstant.json').read_text() + script = (op_dir / 'AddConstant.py').read_text() + + t = LegacyPythonTransform() + t.deserialize({'description': desc, 'script': script, + 'arguments': {'constant': 7.0}}) + + arr = np.zeros((2, 2, 3), dtype=np.float32) + ds = Dataset({'ImageScalars': arr}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + result = t.transform({'volume': PortData(ds, 'ImageData')}) + out_arr = result[t._primary_output_name].payload.active_scalars + np.testing.assert_array_equal(out_arr, np.full_like(arr, 7.0)) + + +def test_threshold_produces_binary_mask(): + arr = np.array([[[0, 1, 2], [3, 4, 5]]], dtype=np.float32) + ds = _make_dataset(arr) + t = ThresholdTransform() + t.deserialize({'minValue': 1.5, 'maxValue': 3.5}) + out = _run(t, ds) + mask = out['mask'].payload.active_scalars + expected = ((arr >= 1.5) & (arr <= 3.5)).astype(np.float32) + np.testing.assert_array_equal(mask, expected) + assert mask.dtype == np.float32 + + +# ---- CylindricalCrop (legacy operator) ------------------------------------ + + +def _run_cylindrical_crop( + arr: np.ndarray, **kwargs: float, +) -> np.ndarray: + from pathlib import Path + + from tomviz.pipeline.transforms.legacy_python import ( + LegacyPythonTransform, + ) + + op_dir = (Path(__file__).parent / '..' / '..' / 'tomviz' + / 'python').resolve() + desc = (op_dir / 'CylindricalCrop.json').read_text() + script = (op_dir / 'CylindricalCrop.py').read_text() + + t = LegacyPythonTransform() + t.deserialize({'description': desc, 'script': script, + 'arguments': kwargs}) + + ds = Dataset({'ImageScalars': arr.copy()}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + result = t.transform({'volume': PortData(ds, 'ImageData')}) + return result[t._primary_output_name].payload.active_scalars + + +def test_cylindrical_crop_defaults_preserve_center() -> None: + """With default params (center=volume center, radius=min(nx,ny)/2), + the center voxel should always be preserved.""" + arr = np.ones((10, 10, 10), dtype=np.float32) + out = _run_cylindrical_crop(arr) + assert out[5, 5, 5] == 1.0 + + +def test_cylindrical_crop_zeros_corners() -> None: + """Corners of a cube should be outside the default cylinder.""" + arr = np.ones((10, 10, 10), dtype=np.float32) + out = _run_cylindrical_crop(arr) + assert out[0, 0, 5] == 0.0 + assert out[9, 9, 5] == 0.0 + assert out[0, 9, 5] == 0.0 + assert out[9, 0, 5] == 0.0 + + +def test_cylindrical_crop_custom_fill_value() -> None: + arr = np.ones((10, 10, 10), dtype=np.float32) + out = _run_cylindrical_crop(arr, fill_value=-999.0) + assert out[0, 0, 0] == -999.0 + + +def test_cylindrical_crop_small_radius() -> None: + """A very small radius should zero out almost everything.""" + arr = np.ones((10, 10, 10), dtype=np.float32) + out = _run_cylindrical_crop(arr, radius=0.5) + kept = np.count_nonzero(out) + assert kept < arr.size * 0.05 # less than 5% preserved + + +def test_cylindrical_crop_large_radius_preserves_all() -> None: + """A very large radius should keep everything.""" + arr = np.arange(1000, dtype=np.float32).reshape((10, 10, 10)) + out = _run_cylindrical_crop(arr, radius=100.0) + np.testing.assert_array_equal(out, arr) + + +def test_cylindrical_crop_custom_axis() -> None: + """With axis along X, the crop should be cylindrical around X.""" + arr = np.ones((10, 10, 10), dtype=np.float32) + out = _run_cylindrical_crop( + arr, axis_x=1.0, axis_y=0.0, axis_z=0.0, radius=2.0, + ) + # Center of YZ face should be preserved + assert out[0, 5, 5] == 1.0 + # Corner of YZ face should be zeroed + assert out[0, 0, 0] == 0.0 diff --git a/tests/python/pipeline_v2_nodes_test.py b/tests/python/pipeline_v2_nodes_test.py new file mode 100644 index 000000000..8f4a9de77 --- /dev/null +++ b/tests/python/pipeline_v2_nodes_test.py @@ -0,0 +1,344 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""Tests for the schema-v2 Python node runtime — the CLI side of +``tomviz.pipeline.transforms.python_transform.PythonTransform`` and +``tomviz.pipeline.sources.python_source.PythonSource`` — plus the +abstract Dataset helpers (``apply_to_each_scalar_array``, +``empty_copy``) used heavily by v2 operator authors. + +The C++ side of the same code paths is covered by the +PipelinePythonTest gtest cases (``PythonTransformV2``, etc.); this +file mirrors them on the Python runtime so the two sides don't drift. +""" + +import json + +import numpy as np +import pytest + +from tomviz.external_dataset import Dataset, LegacyDataset +from tomviz.pipeline import PortData, register_builtins +from tomviz.pipeline.sources.python_source import PythonSource +from tomviz.pipeline.transforms.python_transform import PythonTransform + + +@pytest.fixture(autouse=True) +def _builtins(): + register_builtins() + + +def _multi_array_dataset(): + """Build an external Dataset with two scalar arrays so the + apply_to_each / filter / active-preserve paths can all be + exercised against the same fixture.""" + ds = Dataset({'a': np.array([1.0, 2.0, 3.0]), + 'b': np.array([10.0, 20.0, 30.0])}, active='a') + ds.spacing = (0.5, 0.5, 0.5) + ds.tilt_axis = 1 + return ds + + +# ============================================================ +# Dataset.apply_to_each_scalar_array (return-new + filter) +# ============================================================ + + +def test_apply_to_each_scalar_array_returns_new_dataset(): + src = _multi_array_dataset() + out = src.apply_to_each_scalar_array(lambda a: a * 2) + + assert out is not src + # Source untouched + np.testing.assert_array_equal(src.arrays['a'], [1.0, 2.0, 3.0]) + np.testing.assert_array_equal(src.arrays['b'], [10.0, 20.0, 30.0]) + # Output transformed + np.testing.assert_array_equal(out.arrays['a'], [2.0, 4.0, 6.0]) + np.testing.assert_array_equal(out.arrays['b'], [20.0, 40.0, 60.0]) + + +def test_apply_to_each_scalar_array_preserves_metadata(): + src = _multi_array_dataset() + out = src.apply_to_each_scalar_array(lambda a: a + 1) + + assert tuple(out.spacing) == (0.5, 0.5, 0.5) + assert out.tilt_axis == 1 + assert out.active_name == 'a' + assert sorted(out.scalars_names) == ['a', 'b'] + + +def test_apply_to_each_scalar_array_accepts_in_place_mutation(): + src = _multi_array_dataset() + + def in_place(arr): + arr += 100 + return arr + + out = src.apply_to_each_scalar_array(in_place) + np.testing.assert_array_equal(out.arrays['a'], [101.0, 102.0, 103.0]) + + +def test_apply_to_each_scalar_array_filters_on_none(): + src = _multi_array_dataset() + # Drop 'b' by returning None for it. + out = src.apply_to_each_scalar_array( + lambda a: None if a[0] == 10.0 else a * 2) + + assert out.scalars_names == ['a'] + np.testing.assert_array_equal(out.arrays['a'], [2.0, 4.0, 6.0]) + assert out.active_name == 'a' + + +def test_apply_to_each_scalar_array_filters_active_falls_back(): + """When fn filters out the active array, the next remaining + scalar should become the new active.""" + src = _multi_array_dataset() + out = src.apply_to_each_scalar_array( + lambda a: None if a[0] == 1.0 else a) + + assert out.scalars_names == ['b'] + assert out.active_name == 'b' + + +def test_apply_to_each_scalar_array_filters_all_returns_empty(): + src = _multi_array_dataset() + out = src.apply_to_each_scalar_array(lambda a: None) + + assert out.scalars_names == [] + # Source untouched + assert sorted(src.scalars_names) == ['a', 'b'] + + +def test_apply_to_each_scalar_array_preserves_concrete_type(): + """LegacyDataset in → LegacyDataset out (so chained v1 operators + keep create_child_dataset access).""" + src = LegacyDataset({'a': np.array([1.0])}, active='a') + out = src.apply_to_each_scalar_array(lambda a: a * 2) + assert isinstance(out, LegacyDataset) + + +# ============================================================ +# Dataset.empty_copy +# ============================================================ + + +def test_empty_copy_preserves_metadata_drops_arrays(): + src = _multi_array_dataset() + out = src.empty_copy() + + assert out is not src + assert out.scalars_names == [] + assert tuple(out.spacing) == (0.5, 0.5, 0.5) + assert out.tilt_axis == 1 + # Source untouched + assert sorted(src.scalars_names) == ['a', 'b'] + + +def test_empty_copy_preserves_concrete_type(): + src = LegacyDataset({'a': np.array([1.0])}, active='a') + out = src.empty_copy() + assert isinstance(out, LegacyDataset) + + +# ============================================================ +# PythonTransform CLI runtime: end-to-end execute +# ============================================================ + + +def _multiply_v2_description(persistent=True): + return json.dumps({ + 'schemaVersion': 2, + 'name': 'MultiplyBy', + 'label': 'Multiply By', + 'inputs': [{'name': 'volume', 'type': 'ImageData'}], + 'outputs': [{'name': 'volume', 'type': 'ImageData', + 'persistent': persistent}], + 'parameters': [{'name': 'factor', 'type': 'double', + 'default': 1.0}], + }) + + +_MULTIPLY_V2_SCRIPT = """ +import tomviz.nodes + + +class MultiplyBy(tomviz.nodes.TransformNode): + def transform(self, inputs, factor=1.0): + ds = inputs["volume"] + return {"volume": ds.apply_to_each_scalar_array(lambda a: a * factor)} +""" + + +def test_python_transform_v2_executes_end_to_end(): + transform = PythonTransform() + transform.set_json_description(_multiply_v2_description()) + transform.script = _MULTIPLY_V2_SCRIPT + transform._backend.parameters['factor'] = 3.0 + + src = Dataset({'a': np.array([1.0, 2.0, 3.0])}, active='a') + inputs = {'volume': PortData(src, 'ImageData')} + + outputs = transform.transform(inputs) + assert 'volume' in outputs + out_ds = outputs['volume'].payload + np.testing.assert_array_equal(out_ds.arrays['a'], [3.0, 6.0, 9.0]) + # Apply-helper return-new: source unchanged. + np.testing.assert_array_equal(src.arrays['a'], [1.0, 2.0, 3.0]) + + +def test_python_transform_v2_label_and_ports_from_json(): + """Setting the JSON description should populate the host's label + and create the input/output ports.""" + transform = PythonTransform() + transform.set_json_description(_multiply_v2_description()) + + assert transform.label == 'Multiply By' + assert [p.name for p in transform.input_ports()] == ['volume'] + assert [p.name for p in transform.output_ports()] == ['volume'] + + +def test_python_transform_v2_none_return_signals_failure(): + """A transform that returns None should produce no outputs — the + backend collapses non-dict (including None) to an empty result.""" + transform = PythonTransform() + transform.set_json_description(_multiply_v2_description()) + transform.script = """ +import tomviz.nodes + +class Refuse(tomviz.nodes.TransformNode): + def transform(self, inputs, factor=1.0): + return # None — signals "no output produced" +""" + + src = Dataset({'a': np.array([1.0])}, active='a') + inputs = {'volume': PortData(src, 'ImageData')} + outputs = transform.transform(inputs) + assert outputs == {} + + +def test_python_transform_v2_exception_signals_failure(): + """A transform that raises should be caught and produce no + outputs (logged at the runtime).""" + transform = PythonTransform() + transform.set_json_description(_multiply_v2_description()) + transform.script = """ +import tomviz.nodes + +class Boom(tomviz.nodes.TransformNode): + def transform(self, inputs, factor=1.0): + raise RuntimeError("intentional") +""" + + src = Dataset({'a': np.array([1.0])}, active='a') + inputs = {'volume': PortData(src, 'ImageData')} + outputs = transform.transform(inputs) + assert outputs == {} + + +def test_python_transform_v2_supports_cancel_flag_from_json(): + transform = PythonTransform() + transform.set_json_description(json.dumps({ + 'schemaVersion': 2, + 'name': 'Demo', + 'inputs': [{'name': 'volume', 'type': 'ImageData'}], + 'outputs': [{'name': 'volume', 'type': 'ImageData'}], + 'supportsCancel': True, + 'supportsComplete': True, + })) + assert transform._backend.supports_cancel is True + assert transform._backend.supports_complete is True + + +# ============================================================ +# PythonSource CLI runtime: end-to-end produce +# ============================================================ + + +def _constant_source_description(): + return json.dumps({ + 'schemaVersion': 2, + 'name': 'ConstantSource', + 'label': 'Constant Source', + 'inputs': [], + 'outputs': [{'name': 'volume', 'type': 'ImageData', + 'persistent': True}], + 'parameters': [{'name': 'value', 'type': 'double', + 'default': 0.0}], + }) + + +_CONSTANT_SOURCE_SCRIPT = """ +import numpy as np +import tomviz.nodes +from tomviz.external_dataset import Dataset + + +class ConstantSource(tomviz.nodes.SourceNode): + def produce(self, value=0.0): + arr = np.full((2, 2, 2), value, dtype=np.float32) + return {"volume": Dataset({"Scalars": arr}, active="Scalars")} +""" + + +def test_python_source_v2_executes_end_to_end(): + source = PythonSource() + source.set_json_description(_constant_source_description()) + source.script = _CONSTANT_SOURCE_SCRIPT + source._backend.parameters['value'] = 7.0 + + assert source.execute() is True + port = source.output_port('volume') + assert port.has_data() + out_ds = port.data().payload + assert out_ds.scalars('Scalars').shape == (2, 2, 2) + np.testing.assert_array_equal( + out_ds.scalars('Scalars'), + np.full((2, 2, 2), 7.0, dtype=np.float32)) + + +def test_python_source_v2_none_return_fails_execute(): + source = PythonSource() + source.set_json_description(_constant_source_description()) + source.script = """ +import tomviz.nodes + +class Refuse(tomviz.nodes.SourceNode): + def produce(self, value=0.0): + return # None — no output produced +""" + # outputs declared but produce() returned None → execute() returns + # False (no outputs produced for declared ports). + assert source.execute() is False + assert not source.output_port('volume').has_data() + + +# ============================================================ +# Schema validation +# ============================================================ + + +def test_python_transform_v2_persistent_default_false(): + """When the JSON omits `persistent` for an output, the schema-v2 + convention is `false` (transient) — the backend must call + setPersistent(false) explicitly.""" + transform = PythonTransform() + transform.set_json_description(json.dumps({ + 'schemaVersion': 2, + 'name': 'Demo', + 'inputs': [{'name': 'volume', 'type': 'ImageData'}], + 'outputs': [{'name': 'volume', 'type': 'ImageData'}], # no persistent + })) + assert transform.output_port('volume').persistent is False + + +def test_python_transform_v2_persistent_explicit_true(): + transform = PythonTransform() + transform.set_json_description(json.dumps({ + 'schemaVersion': 2, + 'name': 'Demo', + 'inputs': [{'name': 'volume', 'type': 'ImageData'}], + 'outputs': [{'name': 'volume', 'type': 'ImageData', + 'persistent': True}], + })) + assert transform.output_port('volume').persistent is True diff --git a/tests/python/pipeline_writers_test.py b/tests/python/pipeline_writers_test.py new file mode 100644 index 000000000..1e57d96e7 --- /dev/null +++ b/tests/python/pipeline_writers_test.py @@ -0,0 +1,126 @@ +############################################################################### +# This source file is part of the Tomviz project, https://tomviz.org/. +# It is released under the 3-Clause BSD License, see "LICENSE". +############################################################################### +"""Tests for the per-port-type output writers (CSV for tables, XYZ for +molecules, plus the registry plumbing). Build the VTK objects by hand +so we don't need an operator end-to-end.""" + +import csv + +import pytest +import vtk + +from tomviz.pipeline.writers import ( + register_writer, + writer_for, + write_molecule_xyz, + write_table_csv, +) + + +def _build_table(): + """A vtkTable with a numeric column and a string column.""" + t = vtk.vtkTable() + + radius = vtk.vtkFloatArray() + radius.SetName('radius') + radius.SetNumberOfComponents(1) + radius.SetNumberOfTuples(3) + for i, v in enumerate([1.5, 2.0, 3.25]): + radius.SetValue(i, v) + t.AddColumn(radius) + + label = vtk.vtkStringArray() + label.SetName('label') + label.SetNumberOfValues(3) + for i, s in enumerate(['small', 'medium', 'large']): + label.SetValue(i, s) + t.AddColumn(label) + + return t + + +def _build_methane_molecule(): + """vtkMolecule for methane (CH4) with one bond per H — bonds aren't + written by the XYZ writer but we add them to verify they're tolerated.""" + m = vtk.vtkMolecule() + # central carbon at origin, four hydrogens at unit distance + m.AppendAtom(6, 0.0, 0.0, 0.0) + m.AppendAtom(1, 1.0, 0.0, 0.0) + m.AppendAtom(1, -1.0, 0.0, 0.0) + m.AppendAtom(1, 0.0, 1.0, 0.0) + m.AppendAtom(1, 0.0, -1.0, 0.0) + for h in range(1, 5): + m.AppendBond(0, h, 1) + return m + + +def test_table_csv_round_trip(tmp_path): + target = tmp_path / 'out.csv' + write_table_csv(_build_table(), target) + with open(target, newline='') as f: + rows = list(csv.reader(f)) + assert rows[0] == ['radius', 'label'] + # Numeric values come out as Python floats (str-formatted by csv). + assert rows[1] == ['1.5', 'small'] + assert rows[2] == ['2.0', 'medium'] + assert rows[3] == ['3.25', 'large'] + + +def test_table_csv_rejects_non_table(tmp_path): + with pytest.raises(TypeError): + write_table_csv({'not': 'a table'}, tmp_path / 'x.csv') + + +def test_molecule_xyz_writes_methane(tmp_path): + target = tmp_path / 'methane.xyz' + write_molecule_xyz(_build_methane_molecule(), target) + lines = target.read_text().splitlines() + assert lines[0] == '5' + # Comment line is freeform; just make sure it's there. + assert lines[1] + # Five atom lines starting with element symbols. + symbols = [line.split()[0] for line in lines[2:7]] + assert symbols == ['C', 'H', 'H', 'H', 'H'] + # First C is at the origin. + parts = lines[2].split() + assert float(parts[1]) == pytest.approx(0.0) + assert float(parts[2]) == pytest.approx(0.0) + assert float(parts[3]) == pytest.approx(0.0) + + +def test_molecule_xyz_rejects_non_molecule(tmp_path): + with pytest.raises(TypeError): + write_molecule_xyz({'not': 'a molecule'}, tmp_path / 'x.xyz') + + +def test_registry_lookup(): + # Built-in port types resolve to their declared writers. + assert writer_for('Volume')[0] == 'emd' + assert writer_for('TiltSeries')[0] == 'emd' + assert writer_for('Table')[0] == 'csv' + assert writer_for('Molecule')[0] == 'xyz' + # Unknown types return None so the CLI can skip with a warning. + assert writer_for('Bogus') is None + + +def test_register_writer_overrides(tmp_path): + sentinel = [] + + def custom(payload, target): + sentinel.append((payload, target)) + target.write_text('ok') + + try: + register_writer('Bogus', 'bin', custom) + ext, w = writer_for('Bogus') + assert ext == 'bin' + target = tmp_path / 'out.bin' + w('payload', target) + assert target.read_text() == 'ok' + assert sentinel == [('payload', target)] + finally: + # Clean up — the registry is process-global. + from tomviz.pipeline.writers import _REGISTRY + _REGISTRY.pop('Bogus', None) diff --git a/tests/python/random_particles_test.py b/tests/python/random_particles_test.py index 6e6862577..22dec15da 100644 --- a/tests/python/random_particles_test.py +++ b/tests/python/random_particles_test.py @@ -3,55 +3,59 @@ import numpy as np import pytest -from utils import load_operator_module +from utils import load_operator_module, load_node_class -def test_generate_dataset_basic(): - """Test that generate_dataset produces non-zero output.""" +def test_produce_basic(): + """Test that produce generates non-zero output.""" module = load_operator_module('RandomParticles') + node = load_node_class(module) - array = np.zeros((16, 16, 16), dtype=np.float64) - module.generate_dataset(array) + result = node.produce(shape=[16, 16, 16]) + ds = result['volume'] + array = ds.active_scalars assert not np.allclose(array, 0), "Output should not be all zeros" assert np.amax(array) == pytest.approx(1.0, abs=0.01), \ "Max value should be approximately 1.0 after normalization" -def test_generate_dataset_shape_preserved(): - """Test that output shape matches input shape.""" +def test_produce_shape(): + """Test that output shape matches requested shape.""" module = load_operator_module('RandomParticles') + node = load_node_class(module) - shape = (20, 24, 18) - array = np.zeros(shape, dtype=np.float64) - module.generate_dataset(array) + shape = [20, 24, 18] + result = node.produce(shape=shape) + ds = result['volume'] + array = ds.active_scalars - assert array.shape == shape + assert array.shape == tuple(shape) -def test_generate_dataset_sparsity(): +def test_produce_sparsity(): """Test that sparsity parameter controls fraction of zero voxels.""" module = load_operator_module('RandomParticles') + node = load_node_class(module) - array = np.zeros((32, 32, 32), dtype=np.float64) sparsity = 0.1 - module.generate_dataset(array, sparsity=sparsity) + result = node.produce(shape=[32, 32, 32], sparsity=sparsity) + ds = result['volume'] + array = ds.active_scalars zero_fraction = np.count_nonzero(array == 0) / array.size - # With sparsity=0.1, approximately 90% of voxels should be zero assert zero_fraction > 0.8, \ f"Expected ~90% zeros with sparsity=0.1, got {zero_fraction*100:.1f}%" -def test_generate_dataset_uses_int64(): - """Test that no numpy deprecation warnings are raised (the fix changed - np.int to np.int64).""" +def test_produce_uses_int64(): + """Test that no numpy deprecation warnings are raised.""" module = load_operator_module('RandomParticles') + node = load_node_class(module) - array = np.zeros((16, 16, 16), dtype=np.float64) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always", DeprecationWarning) - module.generate_dataset(array) + node.produce(shape=[16, 16, 16]) numpy_deprecation_warnings = [ x for x in w diff --git a/tests/python/sam2_operator_test.py b/tests/python/sam2_operator_test.py new file mode 100644 index 000000000..570f80cfa --- /dev/null +++ b/tests/python/sam2_operator_test.py @@ -0,0 +1,124 @@ +import importlib.util +import json +import os + +import numpy as np +import pytest + +_PYTHON_DIR = os.path.join( + os.path.dirname(__file__), '..', '..', 'tomviz', 'python') + + +def _load_module(): + path = os.path.join(_PYTHON_DIR, 'SAM2Segment3D.py') + spec = importlib.util.spec_from_file_location('SAM2Segment3D', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_json_description(): + path = os.path.join(_PYTHON_DIR, 'SAM2Segment3D.json') + with open(path) as f: + desc = json.load(f) + + assert desc['name'] == 'SAM2Segment3D' + assert desc['externalOnly'] is True + assert desc['externalCompatible'] is True + # The description must not carry a machine-specific env path. + assert 'tomviz_pipeline_env' not in desc + + names = [p['name'] for p in desc['parameters']] + assert 'prompt_mode' in names + assert 'checkpoint_dir' in names + + +def test_module_imports_without_sam2(): + # Heavy imports (torch, sam2, PIL, skimage) live inside transform(), + # so loading the module must succeed in the application environment. + module = _load_module() + assert hasattr(module, 'SAM2Segment3D') + + +def test_transform_raises_install_instructions_without_sam2(): + pytest.importorskip('numpy') + try: + import sam2 # noqa: F401 + pytest.skip('sam2 is installed; ImportError path not reachable') + except ImportError: + pass + + module = _load_module() + op = module.SAM2Segment3D.__new__(module.SAM2Segment3D) + + class Progress: + maximum = 0 + value = 0 + message = '' + + op.progress = Progress() + + with pytest.raises(ImportError) as excinfo: + op.transform(dataset=None) + + # The error must tell the user how to set up the environment. + text = str(excinfo.value) + assert 'conda env create' in text + assert 'sam2-tomviz-cpu.yml' in text + assert 'Execution' in text + + +def test_auto_seed_mask(): + skimage = pytest.importorskip('skimage') # noqa: F841 + module = _load_module() + + img = np.zeros((64, 64), dtype=np.float32) + img[8:24, 8:24] = 100.0 # large bright blob + img[40:44, 40:44] = 100.0 # small bright blob + + mask = module._auto_seed_mask(img) + assert mask.dtype == bool + assert mask.shape == img.shape + # Only the largest component survives. + assert mask[10, 10] + assert not mask[41, 41] + + # Inverted contrast selects the (dark) background instead. + inv = module._auto_seed_mask(img, invert=True) + assert inv[32, 32] + assert not inv[10, 10] + + +def test_cleanup_mask(): + scipy = pytest.importorskip('scipy') # noqa: F841 + module = _load_module() + + vol = np.zeros((40, 40, 40), dtype=np.float32) + vol[5:15, 5:15, 5:15] = 200.0 # the clicked particle + vol[25:35, 25:35, 25:35] = 200.0 # a neighbor the tracker drifted onto + + mask = np.zeros_like(vol, dtype=np.uint8) + mask[4:16, 4:16, 4:16] = 1 # particle + a 1-voxel halo + mask[25:35, 25:35, 25:35] = 1 # drift region + mask[15:25, 10, 10] = 1 # thin dark bridge from drift + + seed = (10, 10, 10) + out = module._cleanup_mask(mask, vol, seed, + trim_fraction=0.1, keep_seed_component=True) + assert out.dtype == np.uint8 + # Only the seed-connected bright region survives. + assert out[10, 10, 10] == 1 + assert out[30, 30, 30] == 0 + assert out[20, 10, 10] == 0 # bridge trimmed (below intensity floor) + assert out.sum() == 10 * 10 * 10 + + # Seed outside the mask (auto-mask prompts): fall back to the + # largest component instead of dropping everything. + out2 = module._cleanup_mask(mask, vol, (0, 0, 0), + trim_fraction=0.1, keep_seed_component=True) + assert out2.sum() == 10 * 10 * 10 + + # Disabled cleanup passes the mask through untouched. + out3 = module._cleanup_mask(mask, vol, seed, + trim_fraction=0.0, keep_seed_component=False) + assert (out3 == mask).all() diff --git a/tests/python/sam3_operator_test.py b/tests/python/sam3_operator_test.py new file mode 100644 index 000000000..f0f89f323 --- /dev/null +++ b/tests/python/sam3_operator_test.py @@ -0,0 +1,140 @@ +import importlib.util +import json +import os + +import numpy as np +import pytest + +_PYTHON_DIR = os.path.join( + os.path.dirname(__file__), '..', '..', 'tomviz', 'python') + + +def _load_module(): + path = os.path.join(_PYTHON_DIR, 'SAM3Segment3D.py') + spec = importlib.util.spec_from_file_location('SAM3Segment3D', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_json_description(): + path = os.path.join(_PYTHON_DIR, 'SAM3Segment3D.json') + with open(path) as f: + desc = json.load(f) + + assert desc['name'] == 'SAM3Segment3D' + assert desc['externalOnly'] is True + assert desc['externalCompatible'] is True + assert 'tomviz_pipeline_env' not in desc + + names = [p['name'] for p in desc['parameters']] + assert 'text_prompt' in names + assert 'vote_threshold' in names + assert 'checkpoint_path' in names + + +def test_module_imports_without_sam3(): + # Heavy imports (torch, sam3, PIL, scipy) live inside transform() or + # the helpers, so loading the module must succeed in the application + # environment. + module = _load_module() + assert hasattr(module, 'SAM3Segment3D') + + +def test_transform_raises_install_instructions_without_sam3(): + try: + import sam3 # noqa: F401 + pytest.skip('sam3 is installed; ImportError path not reachable') + except ImportError: + pass + + module = _load_module() + op = module.SAM3Segment3D.__new__(module.SAM3Segment3D) + + class Progress: + maximum = 0 + value = 0 + message = '' + + op.progress = Progress() + + with pytest.raises(ImportError) as excinfo: + op.transform(dataset=None) + + text = str(excinfo.value) + assert 'conda env create' in text + assert 'sam3-tomviz-cuda.yml' in text + assert 'Execution' in text + + +def test_vote(): + module = _load_module() + a = np.zeros((2, 2, 2), dtype=bool) + b = np.zeros((2, 2, 2), dtype=bool) + c = np.zeros((2, 2, 2), dtype=bool) + a[0, 0, 0] = b[0, 0, 0] = c[0, 0, 0] = True # 3 votes + a[1, 1, 1] = b[1, 1, 1] = True # 2 votes + c[0, 1, 0] = True # 1 vote + + out = module._vote({'xy': a, 'xz': b, 'yz': c}, threshold=2) + assert out.dtype == np.uint8 + assert out[0, 0, 0] == 1 + assert out[1, 1, 1] == 1 + assert out[0, 1, 0] == 0 + + +def test_stitch_instances(): + scipy = pytest.importorskip('scipy') # noqa: F841 + module = _load_module() + + binary = np.zeros((12, 12, 12), dtype=np.uint8) + binary[1:5, 1:5, 1:5] = 1 # 64 voxels, kept + binary[9:11, 9:11, 9:11] = 1 # 8 voxels, filtered out + + labels = module._stitch_instances(binary, min_voxels=20) + assert labels.dtype == np.int32 + assert labels.max() == 1 + assert labels[2, 2, 2] == 1 + assert labels[9, 9, 9] == 0 + + +def test_norm_to_uint8(): + module = _load_module() + vol = np.linspace(0.0, 1.0, 1000, dtype=np.float32).reshape(10, 10, 10) + u8 = module._norm_to_uint8(vol) + assert u8.dtype == np.uint8 + assert u8.min() == 0 + assert u8.max() == 255 + # Constant volumes must not divide by zero. + flat = module._norm_to_uint8(np.ones((4, 4, 4), dtype=np.float32)) + assert flat.max() == 0 + + +def test_stitch_instances_split(): + scipy = pytest.importorskip('scipy') # noqa: F841 + module = _load_module() + + # Two 8x8x8 cubes joined by a thin 1x1 bridge. + binary = np.zeros((30, 12, 12), dtype=np.uint8) + binary[2:10, 2:10, 2:10] = 1 + binary[18:26, 2:10, 2:10] = 1 + binary[10:18, 5, 5] = 1 + # A thin plate elsewhere: too thin to survive erosion (no core). + binary[2:10, 2:10, 11] = 1 + + # Radius 0: everything bridged merges; the plate is separate. + plain = module._stitch_instances(binary, min_voxels=50, + split_erosion_radius=0) + assert plain.max() == 2 + + # Radius 2 breaks the bridge into two cube instances, and the + # coreless plate keeps its own identity instead of adopting a + # distant core's label. + split = module._stitch_instances(binary, min_voxels=50, + split_erosion_radius=2) + assert split.max() == 3 + assert split[5, 5, 5] != split[22, 5, 5] + plate = split[5, 5, 11] + assert plate not in (0, split[5, 5, 5], split[22, 5, 5]) + # Bridge voxels are assigned to one of the cubes, not dropped. + assert split[14, 5, 5] in (split[5, 5, 5], split[22, 5, 5]) diff --git a/tests/python/shift_rotation_center_test.py b/tests/python/shift_rotation_center_test.py index c7993f866..f90aa28a3 100644 --- a/tests/python/shift_rotation_center_test.py +++ b/tests/python/shift_rotation_center_test.py @@ -5,7 +5,7 @@ from utils import load_operator_module -from tomviz.external_dataset import Dataset +from tomviz.external_dataset import LegacyDataset as Dataset try: import tomopy diff --git a/tests/python/utils.py b/tests/python/utils.py index 880d5e6e2..194462658 100644 --- a/tests/python/utils.py +++ b/tests/python/utils.py @@ -4,12 +4,14 @@ import importlib.util import inspect import shutil +import time +import urllib.error import urllib.request import zipfile -from tomviz.executor import OperatorWrapper from tomviz.operators import Operator -from tomviz._internal import add_transform_decorators +from tomviz.nodes import Node +from tomviz._internal import OperatorWrapper, add_transform_decorators OPERATOR_PATH = Path(__file__).parent.parent.parent / 'tomviz/python' @@ -56,6 +58,14 @@ def load_operator_class(operator_module: ModuleType) -> Operator | None: return operator +def load_node_class(operator_module: ModuleType) -> Node | None: + for v in operator_module.__dict__.values(): + if inspect.isclass(v) and issubclass(v, Node) and v is not Node: + node = v() + node._operator_wrapper = OperatorWrapper() + return node + + def download_file(url: str, destination: str): filename = Path('downloaded_file') if filename.exists(): @@ -74,7 +84,23 @@ def download_progress_hook(block_num, block_size, total_size): print(f"\rDownload Progress: {progress}% ({downloaded_mb:.2f}/{total_size_mb:.2f} MB)") print(f'Downloading "{url}" to "{filename}"') - urllib.request.urlretrieve(url, filename, reporthook=download_progress_hook) + # Retry on transient network errors (e.g. a 5xx from the data server) with + # exponential backoff, since the test fixtures are downloaded at run time. + attempts = 5 + for attempt in range(1, attempts + 1): + try: + urllib.request.urlretrieve(url, filename, + reporthook=download_progress_hook) + break + except (urllib.error.URLError, TimeoutError) as e: + # Don't retry on 4xx (client) errors - those won't fix themselves. + code = getattr(e, 'code', None) + if (code is not None and 400 <= code < 500) or attempt == attempts: + raise + wait = 2 ** (attempt - 1) + print(f'Download failed ({e}); retrying in {wait}s ' + f'({attempt}/{attempts - 1})') + time.sleep(wait) print('\n') shutil.copy(filename, destination) diff --git a/tomviz/AboutDialog.cxx b/tomviz/AboutDialog.cxx index 3bde090f6..fe07d34f4 100644 --- a/tomviz/AboutDialog.cxx +++ b/tomviz/AboutDialog.cxx @@ -4,9 +4,7 @@ #include "AboutDialog.h" #include "ui_AboutDialog.h" -#include "pqApplicationCore.h" -#include "pqServerManagerModel.h" -#include "pqServer.h" +#include "ActiveObjects.h" #include "PythonUtilities.h" #include "tomvizConfig.h" @@ -16,8 +14,7 @@ #include #include #include "vtkPVOpenGLInformation.h" -#include "vtkPVRenderingCapabilitiesInformation.h" -#include "vtkSMSession.h" +#include "vtkSMViewProxy.h" #include #include @@ -38,26 +35,21 @@ void buildJson(QJsonObject& details) details["qtVersion"] = QString(QT_VERSION_STR); } -// Get OpenGL information, focus on VTK's view. +// Get OpenGL information from the existing render window to avoid creating a +// new offscreen window (which can cause GLX BadAccess errors). void buildOpenGL(QJsonObject& details) { - pqServerManagerModel* smmodel = pqApplicationCore::instance()->getServerManagerModel(); - QList servers = smmodel->findItems(); - if (!servers.empty()) - { - vtkSMSession* session = servers[0]->session(); - vtkNew renInfo; - session->GatherInformation(vtkPVSession::RENDER_SERVER, renInfo.GetPointer(), 0); - if (renInfo->Supports(vtkPVRenderingCapabilitiesInformation::RENDERING)) - { - vtkNew OpenGLInfo; - session->GatherInformation(vtkPVSession::RENDER_SERVER, OpenGLInfo.GetPointer(), 0); - details["openglVendor"] = QString::fromStdString(OpenGLInfo->GetVendor()); - details["openglVersion"] = QString::fromStdString(OpenGLInfo->GetVersion()); - details["openglRenderer"] = QString::fromStdString(OpenGLInfo->GetRenderer()); - details["openglShaderVersion"] = QString("unknown"); - } + auto* view = ActiveObjects::instance().activeView(); + if (!view || !view->GetRenderWindow()) { + return; } + + vtkNew openGLInfo; + openGLInfo->CopyFromObject(view->GetRenderWindow()); + details["openglVendor"] = QString::fromStdString(openGLInfo->GetVendor()); + details["openglVersion"] = QString::fromStdString(openGLInfo->GetVersion()); + details["openglRenderer"] = QString::fromStdString(openGLInfo->GetRenderer()); + details["openglShaderVersion"] = QString("unknown"); } void buildPython(QJsonObject& details) diff --git a/tomviz/ActiveObjects.cxx b/tomviz/ActiveObjects.cxx index c309bcdd7..64ebc8def 100644 --- a/tomviz/ActiveObjects.cxx +++ b/tomviz/ActiveObjects.cxx @@ -2,14 +2,17 @@ It is released under the 3-Clause BSD License, see "LICENSE". */ #include "ActiveObjects.h" -#include "ModuleManager.h" -#include "Pipeline.h" #include "Utilities.h" +#include "pipeline/Link.h" +#include "pipeline/Node.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineUtils.h" + #include #include #include -#include #include #include #include @@ -17,23 +20,14 @@ #include #include #include -#include #include -#include - namespace tomviz { ActiveObjects::ActiveObjects() : QObject() { connect(&pqActiveObjects::instance(), &pqActiveObjects::viewChanged, this, QOverload::of(&ActiveObjects::viewChanged)); - connect(&ModuleManager::instance(), &ModuleManager::dataSourceRemoved, this, - &ActiveObjects::dataSourceRemoved); - connect(&ModuleManager::instance(), &ModuleManager::moleculeSourceRemoved, - this, &ActiveObjects::moleculeSourceRemoved); - connect(&ModuleManager::instance(), &ModuleManager::moduleRemoved, this, - &ActiveObjects::moduleRemoved); } ActiveObjects::~ActiveObjects() = default; @@ -70,143 +64,12 @@ void ActiveObjects::viewChanged(pqView* view) emit viewChanged(view ? view->getViewProxy() : nullptr); } -void ActiveObjects::dataSourceRemoved(DataSource* ds) -{ - if (m_activeDataSource == ds) { - setActiveDataSource(nullptr); - } -} - -void ActiveObjects::moleculeSourceRemoved(MoleculeSource* ms) -{ - if (m_activeMoleculeSource == ms) { - setActiveMoleculeSource(nullptr); - } -} - -void ActiveObjects::moduleRemoved(Module* mdl) -{ - if (m_activeModule == mdl) { - setActiveModule(nullptr); - } -} - -void ActiveObjects::setActiveDataSource(DataSource* source) -{ - if (source) { - setActiveMoleculeSource(nullptr); - } - if (m_activeDataSource != source) { - if (m_activeDataSource) { - disconnect(m_activeDataSource, &DataSource::dataChanged, this, - static_cast( - &ActiveObjects::dataSourceChanged)); - } - if (source) { - connect(source, &DataSource::dataChanged, this, - static_cast( - &ActiveObjects::dataSourceChanged)); - m_activeDataSourceType = source->type(); - } - m_activeDataSource = source; - - // Setting to nullptr so the traverse logic is re-run. - m_activeParentDataSource = nullptr; - } - emit dataSourceActivated(m_activeDataSource); - emit dataSourceChanged(m_activeDataSource); - - if (!m_activeDataSource.isNull() && - m_activeDataSource->pipeline() != nullptr) { - setActiveTransformedDataSource( - m_activeDataSource->pipeline()->transformedDataSource()); - } -} - -void ActiveObjects::setSelectedDataSource(DataSource* source) -{ - m_selectedDataSource = source; - - if (!m_selectedDataSource.isNull()) { - setActiveDataSource(m_selectedDataSource); - } -} - -void ActiveObjects::setActiveTransformedDataSource(DataSource* source) -{ - if (m_activeTransformedDataSource != source) { - m_activeTransformedDataSource = source; - } - emit transformedDataSourceActivated(m_activeTransformedDataSource); -} - -void ActiveObjects::dataSourceChanged() -{ - if (m_activeDataSource->type() != m_activeDataSourceType) { - m_activeDataSourceType = m_activeDataSource->type(); - emit dataSourceChanged(m_activeDataSource); - } -} - vtkSMSessionProxyManager* ActiveObjects::proxyManager() const { pqServer* server = pqActiveObjects::instance().activeServer(); return server ? server->proxyManager() : nullptr; } -void ActiveObjects::setActiveMoleculeSource(MoleculeSource* source) -{ - if (source) { - setActiveDataSource(nullptr); - } - if (m_activeMoleculeSource != source) { - m_activeMoleculeSource = source; - emit moleculeSourceChanged(source); - } - emit moleculeSourceActivated(source); -} - -void ActiveObjects::setActiveModule(Module* module) -{ - if (module) { - setActiveView(module->view()); - setActiveDataSource(module->dataSource()); - } - if (m_activeModule != module) { - m_activeModule = module; - emit moduleChanged(module); - } - emit moduleActivated(module); -} - -void ActiveObjects::setActiveOperator(Operator* op) -{ - if (op) { - setActiveDataSource(op->dataSource()); - } - if (m_activeOperator != op) { - m_activeOperator = op; - emit operatorChanged(op); - } - emit operatorActivated(op); -} - -void ActiveObjects::setActiveOperatorResult(OperatorResult* result) -{ - if (result) { - auto op = qobject_cast(result->parent()); - if (op) { - setActiveOperator(op); - setActiveDataSource(op->dataSource()); - } - } - if (m_activeOperatorResult != result) { - m_activeOperatorResult = result; - emit resultChanged(result); - } - emit resultActivated(result); -} - void ActiveObjects::createRenderViewIfNeeded() { vtkNew iter; @@ -246,73 +109,145 @@ void ActiveObjects::renderAllViews() pqApplicationCore::instance()->render(); } -DataSource* ActiveObjects::activeParentDataSource() +pqTimeKeeper* ActiveObjects::activeTimeKeeper() const { + pqServer* server = pqActiveObjects::instance().activeServer(); + return server ? server->getTimeKeeper() : nullptr; +} - if (m_activeParentDataSource == nullptr) { - auto pipeline = activePipeline(); - auto dataSource = activeDataSource(); +void ActiveObjects::setPipeline(pipeline::Pipeline* p) +{ + Q_ASSERT(!m_pipeline && "Pipeline should only be set once"); + m_pipeline = p; + connect(p, &pipeline::Pipeline::nodeRemoved, this, + [this](pipeline::Node* node) { + if (m_activeNode == node) { + setActiveNode(nullptr); + } + if (m_activePort && m_activePort->node() == node) { + setActivePort(nullptr); + } + if (m_activeTipOutputPort && + m_activeTipOutputPort->node() == node) { + setActiveTipOutputPort( + pipeline::findTipOutputPort(m_pipeline, nullptr)); + } + }); + connect(p, &pipeline::Pipeline::linkRemoved, this, + [this](pipeline::Link* link) { + if (m_activeLink == link) { + setActiveLink(nullptr); + } + }); + emit activePipelineChanged(p); +} - if (dataSource == nullptr) { - return nullptr; - } +pipeline::Pipeline* ActiveObjects::pipeline() const +{ + return m_pipeline; +} - if (dataSource->forkable()) { - return dataSource; - } +void ActiveObjects::setActiveNode(pipeline::Node* node) +{ + // Only one of node / port / link can be active at a time. Clear the + // others before emitting our own change so listeners (e.g. the + // properties panel) settle on the new selection rather than briefly + // showing it and then being wiped by a trailing port/link clear. + if (node) { + setActivePort(nullptr); + setActiveLink(nullptr); + } - std::function(DataSource*, DataSource*, - QList)> - dfs = [&dfs](DataSource* currentDataSource, DataSource* targetDataSource, - QList path) { - path.append(currentDataSource); - if (currentDataSource == targetDataSource) { - return path; - } - - foreach (Operator* op, currentDataSource->operators()) { - if (op->childDataSource() != nullptr) { - QList p = - dfs(op->childDataSource(), targetDataSource, path); - if (!p.isEmpty()) { - return p; - } - } - } - - return QList(); - }; - - // Find path to the active datasource - auto path = dfs(pipeline->dataSource(), dataSource, QList()); - - // Return the first non output data source - for (auto itr = path.rbegin(); itr != path.rend(); ++itr) { - auto ds = *itr; - if (ds->forkable()) { - m_activeParentDataSource = ds; - break; - } + auto* prevNode = m_activeNode; + if (m_activeNode != node) { + m_activeNode = node; + emit activeNodeChanged(node); + } + if (node) { + auto outputs = node->outputPorts(); + if (outputs.isEmpty()) { + setActiveTipOutputPort( + pipeline::findTipOutputPort(m_pipeline, node)); + } else { + setActiveTipOutputPort(outputs.first()); } + } else if (prevNode) { + setActiveTipOutputPort( + pipeline::findTipOutputPort(m_pipeline, prevNode)); } +} - return m_activeParentDataSource; +pipeline::Node* ActiveObjects::activeNode() const +{ + return m_activeNode; } -pqTimeKeeper* ActiveObjects::activeTimeKeeper() const +void ActiveObjects::setActivePort(pipeline::OutputPort* port) { - pqServer* server = pqActiveObjects::instance().activeServer(); - return server ? server->getTimeKeeper() : nullptr; + if (port) { + setActiveNode(nullptr); + setActiveLink(nullptr); + } + if (m_activePort != port) { + m_activePort = port; + emit activePortChanged(port); + } + if (port) { + setActiveTipOutputPort(port); + } } -Pipeline* ActiveObjects::activePipeline() const +pipeline::OutputPort* ActiveObjects::activePort() const { + return m_activePort; +} - if (m_activeDataSource != nullptr) { - return m_activeDataSource->pipeline(); +void ActiveObjects::setActiveLink(pipeline::Link* link) +{ + if (link) { + setActiveNode(nullptr); + setActivePort(nullptr); } + if (m_activeLink != link) { + m_activeLink = link; + emit activeLinkChanged(link); + } + if (link) { + setActiveTipOutputPort(link->from()); + } +} - return nullptr; +void ActiveObjects::clearActiveSelection() +{ + setActiveNode(nullptr); + setActivePort(nullptr); + setActiveLink(nullptr); +} + +pipeline::Link* ActiveObjects::activeLink() const +{ + return m_activeLink; +} + +pipeline::OutputPort* ActiveObjects::activeTipOutputPort() const +{ + return m_activeTipOutputPort; +} + +void ActiveObjects::setActiveTipOutputPort(pipeline::OutputPort* port) +{ + if (m_activeTipOutputPort != port) { + QObject::disconnect(m_tipEffectiveTypeConn); + m_activeTipOutputPort = port; + if (port) { + m_tipEffectiveTypeConn = connect( + port, &pipeline::OutputPort::effectiveTypeChanged, + this, [this](pipeline::PortType) { + emit activeTipOutputPortChanged(m_activeTipOutputPort); + }); + } + emit activeTipOutputPortChanged(port); + } } } // end of namespace tomviz diff --git a/tomviz/ActiveObjects.h b/tomviz/ActiveObjects.h index 3e756679d..68858f3cd 100644 --- a/tomviz/ActiveObjects.h +++ b/tomviz/ActiveObjects.h @@ -5,13 +5,6 @@ #define tomvizActiveObjects_h #include -#include - -#include "DataSource.h" -#include "Module.h" -#include "MoleculeSource.h" -#include "Operator.h" -#include "OperatorResult.h" class pqRenderView; class pqTimeKeeper; @@ -21,7 +14,12 @@ class vtkSMViewProxy; namespace tomviz { +namespace pipeline { +class Link; class Pipeline; +class Node; +class OutputPort; +} // namespace pipeline /// ActiveObjects keeps track of active objects in tomviz. /// This is similar to pqActiveObjects in ParaView, however it tracks objects @@ -43,77 +41,34 @@ class ActiveObjects : public QObject /// Returns the active pqRenderView object. pqRenderView* activePqRenderView() const; - /// Returns the active data source. - DataSource* activeDataSource() const { return m_activeDataSource; } - - /// Returns the selected data source, nullptr if no data source is selected. - DataSource* selectedDataSource() const { return m_selectedDataSource; } - - /// Returns the active operator. - Operator* activeOperator() const { return m_activeOperator; } - - /// Returns the active data source. - MoleculeSource* activeMoleculeSource() const - { - return m_activeMoleculeSource; - } - - /// Returns the active transformed data source. - DataSource* activeTransformedDataSource() const - { - return m_activeTransformedDataSource; - } - - /// Returns the active module. - Module* activeModule() const { return m_activeModule; } - - /// Returns the active OperatorResult - OperatorResult* activeOperatorResult() const - { - return m_activeOperatorResult; - } - /// Returns the vtkSMSessionProxyManager from the active server/session. /// Provided here for convenience, since we need to access the proxy manager /// often. vtkSMSessionProxyManager* proxyManager() const; - /// Returns the active pipelines. - Pipeline* activePipeline() const; - - /// The "parent" data source is the data source that new operators will be - /// appended to. i.e. The closes parent of the currently active data source - /// that is not an "Output" data source. - DataSource* activeParentDataSource(); - /// Returns the active time keeper pqTimeKeeper* activeTimeKeeper() const; + /// Pipeline (single, set once at startup) + void setPipeline(pipeline::Pipeline* p); + pipeline::Pipeline* pipeline() const; + + /// Active selection tracking. At most one of node / port / link is + /// non-null at a time — setting any of them to a non-null value clears + /// the others. Use clearActiveSelection() to clear them all at once. + void setActiveNode(pipeline::Node* node); + pipeline::Node* activeNode() const; + void setActivePort(pipeline::OutputPort* port); + pipeline::OutputPort* activePort() const; + void setActiveLink(pipeline::Link* link); + pipeline::Link* activeLink() const; + void clearActiveSelection(); + pipeline::OutputPort* activeTipOutputPort() const; + public slots: /// Set the active view; void setActiveView(vtkSMViewProxy*); - /// Set the active data source. - void setActiveDataSource(DataSource* source); - - /// Set the selected data source. - void setSelectedDataSource(DataSource* source); - - /// Set the active molecule source - void setActiveMoleculeSource(MoleculeSource* source); - - /// Set the active transformed data source. - void setActiveTransformedDataSource(DataSource* source); - - /// Set the active module. - void setActiveModule(Module* module); - - /// Set the active operator result. - void setActiveOperatorResult(OperatorResult* result); - - /// Set the active operator. - void setActiveOperator(Operator* op); - /// Create a render view if needed. void createRenderViewIfNeeded(); @@ -123,160 +78,71 @@ public slots: /// Renders all views. void renderAllViews(); - /// Edit interaction modes for all data sources - void enableTranslation(bool b); - void enableRotation(bool b); - void enableScaling(bool b); - /// Set whether to enable time series animations. void enableTimeSeriesAnimations(bool b); void setShowTimeSeriesLabel(bool b); - bool translationEnabled() const { return m_translationEnabled; } - bool rotationEnabled() const { return m_rotationEnabled; } - bool scalingEnabled() const { return m_scalingEnabled; } - bool timeSeriesAnimationsEnabled() const { return m_timeSeriesAnimationsEnabled; } bool showTimeSeriesLabel() const { return m_showTimeSeriesLabel; } - void setFixedInteractionDataSource(DataSource* ds) - { - m_fixedInteractionDataSource = ds; - emit interactionDataSourceFixed(ds); - } - - DataSource* fixedInteractionDataSource() const - { - return m_fixedInteractionDataSource; - }; - signals: /// Fired whenever the active view changes. void viewChanged(vtkSMViewProxy*); - /// Fired whenever the active data source changes (or changes type). - void dataSourceChanged(DataSource*); - - /// Fired whenever the data source is activated, i.e. selected in the - /// pipeline. - void dataSourceActivated(DataSource*); - - /// Fired whenever the data source is activated, i.e. selected in the - /// pipeline. This signal emits the transformed data source. - void transformedDataSourceActivated(DataSource*); - - /// Fired whenever the active module changes. - void moleculeSourceChanged(MoleculeSource*); - - /// Fired whenever a module is activated, i.e. selected in the pipeline. - void moleculeSourceActivated(MoleculeSource*); - - /// Fired whenever the active module changes. - void moduleChanged(Module*); - - /// Fired whenever a module is activated, i.e. selected in the pipeline. - void moduleActivated(Module*); - - /// Fired whenever the active operator changes. - void operatorChanged(Operator*); - - /// Fired whenever an operator is activated, i.e. selected in the pipeline. - void operatorActivated(Operator*); - - /// Fired whenever the active operator changes. - void resultChanged(OperatorResult*); - - /// Fired whenever an OperatorResult is activated. - void resultActivated(OperatorResult*); - - /// Fired when interaction modes change - void translationStateChanged(bool b); - void rotationStateChanged(bool b); - void scalingStateChanged(bool b); - /// Fired when time series animations enable state is changed. void timeSeriesAnimationsEnableStateChanged(bool b); /// Fired when time series label visibility changes void showTimeSeriesLabelChanged(bool b); - /// Fired whenever the color map has changed - void colorMapChanged(DataSource*); - /// Fired to set image viewer mode void setImageViewerMode(bool b); - /// Fired when the interaction data source was fixed. - void interactionDataSourceFixed(DataSource*); + /// Fired whenever the active pipeline changes. + void activePipelineChanged(pipeline::Pipeline*); + + /// Fired whenever the active node changes. + void activeNodeChanged(pipeline::Node*); + + /// Fired whenever the active output port changes. + void activePortChanged(pipeline::OutputPort*); + + /// Fired whenever the active link changes. + void activeLinkChanged(pipeline::Link*); + + /// Fired whenever the active tip output port changes. + void activeTipOutputPortChanged(pipeline::OutputPort*); private slots: void viewChanged(pqView*); - void dataSourceRemoved(DataSource*); - void moleculeSourceRemoved(MoleculeSource*); - void moduleRemoved(Module*); - void dataSourceChanged(); protected: ActiveObjects(); ~ActiveObjects() override; - QPointer m_activeDataSource = nullptr; - QPointer m_activeTransformedDataSource = nullptr; - QPointer m_selectedDataSource = nullptr; - DataSource::DataSourceType m_activeDataSourceType = DataSource::Volume; - QPointer m_activeParentDataSource = nullptr; - QPointer m_activeMoleculeSource = nullptr; - QPointer m_activeModule = nullptr; - QPointer m_activeOperator = nullptr; - QPointer m_activeOperatorResult = nullptr; - QPointer m_fixedInteractionDataSource = nullptr; - - /// interaction states - bool m_translationEnabled = false; - bool m_rotationEnabled = false; - bool m_scalingEnabled = false; + pipeline::Pipeline* m_pipeline = nullptr; + pipeline::Node* m_activeNode = nullptr; + pipeline::OutputPort* m_activePort = nullptr; + pipeline::Link* m_activeLink = nullptr; + pipeline::OutputPort* m_activeTipOutputPort = nullptr; /// Time series bool m_timeSeriesAnimationsEnabled = true; bool m_showTimeSeriesLabel = true; private: + void setActiveTipOutputPort(pipeline::OutputPort* port); + // Tracks the active tip port's effectiveTypeChanged subscription so we + // re-emit activeTipOutputPortChanged when type inference updates the tip + // (e.g. a freshly-added transform whose output type is inferred from its + // input only after the upstream link is created). + QMetaObject::Connection m_tipEffectiveTypeConn; Q_DISABLE_COPY(ActiveObjects) }; -inline void ActiveObjects::enableTranslation(bool b) -{ - if (m_translationEnabled == b) { - return; - } - - m_translationEnabled = b; - emit translationStateChanged(b); -} - -inline void ActiveObjects::enableRotation(bool b) -{ - if (m_rotationEnabled == b) { - return; - } - - m_rotationEnabled = b; - emit rotationStateChanged(b); -} - -inline void ActiveObjects::enableScaling(bool b) -{ - if (m_scalingEnabled == b) { - return; - } - - m_scalingEnabled = b; - emit scalingStateChanged(b); -} - inline void ActiveObjects::enableTimeSeriesAnimations(bool b) { if (m_timeSeriesAnimationsEnabled == b) { diff --git a/tomviz/AddAlignReaction.cxx b/tomviz/AddAlignReaction.cxx index 95933803e..2fa3671ab 100644 --- a/tomviz/AddAlignReaction.cxx +++ b/tomviz/AddAlignReaction.cxx @@ -3,14 +3,8 @@ #include "AddAlignReaction.h" -#include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "Pipeline.h" -#include "TranslateAlignOperator.h" -#include "Utilities.h" - -#include +#include "TransformUtils.h" +#include "pipeline/transforms/TranslateAlignTransform.h" namespace tomviz { @@ -19,20 +13,9 @@ AddAlignReaction::AddAlignReaction(QAction* parentObject) { } -void AddAlignReaction::align(DataSource* source) +void AddAlignReaction::align(DataSource*) { - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - qDebug() << "Exiting early - no data found."; - return; - } - - auto Op = new TranslateAlignOperator(source); - auto dialog = new EditOperatorDialog(Op, source, true, tomviz::mainWidget()); - - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle("Manual Image Alignment"); - dialog->show(); - connect(Op, &QObject::destroyed, dialog, &QDialog::reject); + auto* transform = new pipeline::TranslateAlignTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/AddExpressionReaction.cxx b/tomviz/AddExpressionReaction.cxx deleted file mode 100644 index b7179ae01..000000000 --- a/tomviz/AddExpressionReaction.cxx +++ /dev/null @@ -1,89 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "AddExpressionReaction.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "OperatorPython.h" -#include "PipelineManager.h" -#include "Utilities.h" - -namespace tomviz { - -AddExpressionReaction::AddExpressionReaction(QAction* parentObject) - : Reaction(parentObject) -{ -} - -OperatorPython* AddExpressionReaction::addExpression(DataSource* source) -{ - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - return nullptr; - } - - QString script = getDefaultExpression(source); - - OperatorPython* opPython = new OperatorPython(source); - opPython->setScript(script); - opPython->setLabel("Transform Data"); - opPython->setHelpUrl("operator"); - - // Create a non-modal dialog, delete it once it has been closed. - auto dialog = - new EditOperatorDialog(opPython, source, true, tomviz::mainWidget()); - dialog->setAttribute(Qt::WA_DeleteOnClose, true); - dialog->show(); - connect(opPython, &QObject::destroyed, dialog, &QDialog::reject); - return nullptr; -} - -QString AddExpressionReaction::getDefaultExpression(DataSource* source) -{ - QString actionString = parentAction()->text(); - if (actionString == "Custom ITK Transform") { - return readInPythonScript("DefaultITKTransform"); - } else { - // Build the default script for the python operator - // This was done in the Dialog's UI file, but since it needs to change - // based on the type of dataset, do it here - return QString("# Transform entry point, do not change function name.\n" - "def transform(dataset):\n" - " \"\"\"Define this method for Python operators that \n" - " transform the input array\"\"\"\n" - "\n" - " import numpy as np\n" - "\n" - "%1" - " # Get the current volume as a numpy array.\n" - " array = dataset.active_scalars\n" - "\n" - " # This is where you operate on your data, here we " - "square root it.\n" - " result = np.sqrt(array)\n" - "\n" - " # This is where the transformed data is set, it will " - "display in tomviz.\n" - " dataset.active_scalars = result\n") - .arg(source->type() == DataSource::Volume - ? "" - : " # Get the tilt angles array as a numpy array.\n" - " # You may also set tilt angles with dataset.tilt_angles\n" - " tilt_angles = dataset.tilt_angles\n" - "\n"); - } -} - -void AddExpressionReaction::updateEnableState() -{ - // This is currently compatible in all execution environments - auto compatibleExecutionMode = true; - - parentAction()->setEnabled(ActiveObjects::instance().activeDataSource() != - nullptr && - compatibleExecutionMode); -} - -} // namespace tomviz diff --git a/tomviz/AddExpressionReaction.h b/tomviz/AddExpressionReaction.h deleted file mode 100644 index 53d8ebbc1..000000000 --- a/tomviz/AddExpressionReaction.h +++ /dev/null @@ -1,33 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizAddExpressionReaction_h -#define tomvizAddExpressionReaction_h - -#include - -namespace tomviz { -class DataSource; -class OperatorPython; - -class AddExpressionReaction : public Reaction -{ - Q_OBJECT - -public: - AddExpressionReaction(QAction* parent); - - OperatorPython* addExpression(DataSource* source = nullptr); - -protected: - void onTriggered() override { addExpression(); } - void updateEnableState() override; - -private: - Q_DISABLE_COPY(AddExpressionReaction) - - QString getDefaultExpression(DataSource*); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/AddPythonSourceReaction.cxx b/tomviz/AddPythonSourceReaction.cxx new file mode 100644 index 000000000..da6841d5f --- /dev/null +++ b/tomviz/AddPythonSourceReaction.cxx @@ -0,0 +1,76 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "AddPythonSourceReaction.h" + +#include "LoadDataReaction.h" +#include "MainWindow.h" + +#include "pipeline/DeferredLinkInfo.h" +#include "pipeline/Node.h" +#include "pipeline/NodeEditDialog.h" +#include "pipeline/SourceNode.h" +#include "pipeline/sources/PythonSource.h" + +#include +#include + +namespace tomviz { + +AddPythonSourceReaction::AddPythonSourceReaction(QAction* parentObject, + const QString& script, + const QString& json) + : pqReaction(parentObject), m_script(script), m_json(json) +{ +} + +void AddPythonSourceReaction::onTriggered() +{ + auto* mainWindow = MainWindow::instance(); + auto* pip = mainWindow ? mainWindow->pipeline() : nullptr; + if (!pip) { + return; + } + + // Build the source up-front so the editor dialog can read its JSON + // description, script, and parameter defaults. + auto* source = new pipeline::PythonSource(); + source->setJSONDescription(m_json); + source->setScript(m_script); + + // Add the source to the pipeline before opening the dialog. This is + // what gives us the cancel-rollback symmetry with transform + // insertion: NodeEditDialog::reject() removes the node from the + // pipeline (and deletes it). On OK we finish the standard source + // scaffolding (sinks, color map, execute) via completeSourceSetup. + LoadDataReaction::addSourceToPipeline(source); + + pipeline::DeferredLinkInfo deferred; // sources have no links to defer + auto* dialog = + new pipeline::NodeEditDialog(source, pip, deferred, mainWindow); + dialog->setAttribute(Qt::WA_DeleteOnClose); + + QString title = QJsonDocument::fromJson(m_json.toUtf8()) + .object() + .value(QStringLiteral("label")) + .toString(); + if (title.isEmpty()) { + title = source->label(); + } + dialog->setWindowTitle(QString("Generate - %1").arg(title)); + + // The dialog's onOkay/onApply already mark stale + execute and emit + // insertionCompleted. Hook the signal to finish the source setup + // (sink group + default modules). Cancel removes the source for us. + QObject::connect(dialog, &pipeline::NodeEditDialog::insertionCompleted, + dialog, [](pipeline::Node* node) { + if (auto* src = + qobject_cast(node)) { + LoadDataReaction::completeSourceSetup(src); + } + }); + + dialog->show(); +} + +} // namespace tomviz diff --git a/tomviz/AddPythonSourceReaction.h b/tomviz/AddPythonSourceReaction.h new file mode 100644 index 000000000..27b31850c --- /dev/null +++ b/tomviz/AddPythonSourceReaction.h @@ -0,0 +1,39 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizAddPythonSourceReaction_h +#define tomvizAddPythonSourceReaction_h + +#include + +#include + +namespace tomviz { + +/// Menu reaction for adding a schema-v2 Python source node to the +/// pipeline. Pops up a modal dialog whose parameter form is built +/// from the operator JSON description (via ParameterInterfaceBuilder), +/// constructs a PythonSource with the chosen values, and hands it to +/// LoadDataReaction::sourceNodeAdded so the standard volume-loading +/// machinery (sink group, default modules, color map) kicks in. +class AddPythonSourceReaction : public pqReaction +{ + Q_OBJECT + +public: + AddPythonSourceReaction(QAction* parentObject, const QString& script, + const QString& json); + +protected: + void onTriggered() override; + +private: + Q_DISABLE_COPY(AddPythonSourceReaction) + + QString m_script; + QString m_json; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/AddPythonTransformReaction.cxx b/tomviz/AddPythonTransformReaction.cxx index 24f8f7f79..59f544551 100644 --- a/tomviz/AddPythonTransformReaction.cxx +++ b/tomviz/AddPythonTransformReaction.cxx @@ -4,542 +4,257 @@ #include "AddPythonTransformReaction.h" #include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "ModuleManager.h" -#include "ModuleSlice.h" -#include "OperatorDialog.h" -#include "OperatorFactory.h" -#include "OperatorPython.h" -#include "Pipeline.h" -#include "PipelineManager.h" -#include "SelectVolumeWidget.h" -#include "SpinBox.h" -#include "Utilities.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include +#include "MainWindow.h" +#include "TransformUtils.h" + +#include "pipeline/InputPort.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/TransformNode.h" +#include "pipeline/transforms/LegacyPythonTransform.h" +#include "pipeline/transforms/PythonTransform.h" + +#include +#include +#include #include #include #include #include -#include -#include -#include #include - namespace tomviz { +static pipeline::PortType portTypeFromString(const QString& str) +{ + if (str == "TiltSeries") + return pipeline::PortType::TiltSeries; + if (str == "Volume") + return pipeline::PortType::Volume; + if (str == "ImageData") + return pipeline::PortType::ImageData; + return pipeline::PortType::None; +} + +namespace { + +/// Returns true when the operator description declares schemaVersion 2. +/// Empty / non-object / missing schemaVersion all default to v1. +bool isSchemaV2(const QString& json) +{ + if (json.isEmpty()) { + return false; + } + auto doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isObject()) { + return false; + } + return doc.object().value(QStringLiteral("schemaVersion")).toInt(1) == 2; +} + +template +T* configurePythonTransform(T* node, const QString& label, + const QString& script, const QString& json, + const QMap& arguments) +{ + node->setLabel(label); + node->setScript(script); + if (!json.isEmpty()) { + node->setJSONDescription(json); + } + for (auto it = arguments.constBegin(); it != arguments.constEnd(); ++it) { + node->setParameter(it.key(), it.value()); + } + return node; +} + +/// Build the right Python transform node for @a json (legacy if no +/// schemaVersion or v1, else PythonTransform). Returns nullptr if the +/// JSON declares schemaVersion 2 but has empty inputs — that's a +/// source-shape description being routed to a transform-only entry +/// point and is rejected at construction (Q4 design choice). +pipeline::TransformNode* makePythonTransform( + const QString& label, const QString& script, const QString& json, + const QMap& arguments) +{ + if (isSchemaV2(json)) { + auto doc = QJsonDocument::fromJson(json.toUtf8()); + if (doc.object().value(QStringLiteral("inputs")).toArray().isEmpty()) { + qCritical() + << "AddPythonTransformReaction: schema-v2 description for" + << label + << "declares no inputs (it is source-shaped) — refusing to " + "create a transform from a source description."; + return nullptr; + } + return configurePythonTransform( + new pipeline::PythonTransform(), label, script, json, arguments); + } + return configurePythonTransform( + new pipeline::LegacyPythonTransform(), label, script, json, arguments); +} + +} // namespace + AddPythonTransformReaction::AddPythonTransformReaction( - QAction* parentObject, const QString& l, const QString& s, bool rts, bool rv, - bool rf, const QString& json) + QAction* parentObject, const QString& l, const QString& s, + const QString& json) : pqReaction(parentObject), jsonSource(json), scriptLabel(l), scriptSource(s), - interactive(false), requiresTiltSeries(rts), requiresVolume(rv), - requiresFib(rf) + interactive(false) { connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), + &ActiveObjects::activePipelineChanged, this, &AddPythonTransformReaction::updateEnableState); - connect(&PipelineManager::instance(), &PipelineManager::executionModeUpdated, + connect(&ActiveObjects::instance(), + &ActiveObjects::activeTipOutputPortChanged, this, &AddPythonTransformReaction::updateEnableState); - // If we have JSON, check whether the operator is compatible with being run - // in an external pipeline. + // Parse JSON to extract inputType and externalCompatible. if (!json.isEmpty()) { auto document = QJsonDocument::fromJson(json.toLatin1()); if (!document.isObject()) { qCritical() << "Failed to parse operator JSON"; qCritical() << json; return; - } else { - QJsonObject root = document.object(); - QJsonValueRef externalNode = root["externalCompatible"]; - if (!externalNode.isUndefined() && !externalNode.isNull()) { - this->externalCompatible = externalNode.toBool(); + } + QJsonObject root = document.object(); + QJsonValueRef externalNode = root["externalCompatible"]; + if (!externalNode.isUndefined() && !externalNode.isNull()) { + this->externalCompatible = externalNode.toBool(); + } + m_isSchemaV2 = + root.value(QStringLiteral("schemaVersion")).toInt(1) == 2; + if (m_isSchemaV2) { + // v2 derives accepted input types from the inputs[] array. If + // empty, the description is source-shaped and shouldn't be + // routed through this transform-flavored reaction at all — + // disable the action permanently. + auto inputs = root.value(QStringLiteral("inputs")).toArray(); + if (inputs.isEmpty()) { + qCritical() + << "AddPythonTransformReaction: schema-v2 description for" + << l + << "declares no inputs (it is source-shaped) — disabling."; + m_disabled = true; + } else { + // Use the first input's declared type as the accepted gate. + // Multi-input v2 transforms keep the same first-input gating + // the legacy `inputType` field provided. + auto firstType = + inputs.first().toObject().value(QStringLiteral("type")).toString(); + auto pt = portTypeFromString(firstType); + if (pt != pipeline::PortType::None) { + m_acceptedInputTypes = pt; + } + } + } else if (root.contains("inputType")) { + auto pt = portTypeFromString(root.value("inputType").toString()); + if (pt != pipeline::PortType::None) { + m_acceptedInputTypes = pt; } } } - OperatorFactory::instance().registerPythonOperator(l, s, rts, rv, rf, json); + // Extract the description from JSON and set it on the action for the search + // dialog to display. + if (!json.isEmpty()) { + auto descDoc = QJsonDocument::fromJson(json.toLatin1()); + if (descDoc.isObject()) { + QString desc = descDoc.object().value("description").toString(); + if (!desc.isEmpty()) { + parentObject->setStatusTip(desc); + } + } + } + qApp->installEventFilter(this); updateEnableState(); } -void AddPythonTransformReaction::updateEnableState() +bool AddPythonTransformReaction::eventFilter(QObject* obj, QEvent* event) { - auto pipeline = ActiveObjects::instance().activePipeline(); - bool enable = pipeline != nullptr; - - if (enable) { - auto dataSource = pipeline->transformedDataSource(); - - auto executionModeCompatible = - ((PipelineManager::instance().executionMode() == - Pipeline::ExecutionMode::Docker && - this->externalCompatible) || - (PipelineManager::instance().executionMode() != - Pipeline::ExecutionMode::Docker)); - - if (((requiresTiltSeries && dataSource->type() == DataSource::TiltSeries) || - (requiresVolume && dataSource->type() == DataSource::Volume) || - (requiresFib && dataSource->type() == DataSource::FIB) || - (!requiresTiltSeries && !requiresVolume && !requiresFib)) && - executionModeCompatible) { - enable = true; - } else { - enable = false; + if (event->type() == QEvent::KeyPress || + event->type() == QEvent::KeyRelease) { + auto* ke = static_cast(event); + if (ke->key() == Qt::Key_Control) { + m_ctrlHeld = (event->type() == QEvent::KeyPress); + updateEnableState(); } } - - parentAction()->setEnabled(enable); + return pqReaction::eventFilter(obj, event); } -OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) +void AddPythonTransformReaction::updateEnableState() { - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - return nullptr; + // Permanently disabled (e.g. malformed schema-v2 description). + if (m_disabled) { + parentAction()->setEnabled(false); + return; } - bool hasJson = this->jsonSource.size() > 0; - - if (hasJson) { - OperatorPython* opPython = new OperatorPython(source); - opPython->setJSONDescription(jsonSource); - opPython->setLabel(scriptLabel); - opPython->setScript(scriptSource); - if (scriptLabel == "Auto Tilt Image Align (PyStackReg)") { - // If there are any slice modules on this data source, use the - // slice index as the default value for the slice index. - int defaultSliceIdx = 0; - // Use the output data source of the pipeline if it is available. If - // one is not available, this just defaults to the root data source. - auto tSource = source->pipeline()->transformedDataSource(); - auto sliceModules = ModuleManager::instance().findModules( - tSource, nullptr); - if (sliceModules.size() > 0 && sliceModules[0]) { - defaultSliceIdx = std::max(sliceModules[0]->slice(), 0); - } - - QMap arguments{{"ref_slice_index", defaultSliceIdx}}; - QMap typeInfo{{"ref_slice_index", "int"}}; - opPython->setArguments(arguments); - opPython->setTypeInfo(typeInfo); - } - - // Use JSON to build the interface via the EditOperatorDialog - // If the operator doesn't have parameters, don't show the dialog on first - // execution - if (opPython->numberOfParameters() > 0) { - auto dialog = - new EditOperatorDialog(opPython, source, true, tomviz::mainWidget()); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle(QString("Edit %1").arg(opPython->label())); - dialog->show(); - } else { - source->addOperator(opPython); - } - - // Handle transforms with custom UIs - } else if (scriptLabel == "Shift Volume") { - auto t = source->producer(); - auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!data) { - return nullptr; - } - int* extent = data->GetExtent(); - - QDialog dialog(tomviz::mainWidget()); - dialog.setWindowTitle("Shift Volume"); - - QHBoxLayout* layout = new QHBoxLayout; - QLabel* label = new QLabel("Shift to apply:", &dialog); - layout->addWidget(label); - QSpinBox* spinx = new QSpinBox(&dialog); - spinx->setRange(-(extent[1] - extent[0]), extent[1] - extent[0]); - spinx->setValue(0); - QSpinBox* spiny = new QSpinBox(&dialog); - spiny->setRange(-(extent[3] - extent[2]), extent[3] - extent[2]); - spiny->setValue(0); - QSpinBox* spinz = new QSpinBox(&dialog); - spinz->setRange(-(extent[5] - extent[4]), extent[5] - extent[4]); - spinz->setValue(0); - layout->addWidget(spinx); - layout->addWidget(spiny); - layout->addWidget(spinz); - QVBoxLayout* v = new QVBoxLayout; - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, &dialog); - connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); - v->addLayout(layout); - v->addWidget(buttons); - dialog.setLayout(v); - dialog.layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - - if (dialog.exec() == QDialog::Accepted) { - QMap arguments; - QList values; - values << spinx->value() << spiny->value() << spinz->value(); - arguments.insert("SHIFT", values); - QMap typeInfo; - typeInfo["SHIFT"] = "int"; - - addPythonOperator(source, this->scriptLabel, this->scriptSource, - arguments, typeInfo); - } - } else if (scriptLabel == "Remove Bad Pixels") { - QDialog dialog(tomviz::mainWidget()); - dialog.setWindowTitle("Remove Bad Pixels"); - QHBoxLayout* layout = new QHBoxLayout; - QLabel* label = new QLabel("Remove bad pixels that are ", &dialog); - layout->addWidget(label); - QDoubleSpinBox* threshold = new QDoubleSpinBox(&dialog); - threshold->setMinimum(0); - threshold->setValue(5); - layout->addWidget(threshold); - label = new QLabel("times local standard deviation from local median."); - layout->addWidget(label); - - QVBoxLayout* v = new QVBoxLayout; - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, &dialog); - connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); - v->addLayout(layout); - v->addWidget(buttons); - dialog.setLayout(v); - dialog.layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - - if (dialog.exec() == QDialog::Accepted) { - QMap arguments; - arguments.insert("threshold", threshold->value()); - QMap typeInfo; - typeInfo["threshold"] = "double"; - addPythonOperator(source, this->scriptLabel, this->scriptSource, - arguments, typeInfo); - } - } else if (scriptLabel == "Crop") { - auto t = source->producer(); - auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!data) { - return nullptr; - } - int* extent = data->GetExtent(); - - QDialog dialog(tomviz::mainWidget()); - QHBoxLayout* layout1 = new QHBoxLayout; - QLabel* label = new QLabel("Crop data start:", &dialog); - layout1->addWidget(label); - QSpinBox* spinx = new QSpinBox(&dialog); - spinx->setRange(extent[0], extent[1]); - spinx->setValue(extent[0]); - QSpinBox* spiny = new QSpinBox(&dialog); - spiny->setRange(extent[2], extent[3]); - spiny->setValue(extent[2]); - QSpinBox* spinz = new QSpinBox(&dialog); - spinz->setRange(extent[4], extent[5]); - spinz->setValue(extent[4]); - layout1->addWidget(label); - layout1->addWidget(spinx); - layout1->addWidget(spiny); - layout1->addWidget(spinz); - QHBoxLayout* layout2 = new QHBoxLayout; - label = new QLabel("Crop data end:", &dialog); - layout2->addWidget(label); - QSpinBox* spinxx = new QSpinBox(&dialog); - spinxx->setRange(extent[0], extent[1]); - spinxx->setValue(extent[1]); - QSpinBox* spinyy = new QSpinBox(&dialog); - spinyy->setRange(extent[2], extent[3]); - spinyy->setValue(extent[3]); - QSpinBox* spinzz = new QSpinBox(&dialog); - spinzz->setRange(extent[4], extent[5]); - spinzz->setValue(extent[5]); - layout2->addWidget(label); - layout2->addWidget(spinxx); - layout2->addWidget(spinyy); - layout2->addWidget(spinzz); - QVBoxLayout* v = new QVBoxLayout; - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, &dialog); - connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); - v->addLayout(layout1); - v->addLayout(layout2); - v->addWidget(buttons); - dialog.setLayout(v); - dialog.layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - if (dialog.exec() == QDialog::Accepted) { - QMap arguments; - QList values; - values << spinx->value() << spiny->value() << spinz->value(); - arguments.insert("START_CROP", values); - values.clear(); - - values << spinxx->value() << spinyy->value() << spinzz->value(); - arguments.insert("END_CROP", values); - QMap typeInfo; - typeInfo["START_CROP"] = "int"; - typeInfo["END_CROP"] = "int"; - addPythonOperator(source, this->scriptLabel, this->scriptSource, - arguments, typeInfo); - } - } else if (scriptLabel == "Clear Volume") { - QDialog* dialog = new QDialog(tomviz::mainWidget()); - dialog->setWindowTitle("Select Volume to Clear"); - dialog->setAttribute(Qt::WA_DeleteOnClose, true); - - double origin[3]; - double spacing[3]; - int extent[6]; - auto t = source->producer(); - vtkImageData* image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!image) { - return nullptr; - } - image->GetOrigin(origin); - image->GetSpacing(spacing); - image->GetExtent(extent); - - QVBoxLayout* layout = new QVBoxLayout(); - SelectVolumeWidget* selectionWidget = new SelectVolumeWidget( - origin, spacing, extent, extent, source->displayPosition(), dialog); - QObject::connect(source, &DataSource::displayPositionChanged, - selectionWidget, &SelectVolumeWidget::dataMoved); - QDialogButtonBox* buttons = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); - layout->addWidget(selectionWidget); - layout->addWidget(buttons); - dialog->setLayout(layout); - - connect(dialog, &QDialog::accepted, this, - &AddPythonTransformReaction::addExpressionFromNonModalDialog); - dialog->show(); - dialog->layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - - } else if (scriptLabel == "Background Subtraction (Manual)") { - QDialog* dialog = new QDialog(tomviz::mainWidget()); - dialog->setWindowTitle("Background Subtraction (Manual)"); - dialog->setAttribute(Qt::WA_DeleteOnClose, true); - - double origin[3]; - double spacing[3]; - int extent[6]; - - auto t = source->producer(); - auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!image) { - return nullptr; - } - image->GetOrigin(origin); - image->GetSpacing(spacing); - image->GetExtent(extent); - // Default background region - int currentVolume[6]; - currentVolume[0] = 10; - currentVolume[1] = 50; - currentVolume[2] = 10; - currentVolume[3] = 50; - currentVolume[4] = extent[4]; - currentVolume[5] = extent[5]; - - QVBoxLayout* layout = new QVBoxLayout(); - - QLabel* label = new QLabel( - "Subtract background in each image of a tilt series dataset. Specify the " - "background regions using the x,y,z ranges or graphically in the " - "visualization" - " window. The mean value in the background window will be subtracted " - "from each" - " image tilt (x-y) in the stack's range (z)."); - label->setWordWrap(true); - layout->addWidget(label); - - SelectVolumeWidget* selectionWidget = - new SelectVolumeWidget(origin, spacing, extent, currentVolume, - source->displayPosition(), dialog); - QObject::connect(source, &DataSource::displayPositionChanged, - selectionWidget, &SelectVolumeWidget::dataMoved); - QDialogButtonBox* buttons = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); - layout->addWidget(selectionWidget); - layout->addWidget(buttons); - dialog->setLayout(layout); - dialog->layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - - connect(dialog, &QDialog::accepted, this, - &AddPythonTransformReaction::addExpressionFromNonModalDialog); - dialog->show(); - } else { - OperatorPython* opPython = new OperatorPython(source); - opPython->setLabel(scriptLabel); - opPython->setScript(scriptSource); + // Ctrl held: user wants to add an unconnected node, so always enable. + if (m_ctrlHeld) { + parentAction()->setEnabled(true); + return; + } - if (interactive) { - // Create a non-modal dialog, delete it once it has been closed. - auto dialog = - new EditOperatorDialog(opPython, source, true, tomviz::mainWidget()); - dialog->setAttribute(Qt::WA_DeleteOnClose, true); - dialog->show(); - connect(opPython, &QObject::destroyed, dialog, &QDialog::reject); - } else { - source->addOperator(opPython); - } + auto& ao = ActiveObjects::instance(); + auto* tipPort = ao.activeTipOutputPort(); + if (!tipPort) { + parentAction()->setEnabled(false); + return; } - return nullptr; + parentAction()->setEnabled( + pipeline::isPortTypeCompatible(tipPort->type(), m_acceptedInputTypes)); } -void AddPythonTransformReaction::addExpressionFromNonModalDialog() +void AddPythonTransformReaction::addExpression(DataSource*) { - auto addRanges = [](QMap& arguments, - QMap& typeInfo, int* indices) { - QList range; - range << indices[0] << indices[1]; - arguments.insert("XRANGE", range); - range.clear(); - - range << indices[2] << indices[3]; - arguments.insert("YRANGE", range); - range.clear(); - - range << indices[4] << indices[5]; - arguments.insert("ZRANGE", range); - - typeInfo.insert("XRANGE", "int"); - typeInfo.insert("YRANGE", "int"); - typeInfo.insert("ZRANGE", "int"); - }; - - DataSource* source = ActiveObjects::instance().activeParentDataSource(); - QDialog* dialog = qobject_cast(this->sender()); - if (this->scriptLabel == "Clear Volume") { - QLayout* layout = dialog->layout(); - SelectVolumeWidget* volumeWidget = nullptr; - for (int i = 0; i < layout->count(); ++i) { - if ((volumeWidget = - qobject_cast(layout->itemAt(i)->widget()))) { - break; - } - } - - if (!volumeWidget) { - return; - } - int selection_extent[6]; - volumeWidget->getExtentOfSelection(selection_extent); - - int image_extent[6]; - auto t = source->producer(); - auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!image) { - return; - } - image->GetExtent(image_extent); - - // The image extent is not necessarily zero-based. The numpy array is. - // Also adding 1 to the maximum extents since numpy expects the max to be - // one - // past the last item of interest. - int indices[6]; - indices[0] = selection_extent[0] - image_extent[0]; - indices[1] = selection_extent[1] - image_extent[0] + 1; - indices[2] = selection_extent[2] - image_extent[2]; - indices[3] = selection_extent[3] - image_extent[2] + 1; - indices[4] = selection_extent[4] - image_extent[4]; - indices[5] = selection_extent[5] - image_extent[4] + 1; - - QMap arguments; - QMap typeInfo; - addRanges(arguments, typeInfo, indices); - addPythonOperator(source, this->scriptLabel, this->scriptSource, arguments, - typeInfo); + auto* mainWindow = MainWindow::instance(); + auto* pip = mainWindow ? mainWindow->pipeline() : nullptr; + if (!pip) { + return; } - if (this->scriptLabel == "Background Subtraction (Manual)") { - QLayout* layout = dialog->layout(); - SelectVolumeWidget* volumeWidget = nullptr; - for (int i = 0; i < layout->count(); ++i) { - if ((volumeWidget = - qobject_cast(layout->itemAt(i)->widget()))) { - break; - } - } - if (!volumeWidget) { - return; - } - int selection_extent[6]; - volumeWidget->getExtentOfSelection(selection_extent); - - int image_extent[6]; - auto t = source->producer(); - auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!image) { - return; - } - image->GetExtent(image_extent); - int indices[6]; - indices[0] = selection_extent[0] - image_extent[0]; - indices[1] = selection_extent[1] - image_extent[0] + 1; - indices[2] = selection_extent[2] - image_extent[2]; - indices[3] = selection_extent[3] - image_extent[2] + 1; - indices[4] = selection_extent[4] - image_extent[4]; - indices[5] = selection_extent[5] - image_extent[4] + 1; - - QMap arguments; - QMap typeInfo; - addRanges(arguments, typeInfo, indices); - addPythonOperator(source, this->scriptLabel, this->scriptSource, arguments, - typeInfo); + auto* transform = + makePythonTransform(scriptLabel, scriptSource, jsonSource, {}); + if (!transform) { + return; } + insertTransformIntoPipeline(transform); +} + +void AddPythonTransformReaction::addExpressionFromNonModalDialog() +{ + // Non-modal dialogs for Clear Volume and Background Subtraction are not + // yet supported in the new pipeline. Log and return. + qWarning("Non-modal transform dialogs not yet supported in new pipeline."); } void AddPythonTransformReaction::addPythonOperator( - DataSource* source, const QString& scriptLabel, + DataSource*, const QString& scriptLabel, const QString& scriptBaseString, const QMap arguments, const QString& jsonString) { - // Create and add the operator - OperatorPython* opPython = new OperatorPython(source); - opPython->setJSONDescription(jsonString); - opPython->setLabel(scriptLabel); - opPython->setScript(scriptBaseString); - opPython->setArguments(arguments); - - source->addOperator(opPython); + auto* transform = makePythonTransform( + scriptLabel, scriptBaseString, jsonString, arguments); + if (!transform) { + return; + } + insertTransformIntoPipeline(transform); } void AddPythonTransformReaction::addPythonOperator( - DataSource* source, const QString& scriptLabel, + DataSource*, const QString& scriptLabel, const QString& scriptBaseString, const QMap arguments, - const QMap typeInfo) + const QMap) { - // Create and add the operator - OperatorPython* opPython = new OperatorPython(source); - opPython->setLabel(scriptLabel); - opPython->setScript(scriptBaseString); - opPython->setArguments(arguments); - opPython->setTypeInfo(typeInfo); - - source->addOperator(opPython); + // No JSON description provided → can't be schema-v2; legacy path. + auto* transform = makePythonTransform( + scriptLabel, scriptBaseString, /*json=*/QString(), arguments); + if (!transform) { + return; + } + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/AddPythonTransformReaction.h b/tomviz/AddPythonTransformReaction.h index c9f2878d9..840782b8a 100644 --- a/tomviz/AddPythonTransformReaction.h +++ b/tomviz/AddPythonTransformReaction.h @@ -6,9 +6,14 @@ #include +#include "pipeline/PortType.h" + namespace tomviz { class DataSource; -class OperatorPython; + +namespace pipeline { +class OutputPort; +} class AddPythonTransformReaction : public pqReaction { @@ -17,12 +22,9 @@ class AddPythonTransformReaction : public pqReaction public: AddPythonTransformReaction(QAction* parent, const QString& label, const QString& source, - bool requiresTiltSeries = false, - bool requiresVolume = false, - bool requiresFib = false, const QString& json = QString()); - OperatorPython* addExpression(DataSource* source = nullptr); + void addExpression(DataSource* source = nullptr); void setInteractive(bool isInteractive) { interactive = isInteractive; } @@ -52,6 +54,7 @@ class AddPythonTransformReaction : public pqReaction protected: void updateEnableState() override; + bool eventFilter(QObject* obj, QEvent* event) override; void onTriggered() override { addExpression(); } @@ -66,10 +69,14 @@ private slots: QString scriptSource; bool interactive; - bool requiresTiltSeries; - bool requiresVolume; - bool requiresFib; + bool m_ctrlHeld = false; + pipeline::PortTypes m_acceptedInputTypes = pipeline::PortType::ImageData; bool externalCompatible = true; + bool m_isSchemaV2 = false; + // True if the action is permanently disabled because the JSON + // description is malformed or shape-mismatched (e.g. a v2 source + // description loaded by this transform-flavored reaction). + bool m_disabled = false; }; } // namespace tomviz diff --git a/tomviz/AddResampleReaction.cxx b/tomviz/AddResampleReaction.cxx deleted file mode 100644 index 9314dda47..000000000 --- a/tomviz/AddResampleReaction.cxx +++ /dev/null @@ -1,136 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "AddResampleReaction.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "LoadDataReaction.h" -#include "Utilities.h" - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -AddResampleReaction::AddResampleReaction(QAction* parentObject) - : pqReaction(parentObject) -{ - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &AddResampleReaction::updateEnableState); - updateEnableState(); -} - -void AddResampleReaction::updateEnableState() -{ - parentAction()->setEnabled(ActiveObjects::instance().activeDataSource() != - nullptr); -} - -namespace { -vtkImageData* imageData(DataSource* source) -{ - auto t = source->producer(); - return vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); -} -} // namespace - -void AddResampleReaction::resample(DataSource* source) -{ - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - qDebug() << "Exiting early - no data :-("; - return; - } - vtkImageData* originalData = imageData(source); - int extents[6]; - originalData->GetExtent(extents); - int resolution[3] = { extents[1] - extents[0] + 1, - extents[3] - extents[2] + 1, - extents[5] - extents[4] + 1 }; - - // Find out how big they want to resample it - QDialog dialog(tomviz::mainWidget()); - QHBoxLayout* layout = new QHBoxLayout; - QLabel* label0 = new QLabel(QString("Current resolution: %1, %2, %3") - .arg(resolution[0]) - .arg(resolution[1]) - .arg(resolution[2])); - QLabel* label1 = new QLabel("New resolution:"); - layout->addWidget(label1); - QSpinBox* spinx = new QSpinBox; - spinx->setRange(2, resolution[0]); - spinx->setValue(resolution[0]); - QSpinBox* spiny = new QSpinBox; - spiny->setRange(2, resolution[1]); - spiny->setValue(resolution[1]); - QSpinBox* spinz = new QSpinBox; - spinz->setRange(2, resolution[2]); - spinz->setValue(resolution[2]); - layout->addWidget(spinx); - layout->addWidget(spiny); - layout->addWidget(spinz); - QVBoxLayout* v = new QVBoxLayout; - QDialogButtonBox* buttons = - new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); - v->addWidget(label0); - v->addLayout(layout); - v->addWidget(buttons); - dialog.setLayout(v); - if (dialog.exec() == QDialog::Accepted) { - // Compute the resampled data - double origin[3]; - double spacing[3]; - originalData->GetOrigin(origin); - originalData->GetSpacing(spacing); - int newResolution[3] = { spinx->value(), spiny->value(), spinz->value() }; - double newOrigin[3], newSpacing[3]; - int newExtents[6]; - for (int i = 0; i < 3; ++i) { - newOrigin[i] = origin[i] + extents[2 * i] * spacing[i]; - newExtents[2 * i] = 0; - newExtents[2 * i + 1] = newResolution[i] - 1; - newSpacing[i] = spacing[i] * (extents[2 * i + 1] - extents[2 * i]) / - (double)(newResolution[i]); - } - vtkNew reslice; - reslice->SetInputData(originalData); - reslice->SetInterpolationModeToLinear(); // for now - reslice->SetOutputExtent(newExtents); - reslice->SetOutputSpacing(newSpacing); - reslice->SetOutputOrigin(newOrigin); - reslice->Update(); - - // Create a DataSource and set its data to the resampled data - // TODO - cloning here is really expensive memory-wise, we should figure - // out a different way to do it - // N.B. This does not clone the attached operators. - DataSource* resampledData = source->clone(); - QString name = resampledData->label(); - name = "Downsampled_" + name; - resampledData->setLabel(name); - auto t = resampledData->producer(); - t->SetOutput(reslice->GetOutput()); - resampledData->dataModified(); - - // Add the new DataSource - LoadDataReaction::dataSourceAdded(resampledData); - } -} -} // namespace tomviz diff --git a/tomviz/AddResampleReaction.h b/tomviz/AddResampleReaction.h deleted file mode 100644 index ae032f0d8..000000000 --- a/tomviz/AddResampleReaction.h +++ /dev/null @@ -1,30 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizAddResampleReaction_h -#define tomvizAddResampleReaction_h - -#include - -namespace tomviz { -class DataSource; - -class AddResampleReaction : public pqReaction -{ - Q_OBJECT - -public: - AddResampleReaction(QAction* parent); - - void resample(DataSource* source = nullptr); - -protected: - void updateEnableState() override; - void onTriggered() override { resample(); } - -private: - Q_DISABLE_COPY(AddResampleReaction) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/AlignWidget.cxx b/tomviz/AlignWidget.cxx index 4bca60ec1..099c226da 100644 --- a/tomviz/AlignWidget.cxx +++ b/tomviz/AlignWidget.cxx @@ -5,13 +5,14 @@ #include "ActiveObjects.h" #include "ColorMap.h" -#include "DataSource.h" #include "LoadDataReaction.h" #include "PresetDialog.h" #include "QVTKGLWidget.h" #include "SpinBox.h" -#include "TranslateAlignOperator.h" #include "Utilities.h" +#include "pipeline/InputPort.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/transforms/TranslateAlignTransform.h" #include #include @@ -346,9 +347,9 @@ class ShowDifferenceImageMode : public ViewMode vtkIdType m_ySize; }; -AlignWidget::AlignWidget(TranslateAlignOperator* op, +AlignWidget::AlignWidget(pipeline::TranslateAlignTransform* op, vtkSmartPointer imageData, QWidget* p) - : EditOperatorWidget(p) + : pipeline::EditNodeWidget(p) { m_timer = new QTimer(this); m_operator = op; @@ -369,8 +370,18 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, setMinimumHeight(600); setWindowTitle("Align data"); - // Grab the image data from the data source... - vtkSMProxy* lut = op->getDataSource()->colorMap(); + // Get the LUT from the input VolumeData on the transform's input port + vtkSMProxy* lut = nullptr; + auto* inputPort = op->inputPorts()[0]; + if (inputPort && inputPort->hasData()) { + try { + auto vol = inputPort->data().value(); + if (vol) { + lut = vol->colorMap(); + } + } catch (const std::bad_any_cast&) { + } + } // Set up the rendering pipeline if (imageData) { diff --git a/tomviz/AlignWidget.h b/tomviz/AlignWidget.h index 21b2cefde..d78005444 100644 --- a/tomviz/AlignWidget.h +++ b/tomviz/AlignWidget.h @@ -4,7 +4,7 @@ #ifndef tomvizAlignWidget_h #define tomvizAlignWidget_h -#include "EditOperatorWidget.h" +#include "pipeline/EditNodeWidget.h" #include #include @@ -33,18 +33,22 @@ class vtkRenderer; namespace tomviz { +namespace pipeline { +class TranslateAlignTransform; +} + class DataSource; class SpinBox; -class TranslateAlignOperator; class ViewMode; class QVTKGLWidget; -class AlignWidget : public EditOperatorWidget +class AlignWidget : public pipeline::EditNodeWidget { Q_OBJECT public: - AlignWidget(TranslateAlignOperator* op, vtkSmartPointer data, + AlignWidget(pipeline::TranslateAlignTransform* op, + vtkSmartPointer data, QWidget* parent = nullptr); ~AlignWidget() override; @@ -115,7 +119,7 @@ protected slots: int m_currentMode = 0; QVector m_offsets; - QPointer m_operator; + QPointer m_operator; private: int restoreDraftDialog() const; diff --git a/tomviz/AnimationHelperDialog.cxx b/tomviz/AnimationHelperDialog.cxx index 751c068b7..06d4309d8 100644 --- a/tomviz/AnimationHelperDialog.cxx +++ b/tomviz/AnimationHelperDialog.cxx @@ -6,10 +6,14 @@ #include "ActiveObjects.h" #include "ContourAnimation.h" -#include "ModuleManager.h" #include "SliceAnimation.h" #include "Utilities.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/SliceSink.h" + #include #include #include @@ -24,10 +28,28 @@ #include #include -#include - namespace tomviz { +namespace { + +pipeline::SourceNode* findUpstreamSource(pipeline::Node* node) +{ + if (!node) { + return nullptr; + } + if (auto* src = qobject_cast(node)) { + return src; + } + for (auto* up : node->upstreamNodes()) { + if (auto* src = findUpstreamSource(up)) { + return src; + } + } + return nullptr; +} + +} // anonymous namespace + class AnimationHelperDialog::Internal : public QObject { public: @@ -38,10 +60,8 @@ class AnimationHelperDialog::Internal : public QObject Internal(AnimationHelperDialog* p) : QObject(p), parent(p) { - // Must call setupUi() before using p in any way ui.setupUi(p); - // We will change tabs automatically ui.modulesTabWidget->tabBar()->hide(); updateGui(); @@ -70,18 +90,18 @@ class AnimationHelperDialog::Internal : public QObject } }); - // Modules - connect(&moduleManager(), &ModuleManager::dataSourceAdded, this, - &Internal::onDataSourceAdded); - connect(&moduleManager(), &ModuleManager::dataSourceRemoved, this, - &Internal::onDataSourceRemoved); + // Pipeline node changes + if (auto* pip = pipeline()) { + connect(pip, &pipeline::Pipeline::nodeAdded, this, + &Internal::onNodeAdded); + connect(pip, &pipeline::Pipeline::nodeRemoved, this, + &Internal::onNodeRemoved); + } + + // Sink selection connect(ui.selectedDataSource, QOverload::of(&QComboBox::currentIndexChanged), this, &Internal::selectedDataSourceChanged); - connect(&moduleManager(), &ModuleManager::moduleAdded, this, - &Internal::updateModuleOptions); - connect(&moduleManager(), &ModuleManager::moduleRemoved, this, - &Internal::updateModuleOptions); connect(ui.selectedModule, QOverload::of(&QComboBox::currentIndexChanged), this, &Internal::selectedModuleChanged); @@ -116,6 +136,8 @@ class AnimationHelperDialog::Internal : public QObject void updateEnableStates() { + cleanupNullAnimations(); + bool hasCameraAnimations = false; for (auto* cue : scene()->getCues()) { if (cue->getSMName().startsWith("CameraAnimationCue")) { @@ -125,11 +147,9 @@ class AnimationHelperDialog::Internal : public QObject } bool hasTimeSeries = false; - for (auto* ds : moduleManager().allDataSources()) { - if (ds->hasTimeSteps()) { - hasTimeSeries = true; - break; - } + auto* tk = activeObjects().activeTimeKeeper(); + if (tk && !tk->getTimeSteps().empty()) { + hasTimeSeries = true; } bool timeSeriesEnabled = @@ -137,7 +157,7 @@ class AnimationHelperDialog::Internal : public QObject bool hasDataSourceOptions = ui.selectedDataSource->count() != 0; bool hasModuleOptions = ui.selectedModule->count() != 0; - bool moduleSelected = selectedModule() != nullptr; + bool moduleSelected = selectedSink() != nullptr; bool hasModuleAnimations = !moduleAnimations.empty(); bool hasAnyAnimations = @@ -164,7 +184,6 @@ class AnimationHelperDialog::Internal : public QObject { auto* renderView = activeObjects().activePqRenderView(); - // Remove all previous camera cues, and create the orbit clearCameraCues(renderView->getRenderViewProxy()); createCameraOrbit(renderView->getRenderViewProxy()); @@ -181,36 +200,48 @@ class AnimationHelperDialog::Internal : public QObject return types; } - QStringList allowedModuleTypes() - { - // This is based upon tab texts - return moduleTabTexts(); - } - - // Data sources + // Data sources (pipeline source nodes) void updateDataSourceOptions() { QSignalBlocker blocked(ui.selectedDataSource); - auto* previouslySelected = selectedDataSource(); + auto* previouslySelected = selectedSource(); int previouslySelectedIndex = -1; ui.selectedDataSource->clear(); - auto& manager = moduleManager(); + auto* pip = pipeline(); + if (!pip) { + updateEnableStates(); + return; + } + + QStringList usedLabels; + int idx = 0; + for (auto* node : pip->nodes()) { + auto* source = qobject_cast(node); + if (!source) { + continue; + } - auto dataSources = manager.allDataSourcesDepthFirst(); - auto labels = manager.createUniqueLabels(dataSources); - for (int i = 0; i < dataSources.size(); ++i) { - auto* dataSource = dataSources[i]; - auto label = labels[i]; + auto label = source->label(); + if (label.isEmpty()) { + label = "Source"; + } - QVariant data; - data.setValue(dataSource); - ui.selectedDataSource->addItem(label, data); + auto uniqueLabel = label; + int n = 1; + while (usedLabels.contains(uniqueLabel)) { + uniqueLabel = label + " " + QString::number(++n); + } + usedLabels.append(uniqueLabel); - if (dataSource == previouslySelected) { - previouslySelectedIndex = i; + ui.selectedDataSource->addItem( + uniqueLabel, QVariant::fromValue(static_cast(source))); + + if (source == previouslySelected) { + previouslySelectedIndex = idx; } + ++idx; } if (previouslySelectedIndex != -1) { @@ -222,56 +253,66 @@ class AnimationHelperDialog::Internal : public QObject updateEnableStates(); } - DataSource* selectedDataSource() + pipeline::SourceNode* selectedSource() { if (ui.selectedDataSource->count() == 0) { return nullptr; } - return ui.selectedDataSource->currentData().value(); + return qobject_cast( + ui.selectedDataSource->currentData().value()); } - // Modules + // Sinks (animatable modules) void updateModuleOptions() { QSignalBlocker blocked(ui.selectedModule); - auto* previouslySelected = selectedModule(); + auto* previouslySelected = selectedSink(); int previouslySelectedIndex = -1; ui.selectedModule->clear(); - auto* dataSource = selectedDataSource(); - if (!dataSource) { + auto* source = selectedSource(); + auto* pip = pipeline(); + if (!source || !pip) { + updateEnableStates(); return; } - auto& manager = moduleManager(); + QList sinks; + for (auto* node : pip->nodes()) { + if (qobject_cast(node) || + qobject_cast(node)) { + if (findUpstreamSource(node) == source) { + sinks.append(node); + } + } + } QStringList labels; - QList modules; - auto allowedTypes = allowedModuleTypes(); - for (auto* module : manager.findModulesGeneric(dataSource, nullptr)) { - if (allowedTypes.contains(module->label())) { - modules.append(module); - - int i = 1; - auto label = module->label(); - while (labels.contains(label)) { - label = module->label() + " " + QString::number(++i); + for (auto* sink : sinks) { + auto label = sink->label(); + if (label.isEmpty()) { + if (qobject_cast(sink)) { + label = "Contour"; + } else if (qobject_cast(sink)) { + label = "Slice"; } - labels.append(label); } - } - for (int i = 0; i < modules.size(); ++i) { - auto* module = modules[i]; - auto label = labels[i]; + auto uniqueLabel = label; + int n = 1; + while (labels.contains(uniqueLabel)) { + uniqueLabel = label + " " + QString::number(++n); + } + labels.append(uniqueLabel); + } - QVariant data; - data.setValue(module); - ui.selectedModule->addItem(label, data); + for (int i = 0; i < sinks.size(); ++i) { + ui.selectedModule->addItem( + labels[i], QVariant::fromValue(static_cast(sinks[i]))); - if (module == previouslySelected) { + if (sinks[i] == previouslySelected) { previouslySelectedIndex = i; } } @@ -285,119 +326,117 @@ class AnimationHelperDialog::Internal : public QObject updateEnableStates(); } - Module* selectedModule() + pipeline::Node* selectedSink() { if (ui.selectedModule->count() == 0) { return nullptr; } - return ui.selectedModule->currentData().value(); + return qobject_cast( + ui.selectedModule->currentData().value()); } void selectedDataSourceChanged() { updateModuleOptions(); } void selectedModuleChanged() { - // Show animation options for the selected module - auto* module = selectedModule(); - auto tabIndex = 0; - if (module) { - tabIndex = moduleTabTexts().indexOf(module->label()); - } - - ui.modulesTabWidget->setCurrentIndex(tabIndex); + auto* node = selectedSink(); + int tabIndex = 0; - if (qobject_cast(module)) { - setupContourTab(); - } else if (qobject_cast(module)) { - setupSliceTab(); + if (auto* contour = qobject_cast(node)) { + tabIndex = moduleTabTexts().indexOf("Contour"); + if (tabIndex < 0) { + tabIndex = 0; + } + ui.modulesTabWidget->setCurrentIndex(tabIndex); + setupContourTab(contour); + } else if (auto* slice = qobject_cast(node)) { + tabIndex = moduleTabTexts().indexOf("Slice"); + if (tabIndex < 0) { + tabIndex = 0; + } + ui.modulesTabWidget->setCurrentIndex(tabIndex); + setupSliceTab(slice); + } else { + ui.modulesTabWidget->setCurrentIndex(0); } updateEnableStates(); } - void onDataSourceAdded() + void onNodeAdded(pipeline::Node*) { - // This is done later because when the dataSourceAdded - // signal gets emitted, the data source does not yet have a label, - // but it gets added later. It appears to have the label, though, - // if we simple post this to the event loop to be performed later. - QTimer::singleShot(0, this, &Internal::updateDataSourceOptions); - updateEnableStates(); + QTimer::singleShot(0, this, [this]() { + updateDataSourceOptions(); + updateModuleOptions(); + }); } - void onDataSourceRemoved() + void onNodeRemoved(pipeline::Node*) { + cleanupNullAnimations(); updateDataSourceOptions(); + updateModuleOptions(); updateEnableStates(); } - void setupContourTab() + void setupContourTab(pipeline::ContourSink* sink) { - auto* module = qobject_cast(selectedModule()); double range[2]; - module->isoRange(range); + sink->scalarRange(range); ui.contourStart->setMinimum(range[0]); ui.contourStart->setMaximum(range[1]); ui.contourStop->setMinimum(range[0]); ui.contourStop->setMaximum(range[1]); - // Set reasonable default values ui.contourStart->setValue((range[1] - range[0]) / 3 + range[0]); ui.contourStop->setValue((range[1] - range[0]) * 2 / 3 + range[0]); } - void setupSliceTab() + void setupSliceTab(pipeline::SliceSink* sink) { - auto* module = qobject_cast(selectedModule()); - double max = module->maxSlice(); + double max = sink->maxSlice(); ui.sliceStart->setMinimum(0); ui.sliceStart->setMaximum(max); ui.sliceStop->setMinimum(0); ui.sliceStop->setMaximum(max); - // Default to the full range ui.sliceStart->setValue(0); ui.sliceStop->setValue(max); - // Update the range if the slice direction changes - connect(module, &ModuleSlice::directionChanged, this, [this, module]() { - if (module != this->selectedModule()) { - // Disconnect, as we are no longer looking at this module. - disconnect(module, nullptr, this, nullptr); - return; - } - - // Update the slice tab min/max - this->setupSliceTab(); - }); + connect(sink, &pipeline::SliceSink::directionChanged, this, + [this, sink]() { + if (sink != this->selectedSink()) { + disconnect(sink, nullptr, this, nullptr); + return; + } + this->setupSliceTab(sink); + }); } void addModuleAnimation() { - auto* module = selectedModule(); - if (!module) { + auto* node = selectedSink(); + if (!node) { return; } for (int i = 0; i < moduleAnimations.size(); ++i) { - if (module == moduleAnimations[i]->baseModule) { - // We only allow one animation per module - // Remove the old one - moduleAnimations[i]->deleteLater(); + if (!moduleAnimations[i] || node == moduleAnimations[i]->baseNode) { + if (moduleAnimations[i]) { + moduleAnimations[i]->deleteLater(); + } moduleAnimations.removeAt(i); --i; } } - if (qobject_cast(module)) { + if (qobject_cast(node)) { addContourAnimation(); - } else if (qobject_cast(module)) { + } else if (qobject_cast(node)) { addSliceAnimation(); - } else { - qDebug() << "Unknown module type: " << module; } updateEnableStates(); @@ -408,22 +447,24 @@ class AnimationHelperDialog::Internal : public QObject { auto start = ui.contourStart->value(); auto stop = ui.contourStop->value(); - auto* module = qobject_cast(selectedModule()); - moduleAnimations.append(new ContourAnimation(module, start, stop)); + auto* sink = qobject_cast(selectedSink()); + moduleAnimations.append(new ContourAnimation(sink, start, stop)); } void addSliceAnimation() { auto start = ui.sliceStart->value(); auto stop = ui.sliceStop->value(); - auto* module = qobject_cast(selectedModule()); - moduleAnimations.append(new SliceAnimation(module, start, stop)); + auto* sink = qobject_cast(selectedSink()); + moduleAnimations.append(new SliceAnimation(sink, start, stop)); } void clearModuleAnimations() { for (auto animation : moduleAnimations) { - animation->deleteLater(); + if (animation) { + animation->deleteLater(); + } } moduleAnimations.clear(); @@ -431,12 +472,18 @@ class AnimationHelperDialog::Internal : public QObject updateEnableStates(); } + void cleanupNullAnimations() + { + for (int i = moduleAnimations.size() - 1; i >= 0; --i) { + if (moduleAnimations[i].isNull()) { + moduleAnimations.removeAt(i); + } + } + } + // All animations void numberOfFramesModified() { - // The number of frames only makes sense if the play mode is a sequence. - // If the user modified the number of frames, set the play mode to - // sequence. pqSMAdaptor::setEnumerationProperty( scene()->getProxy()->GetProperty("PlayMode"), "Sequence"); } @@ -456,7 +503,10 @@ class AnimationHelperDialog::Internal : public QObject ActiveObjects& activeObjects() { return ActiveObjects::instance(); } - ModuleManager& moduleManager() { return ModuleManager::instance(); } + pipeline::Pipeline* pipeline() + { + return activeObjects().pipeline(); + } pqAnimationScene* scene() { @@ -469,6 +519,9 @@ class AnimationHelperDialog::Internal : public QObject AnimationHelperDialog::AnimationHelperDialog(QWidget* parent) : QDialog(parent), m_internal(new Internal(this)) { + // Float above the main window so the dialog does not slip behind it on + // macOS. + floatAboveMainWindow(this); } AnimationHelperDialog::~AnimationHelperDialog() = default; diff --git a/tomviz/AnimationHelperDialog.ui b/tomviz/AnimationHelperDialog.ui index fbe57cf4e..a06f82f22 100644 --- a/tomviz/AnimationHelperDialog.ui +++ b/tomviz/AnimationHelperDialog.ui @@ -154,13 +154,13 @@ - Modules + Visualizations - Module: + Visualization: selectedModule @@ -320,10 +320,10 @@ false - <html><head/><body><p>Add an animation for this module using the above settings.</p><p><br/></p><p>Note that each module may currently only have one animation. Any old animations for a given module will be over-written.</p></body></html> + <html><head/><body><p>Add an animation for this visualization using the above settings.</p><p><br/></p><p>Note that each visualization may currently only have one animation. Any old animations for a given visualization will be over-written.</p></body></html> - Add Module Animation + Add Visualization Animation @@ -333,21 +333,21 @@ false - Clear Module Animations + Clear Visualization Animations - <html><head/><body><p>Select a data source. Any modules that support animations and are associated with this data source will be listed below.</p></body></html> + <html><head/><body><p>Select a data source. Any visualizations that support animations and are associated with this data source will be listed below.</p></body></html> - <html><head/><body><p>Select a module for animation.</p><p><br/></p><p>Current supported list:<br/></p><p>Contour</p></body></html> + <html><head/><body><p>Select a visualization for animation.</p><p><br/></p><p>Currently supported:<br/></p><p>Contour, Slice</p></body></html> diff --git a/tomviz/ArrayWranglerReaction.cxx b/tomviz/ArrayWranglerReaction.cxx index 430470182..4e735aada 100644 --- a/tomviz/ArrayWranglerReaction.cxx +++ b/tomviz/ArrayWranglerReaction.cxx @@ -3,13 +3,8 @@ #include "ArrayWranglerReaction.h" -#include -#include - -#include "ActiveObjects.h" -#include "ArrayWranglerOperator.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" +#include "TransformUtils.h" +#include "pipeline/transforms/ArrayWranglerTransform.h" namespace tomviz { @@ -19,19 +14,9 @@ ArrayWranglerReaction::ArrayWranglerReaction(QAction* parentObject, { } -void ArrayWranglerReaction::wrangleArray(DataSource* source) +void ArrayWranglerReaction::wrangleArray(DataSource*) { - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - return; - } - - Operator* Op = new ArrayWranglerOperator(); - - EditOperatorDialog* dialog = - new EditOperatorDialog(Op, source, true, m_mainWindow); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->show(); - connect(Op, &QObject::destroyed, dialog, &QDialog::reject); + auto* transform = new pipeline::ArrayWranglerTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/AxesReaction.cxx b/tomviz/AxesReaction.cxx index 04c3fce10..3b5d07dcc 100644 --- a/tomviz/AxesReaction.cxx +++ b/tomviz/AxesReaction.cxx @@ -23,11 +23,6 @@ AxesReaction::AxesReaction(QAction* parentObject, AxesReaction::Mode mode) QOverload::of(&ActiveObjects::viewChanged), this, &AxesReaction::updateEnableState, Qt::QueuedConnection); - QObject::connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &AxesReaction::updateEnableState); - switch (m_reactionMode) { case SHOW_ORIENTATION_AXES: QObject::connect(parentObject, &QAction::toggled, this, @@ -85,8 +80,8 @@ void AxesReaction::updateEnableState() parentAction()->blockSignals(false); break; case RESET_CENTER: - parentAction()->setEnabled(ActiveObjects::instance().activeDataSource() != - NULL); + // TODO: re-enable when new pipeline provides active data bounds + parentAction()->setEnabled(false); break; default: break; @@ -117,22 +112,7 @@ void AxesReaction::showCenterAxes(bool show_axes) void AxesReaction::resetCenterOfRotationToCenterOfCurrentData() { - pqRenderView* renderView = ActiveObjects::instance().activePqRenderView(); - DataSource* dataSource = ActiveObjects::instance().activeDataSource(); - - if (!renderView || !dataSource) { - // qDebug() << "Active source not shown in active view. Cannot set center."; - return; - } - - double bounds[6]; - dataSource->getBounds(bounds); - double center[3]; - center[0] = (bounds[1] + bounds[0]) / 2.0; - center[1] = (bounds[3] + bounds[2]) / 2.0; - center[2] = (bounds[5] + bounds[4]) / 2.0; - renderView->setCenterOfRotation(center); - renderView->render(); + // TODO: re-implement using new pipeline to get active data bounds } void AxesReaction::pickCenterOfRotation(int posx, int posy) diff --git a/tomviz/Behaviors.cxx b/tomviz/Behaviors.cxx index e0f7db090..5f553d934 100644 --- a/tomviz/Behaviors.cxx +++ b/tomviz/Behaviors.cxx @@ -5,11 +5,14 @@ #include "ActiveObjects.h" #include "AddRenderViewContextMenuBehavior.h" -#include "ShiftRotationCenterWidget.h" #include "ManualManipulationWidget.h" -#include "MoveActiveObject.h" -#include "OperatorPython.h" +#include "PtychoWidget.h" +#include "PyXRFWidget.h" +#include "SAM2SeedWidget.h" +#include "SelectCylinderWidget.h" +#include "ShiftRotationCenterWidget.h" #include "RotateAlignWidget.h" +#include "CustomNodeWidgetRegistry.h" #include "TimeSeriesLabel.h" #include "ViewFrameActions.h" @@ -88,7 +91,6 @@ Behaviors::Behaviors(QMainWindow* mainWindow) : QObject(mainWindow) new tomviz::AddRenderViewContextMenuBehavior(this); - m_moveActiveBehavior.reset(new tomviz::MoveActiveObject(this)); m_timeSeriesLabel.reset(new tomviz::TimeSeriesLabel(this)); // This will trigger the logic to setup reader/writer factories, etc. @@ -102,12 +104,22 @@ Behaviors::Behaviors(QMainWindow* mainWindow) : QObject(mainWindow) void Behaviors::registerCustomOperatorUIs() { - OperatorPython::registerCustomWidget("ShiftRotationCenterWidget", true, - ShiftRotationCenterWidget::New); - OperatorPython::registerCustomWidget("RotationAlignWidget", true, - RotateAlignWidget::New); - OperatorPython::registerCustomWidget("ManualManipulationWidget", true, - ManualManipulationWidget::New); + using namespace pipeline; + + registerCustomNodeWidget( + "RotationAlignWidget", /*needsData=*/true); + registerCustomNodeWidget( + "ShiftRotationCenterWidget", /*needsData=*/true); + registerCustomNodeWidget( + "ManualManipulationWidget", /*needsData=*/true); + registerCustomNodeWidget( + "CylindricalCropWidget", /*needsData=*/true); + registerCustomNodeWidget( + "SAM2SeedWidget", /*needsData=*/true); + registerCustomNodeWidget( + "PyXRFWidget", /*needsData=*/false); + registerCustomNodeWidget( + "PtychoWidget", /*needsData=*/false); } } // end of namespace tomviz diff --git a/tomviz/Behaviors.h b/tomviz/Behaviors.h index 1dc22610e..fe5c26285 100644 --- a/tomviz/Behaviors.h +++ b/tomviz/Behaviors.h @@ -11,7 +11,6 @@ class QMainWindow; namespace tomviz { -class MoveActiveObject; class TimeSeriesLabel; /// Behaviors instantiates tomviz relevant ParaView behaviors (and any new @@ -24,12 +23,9 @@ class Behaviors : public QObject public: Behaviors(QMainWindow* mainWindow); - MoveActiveObject* moveActiveBehavior() { return m_moveActiveBehavior.data(); } - private: Q_DISABLE_COPY(Behaviors) - QScopedPointer m_moveActiveBehavior; QScopedPointer m_timeSeriesLabel; void registerCustomOperatorUIs(); diff --git a/tomviz/BrightnessContrastWidget.cxx b/tomviz/BrightnessContrastWidget.cxx index e338368e0..168b8a410 100644 --- a/tomviz/BrightnessContrastWidget.cxx +++ b/tomviz/BrightnessContrastWidget.cxx @@ -16,7 +16,7 @@ #include #include -#include "DataSource.h" +#include "pipeline/data/VolumeData.h" #include "Utilities.h" static const double PI = vtkMath::Pi(); @@ -26,8 +26,9 @@ namespace tomviz { class BrightnessContrastWidget::Internals { public: - Internals(DataSource* ds, vtkDiscretizableColorTransferFunction* lut) - : m_ds(ds), m_lut(lut) + Internals(pipeline::VolumeDataPtr volumeData, + vtkDiscretizableColorTransferFunction* lut) + : m_volumeData(std::move(volumeData)), m_lut(lut) { m_opacity = m_lut->GetScalarOpacityFunction(); resetOriginalData(); @@ -39,7 +40,7 @@ class BrightnessContrastWidget::Internals ~Internals() { disconnectDataModifiedCallback(); } - QPointer m_ds; + pipeline::VolumeDataPtr m_volumeData; vtkSmartPointer m_lut; vtkSmartPointer m_opacity; vtkNew m_uncroppedLut; @@ -83,14 +84,14 @@ class BrightnessContrastWidget::Internals void updateRanges() { - if (!m_ds) { + if (!m_volumeData || !m_volumeData->isValid()) { return; } auto blockers = blockSignals(); - double range[2]; - m_ds->getRange(range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; // Offset some of the extrema to avoid NaN values double offset = (range[1] - range[0]) / 1000; @@ -112,13 +113,13 @@ class BrightnessContrastWidget::Internals [this](double v) { setContrast(v); }); } - void setDataSource(DataSource* ds) + void setVolumeData(pipeline::VolumeDataPtr volumeData) { - if (m_ds == ds) { + if (m_volumeData == volumeData) { return; } - m_ds = ds; + m_volumeData = std::move(volumeData); updateRanges(); updateGui(); } @@ -169,7 +170,7 @@ class BrightnessContrastWidget::Internals void updateGui() { - if (!m_ds) { + if (!m_volumeData || !m_volumeData->isValid()) { return; } @@ -214,12 +215,13 @@ class BrightnessContrastWidget::Internals double max = computeMaximum(); double mean = (max + min) / 2; - if (!m_ds || min == DBL_MAX || max == -DBL_MAX) { + if (!m_volumeData || !m_volumeData->isValid() || + min == DBL_MAX || max == -DBL_MAX) { return -1; } - double range[2]; - m_ds->getRange(range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; return rescale(mean, range[0], range[1], 100, 0); } @@ -229,12 +231,13 @@ class BrightnessContrastWidget::Internals double min = computeMinimum(); double max = computeMaximum(); - if (!m_ds || min == DBL_MAX || max == -DBL_MAX) { + if (!m_volumeData || !m_volumeData->isValid() || + min == DBL_MAX || max == -DBL_MAX) { return -1; } - double range[2]; - m_ds->getRange(range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; double width = max - min; double dataWidth = range[1] - range[0]; @@ -265,12 +268,12 @@ class BrightnessContrastWidget::Internals void setContrast(double v) { - if (!m_ds) { + if (!m_volumeData || !m_volumeData->isValid()) { return; } - double range[2]; - m_ds->getRange(range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; double dataWidth = range[1] - range[0]; @@ -291,12 +294,12 @@ class BrightnessContrastWidget::Internals void setBrightness(double v) { - if (!m_ds) { + if (!m_volumeData || !m_volumeData->isValid()) { return; } - double range[2]; - m_ds->getRange(range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; double previousBrightness = computeBrightness(); double previousMean = @@ -323,18 +326,21 @@ class BrightnessContrastWidget::Internals void pushChanges() { m_pushingChanges = true; - if (!m_ds) { + if (!m_volumeData || !m_volumeData->isValid()) { m_pushingChanges = false; return; } + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; + m_lut->DeepCopy(m_uncroppedLut); - addPlaceholderNodes(m_lut, m_ds); - removePointsOutOfRange(m_lut, m_ds); + addPlaceholderNodes(m_lut, range); + removePointsOutOfRange(m_lut, range); m_opacity->DeepCopy(m_uncroppedOpacity); - addPlaceholderNodes(m_opacity, m_ds); - removePointsOutOfRange(m_opacity, m_ds); + addPlaceholderNodes(m_opacity, range); + removePointsOutOfRange(m_opacity, range); m_pushingChanges = false; } @@ -376,8 +382,11 @@ class BrightnessContrastWidget::Internals }; BrightnessContrastWidget::BrightnessContrastWidget( - DataSource* ds, vtkDiscretizableColorTransferFunction* lut, QWidget* p) - : Superclass(p), m_internals(new BrightnessContrastWidget::Internals(ds, lut)) + pipeline::VolumeDataPtr volumeData, + vtkDiscretizableColorTransferFunction* lut, QWidget* p) + : Superclass(p), + m_internals( + new BrightnessContrastWidget::Internals(std::move(volumeData), lut)) { auto& ui = m_internals->ui; ui.setupUi(this); @@ -399,9 +408,9 @@ BrightnessContrastWidget::BrightnessContrastWidget( BrightnessContrastWidget::~BrightnessContrastWidget() = default; -void BrightnessContrastWidget::setDataSource(DataSource* ds) +void BrightnessContrastWidget::setVolumeData(pipeline::VolumeDataPtr volumeData) { - m_internals->setDataSource(ds); + m_internals->setVolumeData(std::move(volumeData)); } void BrightnessContrastWidget::setLut( diff --git a/tomviz/BrightnessContrastWidget.h b/tomviz/BrightnessContrastWidget.h index 3a02b5c54..b9be9e92e 100644 --- a/tomviz/BrightnessContrastWidget.h +++ b/tomviz/BrightnessContrastWidget.h @@ -7,11 +7,16 @@ #include #include +#include + class vtkDiscretizableColorTransferFunction; namespace tomviz { -class DataSource; +namespace pipeline { +class VolumeData; +using VolumeDataPtr = std::shared_ptr; +} // namespace pipeline class BrightnessContrastWidget : public QWidget { @@ -24,12 +29,12 @@ class BrightnessContrastWidget : public QWidget typedef QWidget Superclass; public: - BrightnessContrastWidget(DataSource* ds, + BrightnessContrastWidget(pipeline::VolumeDataPtr volumeData, vtkDiscretizableColorTransferFunction* lut, QWidget* parent = nullptr); virtual ~BrightnessContrastWidget(); - void setDataSource(DataSource* ds); + void setVolumeData(pipeline::VolumeDataPtr volumeData); void setLut(vtkDiscretizableColorTransferFunction* lut); void updateGui(); diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 6a88e0e2b..b6c228346 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -15,13 +15,11 @@ set(SOURCES ActiveObjects.h AddAlignReaction.cxx AddAlignReaction.h - AddExpressionReaction.cxx - AddExpressionReaction.h + AddPythonSourceReaction.cxx + AddPythonSourceReaction.h AddPythonTransformReaction.cxx AddRenderViewContextMenuBehavior.cxx AddRenderViewContextMenuBehavior.h - AddResampleReaction.cxx - AddResampleReaction.h AlignWidget.cxx AlignWidget.h AnimationHelperDialog.cxx @@ -46,14 +44,14 @@ set(SOURCES ColorMapSettingsWidget.h ComboTextEditor.cxx ComboTextEditor.h - ConformVolumeDialog.cxx - ConformVolumeDialog.h - ConformVolumeReaction.cxx - ConformVolumeReaction.h ConvertToFloatReaction.cxx ConvertToFloatReaction.h CropReaction.cxx CropReaction.h + SAM2SeedWidget.cxx + SAM2SeedWidget.h + SelectCylinderWidget.cxx + SelectCylinderWidget.h SelectVolumeWidget.cxx SelectVolumeWidget.h DataBroker.cxx @@ -70,30 +68,16 @@ set(SOURCES DataExchangeFormat.h DataPropertiesModel.cxx DataPropertiesModel.h - DataPropertiesPanel.cxx - DataPropertiesPanel.h - DataSource.cxx - DataSource.h DataTransformMenu.cxx DataTransformMenu.h DeleteDataReaction.cxx DeleteDataReaction.h - DockerExecutor.cxx - DockerExecutor.h - DockerUtilities.cxx - DockerUtilities.h DoubleSliderWidget.cxx DoubleSliderWidget.h DoubleSpinBox.cxx DoubleSpinBox.h - DuplicateModuleReaction.h - DuplicateModuleReaction.cxx EmdFormat.cxx EmdFormat.h - ExportDataReaction.cxx - ExportDataReaction.h - ExternalPythonExecutor.cxx - ExternalPythonExecutor.h FileFormatManager.cxx FileFormatManager.h FxiFormat.cxx @@ -116,8 +100,6 @@ set(SOURCES ImageStackDialog.cxx ImageStackModel.h ImageStackModel.cxx - InterfaceBuilder.h - InterfaceBuilder.cxx InternalPythonHelper.h InternalPythonHelper.cxx IntSliderWidget.cxx @@ -136,34 +118,14 @@ set(SOURCES Logger.h ManualManipulationWidget.cxx ManualManipulationWidget.h - MergeImagesDialog.cxx - MergeImagesDialog.h - MergeImagesReaction.cxx - MergeImagesReaction.h - MoleculeSource.cxx - MoleculeSource.h + OperatorSearchDialog.cxx + OperatorSearchDialog.h MoleculeProperties.cxx MoleculeProperties.h - MoleculePropertiesPanel.cxx - MoleculePropertiesPanel.h - MoveActiveObject.cxx - MoveActiveObject.h - Pipeline.cxx - Pipeline.h - PipelineExecutor.cxx - PipelineExecutor.h - PipelineManager.cxx - PipelineManager.h - PipelineModel.cxx - PipelineModel.h - PipelineProxy.cxx - PipelineProxy.h - PipelineView.cxx - PipelineView.h - PipelineWorker.cxx - PipelineWorker.h - PipelineSettingsDialog.cxx - PipelineSettingsDialog.h + InteractiveTransformWidget.cxx + InteractiveTransformWidget.h + PortDataWriter.cxx + PortDataWriter.h PresetDialog.cxx PresetDialog.h PresetModel.cxx @@ -172,22 +134,18 @@ set(SOURCES ProgressDialog.h ProgressDialogManager.cxx ProgressDialogManager.h - PtychoDialog.cxx - PtychoDialog.h + PtychoWidget.cxx + PtychoWidget.h PtychoRunner.cxx PtychoRunner.h - PythonGeneratedDatasetReaction.cxx - PythonGeneratedDatasetReaction.h PythonReader.cxx PythonReader.h PythonUtilities.cxx PythonUtilities.h PythonWriter.cxx PythonWriter.h - PyXRFMakeHDF5Dialog.cxx - PyXRFMakeHDF5Dialog.h - PyXRFProcessDialog.cxx - PyXRFProcessDialog.h + PyXRFWidget.cxx + PyXRFWidget.h PyXRFRunner.cxx PyXRFRunner.h pybind11/PythonTypeConversions.cxx @@ -208,7 +166,10 @@ set(SOURCES ResetReaction.h RotateAlignWidget.cxx RotateAlignWidget.h + SaveDataDialog.cxx + SaveDataDialog.h SaveDataReaction.cxx + SaveDataReaction.h SaveLoadStateReaction.cxx SaveLoadStateReaction.h SaveLoadTemplateReaction.cxx @@ -226,12 +187,8 @@ set(SOURCES SetDataTypeReaction.cxx SetTiltAnglesReaction.cxx SetTiltAnglesReaction.h - SliceViewDialog.cxx - SliceViewDialog.h SpinBox.cxx SpinBox.h - ThreadedExecutor.cxx - ThreadedExecutor.h TimeSeriesLabel.h TimeSeriesLabel.cxx TimeSeriesStep.h @@ -239,10 +196,191 @@ set(SOURCES TomographyReconstruction.cxx TomographyTiltSeries.h TomographyTiltSeries.cxx + TransformUtils.h + TransformUtils.cxx TransposeDataReaction.h TransposeDataReaction.cxx + pipeline/transforms/CropTransform.h + pipeline/transforms/CropTransform.cxx + pipeline/transforms/ReconstructionTransform.h + pipeline/transforms/ReconstructionTransform.cxx + pipeline/transforms/TranslateAlignTransform.h + pipeline/transforms/TranslateAlignTransform.cxx + pipeline/PortData.cxx + pipeline/PortData.h + pipeline/PortDataDiskCache.cxx + pipeline/PortDataDiskCache.h + pipeline/PortDataMetadata.cxx + pipeline/PortDataMetadata.h + pipeline/PersistenceMode.h + pipeline/PipelineSettings.cxx + pipeline/PipelineSettings.h + pipeline/PortType.h + pipeline/NodeState.h + pipeline/Port.cxx + pipeline/Port.h + pipeline/InputPort.cxx + pipeline/InputPort.h + pipeline/OutputPort.cxx + pipeline/OutputPort.h + pipeline/VolumeOutputPort.cxx + pipeline/VolumeOutputPort.h + pipeline/Link.cxx + pipeline/Link.h + pipeline/Node.cxx + pipeline/Node.h + pipeline/NodeFactory.cxx + pipeline/NodeFactory.h + pipeline/SourceNode.cxx + pipeline/SourceNode.h + pipeline/TransformNode.cxx + pipeline/TransformNode.h + pipeline/SinkNode.cxx + pipeline/SinkNode.h + pipeline/SinkGroupNode.cxx + pipeline/SinkGroupNode.h + pipeline/PassthroughOutputPort.cxx + pipeline/PassthroughOutputPort.h + pipeline/LegacyStateLoader.cxx + pipeline/LegacyStateLoader.h + pipeline/Pipeline.cxx + pipeline/Pipeline.h + pipeline/PipelineStateIO.cxx + pipeline/PipelineStateIO.h + pipeline/PipelineExecutor.cxx + pipeline/PipelineExecutor.h + pipeline/NodeExecutor.cxx + pipeline/NodeExecutor.h + pipeline/InternalNodeExecutor.cxx + pipeline/InternalNodeExecutor.h + pipeline/ExternalNodeExecutor.cxx + pipeline/ExternalNodeExecutor.h + pipeline/NodeExecutorFactory.cxx + pipeline/NodeExecutorFactory.h + pipeline/ProgressReader.cxx + pipeline/ProgressReader.h + pipeline/PythonNodeWrapper.cxx + pipeline/PythonNodeWrapper.h + pipeline/PythonNodeUtils.cxx + pipeline/PythonNodeUtils.h + pipeline/PythonNodeBackend.cxx + pipeline/PythonNodeBackend.h + pipeline/CustomNodeWidgetRegistry.cxx + pipeline/CustomNodeWidgetRegistry.h + pipeline/DefaultExecutor.cxx + pipeline/DefaultExecutor.h + pipeline/ExecutionFuture.cxx + pipeline/ExecutionFuture.h + pipeline/ThreadedExecutor.cxx + pipeline/ThreadedExecutor.h + pipeline/data/VolumeData.cxx + pipeline/data/VolumeData.h + pipeline/sources/SphereSource.cxx + pipeline/sources/SphereSource.h + pipeline/sources/ReaderSourceNode.cxx + pipeline/sources/ReaderSourceNode.h + pipeline/sources/PythonSource.cxx + pipeline/sources/PythonSource.h + pipeline/transforms/ThresholdTransform.cxx + pipeline/transforms/ThresholdTransform.h + pipeline/transforms/SetTiltAnglesTransform.cxx + pipeline/transforms/SetTiltAnglesTransform.h + pipeline/transforms/LegacyPythonTransform.cxx + pipeline/transforms/LegacyPythonTransform.h + pipeline/transforms/PythonTransform.cxx + pipeline/transforms/PythonTransform.h + pipeline/transforms/ConvertToFloatTransform.cxx + pipeline/transforms/ConvertToFloatTransform.h + pipeline/transforms/ConvertToVolumeTransform.cxx + pipeline/transforms/ConvertToVolumeTransform.h + pipeline/transforms/ArrayWranglerTransform.cxx + pipeline/transforms/ArrayWranglerTransform.h + pipeline/transforms/TransposeDataTransform.cxx + pipeline/transforms/TransposeDataTransform.h + pipeline/transforms/SnapshotTransform.cxx + pipeline/transforms/SnapshotTransform.h + pipeline/PortUtils.h + pipeline/ThreadUtils.h + pipeline/pybind11/PybindVTKTypeCaster.h + pipeline/DeferredLinkInfo.h + pipeline/sinks/VolumeStatsSink.cxx + pipeline/sinks/VolumeStatsSink.h + pipeline/sinks/LegacyModuleSink.cxx + pipeline/sinks/LegacyModuleSink.h + pipeline/sinks/VolumeSink.cxx + pipeline/sinks/VolumeSink.h + pipeline/sinks/VolumeBricking.cxx + pipeline/sinks/VolumeBricking.h + pipeline/sinks/SliceSink.cxx + pipeline/sinks/SliceSink.h + pipeline/sinks/ContourSink.cxx + pipeline/sinks/ContourSink.h + pipeline/sinks/ThresholdSink.cxx + pipeline/sinks/ThresholdSink.h + pipeline/sinks/SegmentSink.cxx + pipeline/sinks/SegmentSink.h + pipeline/sinks/OutlineSink.cxx + pipeline/sinks/OutlineSink.h + pipeline/sinks/ClipSink.cxx + pipeline/sinks/ClipSink.h + pipeline/sinks/RulerSink.cxx + pipeline/sinks/RulerSink.h + pipeline/sinks/ScaleCubeSink.cxx + pipeline/sinks/ScaleCubeSink.h + pipeline/sinks/PlotSink.cxx + pipeline/sinks/PlotSink.h + pipeline/sinks/MoleculeSink.cxx + pipeline/sinks/MoleculeSink.h + pipeline/sinks/VolumeSinkWidget.cxx + pipeline/sinks/VolumeSinkWidget.h + pipeline/sinks/ContourSinkWidget.cxx + pipeline/sinks/ContourSinkWidget.h + pipeline/sinks/ThresholdSinkWidget.cxx + pipeline/sinks/ThresholdSinkWidget.h + pipeline/sinks/SegmentSinkWidget.cxx + pipeline/sinks/SegmentSinkWidget.h + pipeline/ParameterBindingUtils.cxx + pipeline/ParameterBindingUtils.h + pipeline/ParameterInterfaceBuilder.cxx + pipeline/ParameterInterfaceBuilder.h + pipeline/CustomPythonNodeWidget.cxx + pipeline/CustomPythonNodeWidget.h + pipeline/InputsNotReadyWidget.cxx + pipeline/InputsNotReadyWidget.h + pipeline/GatedEditorWidget.cxx + pipeline/GatedEditorWidget.h + pipeline/EditNodeWidget.cxx + pipeline/EditNodeWidget.h + pipeline/NodePropertiesWidget.cxx + pipeline/NodePropertiesWidget.h + pipeline/PythonNodeEditorWidget.cxx + pipeline/PythonNodeEditorWidget.h + pipeline/NodePropertiesPanel.cxx + pipeline/NodePropertiesPanel.h + pipeline/NodeEditDialog.cxx + pipeline/NodeEditDialog.h + pipeline/PipelineUtils.cxx + pipeline/PipelineUtils.h + pipeline/PipelineControlsWidget.cxx + pipeline/PipelineControlsWidget.h + pipeline/PipelineStripWidget.cxx + pipeline/PipelineStripWidget.h + pipeline/LinkPropertiesWidget.cxx + pipeline/LinkPropertiesWidget.h + pipeline/SinkGroupPropertiesWidget.cxx + pipeline/SinkGroupPropertiesWidget.h + pipeline/VolumeScalarsModel.cxx + pipeline/VolumeScalarsModel.h + pipeline/VolumePropertiesWidget.cxx + pipeline/VolumePropertiesWidget.h + pipeline/vtkNonOrthoImagePlaneWidget.cxx + pipeline/vtkNonOrthoImagePlaneWidget.h + FileReader.cxx + FileReader.h Tvh5Format.cxx Tvh5Format.h + ViewsLayoutsSerializer.cxx + ViewsLayoutsSerializer.h Utilities.cxx Utilities.h core/Variant.h @@ -268,8 +406,6 @@ set(SOURCES vtkLengthScaleRepresentation.cxx vtkActiveScalarsProducer.h vtkActiveScalarsProducer.cxx - vtkNonOrthoImagePlaneWidget.cxx - vtkNonOrthoImagePlaneWidget.h vtkOMETiffReader.cxx vtkOMETiffReader.h vtkTriangleBar.cxx @@ -284,51 +420,12 @@ list(APPEND SOURCES ) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/loguru) -list(APPEND SOURCES - modules/Module.cxx - modules/Module.h - modules/ModuleClip.cxx - modules/ModuleClip.h - modules/ModuleContour.cxx - modules/ModuleContour.h - modules/ModuleContourWidget.cxx - modules/ModuleContourWidget.h - modules/ModuleFactory.cxx - modules/ModuleFactory.h - modules/ModuleManager.cxx - modules/ModuleManager.h - modules/ModuleScaleCube.cxx - modules/ModuleScaleCube.h - modules/ModuleScaleCubeWidget.cxx - modules/ModuleScaleCubeWidget.h - modules/ModuleMenu.cxx - modules/ModuleMenu.h - modules/ModuleMolecule.cxx - modules/ModuleMolecule.h - modules/ModuleOutline.cxx - modules/ModuleOutline.h - modules/ModulePlot.cxx - modules/ModulePlot.h - modules/ModulePropertiesPanel.cxx - modules/ModulePropertiesPanel.h - modules/ModuleRuler.cxx - modules/ModuleRuler.h - modules/ModuleSegment.cxx - modules/ModuleSegment.h - modules/ModuleSlice.cxx - modules/ModuleSlice.h - modules/ModuleThreshold.cxx - modules/ModuleThreshold.h - modules/ModuleVolume.cxx - modules/ModuleVolume.h - modules/ModuleVolumeWidget.cxx - modules/ModuleVolumeWidget.h - modules/ScalarsComboBox.cxx - modules/ScalarsComboBox.h - modules/VolumeManager.cxx - modules/VolumeManager.h -) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/modules) +# loguru is vendored third-party code; silence warnings we will not patch +# upstream for (empty variadic macro args and a benign snprintf truncation). +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + set_source_files_properties(loguru/loguru.cpp PROPERTIES + COMPILE_OPTIONS "-Wno-c++20-extensions;-Wno-format-truncation") +endif() # acquisition/ list(APPEND SOURCES @@ -336,82 +433,21 @@ list(APPEND SOURCES acquisition/AcquisitionWidget.h acquisition/AcquisitionClient.cxx acquisition/AcquisitionClient.h - acquisition/AdvancedFormatWidget.cxx - acquisition/AdvancedFormatWidget.h - acquisition/BasicFormatWidget.cxx - acquisition/BasicFormatWidget.h acquisition/Connection.cxx acquisition/Connection.h acquisition/ConnectionsWidget.cxx acquisition/ConnectionsWidget.h acquisition/ConnectionDialog.cxx acquisition/ConnectionDialog.h - acquisition/CustomFormatWidget.cxx - acquisition/CustomFormatWidget.h acquisition/JsonRpcClient.cxx acquisition/JsonRpcClient.h - acquisition/PassiveAcquisitionWidget.cxx - acquisition/PassiveAcquisitionWidget.h - acquisition/RegexGroupDialog.cxx - acquisition/RegexGroupDialog.h - acquisition/RegexGroupsWidget.cxx - acquisition/RegexGroupsWidget.h - acquisition/RegexGroupSubstitution.cxx - acquisition/RegexGroupSubstitution.h - acquisition/RegexGroupSubstitutionDialog.cxx - acquisition/RegexGroupSubstitutionDialog.h - acquisition/RegexGroupsSubstitutionsWidget.cxx - acquisition/RegexGroupsSubstitutionsWidget.h - acquisition/StartServerDialog.cxx - acquisition/StartServerDialog.h ) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/acquisition) list(APPEND SOURCES - operators/ArrayWranglerOperator.cxx - operators/ArrayWranglerOperator.h - operators/ConvertToFloatOperator.cxx - operators/ConvertToFloatOperator.h - operators/ConvertToVolumeOperator.cxx - operators/ConvertToVolumeOperator.h - operators/CustomPythonOperatorWidget.cxx - operators/CustomPythonOperatorWidget.h - operators/CropOperator.cxx - operators/CropOperator.h - operators/EditOperatorDialog.cxx - operators/EditOperatorDialog.h - operators/EditOperatorWidget.cxx - operators/EditOperatorWidget.h - operators/Operator.cxx - operators/Operator.h - operators/OperatorDialog.cxx - operators/OperatorDialog.h - operators/OperatorFactory.cxx - operators/OperatorFactory.h - operators/OperatorPropertiesPanel.cxx - operators/OperatorPropertiesPanel.h - operators/OperatorProxy.cxx - operators/OperatorProxy.h - operators/OperatorPython.cxx - operators/OperatorPython.h - operators/OperatorResult.cxx - operators/OperatorResult.h - operators/OperatorResultPropertiesPanel.cxx - operators/OperatorResultPropertiesPanel.h - operators/OperatorWidget.cxx - operators/OperatorWidget.h - operators/ReconstructionOperator.cxx - operators/ReconstructionOperator.h - operators/SetTiltAnglesOperator.cxx - operators/SetTiltAnglesOperator.h - operators/SnapshotOperator.h - operators/SnapshotOperator.cxx - operators/TranslateAlignOperator.h - operators/TranslateAlignOperator.cxx - operators/TransposeDataOperator.h - operators/TransposeDataOperator.cxx + PipelineModuleMenu.cxx + PipelineModuleMenu.h ) -include_directories(${CMAKE_CURRENT_SOURCE_DIR}/operators) # animations/ list(APPEND SOURCES @@ -425,16 +461,19 @@ list(APPEND SOURCES h5cpp/h5readwrite.cpp ) -set(exec_sources +list(APPEND SOURCES AboutDialog.h AboutDialog.cxx - main.cxx MainWindow.cxx MainWindow.h WelcomeDialog.cxx WelcomeDialog.h ) +set(exec_sources + main.cxx +) + set(python_files AddPoissonNoise.py BinaryThreshold.py @@ -491,6 +530,8 @@ set(python_files ClearVolume.py ConstantDataset.py RandomParticles.py + PtychoSource.py + PyXRFSource.py GenerateTiltSeries.py SetNegativeVoxelsToZero.py ShiftTiltSeriesRandomly.py @@ -499,12 +540,16 @@ set(python_files Pad_Data.py BinVolumeByTwo.py BinTiltSeriesByTwo.py + DefaultCustomTransform.py DefaultITKTransform.py BinaryMinMaxCurvatureFlow.py ReinterpretSignedToUnsigned.py SegmentParticles.py + SAM2Segment3D.py + SAM3Segment3D.py UnsharpMask.py AddConstant.py + CylindricalCrop.py SegmentPores.py RotationAlign.py WienerFilter.py @@ -565,8 +610,11 @@ set(json_files SwapAxes.json BinaryMinMaxCurvatureFlow.json SegmentParticles.json + SAM2Segment3D.json + SAM3Segment3D.json UnsharpMask.json AddConstant.json + CylindricalCrop.json SegmentPores.json RotationAlign.json WienerFilter.json @@ -577,6 +625,12 @@ set(json_files FourierShellCorrelation.json RemoveArrays.json Recon_real_time_tomography.json + InvertData.json + ConstantDataset.json + RandomParticles.json + PtychoSource.json + PyXRFSource.json + STEM_probe.json ) set(resource_files @@ -608,6 +662,21 @@ foreach(file ${json_files}) DESTINATION "${tomviz_data_install_dir}/scripts" COMPONENT "Scripts") endforeach() +set(environment_files + sam2-tomviz-cpu.yml + sam2-tomviz-cuda.yml + sam3-tomviz-cuda.yml + ) +file(MAKE_DIRECTORY "${tomviz_BINARY_DIR}/share/tomviz/environments") +foreach(file ${environment_files}) + list(APPEND SOURCES "python/environments/${file}") + execute_process(COMMAND ${CMAKE_COMMAND} -E ${script_cmd} + "${tomviz_SOURCE_DIR}/tomviz/python/environments/${file}" + "${tomviz_BINARY_DIR}/share/tomviz/environments/${file}") + install(FILES "python/environments/${file}" + DESTINATION "${tomviz_data_install_dir}/environments" + COMPONENT "Scripts") +endforeach() foreach(file ${resource_files}) list(APPEND SOURCES "resources/${file}") execute_process(COMMAND ${CMAKE_COMMAND} -E ${script_cmd} @@ -638,9 +707,9 @@ set(tomviz_python_modules __init__.py _internal.py dataset.py - executor.py external_dataset.py fix_pdb.py + nodes.py operators.py internal_dataset.py internal_utils.py @@ -694,7 +763,7 @@ endforeach() set(tomviz_python_pyxrf_modules __init__.py ic_names.py - load_output.py + scan_metadata.py sids.py ) @@ -745,10 +814,15 @@ install(FILES "${tomviz_SOURCE_DIR}/docs/TomvizBasicUserGuide.pdf" DESTINATION "${tomviz_data_install_dir}/docs" COMPONENT "Documentation") -if(APPLE) +if(APPLE AND TOMVIZ_MACOSX_BUNDLE) list(APPEND exec_sources icons/tomviz.icns) set(MACOSX_BUNDLE_ICON_FILE tomviz.icns) set(MACOSX_BUNDLE_BUNDLE_VERSION "${tomviz_version}") + # A non-empty bundle identifier and name are required for macOS to present + # privacy-gated native panels (NSOpenPanel/NSSavePanel). Without them the + # native QFileDialog silently fails to appear. Match the packaged bundle id. + set(MACOSX_BUNDLE_GUI_IDENTIFIER "org.tomviz.tomviz") + set(MACOSX_BUNDLE_BUNDLE_NAME "tomviz") set_source_files_properties(icons/tomviz.icns PROPERTIES MACOSX_PACKAGE_LOCATION Resources) elseif(WIN32) @@ -758,7 +832,9 @@ endif() include_directories( ${CMAKE_CURRENT_BINARY_DIR}/core ${CMAKE_CURRENT_BINARY_DIR} - ${CMAKE_CURRENT_SOURCE_DIR}) + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/core + ${CMAKE_CURRENT_SOURCE_DIR}/pipeline) configure_file(tomvizConfig.h.in tomvizConfig.h @ONLY) configure_file(tomvizPythonConfig.h.in tomvizPythonConfig.h @ONLY) @@ -767,6 +843,8 @@ add_library(tomvizlib STATIC ${SOURCES} ${accel_srcs}) if(NOT WIN32) set_target_properties(tomvizlib PROPERTIES OUTPUT_NAME tomviz) endif() +set_target_properties(tomvizlib PROPERTIES AUTOUIC_SEARCH_PATHS + "${CMAKE_CURRENT_SOURCE_DIR}/pipeline;${CMAKE_CURRENT_SOURCE_DIR}/pipeline/sinks") # Need to set this property so that in stack traces, tomviz symbols # can be resolved. @@ -774,8 +852,17 @@ set_target_properties(tomvizlib PROPERTIES ENABLE_EXPORTS TRUE) set_property(TARGET tomvizlib PROPERTY POSITION_INDEPENDENT_CODE TRUE) -add_executable(tomviz WIN32 MACOSX_BUNDLE ${exec_sources} resources.qrc) +find_package(Viskores QUIET) + +if(APPLE AND TOMVIZ_MACOSX_BUNDLE) + add_executable(tomviz MACOSX_BUNDLE ${exec_sources} resources.qrc pipeline/pipeline_icons.qrc) +else() + add_executable(tomviz WIN32 ${exec_sources} resources.qrc pipeline/pipeline_icons.qrc) +endif() target_link_libraries(tomviz PRIVATE tomvizlib ${OPENGL_LIBRARIES} ${extra_link_libraries}) +if(Viskores_FOUND) + target_link_libraries(tomviz PRIVATE viskores::cont) +endif() # Need to set this property so that in stack traces, tomviz symbols # can be resolved. @@ -792,6 +879,8 @@ target_link_libraries(tomvizlib VTK::pugixml VTK::tiff VTK::CommonColor + VTK::CommonCore + VTK::CommonDataModel VTK::DomainsChemistry VTK::IOChemistry VTK::hdf5 @@ -799,11 +888,28 @@ target_link_libraries(tomvizlib VTK::RenderingVolume VTK::RenderingVolumeOpenGL2 Qt6::Network + Qt6::Widgets PRIVATE Qt6::Core5Compat - Python3::Python) - -if(APPLE) + Python3::Python + $<$:dbghelp shell32> + pybind11::embed + VTK::WrappingPythonCore + VTK::IOImage + VTK::IOXML + VTK::IOLegacy + VTK::RenderingCore + VTK::FiltersCore + VTK::FiltersSources + VTK::FiltersGeneral + VTK::FiltersModeling + VTK::InteractionWidgets + VTK::ImagingCore + VTK::RenderingGridAxes + VTK::ChartsCore + VTK::ViewsContext2D) + +if(APPLE AND TOMVIZ_MACOSX_BUNDLE) set_target_properties(tomviz PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/tomviz.plist.in) @@ -817,7 +923,6 @@ install(TARGETS tomvizlib ARCHIVE DESTINATION "${INSTALL_ARCHIVE_DIR}") if(tomviz_data_DIR) - add_definitions(-DTOMVIZ_DATA) install(DIRECTORY "${tomviz_data_DIR}" DESTINATION "${tomviz_data_install_dir}" USE_SOURCE_PERMISSIONS diff --git a/tomviz/CentralWidget.cxx b/tomviz/CentralWidget.cxx index d2ee1b2cd..0180cf3b5 100644 --- a/tomviz/CentralWidget.cxx +++ b/tomviz/CentralWidget.cxx @@ -4,24 +4,23 @@ #include "CentralWidget.h" #include "ui_CentralWidget.h" +#include #include #include -#include #include #include #include #include -#include -#include #include +#include #include +#include #include #include #include #include -#include #include #include @@ -30,13 +29,10 @@ #include "GradientOpacityWidget.h" #include "Histogram2DWidget.h" #include "HistogramWidget.h" -#include "DataSource.h" #include "HistogramManager.h" -#include "Module.h" -#include "ModuleManager.h" -#include "ModuleSlice.h" -#include "Pipeline.h" -#include "Utilities.h" + +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/data/VolumeData.h" namespace tomviz { @@ -69,7 +65,7 @@ class Transfer2DModel : public AbstractDataModel /** * Initializes with a default TFBoxItem, which will be used to hold the - * default Module/DataSource transfer functions. + * default transfer functions. */ void populate() { @@ -87,7 +83,7 @@ class Transfer2DModel : public AbstractDataModel /** * Returns the first element of the list which refers to the default - * Module/DataSource transfer function box. + * transfer function box. */ const ItemBoxPtr& getDefault() { return get(index(0, 0, QModelIndex())); } @@ -151,7 +147,8 @@ CentralWidget::CentralWidget(QWidget* parentObject, Qt::WindowFlags wflags) m_timer->setInterval(200); m_timer->setSingleShot(true); - connect(m_timer.data(), &QTimer::timeout, this, &CentralWidget::refreshHistogram); + connect(m_timer.data(), &QTimer::timeout, this, + &CentralWidget::refreshHistogram); layout()->setContentsMargins(0, 0, 0, 0); layout()->setSpacing(0); @@ -166,127 +163,10 @@ CentralWidget::~CentralWidget() HistogramManager::instance().finalize(); } -void CentralWidget::setActiveColorMapDataSource(DataSource* source) -{ - auto selected = ActiveObjects::instance().selectedDataSource(); - - // If we have a data source selected directly ( i.e. the user has clicked on - // it ) use that, otherwise use the active transformed data source passed in. - if (selected != nullptr) { - source = selected; - // set the active module to null so we use the color map for the data - // source. - m_activeModule = nullptr; - } - - setColorMapDataSource(source); -} - -void CentralWidget::setActiveModule(Module* module) -{ - if (m_activeModule) { - m_activeModule->disconnect(this); - } - m_activeModule = module; - if (m_activeModule) { - connect(m_activeModule, &Module::colorMapChanged, this, - &CentralWidget::onColorMapDataSourceChanged); - setColorMapDataSource(module->colorMapDataSource()); - connect(m_activeModule, &Module::transferModeChanged, this, - &CentralWidget::onTransferModeChanged); - onTransferModeChanged(static_cast(m_activeModule->getTransferMode())); - - } else { - setColorMapDataSource(nullptr); - } -} - -void CentralWidget::setActiveOperator(Operator* op) -{ - if (op != nullptr) { - DataSource* source = op->dataSource(); - if (op->isNew() && op->isEditing() && source && source->pipeline()) { - // If we are editing a new operator, show the most recently - // transformed datasource's histogram rather than the parent - // datasource's histogram. - source = source->pipeline()->transformedDataSource(); - } - m_activeModule = nullptr; - setColorMapDataSource(source); - } -} - -void CentralWidget::setColorMapDataSource(DataSource* source) -{ - if (m_activeColorMapDataSource) { - m_activeColorMapDataSource->disconnect(this); - m_ui->histogramWidget->disconnect(m_activeColorMapDataSource); - } - m_activeColorMapDataSource = source; - - if (source) { - connect(source, &DataSource::dataChanged, this, &CentralWidget::onColorMapDataSourceChanged); - } - - if (!source) { - m_ui->histogramWidget->setInputData(nullptr, "", ""); - m_ui->gradientOpacityWidget->setInputData(nullptr, "", ""); - m_ui->histogram2DWidget->setTransfer2D(nullptr, nullptr); - return; - } - - // Get the actual data source, build a histogram out of it. - auto image = vtkImageData::SafeDownCast(source->dataObject()); - - if (!image || image->GetPointData()->GetScalars() == nullptr) { - return; - } - - // Get the current color map - if (m_activeModule) { - m_ui->histogramWidget->setLUTProxy(m_activeModule->colorMap()); - if (m_activeModule->supportsGradientOpacity()) { - m_ui->gradientOpacityWidget->setLUT(m_activeModule->gradientOpacityMap()); - m_transfer2DModel->getDefault()->SetColorFunction( - vtkColorTransferFunction::SafeDownCast( - m_activeModule->colorMap()->GetClientSideObject())); - m_transfer2DModel->getDefault()->SetOpacityFunction( - vtkPiecewiseFunction::SafeDownCast( - m_activeModule->opacityMap()->GetClientSideObject())); - m_ui->histogram2DWidget->setTransfer2D( - m_activeModule->transferFunction2D(), - m_activeModule->transferFunction2DBox()); - } - } else { - m_ui->histogramWidget->setLUTProxy(source->colorMap()); - m_ui->gradientOpacityWidget->setLUT(source->gradientOpacityMap()); - - m_transfer2DModel->getDefault()->SetColorFunction( - vtkColorTransferFunction::SafeDownCast( - source->colorMap()->GetClientSideObject())); - m_transfer2DModel->getDefault()->SetOpacityFunction( - vtkPiecewiseFunction::SafeDownCast( - source->opacityMap()->GetClientSideObject())); - m_ui->histogram2DWidget->setTransfer2D(source->transferFunction2D(), - source->transferFunction2DBox()); - } - m_ui->histogram2DWidget->updateTransfer2D(); - - vtkSmartPointer const imageSP = image; - auto histogram = HistogramManager::instance().getHistogram(imageSP); - auto histogram2D = HistogramManager::instance().getHistogram2D(imageSP); - - if (histogram) { - setHistogramTable(histogram); - } - if (histogram2D) { - m_ui->histogram2DWidget->setHistogram(histogram2D); - m_ui->histogram2DWidget->addFunctionItem(m_transfer2DModel->getDefault()); - } -} - void CentralWidget::onColorMapUpdated() { + // HistogramWidget/GradientOpacityWidget already triggered a coalesced + // render via pqView::render(). Refresh the histogram to stay in sync. onColorMapDataSourceChanged(); } @@ -304,42 +184,18 @@ void CentralWidget::onColorLegendToggled(bool visibility) void CentralWidget::setImageViewerMode(bool enabled) { - // First, reset the state of the slider + // Reset the state of the slider m_ui->imageViewerSlider->setVisible(false); m_ui->imageViewerSlider->disconnect(); m_ui->imageViewerSlider->setValue(0); m_ui->imageViewerSlider->setMaximum(0); if (!enabled) { - // Nothing more to do - return; - } - - // Find the first slice module and connect to it - auto* ds = ActiveObjects::instance().activeDataSource(); - auto* view = - vtkSMRenderViewProxy::SafeDownCast(ActiveObjects::instance().activeView()); - - auto& moduleManager = ModuleManager::instance(); - auto sliceModules = moduleManager.findModules(ds, view); - auto* sliceModule = !sliceModules.empty() ? sliceModules[0] : nullptr; - - if (!sliceModule) { - qCritical() << "Error: in CentralWidget::setImageViewerMode: " - << "no slice module found"; return; } - // Connect the two sliders together - m_ui->imageViewerSlider->setMaximum(sliceModule->maxSlice()); - connect(m_ui->imageViewerSlider, &IntSliderWidget::valueEdited, sliceModule, - QOverload::of(&ModuleSlice::onSliceChanged)); - connect(sliceModule, &ModuleSlice::sliceChanged, m_ui->imageViewerSlider, - &IntSliderWidget::setValue); - connect(sliceModule, &QObject::destroyed, m_ui->imageViewerSlider, - &IntSliderWidget::hide); - - m_ui->imageViewerSlider->setVisible(true); + // TODO: migrate to new pipeline — find a SliceSink from the active node + // and connect its slice index to the slider } void CentralWidget::onColorMapDataSourceChanged() @@ -352,14 +208,30 @@ void CentralWidget::onColorMapDataSourceChanged() void CentralWidget::refreshHistogram() { - setColorMapDataSource(m_activeColorMapDataSource); + if (m_activeSink) { + setActiveSinkNode(m_activeSink); + return; + } + if (m_activeVolumeData) { + setActiveVolumeData(m_activeVolumeData); + return; + } } void CentralWidget::histogramReady(vtkSmartPointer input, vtkSmartPointer output) { vtkImageData* inputIm = getInputImage(input); - if (!inputIm || !output) { + if (!inputIm) { + // The image pointer doesn't match the current active data. + // The pipeline may have replaced the image. Trigger a refresh + // so we request a histogram for the current image. + if (m_activeVolumeData && m_activeVolumeData->isValid() && input) { + refreshHistogram(); + } + return; + } + if (!output) { return; } @@ -385,22 +257,13 @@ vtkImageData* CentralWidget::getInputImage(vtkSmartPointer input) return nullptr; } - // If we no longer have an active datasource, ignore showing the histogram - // since the data has been deleted - if (!m_activeColorMapDataSource) { - return nullptr; - } - - auto image = - vtkImageData::SafeDownCast(m_activeColorMapDataSource->dataObject()); - - // The current dataset has changed since the histogram was requested, - // ignore this histogram and wait for the next one queued... - if (image != input) { - return nullptr; + if (m_activeVolumeData && m_activeVolumeData->isValid()) { + if (m_activeVolumeData->imageData() == input.Get()) { + return input; + } } - return image; + return nullptr; } void CentralWidget::setHistogramTable(vtkTable* table) @@ -415,26 +278,89 @@ void CentralWidget::setHistogramTable(vtkTable* table) "image_pops"); } -void CentralWidget::onTransferModeChanged(const int mode) +void CentralWidget::setActiveSinkNode(pipeline::LegacyModuleSink* sink) +{ + // Disconnect previous sink + if (m_activeSink) { + disconnect(m_activeSink, &pipeline::LegacyModuleSink::colorMapChanged, + this, nullptr); + } + + m_activeSink = sink; + + if (!sink) { + m_activeVolumeData.reset(); + m_ui->histogramWidget->setVolumeData(nullptr); + m_ui->histogramWidget->setLUTProxy(nullptr); + m_ui->histogramWidget->setInputData(nullptr, "", ""); + m_ui->gradientOpacityWidget->setInputData(nullptr, "", ""); + return; + } + + // Cache the VolumeData for histogram computation + auto vol = sink->volumeData(); + m_activeVolumeData = vol; + m_ui->histogramWidget->setVolumeData(vol); + + // Set (or clear) the color map proxy on the histogram widget; this + // also refreshes the edit buttons' enabled state. + m_ui->histogramWidget->setLUTProxy(sink->colorMap()); + auto* gradOp = sink->gradientOpacity(); + if (gradOp) { + m_ui->gradientOpacityWidget->setLUT(gradOp); + } + + // Request histogram from the VolumeData's image + if (vol && vol->isValid()) { + vtkSmartPointer image = vol->imageData(); + auto histogram = HistogramManager::instance().getHistogram(image); + if (histogram) { + setHistogramTable(histogram); + } + // If not cached, HistogramManager will emit histogramReady later + } + + // Re-run on color map changes + connect(sink, &pipeline::LegacyModuleSink::colorMapChanged, this, + [this, sink]() { setActiveSinkNode(sink); }); +} + +void CentralWidget::setActiveVolumeData(pipeline::VolumeDataPtr volumeData) { - if (!m_activeModule) { + // Disconnect previous sink + if (m_activeSink) { + disconnect(m_activeSink, &pipeline::LegacyModuleSink::colorMapChanged, + this, nullptr); + m_activeSink = nullptr; + } + + m_activeVolumeData = volumeData; + m_ui->histogramWidget->setVolumeData(volumeData); + + if (!volumeData || !volumeData->isValid()) { + m_ui->histogramWidget->setLUTProxy(nullptr); + m_ui->histogramWidget->setInputData(nullptr, "", ""); + m_ui->gradientOpacityWidget->setInputData(nullptr, "", ""); return; } - int index = 0; - switch (static_cast(mode)) { - case Module::SCALAR: - m_ui->gradientOpacityWidget->hide(); - break; - case Module::GRADIENT_1D: - m_ui->gradientOpacityWidget->show(); - break; - case Module::GRADIENT_2D: - index = 1; - break; + // Only set up color map editing if the VolumeData already has a color map + // (e.g., from a sink). Don't lazily create one for a bare output port — + // the SM proxy created by initColorMap() may not be fully initialized. + if (volumeData->hasColorMap()) { + m_ui->histogramWidget->setLUTProxy(volumeData->colorMap()); + m_ui->gradientOpacityWidget->setLUT(volumeData->gradientOpacity()); + } else { + m_ui->histogramWidget->setLUTProxy(nullptr); } - m_ui->swTransferMode->setCurrentIndex(index); + // Request histogram + vtkSmartPointer image = volumeData->imageData(); + auto histogram = HistogramManager::instance().getHistogram(image); + if (histogram) { + setHistogramTable(histogram); + } + // If not cached, HistogramManager will emit histogramReady later } } // end of namespace tomviz diff --git a/tomviz/CentralWidget.h b/tomviz/CentralWidget.h index 1fc62b97d..8eb87d751 100644 --- a/tomviz/CentralWidget.h +++ b/tomviz/CentralWidget.h @@ -4,18 +4,17 @@ #ifndef tomvizCentralWidget_h #define tomvizCentralWidget_h -#include #include #include #include +#include #include #include -class vtkImageData; +#include class vtkPVDiscretizableColorTransferFunction; - class QThread; class QTimer; @@ -24,11 +23,13 @@ class CentralWidget; } namespace tomviz { -class DataSource; -class HistogramMaker; -class Module; class Transfer2DModel; -class Operator; + +namespace pipeline { +class LegacyModuleSink; +class VolumeData; +using VolumeDataPtr = std::shared_ptr; +} // namespace pipeline /// CentralWidget is a QWidget that is used as the central widget /// for the application. This include a histogram at the top and a @@ -38,52 +39,46 @@ class CentralWidget : public QWidget Q_OBJECT public: - CentralWidget(QWidget* parent = nullptr, Qt::WindowFlags f = Qt::WindowFlags()); + CentralWidget(QWidget* parent = nullptr, + Qt::WindowFlags f = Qt::WindowFlags()); ~CentralWidget() override; public slots: - /// Set the data source that is shown and color by the data source's - /// color map - void setActiveColorMapDataSource(DataSource*); - - /// Set the data source that is shown to the module's data source and color - /// by the module's color map - void setActiveModule(Module*); - void setActiveOperator(Operator*); void onColorMapUpdated(); void onColorLegendToggled(bool visibility); void setImageViewerMode(bool b); + /// Set the active sink node for color map editing. + void setActiveSinkNode(pipeline::LegacyModuleSink* sink); + + /// Set the active VolumeData for color map editing. + void setActiveVolumeData(pipeline::VolumeDataPtr volumeData); + private slots: - void histogramReady(vtkSmartPointer, vtkSmartPointer); + void histogramReady(vtkSmartPointer, + vtkSmartPointer); void histogram2DReady(vtkSmartPointer input, vtkSmartPointer output); void onColorMapDataSourceChanged(); void refreshHistogram(); - /// The active transfer mode is tracked through the tab index of the TabWidget - /// holding the 1D/2D histograms (tabs are expected to follow the order of - /// Module::TransferMode). - void onTransferModeChanged(const int mode); - private: Q_DISABLE_COPY(CentralWidget) /// Set of input checks shared between 1D and 2D histograms. vtkImageData* getInputImage(vtkSmartPointer input); - /// Set the data source to from which the data is "histogrammed" and shown - /// in the histogram view. - void setColorMapDataSource(DataSource*); void setHistogramTable(vtkTable* table); QScopedPointer m_ui; QScopedPointer m_timer; - QPointer m_activeColorMapDataSource; - QPointer m_activeModule; Transfer2DModel* m_transfer2DModel; + + // New pipeline color map state + QPointer m_activeSink; + pipeline::VolumeDataPtr m_activeVolumeData; }; } // namespace tomviz diff --git a/tomviz/CloneDataReaction.cxx b/tomviz/CloneDataReaction.cxx index 417b7e146..3a279ff2f 100644 --- a/tomviz/CloneDataReaction.cxx +++ b/tomviz/CloneDataReaction.cxx @@ -4,10 +4,21 @@ #include "CloneDataReaction.h" #include "ActiveObjects.h" -#include "DataSource.h" #include "LoadDataReaction.h" +#include "MainWindow.h" #include "Utilities.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/PortUtils.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" + +#include +#include + +#include #include namespace tomviz { @@ -19,8 +30,39 @@ CloneDataReaction::CloneDataReaction(QAction* parentObject) DataSource* CloneDataReaction::clone(DataSource* toClone) { - toClone = toClone ? toClone : ActiveObjects::instance().activeDataSource(); - if (!toClone) { + // TODO: This still uses DataSource for the clone source. Once the pipeline + // migration is complete, this should work entirely with SourceNode. + // For now, we get the active SourceNode's VolumeData and deep-copy it. + Q_UNUSED(toClone); + + // Get the VolumeData from the active pipeline (via MainWindow) + auto* mainWindow = MainWindow::instance(); + if (!mainWindow) { + return nullptr; + } + + auto* pip = mainWindow->pipeline(); + if (!pip) { + return nullptr; + } + + // Find a source node to clone + pipeline::SourceNode* activeSource = nullptr; + for (auto* node : pip->nodes()) { + auto* src = qobject_cast(node); + if (src) { + activeSource = src; + break; + } + } + + if (!activeSource) { + return nullptr; + } + + auto vol = + pipeline::getOutputData(activeSource); + if (!vol || !vol->imageData()) { return nullptr; } @@ -37,19 +79,23 @@ DataSource* CloneDataReaction::clone(DataSource* toClone) /*ok*/ &userOkayed); if (userOkayed) { - DataSource* newClone = toClone->clone(); - LoadDataReaction::dataSourceAdded(newClone); - auto cloneOperators = selection == items[1]; - // We clone the operators after adding the data source as at this point the - // appropriate signals are connected on the data source. - if (cloneOperators) { - // now, clone the operators. - foreach (Operator* op, toClone->operators()) { - Operator* opClone(op->clone()); - newClone->addOperator(opClone); - } - } - return newClone; + // Deep-copy the vtkImageData + vtkNew clonedImage; + clonedImage->DeepCopy(vol->imageData()); + + auto* newSource = new pipeline::SourceNode(); + newSource->setLabel(activeSource->label() + " (clone)"); + newSource->addOutput("volume", pipeline::PortType::ImageData); + auto newVol = std::make_shared(clonedImage); + newVol->setLabel(newSource->label()); + newSource->setOutputData( + "volume", + pipeline::PortData(newVol, pipeline::PortType::ImageData)); + + LoadDataReaction::sourceNodeAdded(newSource); + + // TODO: operator cloning not yet supported in new pipeline + return nullptr; } return nullptr; } diff --git a/tomviz/ColorMap.cxx b/tomviz/ColorMap.cxx index 3ca2cfc51..988c12700 100644 --- a/tomviz/ColorMap.cxx +++ b/tomviz/ColorMap.cxx @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -16,9 +17,109 @@ #include #include +#include +#include + namespace tomviz { +QJsonObject buildSegmentationPreset(vtkDataArray* scalars) +{ + if (!scalars || scalars->GetNumberOfTuples() == 0) { + return QJsonObject(); + } + const int dataType = scalars->GetDataType(); + if (dataType == VTK_FLOAT || dataType == VTK_DOUBLE) { + return QJsonObject(); + } + + std::set uniqueValues; + const vtkIdType numTuples = scalars->GetNumberOfTuples(); + for (vtkIdType i = 0; i < numTuples; ++i) { + uniqueValues.insert(scalars->GetTuple1(i)); + } + if (uniqueValues.empty()) { + return QJsonObject(); + } + + static const double goldenAngle = 137.508; + QJsonArray colors; + int idx = 0; + const int lastIdx = static_cast(uniqueValues.size()) - 1; + for (double val : uniqueValues) { + double hue = std::fmod(idx * goldenAngle, 360.0) / 360.0; + double sat = 0.65 + 0.35 * ((idx % 3) / 2.0); + double brightness = 0.75 + 0.25 * ((idx + 1) % 2); + + double c = brightness * sat; + double x = c * (1.0 - std::abs(std::fmod(hue * 6.0, 2.0) - 1.0)); + double m = brightness - c; + + double r, g, b; + double h6 = hue * 6.0; + if (h6 < 1.0) { + r = c; g = x; b = 0; + } else if (h6 < 2.0) { + r = x; g = c; b = 0; + } else if (h6 < 3.0) { + r = 0; g = c; b = x; + } else if (h6 < 4.0) { + r = 0; g = x; b = c; + } else if (h6 < 5.0) { + r = x; g = 0; b = c; + } else { + r = c; g = 0; b = x; + } + + // Two nodes per label at val -/+ 0.25 with the same color give each + // integer label its own constant-color band under plain RGB + // interpolation. Do not use the Step color space here: VTK places + // its step transitions near segment midpoints, not at the nodes, so + // consecutive labels can land inside one step and share a color. + // The ramps between bands sit at non-integer values no label + // occupies, and the 0.5 minimum node gap keeps the GPU lookup + // texture size estimate small. The outermost edges sit exactly at + // the data min/max so a rescale to the data range (e.g. the user's + // "Reset data range") is an identity and cannot shift the bands. + double lowOffset = (idx == 0) ? 0.0 : -0.25; + double highOffset = (idx == lastIdx) ? 0.0 : 0.25; + for (double offset : { lowOffset, highOffset }) { + colors.append(val + offset); + colors.append(r + m); + colors.append(g + m); + colors.append(b + m); + } + ++idx; + } + + return QJsonObject{ { "name", "Segmentation" }, + { "colorSpace", "RGB" }, + { "colors", colors } }; +} + +void applyPresetToProxy(const QJsonObject& preset, vtkSMProxy* proxy, + bool rescaleToCurrentRange) +{ + if (!proxy || preset.isEmpty()) { + return; + } + + QJsonObject pqPreset(preset); + pqPreset.insert("RGBPoints", pqPreset["colors"]); + pqPreset.insert("ColorSpace", pqPreset["colorSpace"]); + + QJsonDocument doc(pqPreset); + QByteArray json = doc.toJson(QJsonDocument::Compact); + Json::Value value; + std::string errors; + Json::CharReaderBuilder builder; + std::unique_ptr reader(builder.newCharReader()); + reader->parse(json.data(), json.data() + json.size(), &value, &errors); + + vtkSMTransferFunctionProxy::ApplyPreset(proxy, value, + rescaleToCurrentRange); +} + ColorMap::ColorMap() { auto settings = pqApplicationCore::instance()->settings(); @@ -136,26 +237,10 @@ void ColorMap::applyPreset(vtkSMProxy* proxy) const void ColorMap::applyPreset(int index, vtkSMProxy* proxy) const { - if (!proxy || index < 0 || index >= m_presets.size()) { + if (index < 0 || index >= m_presets.size()) { return; } - - QJsonObject pqPreset(m_presets[index].toObject()); - pqPreset.insert("RGBPoints", pqPreset["colors"]); - pqPreset.insert("ColorSpace", pqPreset["colorSpace"]); - - QJsonDocument doc(pqPreset); - QString chosen(doc.toJson(QJsonDocument::Compact)); - QByteArray chosenLatin1 = chosen.toLatin1(); - Json::Value value; - std::string errors; - Json::CharReaderBuilder builder; - std::unique_ptr reader(builder.newCharReader()); - reader->parse(chosenLatin1.data(), chosenLatin1.data() + chosenLatin1.size(), - &value, &errors); - - - vtkSMTransferFunctionProxy::ApplyPreset(proxy, value, true); + applyPresetToProxy(m_presets[index].toObject(), proxy); } void ColorMap::applyPreset(const QString& name, vtkSMProxy* proxy) const diff --git a/tomviz/ColorMap.h b/tomviz/ColorMap.h index b265baa8c..06c08cfeb 100644 --- a/tomviz/ColorMap.h +++ b/tomviz/ColorMap.h @@ -7,12 +7,34 @@ #include #include +#include #include +class vtkDataArray; class vtkSMProxy; namespace tomviz { +/// Build a step-interpolated, distinct-color segmentation colormap +/// preset from an integer-valued scalar array. Scans @a scalars for +/// unique values and emits a preset with one color per label, using +/// golden-angle hue spacing. +/// +/// Returns an empty QJsonObject if @a scalars is null, floating-point, +/// or empty. Colors cycle via golden-angle hue spacing, so duplicate +/// colors will appear for large label counts. +QJsonObject buildSegmentationPreset(vtkDataArray* scalars); + +/// Apply a tomviz-format preset JSON object (fields "name", +/// "colorSpace", "colors") to a transfer function proxy. With +/// rescaleToCurrentRange (the default), the preset's node positions are +/// remapped into the proxy's current range - right for generic presets, +/// wrong for presets whose positions are already in data coordinates +/// (e.g. the per-label segmentation preset), which must pass false. +void applyPresetToProxy(const QJsonObject& preset, vtkSMProxy* proxy, + bool rescaleToCurrentRange = true); + + /** * Keep track of the loaded color maps, the current default, setting colors. */ diff --git a/tomviz/ComputeHistogram.h b/tomviz/ComputeHistogram.h index 746cf3938..2b7c3ff05 100644 --- a/tomviz/ComputeHistogram.h +++ b/tomviz/ComputeHistogram.h @@ -8,18 +8,96 @@ #include #include +#include #include +#include namespace tomviz { +// Mirrors the bin count in HistogramManager::PopulateHistogram. +constexpr int kHistogramBins = 256; + +/** + * Compute the finite value range used for histogram binning, reading the raw + * buffer directly. + * + * This deliberately avoids vtkDataArray::GetRange()/GetFiniteRange(), which + * lazily cache their result inside the array's vtkInformation object. That + * cache write is NOT thread-safe: the histogram runs on a background thread + * while the main (render) thread touches the same shared array, and concurrent + * range caching corrupts the Information's reference counts -> "delete object + * with non-zero reference count" -> crash in vtkGarbageCollector. Computing the + * range from the buffer here keeps the background thread read-only with respect + * to the shared array. + * + * \param useMagnitude when true and numComponents > 1, ranges over the vector + * magnitude (matching GetFiniteRange(range, -1) and the multi-component + * binning path in CalculateHistogram). Otherwise ranges over the raw + * component values (matching the per-component range used for the 2D + * histogram). For single-component arrays the two are equivalent. + */ +template +void ComputeFiniteRange(const T* values, const vtkIdType numTuples, + const vtkIdType numComponents, const bool useMagnitude, + double range[2]) +{ + double minValue = std::numeric_limits::max(); + double maxValue = -std::numeric_limits::max(); + + if (useMagnitude && numComponents > 1) { + for (vtkIdType j = 0; j < numTuples; ++j) { + double squaredSum = 0.0; + bool valid = true; + for (vtkIdType c = 0; c < numComponents; ++c) { + double value = static_cast(values[j * numComponents + c]); + if (!vtkMath::IsFinite(value)) { + valid = false; + break; + } + squaredSum += value * value; + } + if (valid) { + double mag = std::sqrt(squaredSum); + minValue = std::min(minValue, mag); + maxValue = std::max(maxValue, mag); + } + } + } else { + const vtkIdType total = numTuples * numComponents; + for (vtkIdType i = 0; i < total; ++i) { + double value = static_cast(values[i]); + if (vtkMath::IsFinite(value)) { + minValue = std::min(minValue, value); + maxValue = std::max(maxValue, value); + } + } + } + + if (minValue > maxValue) { + // No finite values found; fall back to a benign range. + minValue = 0.0; + maxValue = 0.0; + } + + range[0] = minValue; + range[1] = maxValue; +} + /** Single component integral type specialization. */ template ::value>::type* = nullptr> void calcHistogram(T* values, const vtkIdType numTuples, const float min, - const float inv, uint64_t* pops, int&) + const float inv, uint64_t* pops, int& invalid) { + // Clamp idx so garbage data (e.g. uninit numpy buffer pushed + // before being filled) doesn't write past pops[]. for (vtkIdType j = 0; j < numTuples; ++j) { - ++pops[static_cast((*values++ - min) * inv)]; + int idx = static_cast((*values++ - min) * inv); + if (idx >= 0 && idx < kHistogramBins) { + ++pops[idx]; + } else { + ++invalid; + } } } @@ -34,6 +112,7 @@ void calcHistogram(T*, const vtkIdType, uint64_t*) void calcHistogram(unsigned char* values, const vtkIdType numTuples, uint64_t* pops) { + // unsigned char is always in [0, kBins-1], no clamp needed. for (vtkIdType j = 0; j < numTuples; ++j) { ++pops[*values++]; } @@ -48,7 +127,12 @@ void calcHistogram(T* values, const vtkIdType numTuples, const float min, for (vtkIdType j = 0; j < numTuples; ++j) { T value = *(values++); if (std::isfinite(value)) { - ++pops[static_cast((value - min) * inv)]; + int idx = static_cast((value - min) * inv); + if (idx >= 0 && idx < kHistogramBins) { + ++pops[idx]; + } else { + ++invalid; + } } else { ++invalid; } diff --git a/tomviz/ConformVolumeDialog.cxx b/tomviz/ConformVolumeDialog.cxx deleted file mode 100644 index d5ab4617d..000000000 --- a/tomviz/ConformVolumeDialog.cxx +++ /dev/null @@ -1,56 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ConformVolumeDialog.h" -#include "ui_ConformVolumeDialog.h" - -#include "DataSource.h" - -namespace tomviz { - -ConformVolumeDialog::ConformVolumeDialog(QWidget* parent) - : QDialog(parent), m_ui(new Ui::ConformVolumeDialog) -{ - m_ui->setupUi(this); - - setupConnections(); -} - -ConformVolumeDialog::~ConformVolumeDialog() = default; - -void ConformVolumeDialog::setupConnections() -{ - connect(m_ui->conformingVolume, - QOverload::of(&QComboBox::currentIndexChanged), this, - &ConformVolumeDialog::updateConformToLabel); -} - -void ConformVolumeDialog::updateConformToLabel() -{ - auto current = m_ui->conformingVolume->currentText(); - for (const auto* volume : m_volumes) { - if (current != volume->label()) { - m_ui->conformToVolumeLabel->setText(volume->label()); - break; - } - } -} - -void ConformVolumeDialog::setVolumes(QList volumes) -{ - m_volumes = volumes; - - // Set up the combo box options - m_ui->conformingVolume->clear(); - for (const auto* volume : volumes) { - m_ui->conformingVolume->addItem(volume->label()); - } -} - -DataSource* ConformVolumeDialog::selectedVolume() -{ - auto selectedIndex = m_ui->conformingVolume->currentIndex(); - return m_volumes[selectedIndex]; -} - -} // namespace tomviz diff --git a/tomviz/ConformVolumeDialog.h b/tomviz/ConformVolumeDialog.h deleted file mode 100644 index e7c1fdd73..000000000 --- a/tomviz/ConformVolumeDialog.h +++ /dev/null @@ -1,41 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizConformVolumeDialog_h -#define tomvizConformVolumeDialog_h - -#include -#include - -namespace Ui { -class ConformVolumeDialog; -} - -namespace tomviz { - -class DataSource; - -class ConformVolumeDialog : public QDialog -{ - Q_OBJECT - -public: - ConformVolumeDialog(QWidget* parent = nullptr); - ~ConformVolumeDialog() override; - - void setupConnections(); - - void setVolumes(QList volumes); - DataSource* selectedVolume(); - -private: - void updateConformToLabel(); - - QList m_volumes; - - Q_DISABLE_COPY(ConformVolumeDialog) - QScopedPointer m_ui; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/ConformVolumeDialog.ui b/tomviz/ConformVolumeDialog.ui deleted file mode 100644 index 3511d8319..000000000 --- a/tomviz/ConformVolumeDialog.ui +++ /dev/null @@ -1,123 +0,0 @@ - - - ConformVolumeDialog - - - - 0 - 0 - 460 - 268 - - - - Conform Volume - - - - - - Conforming volume: - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - The conforming volume will be resampled and reshaped so that the size, shape, extents, and origin of the data will match the volume it is conforming to. - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - true - - - - - - - - - - will be conformed to: - - - - - - - - 75 - true - - - - TextLabel - - - Qt::AlignCenter - - - - - - - - - buttonBox - accepted() - ConformVolumeDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - ConformVolumeDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/tomviz/ConformVolumeReaction.cxx b/tomviz/ConformVolumeReaction.cxx deleted file mode 100644 index 58b82869d..000000000 --- a/tomviz/ConformVolumeReaction.cxx +++ /dev/null @@ -1,120 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ConformVolumeReaction.h" - -#include "vtkImageChangeInformation.h" -#include "vtkImageData.h" -#include "vtkImageResize.h" -#include "vtkNew.h" - -#include "ConformVolumeDialog.h" -#include "DataSource.h" -#include "LoadDataReaction.h" - -namespace tomviz { - -ConformVolumeReaction::ConformVolumeReaction(QAction* parentObject) - : pqReaction(parentObject) -{ - updateEnableState(); -} - -void ConformVolumeReaction::onTriggered() -{ - // Check which volume should be the conforming one - ConformVolumeDialog dialog; - dialog.setVolumes(m_dataSources.values()); - - if (dialog.exec() == QDialog::Rejected) { - return; - } - - m_conformingVolume = dialog.selectedVolume(); - - auto newSource = createConformedVolume(); - if (newSource) { - LoadDataReaction::dataSourceAdded(newSource); - } -} - -void ConformVolumeReaction::updateDataSources(QSet sources) -{ - m_dataSources = sources; - updateEnableState(); -} - -void ConformVolumeReaction::updateEnableState() -{ - updateVisibleState(); -} - -void ConformVolumeReaction::updateVisibleState() -{ - parentAction()->setVisible(false); - - if (m_dataSources.size() != 2) { - return; - } - - QList sourceList = m_dataSources.values(); - // Check that both DataSources are volumes - for (auto ds : m_dataSources) { - if (ds->type() != DataSource::Volume) { - return; - } - } - - // Make sure the names are not the same... - if (sourceList[0]->label() == sourceList[1]->label()) { - return; - } - - parentAction()->setVisible(true); -} - -DataSource* ConformVolumeReaction::createConformedVolume() -{ - if (m_dataSources.size() != 2 || - !m_dataSources.contains(m_conformingVolume)) { - return nullptr; - } - - auto* conformingVolume = m_conformingVolume; - - DataSource* conformToVolume = nullptr; - for (auto it = m_dataSources.cbegin(); it != m_dataSources.cend(); ++it) { - if (*it != conformingVolume) { - conformToVolume = *it; - break; - } - } - - if (!conformToVolume) { - return nullptr; - } - - // Begin the volume conforming - vtkNew resize; - resize->SetInputData(conformingVolume->imageData()); - resize->SetOutputDimensions(conformToVolume->imageData()->GetDimensions()); - resize->Update(); - - vtkNew changeInfo; - changeInfo->SetInputData(resize->GetOutputDataObject(0)); - changeInfo->SetInformationInputData(conformToVolume->imageData()); - changeInfo->Update(); - - auto* output = vtkImageData::SafeDownCast(changeInfo->GetOutputDataObject(0)); - if (!output) { - return nullptr; - } - - auto* newSource = new DataSource(output); - newSource->setFileName("Conformed Volume"); - // Make the display position match as well - newSource->setDisplayPosition(conformToVolume->displayPosition()); - return newSource; -} - -} // namespace tomviz diff --git a/tomviz/ConformVolumeReaction.h b/tomviz/ConformVolumeReaction.h deleted file mode 100644 index 210e780ce..000000000 --- a/tomviz/ConformVolumeReaction.h +++ /dev/null @@ -1,40 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizConformVolumeReaction_h -#define tomvizConformVolumeReaction_h - -#include - -#include - -namespace tomviz { - -class DataSource; - -class ConformVolumeReaction : pqReaction -{ - Q_OBJECT - -public: - ConformVolumeReaction(QAction* action); - -public slots: - void updateDataSources(QSet); - -protected: - void onTriggered() override; - void updateEnableState() override; - void updateVisibleState(); - - DataSource* createConformedVolume(); - -private: - Q_DISABLE_COPY(ConformVolumeReaction) - - QSet m_dataSources; - DataSource* m_conformingVolume = nullptr; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/ConvertToFloatReaction.cxx b/tomviz/ConvertToFloatReaction.cxx index fbc359328..b238649f5 100644 --- a/tomviz/ConvertToFloatReaction.cxx +++ b/tomviz/ConvertToFloatReaction.cxx @@ -3,11 +3,8 @@ #include "ConvertToFloatReaction.h" -#include - -#include "ActiveObjects.h" -#include "ConvertToFloatOperator.h" -#include "DataSource.h" +#include "TransformUtils.h" +#include "pipeline/transforms/ConvertToFloatTransform.h" namespace tomviz { @@ -18,13 +15,7 @@ ConvertToFloatReaction::ConvertToFloatReaction(QAction* parentObject) void ConvertToFloatReaction::convertToFloat() { - DataSource* source = ActiveObjects::instance().activeParentDataSource(); - if (!source) { - qDebug() << "Exiting early - no data found."; - return; - } - Operator* Op = new ConvertToFloatOperator(); - - source->addOperator(Op); + auto* transform = new pipeline::ConvertToFloatTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/CropReaction.cxx b/tomviz/CropReaction.cxx index d7ef7853a..125dc3a86 100644 --- a/tomviz/CropReaction.cxx +++ b/tomviz/CropReaction.cxx @@ -3,13 +3,8 @@ #include "CropReaction.h" -#include -#include - -#include "ActiveObjects.h" -#include "CropOperator.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" +#include "TransformUtils.h" +#include "pipeline/transforms/CropTransform.h" namespace tomviz { @@ -18,19 +13,9 @@ CropReaction::CropReaction(QAction* parentObject, QMainWindow* mw) { } -void CropReaction::crop(DataSource* source) +void CropReaction::crop(DataSource*) { - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - return; - } - - Operator* Op = new CropOperator(); - - EditOperatorDialog* dialog = - new EditOperatorDialog(Op, source, true, m_mainWindow); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->show(); - connect(Op, &QObject::destroyed, dialog, &QDialog::reject); + auto* transform = new pipeline::CropTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/DataBroker.cxx b/tomviz/DataBroker.cxx index 526daeeef..db396eb93 100644 --- a/tomviz/DataBroker.cxx +++ b/tomviz/DataBroker.cxx @@ -3,7 +3,6 @@ #include "DataBroker.h" -#include "DataSource.h" #include "Utilities.h" #include diff --git a/tomviz/DataBrokerLoadReaction.cxx b/tomviz/DataBrokerLoadReaction.cxx index a204baef6..0a5d97a69 100644 --- a/tomviz/DataBrokerLoadReaction.cxx +++ b/tomviz/DataBrokerLoadReaction.cxx @@ -4,11 +4,15 @@ #include "DataBrokerLoadReaction.h" #include "DataBrokerLoadDialog.h" -#include "DataSource.h" #include "GenericHDF5Format.h" #include "LoadDataReaction.h" #include "Utilities.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" + #include #include @@ -43,18 +47,29 @@ void DataBrokerLoadReaction::loadData() connect(call, &LoadDataCall::complete, dataBroker, [dataBroker, catalog, runUid, table, variable](vtkSmartPointer imageData) { - // Relabel axes first, short-term workaround reorder to C (again). + // Relabel axes first, short-term workaround reorder to C + // (again). GenericHDF5Format::reorderData(imageData, ReorderMode::FortranToC); relabelXAndZAxes(imageData); - auto dataSource = - new DataSource(imageData, DataSource::TiltSeries); - dataSource->setLabel(QString("db:///%1/%2/%3/%4") - .arg(catalog) - .arg(runUid) - .arg(table) - .arg(variable)); - LoadDataReaction::dataSourceAdded(dataSource, true, false); + + auto* source = new pipeline::SourceNode(); + auto label = QString("db:///%1/%2/%3/%4") + .arg(catalog) + .arg(runUid) + .arg(table) + .arg(variable); + source->setLabel(label); + source->addOutput("volume", pipeline::PortType::ImageData); + auto volumeData = + std::make_shared(imageData); + volumeData->setLabel(label); + source->setOutputData( + "volume", + pipeline::PortData(volumeData, pipeline::PortType::ImageData)); + source->setProperty("dataType", "tiltSeries"); + + LoadDataReaction::sourceNodeAdded(source, true, false); dataBroker->deleteLater(); tomviz::mainWidget()->unsetCursor(); }); diff --git a/tomviz/DataBrokerLoadReaction.h b/tomviz/DataBrokerLoadReaction.h index 1efd6007c..d52e14598 100644 --- a/tomviz/DataBrokerLoadReaction.h +++ b/tomviz/DataBrokerLoadReaction.h @@ -9,7 +9,6 @@ #include "DataBroker.h" namespace tomviz { -class DataSource; /// DataBrokerLoadReaction handles the "Import From DataBroker" action in /// tomviz. On trigger, this will open a dialog where the user "drill down" to diff --git a/tomviz/DataBrokerSaveReaction.cxx b/tomviz/DataBrokerSaveReaction.cxx index 734301a24..b5690fa19 100644 --- a/tomviz/DataBrokerSaveReaction.cxx +++ b/tomviz/DataBrokerSaveReaction.cxx @@ -2,18 +2,10 @@ It is released under the 3-Clause BSD License, see "LICENSE". */ #include "DataBrokerSaveReaction.h" -#include "DataBrokerSaveDialog.h" -#include "ActiveObjects.h" -#include "DataSource.h" -#include "GenericHDF5Format.h" -#include "LoadDataReaction.h" #include "Utilities.h" -#include - #include -#include namespace tomviz { @@ -21,13 +13,9 @@ DataBrokerSaveReaction::DataBrokerSaveReaction(QAction* parentObject, MainWindow* mainWindow) : pqReaction(parentObject), m_mainWindow(mainWindow) { - QObject::connect( - &ActiveObjects::instance(), - QOverload::of(&ActiveObjects::dataSourceChanged), this, - [this](DataSource* dataSource) { - parentAction()->setEnabled(dataSource != nullptr && - m_dataBrokerInstalled); - }); + // TODO: migrate to new pipeline + // was: connected to ActiveObjects::dataSourceChanged to enable/disable action + parentAction()->setEnabled(false); } DataBrokerSaveReaction::~DataBrokerSaveReaction() = default; @@ -44,60 +32,9 @@ void DataBrokerSaveReaction::setDataBrokerInstalled(bool installed) void DataBrokerSaveReaction::saveData() { - auto dataBroker = new DataBroker(tomviz::mainWidget()); - DataBrokerSaveDialog dialog(dataBroker, tomviz::mainWidget()); - - if (dialog.exec() == QDialog::Accepted) { - auto name = dialog.name(); - - auto ds = ActiveObjects::instance().activeDataSource(); - if (ds == nullptr) { - qWarning() << "No active data source!"; - return; - } - - auto data = ds->imageData(); - - vtkNew permutedData; - if (DataSource::hasTiltAngles(data)) { - // No deep copies of data needed. Just re-label the axes. - permutedData->ShallowCopy(data); - relabelXAndZAxes(permutedData); - } else { - // Need to re-order to C ordering before writing - GenericHDF5Format::reorderData(data, permutedData, - ReorderMode::FortranToC); - } - - tomviz::mainWidget()->setCursor(Qt::WaitCursor); - auto call = dataBroker->saveData("fxi", name, data); - connect( - call, &SaveDataCall::complete, dataBroker, - [dataBroker, this](const QString& id) { - dataBroker->deleteLater(); - tomviz::mainWidget()->unsetCursor(); - QMessageBox messageBox( - QMessageBox::Information, "tomviz", - QString( - "The active dataset was successfully exported to DataBroker: %1") - .arg(id), - QMessageBox::Ok, m_mainWindow); - messageBox.exec(); - }); - - connect(call, &DataBrokerCall::error, dataBroker, - [dataBroker, this](const QString& errorMessage) { - tomviz::mainWidget()->unsetCursor(); - dataBroker->deleteLater(); - QMessageBox messageBox( - QMessageBox::Warning, "tomviz", - QString("Error export data to DataBroker: %1. Please check " - "message log for details.") - .arg(errorMessage), - QMessageBox::Ok, m_mainWindow); - messageBox.exec(); - }); - } + // TODO: migrate to new pipeline. Export to DataBroker is not yet supported + // with the new pipeline; the action is disabled in the constructor. + qWarning() << "Export to DataBroker is not supported in the new pipeline."; } } // namespace tomviz diff --git a/tomviz/DataExchangeFormat.cxx b/tomviz/DataExchangeFormat.cxx index 32111da5e..a94e738f4 100644 --- a/tomviz/DataExchangeFormat.cxx +++ b/tomviz/DataExchangeFormat.cxx @@ -3,7 +3,6 @@ #include "DataExchangeFormat.h" -#include "DataSource.h" #include "GenericHDF5Format.h" #include "Utilities.h" @@ -44,64 +43,56 @@ bool DataExchangeFormat::read(const std::string& fileName, vtkImageData* image, return readDataSet(fileName, path, image, options); } -bool DataExchangeFormat::read(const std::string& fileName, - DataSource* dataSource, - const QVariantMap& options) +HDF5ReadResult DataExchangeFormat::readAll(const std::string& fileName, + const QVariantMap& options) { + HDF5ReadResult result; + vtkNew image; if (!read(fileName, image, options)) { std::cerr << "Failed to read data in: " + fileName + "\n"; - return false; + return result; } - dataSource->setData(image); - - // Use the same strides and volume bounds for the dark and white data, - // except for the tilt axis. - QVariantMap darkWhiteOptions = options; - int strides[3]; - int bs[6]; - dataSource->subsampleStrides(strides); - dataSource->subsampleVolumeBounds(bs); + result.imageData = image; - QVariantList stridesList = { 1, strides[1], strides[2] }; - QVariantList boundsList = { 0, 1, bs[2], bs[3], bs[4], bs[5] }; - - darkWhiteOptions["subsampleStrides"] = stridesList; - darkWhiteOptions["subsampleVolumeBounds"] = boundsList; - darkWhiteOptions["askForSubsample"] = false; - - // Read in the dark and white image data as well + // Read dark and white data vtkNew darkImage, whiteImage; - readDark(fileName, darkImage, darkWhiteOptions); - if (darkImage->GetPointData()->GetNumberOfArrays() != 0) - dataSource->setDarkData(std::move(darkImage)); + readDark(fileName, darkImage, options); + if (darkImage->GetPointData()->GetNumberOfArrays() != 0) { + result.darkData = darkImage; + } - readWhite(fileName, whiteImage, darkWhiteOptions); - if (whiteImage->GetPointData()->GetNumberOfArrays() != 0) - dataSource->setWhiteData(std::move(whiteImage)); + readWhite(fileName, whiteImage, options); + if (whiteImage->GetPointData()->GetNumberOfArrays() != 0) { + result.whiteData = whiteImage; + } - QVector angles = readTheta(fileName, options); + result.tiltAngles = readTheta(fileName, options); - if (angles.isEmpty()) { + if (result.tiltAngles.isEmpty()) { // Re-order the data to Fortran ordering GenericHDF5Format::reorderData(image, ReorderMode::CToFortran); - GenericHDF5Format::reorderData(dataSource->darkData(), - ReorderMode::CToFortran); - GenericHDF5Format::reorderData(dataSource->whiteData(), - ReorderMode::CToFortran); + if (result.darkData) { + GenericHDF5Format::reorderData(result.darkData, ReorderMode::CToFortran); + } + if (result.whiteData) { + GenericHDF5Format::reorderData(result.whiteData, + ReorderMode::CToFortran); + } } else { // No re-order needed. Just re-label the axes. relabelXAndZAxes(image); - relabelXAndZAxes(dataSource->darkData()); - relabelXAndZAxes(dataSource->whiteData()); - dataSource->setTiltAngles(angles); - dataSource->setType(DataSource::TiltSeries); + if (result.darkData) { + relabelXAndZAxes(result.darkData); + } + if (result.whiteData) { + relabelXAndZAxes(result.whiteData); + } + result.isTiltSeries = true; } - dataSource->dataModified(); - - return true; + return result; } bool DataExchangeFormat::readDark(const std::string& fileName, @@ -136,101 +127,5 @@ QVector DataExchangeFormat::readTheta(const std::string& fileName, return GenericHDF5Format::readAngles(reader, path, options); } -namespace { - -bool writeData(h5::H5ReadWrite& writer, vtkImageData* image) -{ - vtkNew permutedImage; - if (DataSource::hasTiltAngles(image)) { - // No deep copies of data needed. Just re-label the axes. - permutedImage->ShallowCopy(image); - relabelXAndZAxes(permutedImage); - } else { - // Need to re-order to C ordering before writing - GenericHDF5Format::reorderData(image, permutedImage, - ReorderMode::FortranToC); - } - - // Assume /exchange already exists - return GenericHDF5Format::writeVolume(writer, "/exchange", "data", - permutedImage); -} - -bool writeExtraData(h5::H5ReadWrite& writer, vtkImageData* image, - const std::string& path, const std::string& name, - bool isTiltSeries = false) -{ - vtkNew permutedImage; - if (isTiltSeries) { - // No deep copying needed. Just re-label the axes. - permutedImage->ShallowCopy(image); - relabelXAndZAxes(permutedImage); - } else { - GenericHDF5Format::reorderData(image, permutedImage, - ReorderMode::FortranToC); - } - - // Assume /exchange already exists - return GenericHDF5Format::writeVolume(writer, path, name, permutedImage); -} - -bool writeDark(h5::H5ReadWrite& writer, vtkImageData* image, - bool isTiltSeries = false) -{ - return writeExtraData(writer, image, "/exchange", "data_dark", isTiltSeries); -} - -bool writeWhite(h5::H5ReadWrite& writer, vtkImageData* image, - bool isTiltSeries = false) -{ - return writeExtraData(writer, image, "/exchange", "data_white", isTiltSeries); -} - -bool writeTheta(h5::H5ReadWrite& writer, vtkImageData* image) -{ - QVector angles = DataSource::getTiltAngles(image); - - if (angles.isEmpty()) - return false; - - // Assume /exchange already exists - return writer.writeData("/exchange", "theta", { static_cast(angles.size()) }, - angles.data()); -} - -} // namespace - -bool DataExchangeFormat::write(const std::string& fileName, DataSource* source) -{ - using h5::H5ReadWrite; - H5ReadWrite::OpenMode mode = H5ReadWrite::OpenMode::WriteOnly; - H5ReadWrite writer(fileName, mode); - - // Create a "/exchange" group - writer.createGroup("/exchange"); - - auto t = source->producer(); - auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!writeData(writer, image)) - return false; - - bool isTiltSeries = source->hasTiltAngles(); - - if (source->darkData()) { - if (!writeDark(writer, source->darkData(), isTiltSeries)) - return false; - } - - if (source->whiteData()) { - if (!writeWhite(writer, source->whiteData(), isTiltSeries)) - return false; - } - - if (isTiltSeries) { - return writeTheta(writer, image); - } - - return true; -} } // namespace tomviz diff --git a/tomviz/DataExchangeFormat.h b/tomviz/DataExchangeFormat.h index a8a0d32cf..fc9c5d93d 100644 --- a/tomviz/DataExchangeFormat.h +++ b/tomviz/DataExchangeFormat.h @@ -8,25 +8,21 @@ #include +#include "HDF5ReadResult.h" + class vtkImageData; namespace tomviz { -class DataSource; - class DataExchangeFormat { public: // This will only read /exchange/data, nothing else bool read(const std::string& fileName, vtkImageData* data, const QVariantMap& options = QVariantMap()); - // This will read the data as well as dark, white, and the - // theta angles, and it will swap x and z for tilt series. - bool read(const std::string& fileName, DataSource* source, - const QVariantMap& options = QVariantMap()); - // A data source is required for writing - bool write(const std::string& fileName, DataSource* source); - + // Read everything into an HDF5ReadResult (no DataSource needed) + HDF5ReadResult readAll(const std::string& fileName, + const QVariantMap& options = QVariantMap()); private: // Read the dark dataset into the image data bool readDark(const std::string& fileName, vtkImageData* data, diff --git a/tomviz/DataPropertiesModel.h b/tomviz/DataPropertiesModel.h index 1b61ef6c3..1124626e0 100644 --- a/tomviz/DataPropertiesModel.h +++ b/tomviz/DataPropertiesModel.h @@ -6,8 +6,6 @@ #include -#include "DataSource.h" - #include #include @@ -29,9 +27,9 @@ class DataPropertiesModel : public QAbstractTableModel QVariant headerData(int section, Qt::Orientation orientation, int role) const override; - Qt::ItemFlags flags(const QModelIndex& index) const; + Qt::ItemFlags flags(const QModelIndex& index) const override; bool setData(const QModelIndex& index, const QVariant& value, - int role = Qt::EditRole); + int role = Qt::EditRole) override; QList getArraysInfo() const; diff --git a/tomviz/DataPropertiesPanel.cxx b/tomviz/DataPropertiesPanel.cxx deleted file mode 100644 index c7c77c275..000000000 --- a/tomviz/DataPropertiesPanel.cxx +++ /dev/null @@ -1,916 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "DataPropertiesPanel.h" - -#include "ui_DataPropertiesPanel.h" - -#include -#include - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "ListEditorWidget.h" -#include "SetTiltAnglesOperator.h" -#include "SetTiltAnglesReaction.h" -#include "TimeSeriesStep.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -DataPropertiesPanel::DataPropertiesPanel(QWidget* parentObject) - : QWidget(parentObject), m_ui(new Ui::DataPropertiesPanel) -{ - m_ui->setupUi(this); - m_ui->xLengthBox->setValidator(new QDoubleValidator(m_ui->xLengthBox)); - m_ui->yLengthBox->setValidator(new QDoubleValidator(m_ui->yLengthBox)); - m_ui->zLengthBox->setValidator(new QDoubleValidator(m_ui->zLengthBox)); - m_ui->xVoxelSizeBox->setValidator(new QDoubleValidator(m_ui->xVoxelSizeBox)); - m_ui->yVoxelSizeBox->setValidator(new QDoubleValidator(m_ui->yVoxelSizeBox)); - m_ui->zVoxelSizeBox->setValidator(new QDoubleValidator(m_ui->zVoxelSizeBox)); - m_ui->TiltAnglesTable->installEventFilter(this); - m_ui->ScalarsTable->setModel(&m_scalarsTableModel); - - QVBoxLayout* l = m_ui->verticalLayout; - l->setSpacing(pqPropertiesPanel::suggestedVerticalSpacing()); - - // add separator labels. - QWidget* separator = pqProxyWidget::newGroupLabelWidget("Filename", this); - l->insertWidget(l->indexOf(m_ui->FileName), separator); - - // add separator labels. - separator = pqProxyWidget::newGroupLabelWidget("Active Scalars", this); - l->insertWidget(l->indexOf(m_ui->ActiveScalars), separator); - - m_timeSeriesSeparator = - pqProxyWidget::newGroupLabelWidget("Time Series", this); - l->insertWidget(l->indexOf(m_ui->timeSeriesGroup), m_timeSeriesSeparator); - - separator = pqProxyWidget::newGroupLabelWidget("Dimensions & Range", this); - l->insertWidget(l->indexOf(m_ui->DataRange), separator); - - separator = pqProxyWidget::newGroupLabelWidget("Units and Size", this); - auto index = l->indexOf(m_ui->LengthWidget); - l->insertWidget(index, separator); - - separator = pqProxyWidget::newGroupLabelWidget("Transformations", this); - index = l->indexOf(m_ui->transformWidget); - l->insertWidget(index, separator); - - m_tiltAnglesSeparator = - pqProxyWidget::newGroupLabelWidget("Tilt Angles", this); - l->insertWidget(l->indexOf(m_ui->SetTiltAnglesButton), m_tiltAnglesSeparator); - - clear(); - - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &DataPropertiesPanel::setDataSource); - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &DataPropertiesPanel::updateAxesGridLabels); - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::viewChanged), - this, &DataPropertiesPanel::updateAxesGridLabels); - connect(m_ui->SetTiltAnglesButton, &QPushButton::clicked, this, - &DataPropertiesPanel::setTiltAngles); - connect(m_ui->saveTiltAngles, &QPushButton::clicked, this, - &DataPropertiesPanel::saveTiltAngles); - connect(m_ui->unitBox, &QLineEdit::editingFinished, this, - &DataPropertiesPanel::updateUnits); - connect(m_ui->xLengthBox, &QLineEdit::editingFinished, - [this]() { this->updateLength(m_ui->xLengthBox, 0); }); - connect(m_ui->yLengthBox, &QLineEdit::editingFinished, - [this]() { this->updateLength(m_ui->yLengthBox, 1); }); - connect(m_ui->zLengthBox, &QLineEdit::editingFinished, - [this]() { this->updateLength(m_ui->zLengthBox, 2); }); - - connect(m_ui->xVoxelSizeBox, &QLineEdit::editingFinished, - [this]() { this->updateVoxelSize(m_ui->xVoxelSizeBox, 0); }); - connect(m_ui->yVoxelSizeBox, &QLineEdit::editingFinished, - [this]() { this->updateVoxelSize(m_ui->yVoxelSizeBox, 1); }); - connect(m_ui->zVoxelSizeBox, &QLineEdit::editingFinished, - [this]() { this->updateVoxelSize(m_ui->zVoxelSizeBox, 2); }); - - connect(m_ui->xOriginBox, &QLineEdit::editingFinished, - [this]() { this->updateOrigin(m_ui->xOriginBox, 0); }); - connect(m_ui->yOriginBox, &QLineEdit::editingFinished, - [this]() { this->updateOrigin(m_ui->yOriginBox, 1); }); - connect(m_ui->zOriginBox, &QLineEdit::editingFinished, - [this]() { this->updateOrigin(m_ui->zOriginBox, 2); }); - - connect(m_ui->xOrientationBox, &QLineEdit::editingFinished, - [this]() { this->updateOrientation(m_ui->xOrientationBox, 0); }); - connect(m_ui->yOrientationBox, &QLineEdit::editingFinished, - [this]() { this->updateOrientation(m_ui->yOrientationBox, 1); }); - connect(m_ui->zOrientationBox, &QLineEdit::editingFinished, - [this]() { this->updateOrientation(m_ui->zOrientationBox, 2); }); - - connect(m_ui->ActiveScalars, &QComboBox::currentTextChanged, this, - &DataPropertiesPanel::setActiveScalars); - connect(&m_scalarsTableModel, &DataPropertiesModel::activeScalarsChanged, - this, &DataPropertiesPanel::setActiveScalars); - connect(m_ui->componentNamesEditor, &ComboTextEditor::itemEdited, this, - &DataPropertiesPanel::componentNameEdited); - - connect(m_ui->showTimeSeriesLabel, &QCheckBox::toggled, - &ActiveObjects::instance(), &ActiveObjects::setShowTimeSeriesLabel); - connect(&ActiveObjects::instance(), - &ActiveObjects::showTimeSeriesLabelChanged, this, - &DataPropertiesPanel::updateTimeSeriesGroup); - - connect(m_ui->editTimeSeries, &QPushButton::clicked, this, - &DataPropertiesPanel::editTimeSeries); - - connect(m_ui->interactTranslate, &QCheckBox::clicked, - &ActiveObjects::instance(), &ActiveObjects::enableTranslation); - connect(m_ui->interactRotate, &QCheckBox::clicked, &ActiveObjects::instance(), - &ActiveObjects::enableRotation); - connect(m_ui->interactScale, &QCheckBox::clicked, &ActiveObjects::instance(), - &ActiveObjects::enableScaling); - - connect(&ActiveObjects::instance(), &ActiveObjects::translationStateChanged, - m_ui->interactTranslate, &QCheckBox::setChecked); - connect(&ActiveObjects::instance(), &ActiveObjects::rotationStateChanged, - m_ui->interactRotate, &QCheckBox::setChecked); - connect(&ActiveObjects::instance(), &ActiveObjects::scalingStateChanged, - m_ui->interactScale, &QCheckBox::setChecked); - - m_ui->interactTranslate->setChecked( - ActiveObjects::instance().translationEnabled()); - m_ui->interactRotate->setChecked(ActiveObjects::instance().rotationEnabled()); - m_ui->interactScale->setChecked(ActiveObjects::instance().scalingEnabled()); -} - -DataPropertiesPanel::~DataPropertiesPanel() {} - -void DataPropertiesPanel::paintEvent(QPaintEvent* e) -{ - updateData(); - QWidget::paintEvent(e); -} - -void DataPropertiesPanel::setDataSource(DataSource* dsource) -{ - if (m_currentDataSource) { - disconnect(m_currentDataSource, 0, this, 0); - disconnect(&m_scalarsTableModel, 0, m_currentDataSource, 0); - } - m_currentDataSource = dsource; - if (dsource) { - connect(dsource, &DataSource::dataChanged, this, - &DataPropertiesPanel::scheduleUpdate, Qt::UniqueConnection); - connect(dsource, &DataSource::dataPropertiesChanged, this, - &DataPropertiesPanel::onDataPropertiesChanged); - connect(dsource, &DataSource::displayPositionChanged, this, - &DataPropertiesPanel::onDataPositionChanged); - connect(dsource, &DataSource::displayOrientationChanged, this, - &DataPropertiesPanel::onDataOrientationChanged); - connect(&m_scalarsTableModel, &DataPropertiesModel::scalarsRenamed, dsource, - &DataSource::renameScalarsArray); - } - m_scalarIndexes.clear(); - scheduleUpdate(); -} - -void DataPropertiesPanel::onDataPropertiesChanged() -{ - if (!m_currentDataSource) { - return; - } - - double length[3]; - m_currentDataSource->getPhysicalDimensions(length); - auto origin = m_currentDataSource->displayPosition(); - auto orientation = m_currentDataSource->displayOrientation(); - auto* spacing = m_currentDataSource->getSpacing(); - - m_ui->xLengthBox->setText(QString("%1").arg(length[0])); - m_ui->yLengthBox->setText(QString("%1").arg(length[1])); - m_ui->zLengthBox->setText(QString("%1").arg(length[2])); - - m_ui->xVoxelSizeBox->setText(QString("%1").arg(spacing[0])); - m_ui->yVoxelSizeBox->setText(QString("%1").arg(spacing[1])); - m_ui->zVoxelSizeBox->setText(QString("%1").arg(spacing[2])); - - m_ui->xOriginBox->setText(QString("%1").arg(origin[0])); - m_ui->yOriginBox->setText(QString("%1").arg(origin[1])); - m_ui->zOriginBox->setText(QString("%1").arg(origin[2])); - - m_ui->xOrientationBox->setText(QString("%1").arg(orientation[0])); - m_ui->yOrientationBox->setText(QString("%1").arg(orientation[1])); - m_ui->zOrientationBox->setText(QString("%1").arg(orientation[2])); -} - -void DataPropertiesPanel::onDataPositionChanged(double, double, double) -{ - this->onDataPropertiesChanged(); -} - -void DataPropertiesPanel::onDataOrientationChanged(double, double, double) -{ - this->onDataPropertiesChanged(); -} - -namespace { - -QString getDataDimensionsString(vtkSMSourceProxy* proxy) -{ - vtkPVDataInformation* info = proxy->GetDataInformation(0); - - QString extentString = - QString("Dimensions: %1 x %2 x %3") - .arg(info->GetExtent()[1] - info->GetExtent()[0] + 1) - .arg(info->GetExtent()[3] - info->GetExtent()[2] + 1) - .arg(info->GetExtent()[5] - info->GetExtent()[4] + 1); - - return extentString; -} - -QString getNumVoxelsString(vtkSMSourceProxy* proxy) -{ - vtkPVDataInformation* info = proxy->GetDataInformation(0); - vtkTypeInt64 numVoxels = info->GetNumberOfPoints(); - return "Voxels: " + getSizeNearestThousand(numVoxels); -} - -QString getMemSizeString(vtkSMSourceProxy* proxy) -{ - vtkPVDataInformation* info = proxy->GetDataInformation(0); - - // GetMemorySize() returns kilobytes - // Cast it to size_t to prevent integer overflows - size_t memSize = static_cast(info->GetMemorySize()) * 1000; - - return "Memory: " + getSizeNearestThousand(memSize, true); -} - -} // namespace - -QList DataPropertiesPanel::getArraysInfo(DataSource* dataSource) -{ - QList arraysInfo; - - // If we don't have the scalar indexes, we sort the names and then save the - // indexes, these will be used to preserve the displayed order even after - // a rename. - if (m_scalarIndexes.isEmpty()) { - auto scalars = dataSource->listScalars(); - auto sortedScalars = scalars; - - // sort the scalars names - std::sort(sortedScalars.begin(), sortedScalars.end(), - [](const QString& a, const QString& b) { return a < b; }); - - // Now save the indexes of the sorted scalars - for (auto scalar : sortedScalars) { - auto index = std::distance( - scalars.begin(), std::find(scalars.begin(), scalars.end(), scalar)); - - m_scalarIndexes.push_back(index); - } - } - - // Remove any invalid scalar indices to prevent a crash - QList toRemove; - for (auto i : m_scalarIndexes) { - // name, type, data range, data type, active - auto arrayName = dataSource->scalarsName(i); - auto array = dataSource->getScalarsArray(arrayName); - if (!array) { - toRemove.append(i); - } - } - - while (toRemove.size() != 0) { - m_scalarIndexes.remove(toRemove[0]); - toRemove.removeAt(0); - } - - for (auto i : m_scalarIndexes) { - // name, type, data range, data type, active - auto arrayName = dataSource->scalarsName(i); - auto array = dataSource->getScalarsArray(arrayName); - if (!array) { - continue; - } - - QString dataType = vtkImageScalarTypeNameMacro(array->GetDataType()); - int numComponents = array->GetNumberOfComponents(); - QString dataRange; - double range[2]; - bool active = m_currentDataSource->activeScalars() == arrayName; - - for (int j = 0; j < numComponents; j++) { - if (j != 0) { - dataRange.append(", "); - } - array->GetRange(range, j); - QString componentRange = QString("[%1, %2]").arg(range[0]).arg(range[1]); - dataRange.append(componentRange); - } - - arraysInfo.push_back(ArrayInfo(arrayName, - dataType == "string" ? tr("NA") : dataRange, - dataType, active)); - } - - return arraysInfo; -} - -void DataPropertiesPanel::updateActiveScalarsCombo( - QComboBox* scalarsCombo, const QList& arraysInfo) -{ - scalarsCombo->clear(); - scalarsCombo->blockSignals(true); - - foreach (auto array, arraysInfo) { - scalarsCombo->addItem(array.name); - if (array.active) { - scalarsCombo->setCurrentText(array.name); - } - } - - scalarsCombo->blockSignals(false); -} - -void DataPropertiesPanel::updateInformationWidget( - QTableView* scalarsTable, const QList& arraysInfo) -{ - auto model = static_cast(scalarsTable->model()); - model->setArraysInfo(arraysInfo); - scalarsTable->resizeColumnsToContents(); - scalarsTable->horizontalHeader()->setSectionResizeMode(1, - QHeaderView::Stretch); -} - -void DataPropertiesPanel::updateData() -{ - if (!m_updateNeeded) { - return; - } - - disconnect(m_ui->TiltAnglesTable, &QTableWidget::cellChanged, this, - &DataPropertiesPanel::onTiltAnglesModified); - clear(); - - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - - m_ui->FileName->setText(dsource->fileName()); - - m_ui->DataRange->setText(getDataDimensionsString(dsource->proxy())); - m_ui->NumVoxels->setText(getNumVoxelsString(dsource->proxy())); - m_ui->MemSize->setText(getMemSizeString(dsource->proxy())); - - this->onDataPropertiesChanged(); - - m_ui->unitBox->setText(m_currentDataSource->getUnits()); - - auto sourceProxy = vtkSMSourceProxy::SafeDownCast(dsource->proxy()); - if (sourceProxy) { - auto arraysInfo = getArraysInfo(dsource); - updateInformationWidget(m_ui->ScalarsTable, arraysInfo); - updateActiveScalarsCombo(m_ui->ActiveScalars, arraysInfo); - } - - // display tilt series data - if (dsource->type() == DataSource::TiltSeries) { - m_tiltAnglesSeparator->show(); - m_ui->SetTiltAnglesButton->show(); - m_ui->TiltAnglesTable->show(); - m_ui->saveTiltAngles->show(); - QVector tiltAngles = dsource->getTiltAngles(); - QVector scanIDs = dsource->getScanIDs(); - m_hasScanIDs = scanIDs.size() == tiltAngles.size() && !scanIDs.isEmpty(); - m_ui->TiltAnglesTable->setRowCount(tiltAngles.size()); - int numCols = m_hasScanIDs ? 2 : 1; - m_ui->TiltAnglesTable->setColumnCount(numCols); - int tiltCol = m_hasScanIDs ? 1 : 0; - for (int i = 0; i < tiltAngles.size(); ++i) { - if (m_hasScanIDs) { - QTableWidgetItem* scanItem = new QTableWidgetItem(); - scanItem->setData(Qt::DisplayRole, QString::number(scanIDs[i])); - scanItem->setFlags(scanItem->flags() & ~Qt::ItemIsEditable); - m_ui->TiltAnglesTable->setItem(i, 0, scanItem); - } - QTableWidgetItem* item = new QTableWidgetItem(); - item->setData(Qt::DisplayRole, QString::number(tiltAngles[i])); - m_ui->TiltAnglesTable->setItem(i, tiltCol, item); - } - // Set column headers - QStringList headers; - if (m_hasScanIDs) { - headers << "Scan ID"; - } - headers << "Tilt Angle"; - m_ui->TiltAnglesTable->setHorizontalHeaderLabels(headers); - m_ui->TiltAnglesTable->horizontalHeader()->setStretchLastSection(true); - } else { - m_tiltAnglesSeparator->hide(); - m_ui->SetTiltAnglesButton->hide(); - m_ui->TiltAnglesTable->hide(); - m_ui->saveTiltAngles->hide(); - } - connect(m_ui->TiltAnglesTable, &QTableWidget::cellChanged, this, - &DataPropertiesPanel::onTiltAnglesModified); - - updateTimeSeriesGroup(); - updateComponentsCombo(); - - m_updateNeeded = false; -} - -void DataPropertiesPanel::updateTimeSeriesGroup() -{ - auto* ds = m_currentDataSource.data(); - bool visible = ds && ds->hasTimeSteps(); - - m_timeSeriesSeparator->setVisible(visible); - m_ui->timeSeriesGroup->setVisible(visible); - - if (!visible) { - return; - } - - m_ui->showTimeSeriesLabel->setChecked( - ActiveObjects::instance().showTimeSeriesLabel()); -} - -void DataPropertiesPanel::editTimeSeries() -{ - if (m_timeSeriesEditor) { - m_timeSeriesEditor->reject(); - m_timeSeriesEditor->deleteLater(); - } - - QPointer ds = m_currentDataSource; - if (!ds) { - return; - } - - auto steps = ds->timeSeriesSteps(); - QStringList labels; - for (auto& step : steps) { - labels.append(step.label); - } - - m_timeSeriesEditor = new ListEditorDialog(labels, this); - m_timeSeriesEditor->setToolTip("Left-click and drag a row to re-order time " - "series steps. Double-click to edit a label."); - m_timeSeriesEditor->setWindowTitle("Edit Time Series"); - m_timeSeriesEditor->show(); - auto* editor = m_timeSeriesEditor.data(); - - auto onAccepted = [ds, editor, steps]() { - auto newOrder = editor->currentOrder(); - auto newNames = editor->currentNames(); - - // First, re-arrange the time steps according to the new ordering - QList newSteps; - for (auto i : newOrder) { - newSteps.append(steps[i]); - } - - // Now, rename them - for (int i = 0; i < newNames.size(); ++i) { - newSteps[i].label = newNames[i]; - } - - // Finally, set the new steps - ds->setTimeSeriesSteps(newSteps); - }; - - connect(m_timeSeriesEditor, &QDialog::accepted, ds, onAccepted); -} - -void DataPropertiesPanel::updateComponentsCombo() -{ - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - - auto label = m_ui->componentNamesEditorLabel; - auto combo = m_ui->componentNamesEditor; - auto blocked = QSignalBlocker(combo); - - // Only make this editor visible if there is more than one component - bool visible = dsource->scalars() && dsource->scalars()->GetNumberOfComponents() > 1; - label->setVisible(visible); - combo->setVisible(visible); - - if (visible) { - combo->clear(); - combo->addItems(dsource->componentNames()); - } -} - -void DataPropertiesPanel::onTiltAnglesModified(int row, int column) -{ - DataSource* dsource = m_currentDataSource; - // The table shouldn't be shown if this is not true, so this slot shouldn't be - // called - Q_ASSERT(dsource->type() == DataSource::TiltSeries); - // Tilt angles are in column 1 when scan IDs are present, column 0 otherwise - int tiltCol = m_hasScanIDs ? 1 : 0; - if (column != tiltCol) { - return; - } - QTableWidgetItem* item = m_ui->TiltAnglesTable->item(row, column); - auto ok = false; - auto value = item->data(Qt::DisplayRole).toDouble(&ok); - if (ok) { - auto needToAdd = false; - SetTiltAnglesOperator* op = nullptr; - if (dsource->operators().size() > 0) { - op = qobject_cast(dsource->operators().last()); - } - if (!op) { - op = new SetTiltAnglesOperator; - op->setParent(dsource); - needToAdd = true; - } - auto tiltAngles = op->tiltAngles(); - tiltAngles[row] = value; - op->setTiltAngles(tiltAngles); - if (needToAdd) { - dsource->addOperator(op); - } - } else { - std::cerr << "Invalid tilt angle." << std::endl; - } -} - -bool DataPropertiesPanel::eventFilter(QObject* obj, QEvent* event) -{ - QKeyEvent* ke = dynamic_cast(event); - if (ke && obj == m_ui->TiltAnglesTable) { - if (ke->matches(QKeySequence::Paste) && ke->type() == QEvent::KeyPress) { - QClipboard* clipboard = QGuiApplication::clipboard(); - const QMimeData* mimeData = clipboard->mimeData(); - if (mimeData->hasText()) { - QString text = mimeData->text(); - QStringList rows = text.split("\n"); - QStringList angles; - for (const QString& row : rows) { - angles << row.split("\t")[0]; - } - auto ranges = m_ui->TiltAnglesTable->selectedRanges(); - // check if the table in the clipboard is of numbers - for (const QString& angle : angles) { - bool ok; - angle.toDouble(&ok); - if (!ok) { - QMessageBox::warning( - this, "Error", - QString("Error: pasted tilt angle %1 is not a number") - .arg(angle)); - return true; - } - } - // If separate blocks of rows selected, cancel the paste - // since we don't know where to put angles - if (ranges.size() != 1) { - QMessageBox::warning( - this, "Error", - "Pasting is not supported with non-continuous selections"); - return true; - } - // If multiple rows selected and it is not equal to - // the number of angles pasted, cancel the paste - if (ranges[0].rowCount() > 1 && ranges[0].rowCount() != angles.size()) { - QMessageBox::warning( - this, "Error", - QString("Cells selected (%1) does not match number " - "of angles to paste (%2). \n" - "Please select one cell to mark the start " - "location for pasting or select the same " - "number of cells that will be pasted into.") - .arg(ranges[0].rowCount()) - .arg(angles.size())); - return true; - } - auto needToAdd = false; - SetTiltAnglesOperator* op = nullptr; - DataSource* dsource = m_currentDataSource; - if (dsource->operators().size() > 0) { - op = - qobject_cast(dsource->operators().last()); - } - if (!op) { - op = new SetTiltAnglesOperator; - op->setParent(dsource); - needToAdd = true; - } - auto tiltAngles = op->tiltAngles(); - int startRow = ranges[0].topRow(); - for (int i = 0; i < angles.size() && i + startRow < tiltAngles.size(); - ++i) { - bool ok; - tiltAngles[i + startRow] = angles[i].toDouble( - &ok); // no need to check ok, we checked these above - } - op->setTiltAngles(tiltAngles); - if (needToAdd) { - dsource->addOperator(op); - } - } - return true; - } - } - return QWidget::eventFilter(obj, event); -} - -void DataPropertiesPanel::setTiltAngles() -{ - DataSource* dsource = m_currentDataSource; - auto mainWindow = qobject_cast(window()); - SetTiltAnglesReaction::showSetTiltAnglesUI(mainWindow, dsource); -} - -void DataPropertiesPanel::saveTiltAngles() -{ - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - - // Prompt user to select a file for saving - QString fileName = QFileDialog::getSaveFileName( - nullptr, - "Save Tilt Angles", - QString(), // Default directory (or you can specify a path) - "TXT Files (*.txt);;All Files (*)" - ); - - // Check if user cancelled - if (fileName.isEmpty()) { - return; - } - - // Ensure the file has a .txt extension - if (!fileName.endsWith(".txt", Qt::CaseInsensitive)) { - fileName += ".txt"; - } - - auto tiltAngles = dsource->getTiltAngles(); - auto scanIDs = dsource->getScanIDs(); - - // Open file for writing - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - QMessageBox::warning(nullptr, "Error", - "Could not open file for writing: " + file.errorString()); - return; - } - - // Write scan IDs (if available) and tilt angles, one per line - QTextStream out(&file); - for (int i = 0; i < tiltAngles.size(); ++i) { - if (m_hasScanIDs) { - out << scanIDs[i] << " "; - } - out << tiltAngles[i] << "\n"; - } - - file.close(); -} - -void DataPropertiesPanel::scheduleUpdate() -{ - m_updateNeeded = true; - if (isVisible()) { - updateData(); - } -} - -void DataPropertiesPanel::updateUnits() -{ - if (m_currentDataSource) { - const QString& text = m_ui->unitBox->text(); - m_currentDataSource->setUnits(text); - updateAxesGridLabels(); - } -} - -bool DataPropertiesPanel::parseField(QLineEdit* widget, double& value) -{ - const QString& text = widget->text(); - bool ok = false; - value = text.toDouble(&ok); - if (!ok) { - qWarning() << "Failed to parse string"; - } - - return ok; -} - -void DataPropertiesPanel::updateLength(QLineEdit* widget, int axis) -{ - double newLength; - bool ok = DataPropertiesPanel::parseField(widget, newLength); - - if (!ok) { - return; - } - - updateSpacing(axis, newLength); - updateData(); - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - resetCamera(); - emit dsource->dataPropertiesChanged(); -} - -void DataPropertiesPanel::updateVoxelSize(QLineEdit* widget, int axis) -{ - double newSize; - bool ok = DataPropertiesPanel::parseField(widget, newSize); - - if (!ok) { - return; - } - - double spacing[3]; - m_currentDataSource->getSpacing(spacing); - spacing[axis] = newSize; - m_currentDataSource->setSpacing(spacing); - - updateData(); - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - resetCamera(); - emit dsource->dataPropertiesChanged(); -} - -void DataPropertiesPanel::updateOrigin(QLineEdit* widget, int axis) -{ - double newValue; - bool ok = DataPropertiesPanel::parseField(widget, newValue); - - if (!ok) { - return; - } - - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - - auto origin = dsource->displayPosition(); - double newOrigin[3] = { origin[0], origin[1], origin[2] }; - newOrigin[axis] = newValue; - dsource->setDisplayPosition(newOrigin); -} - -void DataPropertiesPanel::updateOrientation(QLineEdit* widget, int axis) -{ - double newValue; - bool ok = DataPropertiesPanel::parseField(widget, newValue); - - if (!ok) { - return; - } - - DataSource* dsource = m_currentDataSource; - if (!dsource) { - return; - } - - auto orientation = dsource->displayOrientation(); - double newOrientation[3] = { orientation[0], orientation[1], orientation[2] }; - newOrientation[axis] = newValue; - dsource->setDisplayOrientation(newOrientation); -} - -void DataPropertiesPanel::resetCamera() -{ - pqView* view = ActiveObjects::instance().activePqView(); - if (view) { - view->resetDisplay(); - } -} - -void DataPropertiesPanel::updateAxesGridLabels() -{ - vtkSMViewProxy* view = ActiveObjects::instance().activeView(); - if (!view) { - return; - } - auto axesGrid = vtkSMPropertyHelper(view, "AxesGrid", true).GetAsProxy(); - DataSource* ds = ActiveObjects::instance().activeDataSource(); - if (!axesGrid || !ds) { - return; - } - QString xTitle = QString("X (%1)").arg(ds->getUnits()); - QString yTitle = QString("Y (%1)").arg(ds->getUnits()); - QString zTitle = QString("Z (%1)").arg(ds->getUnits()); - vtkSMPropertyHelper(axesGrid, "XTitle").Set(xTitle.toUtf8().data()); - vtkSMPropertyHelper(axesGrid, "YTitle").Set(yTitle.toUtf8().data()); - vtkSMPropertyHelper(axesGrid, "ZTitle").Set(zTitle.toUtf8().data()); - axesGrid->UpdateVTKObjects(); - - pqView* qtView = - tomviz::convert(ActiveObjects::instance().activeView()); - if (qtView) { - qtView->render(); - } -} - -void DataPropertiesPanel::setActiveScalars(const QString& activeScalars) -{ - if (activeScalars.size() == 0) { - return; - } - - if (m_currentDataSource && - m_currentDataSource->activeScalars() != activeScalars) { - m_currentDataSource->setActiveScalars(activeScalars); - } -} - -void DataPropertiesPanel::componentNameEdited(int index, const QString& name) -{ - if (!m_currentDataSource) { - return; - } - - m_currentDataSource->setComponentName(index, name); -} - -void DataPropertiesPanel::clear() -{ - m_ui->FileName->setText(""); - m_ui->DataRange->setText(""); - m_ui->NumVoxels->setText(""); - m_ui->MemSize->setText(""); - m_ui->ActiveScalars->clear(); - m_scalarsTableModel.setArraysInfo(QList()); - - if (m_colorMapWidget) { - m_ui->verticalLayout->removeWidget(m_colorMapWidget); - delete m_colorMapWidget; - } - m_tiltAnglesSeparator->hide(); - m_ui->SetTiltAnglesButton->hide(); - m_ui->TiltAnglesTable->clear(); - m_ui->TiltAnglesTable->setRowCount(0); - m_ui->TiltAnglesTable->hide(); - m_ui->saveTiltAngles->hide(); -} - -void DataPropertiesPanel::updateSpacing(int axis, double newLength) -{ - if (!m_currentDataSource) { - return; - } - int extent[6]; - double spacing[3]; - m_currentDataSource->getExtent(extent); - m_currentDataSource->getSpacing(spacing); - spacing[axis] = newLength / (extent[2 * axis + 1] - extent[2 * axis] + 1); - m_currentDataSource->setSpacing(spacing); -} -} // namespace tomviz diff --git a/tomviz/DataPropertiesPanel.h b/tomviz/DataPropertiesPanel.h deleted file mode 100644 index 0fb4463f8..000000000 --- a/tomviz/DataPropertiesPanel.h +++ /dev/null @@ -1,103 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizDataPropertiesPanel_h -#define tomvizDataPropertiesPanel_h - -#include - -#include "DataPropertiesModel.h" - -#include -#include - -class pqProxyWidget; -class QComboBox; -class QLineEdit; -class QTableView; -class vtkPVDataInformation; - -namespace Ui { -class DataPropertiesPanel; -} - -namespace tomviz { - -class DataSource; -class ListEditorDialog; - -/// DataPropertiesPanel is the panel that shows information (and other controls) -/// for a DataSource. It monitors tomviz::ActiveObjects instance and shows -/// information about the active data source, as well allow the user to edit -/// configurable options, such as color map. -class DataPropertiesPanel : public QWidget -{ - Q_OBJECT - -public: - explicit DataPropertiesPanel(QWidget* parent = nullptr); - ~DataPropertiesPanel() override; - - bool eventFilter(QObject*, QEvent*) override; - -protected: - void paintEvent(QPaintEvent*) override; - void updateData(); - void updateTimeSeriesGroup(); - void updateComponentsCombo(); - static bool parseField(QLineEdit* widget, double& value); - void updateLength(QLineEdit* widget, int axis); - void updateVoxelSize(QLineEdit* widget, int axis); - void updateOrigin(QLineEdit* widget, int axis); - void updateOrientation(QLineEdit* widget, int axis); - -private slots: - void setDataSource(DataSource*); - void onTiltAnglesModified(int row, int column); - void setTiltAngles(); - void saveTiltAngles(); - void scheduleUpdate(); - void onDataPropertiesChanged(); - void onDataPositionChanged(double, double, double); - void onDataOrientationChanged(double, double, double); - void editTimeSeries(); - - void updateUnits(); - - void updateAxesGridLabels(); - - void setActiveScalars(const QString& activeScalars); - - void componentNameEdited(int index, const QString& name); - -signals: - void colorMapUpdated(); - -private: - Q_DISABLE_COPY(DataPropertiesPanel) - - bool m_updateNeeded = true; - QScopedPointer m_ui; - QPointer m_currentDataSource; - QPointer m_colorMapWidget; - QPointer m_tiltAnglesSeparator; - QPointer m_timeSeriesSeparator; - QPointer m_timeSeriesEditor; - DataPropertiesModel m_scalarsTableModel; - // Hold the order (the indexes into the field data), so we can preserve - // the order during a rename. - QList m_scalarIndexes; - bool m_hasScanIDs = false; - - void clear(); - void updateSpacing(int axis, double newLength); - QList getArraysInfo(DataSource* dataSource); - void updateInformationWidget(QTableView* scalarsTable, - const QList& arraysInfo); - void updateActiveScalarsCombo(QComboBox* scalarsCombo, - const QList& arraysInfo); - static void resetCamera(); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/DataPropertiesPanel.ui b/tomviz/DataPropertiesPanel.ui deleted file mode 100644 index b9c108ccd..000000000 --- a/tomviz/DataPropertiesPanel.ui +++ /dev/null @@ -1,542 +0,0 @@ - - - DataPropertiesPanel - - - - 0 - 0 - 482 - 707 - - - - Form - - - - 2 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - true - - - false - - - true - - - - - - - - - - - - - Show/hide the time series label in the render view - - - Show label? - - - - - - - Edit the order of time series steps and their labels - - - Edit Time Series - - - - - - - - - - - - Component Names: - - - - - - - - - - - - DataRange - - - true - - - - - - - NumVoxels - - - - - - - MemSize - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 2 - - - - - Units - - - - - - - x - - - - - - - Qt::AlignCenter - - - - - - - ( - - - - - - - Qt::AlignCenter - - - - - - - Y - - - - - - - Qt::AlignCenter - - - - - - - ) - - - - - - - x - - - - - - - X - - - - - - - Qt::AlignCenter - - - - - - - Qt::AlignCenter - - - - - - - Z - - - - - - - Qt::AlignCenter - - - - - - - Qt::AlignCenter - - - - - - - Qt::Horizontal - - - - - - - voxel sizes - - - Qt::AlignCenter - - - - - - - x - - - - - - - x - - - - - - - ( - - - - - - - ) - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 2 - - - - - Z - - - - - - - Rotation: - - - - - - - Qt::AlignCenter - - - - - - - Qt::AlignCenter - - - - - - - X - - - - - - - Interaction - - - - - - <html><head/><body><p>Translate by either left-clicking and dragging the central handle, or by middle-clicking and dragging the data source.</p></body></html> - - - Translate - - - - - - - <html><head/><body><p>Rotate by left-clicking a face and dragging it.</p><p>Rotation interactions are performed about the center of the data source.</p></body></html> - - - Rotate - - - - - - - <html><head/><body><p>Rescale individual axes by left-clicking the handles on the faces and dragging them.</p><p>Rescale with fixed aspect ratio by right-clicking the data source and moving the mouse.</p></body></html> - - - Scale - - - - - - - - - - Qt::AlignCenter - - - - - - - Origin: - - - - - - - Qt::AlignCenter - - - - - - - Qt::AlignCenter - - - - - - - Y - - - - - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 5 - - - - - - - - - - - - - Set Tilt Angles - - - - - - - - 0 - 0 - - - - - - - - <html><head/><body><p>Save the tilt angles to an XY file.</p></body></html> - - - Save Tilt Angles - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - tomviz::ComboTextEditor - QComboBox -
ComboTextEditor.h
-
-
- - FileName - ActiveScalars - showTimeSeriesLabel - editTimeSeries - componentNamesEditor - ScalarsTable - xLengthBox - yLengthBox - zLengthBox - unitBox - xVoxelSizeBox - yVoxelSizeBox - zVoxelSizeBox - xOriginBox - yOriginBox - zOriginBox - xOrientationBox - yOrientationBox - zOrientationBox - interactTranslate - interactRotate - interactScale - SetTiltAnglesButton - TiltAnglesTable - - - -
diff --git a/tomviz/DataSource.cxx b/tomviz/DataSource.cxx deleted file mode 100644 index f3cba6f26..000000000 --- a/tomviz/DataSource.cxx +++ /dev/null @@ -1,1954 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "DataSource.h" - -#include "core/DataSourceBase.h" - -#include "ActiveObjects.h" -#include "ColorMap.h" -#include "DataExchangeFormat.h" -#include "EmdFormat.h" -#include "GenericHDF5Format.h" -#include "ModuleFactory.h" -#include "ModuleManager.h" -#include "Operator.h" -#include "OperatorFactory.h" -#include "Pipeline.h" -#include "TimeSeriesStep.h" -#include "Utilities.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace { -void createOrResizeTiltAnglesArray(vtkDataObject* data) -{ - auto fd = data->GetFieldData(); - auto* imageData = vtkImageData::SafeDownCast(data); - if (!imageData) { - return; - } - int* extent = imageData->GetExtent(); - int numTiltAngles = extent[5] - extent[4] + 1; - if (!fd->HasArray("tilt_angles")) { - vtkNew array; - array->SetName("tilt_angles"); - array->SetNumberOfTuples(numTiltAngles); - array->FillComponent(0, 0.0); - fd->AddArray(array); - } else { - // if it exists, ensure the size of the tilt angles array - // corresponds to the size of the data - auto array = fd->GetArray("tilt_angles"); - if (numTiltAngles != array->GetNumberOfTuples()) { - array->SetNumberOfTuples(numTiltAngles); - } - } -} -} // namespace - -namespace tomviz { - -class DataSource::DSInternals -{ -public: - vtkNew m_transfer2D; - vtkNew GradientOpacityMap; - // vtkSmartPointer DataSourceProxy; - vtkSmartPointer m_darkData; - vtkSmartPointer m_whiteData; - vtkSmartPointer ProducerProxy; - QList Operators; - vtkSmartPointer ColorMap; - DataSource::DataSourceType Type; - vtkSmartPointer Units; - vtkVector3d DisplayPosition; - vtkVector3d DisplayOrientation; - PersistenceState PersistState = PersistenceState::Saved; - vtkRectd m_transferFunction2DBox; - bool UnitsModified = false; - bool Forkable = true; - // Track data array renames - QMap CurrentToOriginal; - QList timeSeriesSteps; - int currentTimeStep = 0; - - // Checks if the tilt angles data array exists on the given VTK data - // and creates it if it does not exist. - void ensureTiltAnglesArrayExists() - { - auto alg = - vtkAlgorithm::SafeDownCast(this->ProducerProxy->GetClientSideObject()); - Q_ASSERT(alg); - auto data = alg->GetOutputDataObject(0); - createOrResizeTiltAnglesArray(data); - } -}; - -DataSource::DataSource(vtkSMSourceProxy* dataSource, DataSourceType dataType) - : QObject(nullptr), Internals(new DSInternals) -{ - const char* sourceFilename = nullptr; - - QByteArray fileNameBytes; - if (vtkSMCoreUtilities::GetFileNameProperty(dataSource)) { - vtkSMPropertyHelper helper( - dataSource, vtkSMCoreUtilities::GetFileNameProperty(dataSource)); - // If we are dealing with an image stack find the prefix to use - // when displaying the data source. - if (helper.GetNumberOfElements() > 1) { - QStringList fileNames; - for (unsigned int i = 0; i < helper.GetNumberOfElements(); i++) { - fileNames << QString(helper.GetAsString(i)); - } - QFileInfo fileInfo(fileNames[0]); - QString fileName = - QString("%1*.%2").arg(findPrefix(fileNames)).arg(fileInfo.suffix()); - fileNameBytes = fileName.toLatin1(); - sourceFilename = fileNameBytes.data(); - } else { - sourceFilename = helper.GetAsString(); - } - } - - dataSource->UpdatePipeline(); - auto algo = vtkAlgorithm::SafeDownCast(dataSource->GetClientSideObject()); - Q_ASSERT(algo); - auto data = algo->GetOutputDataObject(0); - auto image = vtkImageData::SafeDownCast(data); - - // Initialize our object, and set the file name. - init(image, dataType, PersistenceState::Saved); - setFileName(sourceFilename); -} - -DataSource::DataSource(vtkImageData* data, DataSourceType dataType, - QObject* parentObject, PersistenceState persistState) - : QObject(parentObject), Internals(new DSInternals) -{ - init(data, dataType, persistState); -} - -DataSource::DataSource(const QString& label, DataSourceType dataType, - QObject* parent, PersistenceState persistState, - const QJsonObject& sourceInfo) - : QObject(parent), Internals(new DSInternals) -{ - init(nullptr, dataType, persistState); - - if (!label.isNull()) { - setLabel(label); - } - if (!sourceInfo.isEmpty()) { - m_json["sourceInformation"] = sourceInfo; - } -} - -DataSource::~DataSource() -{ - if (this->Internals->ProducerProxy) { - vtkNew controller; - controller->UnRegisterProxy(this->Internals->ProducerProxy); - } - delete m_pythonProxy; -} - -// Simple templated function to extend the image data. -template -void appendImageData(vtkImageData* data, vtkImageData* slice, T* originalPtr) -{ - auto dataArray = data->GetPointData()->GetScalars(); - int extents[6]; - data->GetExtent(extents); - - // Figure out the number of bytes in the original data, and allocate a buffer. - int bufferSize = dataArray->GetNumberOfTuples() * - dataArray->GetNumberOfComponents() * sizeof(T); - unsigned char* buffer = new unsigned char[bufferSize]; - // Copy the original image data, as the allocate scalars will erase it. - std::memcpy(buffer, originalPtr, bufferSize); - // Now increment the z extent, and reallocate the scalar array (destructive). - ++extents[5]; - data->SetExtent(extents); - data->AllocateScalars(data->GetScalarType(), - data->GetNumberOfScalarComponents()); - // Copy the old data back into the new memory location, delete the buffer. - auto ptr = data->GetScalarPointer(); - std::memcpy(ptr, buffer, bufferSize); - delete[] buffer; - buffer = 0; - - // Now copy the new slice into the array. - void* imagePtr = data->GetScalarPointer(0, 0, extents[5]); - auto sliceArray = slice->GetPointData()->GetScalars(); - void* slicePtr = sliceArray->GetVoidPointer(0); - int sliceSize = sliceArray->GetNumberOfTuples() * - sliceArray->GetNumberOfComponents() * sizeof(T); - std::memcpy(imagePtr, slicePtr, sliceSize); - - // Let everyone know the data has changed, then re-execute the pipeline. - data->Modified(); -} - -bool DataSource::appendSlice(vtkImageData* slice) -{ - if (!slice) { - return false; - } - - int sliceExtents[6]; - slice->GetExtent(sliceExtents); - auto tp = algorithm(); - if (tp) { - auto data = vtkImageData::SafeDownCast(tp->GetOutputDataObject(0)); - if (data) { - int extents[6]; - data->GetExtent(extents); - std::cout << "The data is "; - for (int i = 0; i < 6; ++i) - std::cout << extents[i] << ", "; - std::cout << std::endl; - for (int i = 0; i < 4; ++i) { - if (extents[i] != sliceExtents[i]) { - std::cout << "Mismatch: " << extents[i] << " != " << sliceExtents[i] - << std::endl; - return false; - } - } - - // Now to append the slice onto our image data. - switch (data->GetScalarType()) { - vtkTemplateMacro(appendImageData( - data, slice, static_cast(data->GetScalarPointer()))); - } - - emit dataChanged(); - emit dataPropertiesChanged(); - pipeline()->execute()->deleteWhenFinished(); - } - } - return true; -} - -void DataSource::setFileName(const QString& filename) -{ - QStringList fileNames = QStringList(filename); - setFileNames(fileNames); -} - -QString DataSource::fileName() const -{ - auto reader = m_json.value("reader").toObject(QJsonObject()); - if (reader.contains("fileNames")) { - auto fileNames = reader["fileNames"].toArray(); - if (fileNames.size() > 0) { - return fileNames[0].toString(); - } - } - return QString(); -} - -void DataSource::setFileNames(const QStringList fileNames) -{ - auto reader = m_json.value("reader").toObject(QJsonObject()); - QJsonArray files; - foreach (QString file, fileNames) { - files.append(file); - } - - if (fileNames.size() != 0) { - // Set the python proxy file name too - m_pythonProxy->setFileName(fileNames[0].toStdString()); - } - - reader["fileNames"] = files; - m_json["reader"] = reader; -} - -void DataSource::setTvh5NodePath(const QString& path) -{ - auto reader = m_json.value("reader").toObject(); - reader["tvh5NodePath"] = path; - m_json["reader"] = reader; -} - -QString DataSource::tvh5NodePath() const -{ - return m_json.value("reader").toObject().value("tvh5NodePath").toString(); -} - -QStringList DataSource::fileNames() const -{ - auto reader = m_json.value("reader").toObject(QJsonObject()); - QStringList files; - if (reader.contains("fileNames")) { - QJsonArray fileArray = reader["fileNames"].toArray(); - foreach (QJsonValue file, fileArray) { - files.append(file.toString()); - } - } - return files; -} - -void DataSource::setMetadata(const MetadataType& meta) -{ - m_pythonProxy->setMetadata(meta); - m_metadata = meta; -} - -void DataSource::setDarkData(vtkSmartPointer image) -{ - m_pythonProxy->setDarkData(image); - this->Internals->m_darkData = image; -} - -vtkImageData* DataSource::darkData() const -{ - return this->Internals->m_darkData; -} - -void DataSource::setWhiteData(vtkSmartPointer image) -{ - m_pythonProxy->setWhiteData(image); - this->Internals->m_whiteData = image; -} - -vtkImageData* DataSource::whiteData() const -{ - return this->Internals->m_whiteData; -} - -bool DataSource::canReloadAndResample() const -{ - const auto& files = fileNames(); - - // This currently only works for single files - if (files.size() != 1) - return false; - - const auto& file = files[0]; - - static const QStringList h5Extensions = { "emd", "h5", "he5", "hdf5" }; - - // If it looks like an HDF5 type (based on its extension), it can be - // reloaded and resampled. - return std::any_of( - h5Extensions.cbegin(), h5Extensions.cend(), - [&file](const QString& x) { return file.endsWith(x, Qt::CaseInsensitive); }); -} - -bool DataSource::reloadAndResample() -{ - const auto& files = fileNames(); - - // This currently only works for single files - if (files.size() != 1) - return false; - - const auto& file = files[0]; - - auto algo = vtkAlgorithm::SafeDownCast(proxy()->GetClientSideObject()); - Q_ASSERT(algo); - auto data = algo->GetOutputDataObject(0); - auto image = vtkImageData::SafeDownCast(data); - - bool success; - QVariantMap options{ { "askForSubsample", true } }; - if (file.endsWith("emd", Qt::CaseInsensitive)) { - EmdFormat format; - success = format.read(file.toLatin1().data(), image, options); - } else if (GenericHDF5Format::isDataExchange(file.toStdString())) { - DataExchangeFormat format; - success = format.read(file.toLatin1().data(), image, options); - } else { - success = GenericHDF5Format::read(file.toLatin1().data(), image, options); - } - - // If there are operators, re-run the pipeline - if (!operators().empty()) - pipeline()->execute(this, operators().first())->deleteWhenFinished(); - - dataModified(); - emit activeScalarsChanged(); - emit dataPropertiesChanged(); - return success; -} - -bool DataSource::isImageStack() const -{ - auto reader = m_json.value("reader").toObject(QJsonObject()); - if (reader.contains("fileNames") && reader["fileNames"].isArray()) { - return reader["fileNames"].toArray().size() > 1; - } - return false; -} - -void DataSource::setReaderProperties(const QVariantMap& properties) -{ - m_json["reader"] = QJsonObject::fromVariantMap(properties); -} - -QVariantMap DataSource::readerProperties() const -{ - if (m_json.contains("reader") && m_json["reader"].isObject()) { - return m_json["reader"].toObject().toVariantMap(); - } else { - return QVariantMap(); - } -} - -void DataSource::setLabel(const QString& label) -{ - m_json["label"] = label; -} - -QString DataSource::label() const -{ - if (m_json.contains("label")) { - return m_json["label"].toString(); - } else { - return QFileInfo(fileName()).baseName(); - } -} - -QString DataSource::id() const -{ - return QString::asprintf("%p", static_cast(this)); -} - -template -QJsonArray toJsonArray(T* array, int size) -{ - QJsonArray ret; - for (int i = 0; i < size; ++i) { - ret.append(array[i]); - } - return ret; -} - -QJsonObject DataSource::serialize() const -{ - QJsonObject json = m_json; - json["label"] = label(); - - // If the data was subsampled, save the subsampling settings - if (wasSubsampled()) { - QJsonObject settings; - - int s[3]; - subsampleStrides(s); - settings["strides"] = toJsonArray(s, 3); - - int bs[6]; - subsampleVolumeBounds(bs); - settings["volumeBounds"] = toJsonArray(bs, 6); - json["subsampleSettings"] = settings; - } - - if (Internals->UnitsModified) { - json["spacing"] = toJsonArray(getSpacing(), 3); - if (this->Internals->Units) { - json["units"] = this->Internals->Units->GetValue(0).c_str(); - } - } - - json["origin"] = toJsonArray(displayPosition(), 3); - json["orientation"] = toJsonArray(displayOrientation(), 3); - - // Serialize the currently active scalars - json["activeScalars"] = activeScalars(); - QJsonObject scalarsRename; - for (auto it = Internals->CurrentToOriginal.begin(); - it != Internals->CurrentToOriginal.end(); ++it) { - scalarsRename[it.value()] = it.key(); - } - json["scalarsRename"] = scalarsRename; - - // Serialize the color map, opacity map, and others if needed. - json["colorOpacityMap"] = tomviz::serialize(colorMap()); - json["gradientOpacityMap"] = tomviz::serialize(gradientOpacityMap()); - QJsonObject boxJson; - auto& transfer2DBox = this->Internals->m_transferFunction2DBox; - boxJson["x"] = transfer2DBox.GetX(); - boxJson["y"] = transfer2DBox.GetY(); - boxJson["width"] = transfer2DBox.GetWidth(); - boxJson["height"] = transfer2DBox.GetHeight(); - json["colorMap2DBox"] = boxJson; - - // Serialize the operators... - QJsonArray jOperators; - foreach (Operator* op, this->Internals->Operators) { - QJsonObject jOperator = op->serialize(); - jOperators.append(jOperator); - } - if (!jOperators.isEmpty()) { - json["operators"] = jOperators; - } - - // Serialize the modules... - auto modules = ModuleManager::instance().findModulesGeneric(this, nullptr); - QJsonArray jModules; - foreach (Module* module, modules) { - QJsonObject jModule = module->serialize(); - jModules.append(jModule); - } - if (!jModules.isEmpty()) { - json["modules"] = jModules; - } - - json["id"] = id(); - - if (this == ActiveObjects::instance().activeDataSource()) { - // Label itself as the active data source - json["active"] = true; - } - - return json; -} - -template -void fromJsonArray(const QJsonArray& array, T* out); - -template <> -void fromJsonArray(const QJsonArray& array, double* out) -{ - for (int i = 0; i < array.size(); ++i) { - out[i] = array.at(i).toDouble(); - } -} - -template -void fromJsonArray(const QJsonValue& value, T* out) -{ - fromJsonArray(value.toArray(), out); -} - -bool DataSource::deserialize(const QJsonObject& state) -{ - if (!state["label"].isUndefined()) { - setLabel(state["label"].toString()); - } - - if (state.contains("id")) { - ModuleManager::instance().addStateIdToDataSource(state["id"].toString(), - this); - } - - if (state.contains("scalarsRename")) { - auto scalarsRename = state["scalarsRename"].toObject(); - for (auto it = scalarsRename.begin(); it != scalarsRename.end(); ++it) { - renameScalarsArray(it.key(), it.value().toString()); - } - } - - if (state.contains("activeScalars")) { - setActiveScalars(state["activeScalars"].toString()); - } - - if (state.contains("colorOpacityMap")) { - tomviz::deserialize(colorMap(), state["colorOpacityMap"].toObject()); - } - if (state.contains("gradientOpacityMap")) { - tomviz::deserialize(gradientOpacityMap(), - state["gradientOpacityMap"].toObject()); - } - if (state.contains("colorMap2DBox")) { - auto boxJson = state["colorMap2DBox"].toObject(); - auto& transfer2DBox = this->Internals->m_transferFunction2DBox; - transfer2DBox.Set(boxJson["x"].toDouble(), boxJson["y"].toDouble(), - boxJson["width"].toDouble(), - boxJson["height"].toDouble()); - } - - if (state.contains("spacing")) { - double spacing[3]; - fromJsonArray(state["spacing"], spacing); - setSpacing(spacing); - } - - if (state.contains("units")) { - auto units = state["units"].toString(); - setUnits(units); - } - - if (state.contains("origin")) { - double origin[3]; - fromJsonArray(state["origin"], origin); - setDisplayPosition(origin); - } - - if (state.contains("orientation")) { - double orientation[3]; - fromJsonArray(state["orientation"], orientation); - setDisplayOrientation(orientation); - } - - // Check for modules on the data source first. - if (state.contains("modules") && state["modules"].isArray()) { - auto moduleArray = state["modules"].toArray(); - for (int i = 0; i < moduleArray.size(); ++i) { - auto moduleObj = moduleArray[i].toObject(); - auto viewId = moduleObj["viewId"].toInt(); - auto viewProxy = ModuleManager::instance().lookupView(viewId); - - // If we can't find the view, just default the currently active view - if (viewProxy == nullptr) { - viewProxy = ActiveObjects::instance().activeView(); - } - auto type = moduleObj["type"].toString(); - - // Plot modules require an OperatorResult, not a DataSource. They - // will be recreated when the operator pipeline is re-run and the - // user adds the Plot module again. - if (type == "Plot") { - qWarning() << "Skipping Plot module during state restore. Re-run" - << "the pipeline and add the Plot module to restore it."; - continue; - } - - auto m = - ModuleManager::instance().createAndAddModule(type, this, viewProxy); - if (!m) { - qWarning() << "Failed to create module of type:" << type; - continue; - } - m->deserialize(moduleObj); - } - } - // Now check for operators on the data source. - if (state.contains("operators") && state["operators"].isArray()) { - pipeline()->pause(); - Operator* op = nullptr; - QJsonObject operatorObj; - auto operatorArray = state["operators"].toArray(); - for (int i = 0; i < operatorArray.size(); ++i) { - operatorObj = operatorArray[i].toObject(); - op = OperatorFactory::instance().createOperator( - operatorObj["type"].toString(), this); - if (op && op->deserialize(operatorObj)) { - addOperator(op, /*append=*/true); - } - } - - // If we have a child data source we need to restore it once the data source - // has been create by the first execution of the pipeline. - if (op != nullptr && operatorObj.contains("dataSources")) { - // We currently support a single child data source. - auto dataSourcesState = operatorObj["dataSources"].toArray(); - auto connection = new QMetaObject::Connection; - *connection = connect(pipeline(), &Pipeline::finished, op, - [connection, dataSourcesState, op]() { - auto childDataSource = op->childDataSource(); - if (childDataSource != nullptr) { - childDataSource->deserialize( - dataSourcesState[0].toObject()); - } - QObject::disconnect(*connection); - delete connection; - }); - // If the child datasource has its own pipeline of operators increment the - // number of pipelineFinished signals to wait for before emitting - // stateLoaded() - if (dataSourcesState[0].toObject().contains("operators")) { - ModuleManager::instance().incrementPipelinesToWaitFor(); - } - } - - if (ModuleManager::instance().executePipelinesOnLoad()) { - pipeline()->resume(); - pipeline()->execute(this)->deleteWhenFinished(); - } - } - return true; -} - -DataSource* DataSource::clone() const -{ - auto newClone = new DataSource(vtkImageData::SafeDownCast(this->dataObject()), - this->Internals->Type, this->pipeline()); - newClone->setLabel(this->label()); - newClone->setPersistenceState(PersistenceState::Modified); - - if (this->Internals->Type == TiltSeries) { - newClone->setTiltAngles(getTiltAngles()); - } - - if (hasScanIDs(this->dataObject())) { - newClone->setScanIDs(getScanIDs()); - } - - QList newTimeSteps; - for (auto& timeStep : this->Internals->timeSeriesSteps) { - newTimeSteps.append(timeStep.clone()); - } - newClone->setTimeSeriesSteps(newTimeSteps); - - return newClone; -} - -vtkSMSourceProxy* DataSource::proxy() const -{ - return this->Internals->ProducerProxy; -} - -void DataSource::getExtent(int extent[6]) const -{ - vtkAlgorithm* tp = algorithm(); - if (tp) { - vtkImageData* data = vtkImageData::SafeDownCast(tp->GetOutputDataObject(0)); - if (data) { - data->GetExtent(extent); - return; - } - } - for (int i = 0; i < 6; ++i) { - extent[i] = 0; - } -} - -void DataSource::getBounds(double bounds[6]) -{ - vtkAlgorithm* tp = algorithm(); - if (tp) { - vtkImageData* data = vtkImageData::SafeDownCast(tp->GetOutputDataObject(0)); - if (data) { - data->GetBounds(bounds); - return; - } - } - for (int i = 0; i < 6; ++i) { - bounds[i] = 0.0; - } -} - -void DataSource::getRange(vtkImageData* imageData, double range[2]) -{ - for (int i = 0; i < 2; ++i) { - range[i] = 0.0; - } - - if (!imageData) { - return; - } - - auto* arrayPtr = imageData->GetPointData()->GetScalars(); - if (!arrayPtr) { - return; - } - - arrayPtr->GetFiniteRange(range, -1); -} - -void DataSource::getSpacing(double spacing[3]) const -{ - vtkAlgorithm* tp = algorithm(); - if (tp) { - vtkImageData* data = vtkImageData::SafeDownCast(tp->GetOutputDataObject(0)); - if (data) { - data->GetSpacing(spacing); - return; - } - } - for (int i = 0; i < 3; ++i) { - spacing[i] = 1; - } -} - -const double* DataSource::getSpacing() const -{ - return imageData()->GetSpacing(); -} - -void DataSource::setSpacing(const double spacing[3], bool markModified) -{ - if (markModified) { - Internals->UnitsModified = true; - } - - double mySpacing[3] = { spacing[0], spacing[1], spacing[2] }; - auto* data = imageData(); - if (data) { - data->SetSpacing(mySpacing); - } - emit dataPropertiesChanged(); -} - -void DataSource::getPhysicalDimensions(double lengths[3]) const -{ - int extent[6]; - getExtent(extent); - auto* spacing = getSpacing(); - - for (int axis = 0; axis < 3; ++axis) { - lengths[axis] = - spacing[axis] * (extent[2 * axis + 1] - extent[2 * axis] + 1); - } -} - -void DataSource::setActiveScalars(const QString& arrayName) -{ - if (!getScalarsArray(arrayName)) { - qWarning() << QString("Unable to select %1 as the active scalars array") - .arg(arrayName); - return; - } - - vtkAlgorithm* alg = algorithm(); - if (alg) { - vtkImageData* data = - vtkImageData::SafeDownCast(alg->GetOutputDataObject(0)); - if (data) { - data->GetPointData()->SetActiveScalars(arrayName.toLatin1().data()); - } - } - - dataModified(); - - emit activeScalarsChanged(); - emit dataPropertiesChanged(); -} - -void DataSource::setActiveScalars(int arrayIdx) -{ - QStringList scalars = listScalars(); - - if (arrayIdx < 0 || arrayIdx >= scalars.length()) { - return; - } - - setActiveScalars(scalars[arrayIdx]); -} - -QString DataSource::activeScalars() const -{ - QString returnValue; - vtkAlgorithm* alg = algorithm(); - if (alg) { - vtkImageData* data = - vtkImageData::SafeDownCast(alg->GetOutputDataObject(0)); - if (data) { - vtkDataArray* scalars = data->GetPointData()->GetScalars(); - if (scalars) { - returnValue = scalars->GetName(); - } - } - } - - return returnValue; -} - -int DataSource::activeScalarsIdx() const -{ - QString arrayName = activeScalars(); - QStringList scalars = listScalars(); - return scalars.indexOf(arrayName); -} - -QString DataSource::scalarsName(int arrayIdx) const -{ - QString arrayName; - QStringList scalars = listScalars(); - - if (arrayIdx >= 0 && arrayIdx < scalars.length()) { - arrayName = scalars[arrayIdx]; - } - - return arrayName; -} - -int DataSource::scalarsIdx(const QString& arrayName) const -{ - QStringList scalars = listScalars(); - return scalars.indexOf(arrayName); -} - -QStringList DataSource::listScalars() const -{ - QStringList scalars; - vtkAlgorithm* alg = algorithm(); - if (alg) { - vtkImageData* data = - vtkImageData::SafeDownCast(alg->GetOutputDataObject(0)); - if (data) { - vtkPointData* pointData = data->GetPointData(); - auto n = pointData->GetNumberOfArrays(); - for (int i = 0; i < n; ++i) { - scalars << pointData->GetArrayName(i); - } - } - } - return scalars; -} - -void DataSource::renameScalarsArray(const QString& oldName, - const QString& newName) -{ - if (oldName == newName) { - return; - } - - const bool isCurrentScalars = oldName == activeScalars(); - - // Ensure the array actually exist - vtkDataArray* dataArray = getScalarsArray(oldName); - if (dataArray == nullptr) { - return; - } - - // Ensure the target name is not already taken - vtkDataArray* targetArray = getScalarsArray(newName); - if (targetArray != nullptr) { - return; - } - - dataArray->SetName(newName.toLatin1().data()); - - if (isCurrentScalars) { - setActiveScalars(newName); - } else { - dataModified(); - emit activeScalarsChanged(); - emit dataPropertiesChanged(); - } - - auto originalName = Internals->CurrentToOriginal[oldName]; - Internals->CurrentToOriginal.remove(oldName); - Internals->CurrentToOriginal[newName] = originalName; -} - -vtkDataArray* DataSource::getScalarsArray(const QString& arrayName) const -{ - vtkAlgorithm* alg = algorithm(); - if (alg == nullptr) { - return nullptr; - } - vtkImageData* data = vtkImageData::SafeDownCast(alg->GetOutputDataObject(0)); - if (data == nullptr) { - return nullptr; - } - vtkPointData* pointData = data->GetPointData(); - if (pointData == nullptr) { - return nullptr; - } - if (pointData->HasArray(arrayName.toLatin1().data()) == 0) { - return nullptr; - } - return pointData->GetScalars(arrayName.toLatin1().data()); -} - -unsigned int DataSource::getNumberOfComponents() -{ - unsigned int numComponents = 0; - vtkAlgorithm* tp = this->algorithm(); - if (tp) { - vtkImageData* data = vtkImageData::SafeDownCast(tp->GetOutputDataObject(0)); - if (data) { - if (data->GetPointData() && data->GetPointData()->GetScalars()) { - numComponents = static_cast( - data->GetPointData()->GetScalars()->GetNumberOfComponents()); - } - } - } - - return numComponents; -} - -QString DataSource::getUnits() -{ - if (this->Internals->Units) { - return QString(this->Internals->Units->GetValue(0).c_str()); - } else { - return "nm"; - } -} - -void DataSource::setUnits(const QString& units, bool markModified) -{ - if (markModified) { - Internals->UnitsModified = true; - } - - if (!this->Internals->Units) { - this->Internals->Units = vtkSmartPointer::New(); - this->Internals->Units->SetName("units"); - this->Internals->Units->SetNumberOfValues(3); - this->Internals->Units->SetValue(0, "nm"); - this->Internals->Units->SetValue(1, "nm"); - this->Internals->Units->SetValue(2, "nm"); - vtkAlgorithm* alg = algorithm(); - if (alg) { - vtkDataObject* data = alg->GetOutputDataObject(0); - vtkFieldData* fd = data->GetFieldData(); - fd->AddArray(this->Internals->Units); - } - } - for (int i = 0; i < 3; ++i) { - this->Internals->Units->SetValue(i, units.toStdString().c_str()); - } - emit dataPropertiesChanged(); -} - -int DataSource::addOperator(Operator* op, bool append) -{ - op->setParent(this); - int index = -1; - if (!append) { - auto activeOp = ActiveObjects::instance().activeOperator(); - if (activeOp && activeOp->dataSource() == this) { - index = this->Internals->Operators.indexOf(activeOp); - } - } - if (index >= 0) { - // About to insert (not append). Ask the user for confirmation unless - // they previously checked "Don't ask again". - auto settings = pqApplicationCore::instance()->settings(); - bool skipConfirm = - settings->value("OperatorInsertConfirm/DontAsk", false).toBool(); - if (!skipConfirm) { - QMessageBox msgBox; - msgBox.setWindowTitle("Insert Operator?"); - msgBox.setText( - "Insert this operator before the selected operator in the pipeline?"); - auto* insertBtn = msgBox.addButton("Insert", QMessageBox::AcceptRole); - msgBox.addButton("Append to End", QMessageBox::RejectRole); - msgBox.setDefaultButton(insertBtn); - QCheckBox dontAskAgain("Don't ask again (always insert)"); - msgBox.setCheckBox(&dontAskAgain); - - msgBox.exec(); - - if (dontAskAgain.isChecked()) { - settings->setValue("OperatorInsertConfirm/DontAsk", true); - } - - if (msgBox.clickedButton() != insertBtn) { - // Append to the end instead of inserting - index = -1; - } - } - } - if (index >= 0) { - this->Internals->Operators.insert(index, op); - } else { - index = this->Internals->Operators.count(); - this->Internals->Operators.push_back(op); - } - emit operatorAdded(op); - - return index; -} - -bool DataSource::removeOperator(Operator* op) -{ - if (op) { - // We should emit that the operator was removed... - this->Internals->Operators.removeAll(op); - - emit this->operatorRemoved(op); - - op->deleteLater(); - - foreach (Operator* opPtr, this->Internals->Operators) { - std::cout << "Operator: " << opPtr->label().toLatin1().data() << std::endl; - } - - return true; - } - return false; -} - -bool DataSource::removeAllOperators() -{ - // TODO - return false if the pipeline is running - bool success = true; - - while (this->Internals->Operators.size() > 0) { - Operator* lastOperator = this->Internals->Operators.takeLast(); - - if (lastOperator->childDataSource() != nullptr) { - DataSource* childDataSource = lastOperator->childDataSource(); - - // Recurse on the child data source - success = childDataSource->removeAllOperators(); - if (!success) { - break; - } - } - - lastOperator->deleteLater(); - } - - if (success) { - ModuleManager::instance().removeAllModules(this); - } - - return success; -} - -void DataSource::dataModified() -{ - vtkTrivialProducer* tp = producer(); - if (tp == nullptr) { - return; - } - - tp->Modified(); - vtkDataObject* dObject = tp->GetOutputDataObject(0); - dObject->Modified(); - this->Internals->ProducerProxy->MarkModified(nullptr); - - vtkFieldData* fd = dObject->GetFieldData(); - if (fd->HasArray("tomviz_data_source_type")) { - vtkTypeInt8Array* typeArray = - vtkTypeInt8Array::SafeDownCast(fd->GetArray("tomviz_data_source_type")); - - // Casting is a bit hacky here, but it *should* work - setType((DataSourceType)(int)typeArray->GetTuple1(0)); - } else { - vtkNew typeArray; - typeArray->SetNumberOfComponents(1); - typeArray->SetNumberOfTuples(1); - typeArray->SetName("tomviz_data_source_type"); - typeArray->SetTuple1(0, this->Internals->Type); - fd->AddArray(typeArray); - } - - // This indirection is necessary to overcome a bug in VTK/ParaView when - // explicitly calling UpdatePipeline(). The extents don't reset to the whole - // extent. Until a proper fix makes it into VTK, this is needed. - vtkSMSessionProxyManager* pxm = - this->Internals->ProducerProxy->GetSessionProxyManager(); - vtkSMSourceProxy* filter = - vtkSMSourceProxy::SafeDownCast(pxm->NewProxy("filters", "PassThrough")); - Q_ASSERT(filter); - vtkSMPropertyHelper(filter, "Input").Set(this->Internals->ProducerProxy, 0); - filter->UpdateVTKObjects(); - filter->UpdatePipeline(); - filter->Delete(); - - emit dataChanged(); -} - -const QList& DataSource::operators() const -{ - return this->Internals->Operators; -} - -void DataSource::translate(const double deltaPosition[3]) -{ - for (int i = 0; i < 3; ++i) { - this->Internals->DisplayPosition[i] += deltaPosition[i]; - } - emit displayPositionChanged(this->Internals->DisplayPosition[0], - this->Internals->DisplayPosition[1], - this->Internals->DisplayPosition[2]); -} - -const double* DataSource::displayPosition() const -{ - return this->Internals->DisplayPosition.GetData(); -} - -void DataSource::displayPosition(double position[3]) const -{ - auto* pos = displayPosition(); - for (int i = 0; i < 3; ++i) { - position[i] = pos[i]; - } -} - -void DataSource::setDisplayPosition(const double newPosition[3]) -{ - for (int i = 0; i < 3; ++i) { - this->Internals->DisplayPosition[i] = newPosition[i]; - } - emit displayPositionChanged(this->Internals->DisplayPosition[0], - this->Internals->DisplayPosition[1], - this->Internals->DisplayPosition[2]); -} - -const double* DataSource::displayOrientation() const -{ - return this->Internals->DisplayOrientation.GetData(); -} - -void DataSource::setDisplayOrientation(const double newOrientation[3]) -{ - for (int i = 0; i < 3; ++i) { - this->Internals->DisplayOrientation[i] = newOrientation[i]; - } - emit displayOrientationChanged(this->Internals->DisplayOrientation[0], - this->Internals->DisplayOrientation[1], - this->Internals->DisplayOrientation[2]); -} - -vtkDataObject* DataSource::copyData() -{ - this->Internals->ProducerProxy->UpdatePipeline(); - vtkDataObject* data = dataObject(); - vtkDataObject* copy = data->NewInstance(); - copy->DeepCopy(data); - - return copy; -} - -void DataSource::setData(vtkDataObject* newData) -{ - auto tp = producer(); - Q_ASSERT(tp); - tp->SetOutput(newData); - auto fd = newData->GetFieldData(); - vtkSmartPointer typeArray = - vtkTypeInt8Array::SafeDownCast(fd->GetArray("tomviz_data_source_type")); - if (typeArray && typeArray->GetTuple1(0) == TiltSeries) { - this->Internals->ensureTiltAnglesArrayExists(); - this->Internals->Type = TiltSeries; - } else if (typeArray && typeArray->GetTuple1(0) == FIB) { - this->Internals->Type = FIB; - } else { - this->Internals->Type = Volume; - } - if (fd->HasArray("units")) { - this->Internals->Units = - vtkStringArray::SafeDownCast(fd->GetAbstractArray("units")); - } else if (this->Internals->Units) { - fd->AddArray(this->Internals->Units); - } - if (!typeArray) { - typeArray = vtkSmartPointer::New(); - typeArray->SetNumberOfComponents(1); - typeArray->SetNumberOfTuples(1); - typeArray->SetName("tomviz_data_source_type"); - fd->AddArray(typeArray); - } - typeArray->SetTuple1(0, this->Internals->Type); - - // Initialize maps to track array renames - Internals->CurrentToOriginal.clear(); - auto arrayNames = listScalars(); - for (auto name : arrayNames) { - Internals->CurrentToOriginal[name] = name; - } - - // Make sure everything gets updated with the new data - dataModified(); -} - -void DataSource::copyData(vtkDataObject* newData) -{ - auto tp = producer(); - Q_ASSERT(tp); - auto oldData = tp->GetOutputDataObject(0); - Q_ASSERT(oldData); - - oldData->DeepCopy(newData); - - dataModified(); - - emit activeScalarsChanged(); -} - -void DataSource::onTimeChanged() -{ - if (!ActiveObjects::instance().timeSeriesAnimationsEnabled()) { - return; - } - - auto* timeKeeper = ActiveObjects::instance().activeTimeKeeper(); - if (!timeKeeper) { - return; - } - - // Use interpolation to figure out which time step we are at - auto numTimeSteps = numTimeSeriesSteps(); - if (numTimeSteps <= 1) { - return; - } - - auto timeSteps = timeKeeper->getTimeSteps(); - if (timeSteps.size() == 1) { - return; - } else if (timeSteps.size() == 0) { - // It's just a 0 to 1 default. - timeSteps.append(0); - timeSteps.append(1); - } - - auto timeStart = timeSteps.front(); - auto timeStop = timeSteps.back(); - auto time = timeKeeper->getTime(); - - auto scale = ((numTimeSteps - 1) / (timeStop - timeStart)); - auto timeStep = std::round((time - timeStart) * scale); - switchTimeSeriesStep(timeStep); -} - -void DataSource::switchTimeSeriesStep(int i) -{ - if (i >= this->Internals->timeSeriesSteps.size()) { - auto size = this->Internals->timeSeriesSteps.size(); - qCritical() << i - << "is out of bounds for number of time series steps: " << size; - return; - } - - m_changingTimeStep = true; - this->Internals->currentTimeStep = i; - producer()->SetOutput(this->Internals->timeSeriesSteps[i].image); - dataModified(); - m_changingTimeStep = false; - - emit timeStepChanged(); -} - -int DataSource::numTimeSeriesSteps() const -{ - return this->Internals->timeSeriesSteps.size(); -} - -int DataSource::currentTimeSeriesIndex() const -{ - return this->Internals->currentTimeStep; -} - -void DataSource::setTimeSeriesSteps(const QList& steps) -{ - this->Internals->timeSeriesSteps = steps; - emit timeStepsModified(); - - // Update the data if we need to - auto current = currentTimeSeriesIndex(); - if (current < steps.size() && steps[current].image != imageData()) { - // Re-use the logic here - switchTimeSeriesStep(current); - } -} - -void DataSource::addTimeSeriesSteps(const QList& steps) -{ - this->Internals->timeSeriesSteps.append(steps); - emit timeStepsModified(); -} - -void DataSource::addTimeSeriesStep(const TimeSeriesStep& step) -{ - this->Internals->timeSeriesSteps.append(step); - emit timeStepsModified(); -} - -TimeSeriesStep DataSource::currentTimeSeriesStep() -{ - if (!hasTimeSteps()) { - qDebug() << "currentTimeSeriesStep() was called, but there " - << "are no time steps!"; - return TimeSeriesStep(); - } - - auto current = this->Internals->currentTimeStep; - if (current >= numTimeSeriesSteps()) { - qDebug() << "Current time step is out of bounds!"; - return TimeSeriesStep(); - } - - return this->Internals->timeSeriesSteps[current]; -} - -QList DataSource::timeSeriesSteps() const -{ - return this->Internals->timeSeriesSteps; -} - -void DataSource::clearTimeSeriesSteps() -{ - this->Internals->timeSeriesSteps.clear(); - this->Internals->currentTimeStep = 0; - emit timeStepsModified(); - emit timeStepChanged(); -} - -vtkSMProxy* DataSource::colorMap() const -{ - return this->Internals->ColorMap; -} - -DataSource::DataSourceType DataSource::type() const -{ - return this->Internals->Type; -} - -void DataSource::setType(DataSourceType t) -{ - this->Internals->Type = t; - auto data = this->dataObject(); - setType(data, t); - if (t == TiltSeries) { - this->Internals->ensureTiltAnglesArrayExists(); - } - emit dataChanged(); -} - -bool DataSource::hasTiltAngles() -{ - vtkDataObject* data = this->dataObject(); - - return hasTiltAngles(data); -} - -QVector DataSource::getTiltAngles() const -{ - auto data = this->dataObject(); - return getTiltAngles(data); -} - -void DataSource::setTiltAngles(const QVector& angles) -{ - auto data = this->dataObject(); - setTiltAngles(data, angles); - emit dataChanged(); -} - -vtkSMProxy* DataSource::opacityMap() const -{ - return this->Internals->ColorMap - ? vtkSMPropertyHelper(this->Internals->ColorMap, - "ScalarOpacityFunction") - .GetAsProxy() - : nullptr; -} - -vtkPiecewiseFunction* DataSource::gradientOpacityMap() const -{ - return this->Internals->GradientOpacityMap; -} - -vtkImageData* DataSource::transferFunction2D() const -{ - return this->Internals->m_transfer2D; -} - -vtkRectd* DataSource::transferFunction2DBox() const -{ - return &this->Internals->m_transferFunction2DBox; -} - -bool DataSource::hasLabelMap() -{ - auto dataSource = proxy(); - if (!dataSource) { - return false; - } - - // We could just as easily go to the client side VTK object to get this info, - // but we'll go the ParaView route for now. - vtkPVDataInformation* dataInfo = dataSource->GetDataInformation(); - vtkPVDataSetAttributesInformation* pointDataInfo = - dataInfo->GetPointDataInformation(); - vtkPVArrayInformation* labelMapInfo = - pointDataInfo->GetArrayInformation("LabelMap"); - - return labelMapInfo != nullptr; -} - -void DataSource::updateColorMap() -{ - if (m_changingTimeStep) { - // Don't update the color map for time step changes - return; - } - - rescaleColorMap(colorMap(), this); -} - -void DataSource::setPersistenceState(DataSource::PersistenceState state) -{ - this->Internals->PersistState = state; -} - -DataSource::PersistenceState DataSource::persistenceState() const -{ - return this->Internals->PersistState; -} - -vtkTrivialProducer* DataSource::producer() const -{ - Q_ASSERT(vtkTrivialProducer::SafeDownCast(proxy()->GetClientSideObject())); - return vtkTrivialProducer::SafeDownCast(proxy()->GetClientSideObject()); -} - -void DataSource::init(vtkImageData* data, DataSourceType dataType, - PersistenceState persistState) -{ - m_pythonProxy = new DataSourceBase; - this->Internals->Type = dataType; - this->Internals->PersistState = persistState; - this->Internals->DisplayPosition.Set(0, 0, 0); - this->Internals->DisplayOrientation.Set(0, 0, 0); - - // Set up default rect for transfer function 2d... - // The widget knows to interpret a rect with negative width as - // uninitialized. - this->Internals->m_transferFunction2DBox.Set(0, 0, -1, -1); - - vtkNew controller; - auto pxm = ActiveObjects::instance().proxyManager(); - Q_ASSERT(pxm); - - // If dataSource is null then we need to create the producer - vtkSmartPointer source; - source.TakeReference(pxm->NewProxy("sources", "TrivialProducer")); - Q_ASSERT(source != nullptr); - Q_ASSERT(vtkSMSourceProxy::SafeDownCast(source)); - this->Internals->ProducerProxy = vtkSMSourceProxy::SafeDownCast(source); - controller->RegisterPipelineProxy(this->Internals->ProducerProxy); - - if (data) { - auto tp = vtkTrivialProducer::SafeDownCast(source->GetClientSideObject()); - tp->SetOutput(data); - ensureActiveArray(); - } - - // Initialize maps to track array renames - Internals->CurrentToOriginal.clear(); - auto arrayNames = listScalars(); - for (auto name : arrayNames) { - Internals->CurrentToOriginal[name] = name; - } - - // Setup color map for this data-source. - static unsigned int colorMapCounter = 0; - ++colorMapCounter; - - vtkNew tfmgr; - this->Internals->ColorMap = tfmgr->GetColorTransferFunction( - QString("DataSourceColorMap%1").arg(colorMapCounter).toLatin1().data(), - pxm); - ColorMap::instance().applyPreset(this->Internals->ColorMap); - updateColorMap(); - - // Every time the data changes, we should update the color map. - connect(this, &DataSource::dataChanged, this, &DataSource::updateColorMap); - - connect(this, &DataSource::dataPropertiesChanged, - [this]() { this->proxy()->MarkModified(nullptr); }); - - auto* timeKeeper = ActiveObjects::instance().activeTimeKeeper(); - if (timeKeeper) { - connect(timeKeeper, &pqTimeKeeper::timeChanged, this, - &DataSource::onTimeChanged); - } -} - -vtkAlgorithm* DataSource::algorithm() const -{ - if (!proxy() || !proxy()->GetClientSideObject()) { - return nullptr; - } - return vtkAlgorithm::SafeDownCast(proxy()->GetClientSideObject()); -} - -vtkDataObject* DataSource::dataObject() const -{ - auto alg = algorithm(); - if (!alg) { - return nullptr; - } - return alg->GetOutputDataObject(0); -} - -vtkImageData* DataSource::imageData() const -{ - return vtkImageData::SafeDownCast(dataObject()); -} - -vtkDataArray* DataSource::scalars() const -{ - return getScalarsArray(activeScalars()); -} - -QStringList DataSource::componentNames() -{ - ensureValidComponentNames(); - - QStringList names; - for (int i = 0; i < scalars()->GetNumberOfComponents(); ++i) { - names.append(scalars()->GetComponentName(i)); - } - - return names; -} - -void DataSource::setComponentNames(const QStringList& names) -{ - for (int i = 0; i < scalars()->GetNumberOfComponents(); ++i) { - scalars()->SetComponentName(i, names[i].toLatin1().data()); - } - emit componentNamesModified(); -} - -void DataSource::setComponentName(int index, const QString& name) -{ - scalars()->SetComponentName(index, name.toLatin1().data()); - emit componentNamesModified(); -} - -void DataSource::ensureValidComponentNames() -{ - bool modified = false; - QStringList approvedNames; - for (int i = 0; i < scalars()->GetNumberOfComponents(); ++i) { - QString name = scalars()->GetComponentName(i); - if (name.isEmpty() || approvedNames.contains(name)) { - // If this name is empty or duplicated, rename it. - size_t counter = 0; - QString newName; - do { - newName = name + QString::number(++counter); - } while (approvedNames.contains(newName)); - - scalars()->SetComponentName(i, newName.toLatin1().data()); - name = newName; - modified = true; - } - - approvedNames.append(name); - } - - if (modified) { - emit componentNamesModified(); - } -} - -Pipeline* DataSource::pipeline() const -{ - return qobject_cast(parent()); -} - -bool DataSource::unitsModified() -{ - return Internals->UnitsModified; -} - -bool DataSource::isTransient() const -{ - return Internals->PersistState == PersistenceState::Transient; -} - -bool DataSource::forkable() -{ - return Internals->Forkable; -} - -void DataSource::setForkable(bool forkable) -{ - Internals->Forkable = forkable; -} - -void DataSource::ensureActiveArray() -{ - // If there is no active array, then set one. - if (!imageData() || !imageData()->GetPointData()) { - return; - } - - auto* pointData = imageData()->GetPointData(); - if (pointData->GetScalars() || pointData->GetNumberOfArrays() == 0) { - return; - } - - pointData->SetActiveScalars(pointData->GetArrayName(0)); -} - -bool DataSource::hasTiltAngles(vtkDataObject* image) -{ - vtkFieldData* fd = image->GetFieldData(); - - return fd->HasArray("tilt_angles"); -} - -QVector DataSource::getTiltAngles(vtkDataObject* data) -{ - QVector result; - auto fd = data->GetFieldData(); - - if (fd->HasArray("tilt_angles")) { - auto tiltAngles = fd->GetArray("tilt_angles"); - result.resize(tiltAngles->GetNumberOfTuples()); - for (int i = 0; i < result.size(); ++i) { - result[i] = tiltAngles->GetTuple1(i); - } - } - return result; -} - -void DataSource::setTiltAngles(vtkDataObject* data, - const QVector& angles) -{ - auto fd = data->GetFieldData(); - createOrResizeTiltAnglesArray(data); - if (fd->HasArray("tilt_angles")) { - auto tiltAngles = fd->GetArray("tilt_angles"); - for (int i = 0; i < tiltAngles->GetNumberOfTuples() && i < angles.size(); - ++i) { - tiltAngles->SetTuple1(i, angles[i]); - } - } -} - -// My attempt to reduce some of the boiler plate code in the functions below -template -void setFieldDataArray(vtkFieldData* fd, const char* arrayName, int numTuples, - T* data) -{ - if (!fd->HasArray(arrayName)) { - vtkNew typeArray; - typeArray->SetNumberOfComponents(1); - typeArray->SetNumberOfTuples(numTuples); - typeArray->SetName(arrayName); - fd->AddArray(typeArray); - } - - ArrayType* typeArray = ArrayType::SafeDownCast(fd->GetArray(arrayName)); - for (int i = 0; i < numTuples; ++i) - typeArray->SetTuple1(i, data[i]); -} - -template -void getFieldDataArray(vtkFieldData* fd, const char* arrayName, int numTuples, - T* data) -{ - if (!fd->HasArray(arrayName)) - return; - - ArrayType* typeArray = ArrayType::SafeDownCast(fd->GetArray(arrayName)); - for (int i = 0; i < numTuples; ++i) - data[i] = typeArray->GetTuple1(i); -} - -void DataSource::setType(vtkDataObject* image, DataSourceType t) -{ - if (!image) - return; - - // Cast to int before setting - int i = static_cast(t); - - const char* arrayName = "tomviz_data_source_type"; - using ArrayType = vtkTypeInt8Array; - - vtkFieldData* fd = image->GetFieldData(); - setFieldDataArray(fd, arrayName, 1, &i); - - if (t != DataSourceType::TiltSeries) { - // Clear the tilt angles - clearTiltAngles(image); - } -} - -void DataSource::clearTiltAngles(vtkDataObject* image) -{ - if (!image) - return; - - const char* arrayName = "tilt_angles"; - auto fd = image->GetFieldData(); - if (fd->HasArray(arrayName)) { - fd->RemoveArray(arrayName); - } -} - -bool DataSource::hasScanIDs(vtkDataObject* image) -{ - if (!image) - return false; - - return image->GetFieldData()->HasArray("scan_ids"); -} - -QVector DataSource::getScanIDs(vtkDataObject* image) -{ - QVector result; - if (!image) - return result; - - auto fd = image->GetFieldData(); - if (fd->HasArray("scan_ids")) { - auto scanIds = fd->GetArray("scan_ids"); - result.resize(scanIds->GetNumberOfTuples()); - for (int i = 0; i < result.size(); ++i) { - result[i] = static_cast(scanIds->GetTuple1(i)); - } - } - return result; -} - -void DataSource::setScanIDs(vtkDataObject* image, - const QVector& scanIDs) -{ - if (!image) - return; - - auto fd = image->GetFieldData(); - int numTuples = scanIDs.size(); - std::vector data(numTuples); - for (int i = 0; i < numTuples; ++i) { - data[i] = scanIDs[i]; - } - setFieldDataArray(fd, "scan_ids", numTuples, data.data()); -} - -void DataSource::clearScanIDs(vtkDataObject* image) -{ - if (!image) - return; - - auto fd = image->GetFieldData(); - if (fd->HasArray("scan_ids")) { - fd->RemoveArray("scan_ids"); - } -} - -bool DataSource::hasScanIDs() -{ - return hasScanIDs(dataObject()); -} - -QVector DataSource::getScanIDs() const -{ - return getScanIDs(dataObject()); -} - -void DataSource::setScanIDs(const QVector& scanIDs) -{ - setScanIDs(dataObject(), scanIDs); -} - -void DataSource::clearScanIDs() -{ - clearScanIDs(dataObject()); -} - -bool DataSource::wasSubsampled(vtkDataObject* image) -{ - bool ret = false; - - if (!image) - return ret; - - const char* arrayName = "was_subsampled"; - using ArrayType = vtkTypeInt8Array; - - vtkFieldData* fd = image->GetFieldData(); - getFieldDataArray(fd, arrayName, 1, &ret); - return ret; -} - -void DataSource::setWasSubsampled(vtkDataObject* image, bool b) -{ - if (!image) - return; - - const char* arrayName = "was_subsampled"; - using ArrayType = vtkTypeInt8Array; - - vtkFieldData* fd = image->GetFieldData(); - setFieldDataArray(fd, arrayName, 1, &b); -} - -void DataSource::subsampleStrides(vtkDataObject* image, int s[3]) -{ - // Set the default in case we return - for (int i = 0; i < 3; ++i) - s[i] = 1; - - if (!image) - return; - - const char* arrayName = "subsample_strides"; - using ArrayType = vtkTypeInt32Array; - - vtkFieldData* fd = image->GetFieldData(); - getFieldDataArray(fd, arrayName, 3, s); -} - -void DataSource::setSubsampleStrides(vtkDataObject* image, int s[3]) -{ - if (!image) - return; - - const char* arrayName = "subsample_strides"; - using ArrayType = vtkTypeInt32Array; - - vtkFieldData* fd = image->GetFieldData(); - setFieldDataArray(fd, arrayName, 3, s); -} - -void DataSource::subsampleVolumeBounds(vtkDataObject* image, int bs[6]) -{ - // Set the default in case we return - for (int i = 0; i < 6; ++i) - bs[i] = -1; - - if (!image) - return; - - const char* arrayName = "subsample_volume_bounds"; - using ArrayType = vtkTypeInt32Array; - - vtkFieldData* fd = image->GetFieldData(); - getFieldDataArray(fd, arrayName, 6, bs); -} - -void DataSource::setSubsampleVolumeBounds(vtkDataObject* image, int bs[6]) -{ - if (!image) - return; - - const char* arrayName = "subsample_volume_bounds"; - using ArrayType = vtkTypeInt32Array; - - vtkFieldData* fd = image->GetFieldData(); - setFieldDataArray(fd, arrayName, 6, bs); -} - -} // namespace tomviz diff --git a/tomviz/DataSource.h b/tomviz/DataSource.h deleted file mode 100644 index be43745d9..000000000 --- a/tomviz/DataSource.h +++ /dev/null @@ -1,532 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizDataSource_h -#define tomvizDataSource_h - -#include - -#include -#include -#include -#include - -#include -#include - -#include "core/Variant.h" - -class vtkSMProxy; -class vtkSMSourceProxy; -class vtkImageData; -class vtkDataArray; -class vtkDataObject; -class vtkPiecewiseFunction; -class vtkAlgorithm; -class vtkTrivialProducer; - -namespace tomviz { -class DataSourceBase; -class Operator; -class Pipeline; -struct TimeSeriesStep; - -using MetadataType = std::map; - -/// Encapsulation for a DataSource. This class manages a data source, including -/// the provenance for any operations performed on the data source. -class DataSource : public QObject -{ - Q_OBJECT - -public: - /// The type of data in the data source. The data types currently supported - /// are volumetric data and image stacks representing tilt series. - enum DataSourceType - { - Volume, - TiltSeries, - FIB - }; - - enum class PersistenceState - { - Transient, // Doesn't need to be written to disk - Saved, // Written to disk - Modified // Needs to be written to disk - }; - - /// Deprecated constructor, prefer directly setting data. - DataSource(vtkSMSourceProxy* dataSource, DataSourceType dataType = Volume); - - /// \c dataSource is the original reader that reads the data into the - /// application. - DataSource(vtkImageData* dataSource, DataSourceType dataType = Volume, - QObject* parent = nullptr, - PersistenceState persistState = PersistenceState::Saved); - - /// Create a new dataSource not associated with a source proxy - DataSource(const QString& label = QString(), DataSourceType dataType = Volume, - QObject* parent = nullptr, - PersistenceState persistState = PersistenceState::Saved, - const QJsonObject& sourceInfo = QJsonObject()); - - ~DataSource() override; - - /// Append a slice to the data source, this must be of the same x and y - /// dimension as the existing slices in order to be appended. - bool appendSlice(vtkImageData* slice); - - /// Returns the proxy that can be inserted in ParaView pipelines. - /// This proxy instance doesn't change over the lifetime of a DataSource even - /// if new DataOperators are added to the source. - vtkSMSourceProxy* proxy() const; - - //// Returns the trivial producer to insert in VTK pipelines. - vtkTrivialProducer* producer() const; - - /// Returns the output data object associated with the proxy. - vtkDataObject* dataObject() const; - - /// Returns the image data associated with the proxy. - vtkImageData* imageData() const; - - /// Get the active scalars array - vtkDataArray* scalars() const; - - /// Get the names of the components. - /// Calls "ensureValidComponentNames()" first, and the names will be - /// modified if they are invalid. - QStringList componentNames(); - - /// Set the names of the components - void setComponentNames(const QStringList& names); - - /// Set the name of an individual component - void setComponentName(int index, const QString& name); - - /// Ensure component names are valid, and modify them if they are not. - void ensureValidComponentNames(); - - /// Returns a list of operators added to the DataSource. - const QList& operators() const; - - /// Add/remove operators. - int addOperator(Operator* op, bool append = false); - bool removeOperator(Operator* op); - bool removeAllOperators(); - - /// Creates a new clone from this DataSource. - DataSource* clone() const; - - /// Get the unique id for this DataSource. - QString id() const; - - /// Save the state out. - QJsonObject serialize() const; - bool deserialize(const QJsonObject& state); - - /// Set the file name. - void setFileName(const QString& fileName); - - /// Returns the name of the file used to load the data source. - QString fileName() const; - - /// Set the ordered list of file names if loading from a stack of images. - void setFileNames(const QStringList fileNames); - - /// Returns the list of files used to load the volume (if a stack was used). - QStringList fileNames() const; - - /// For a Tvh5 file, set the path to the node to read for this data source - void setTvh5NodePath(const QString& path); - - /// For a Tvh5 file, get the path to the node to read for this data source - QString tvh5NodePath() const; - - /// Return true is data source is an image stack, false otherwise. - bool isImageStack() const; - - /// Set the PV reader properties. - void setReaderProperties(const QVariantMap& properties); - - /// Get the PV reader properties. - QVariantMap readerProperties() const; - - /// Get the metadata - MetadataType metadata() { return m_metadata; } - - /// Set the metadata explicitly - void setMetadata(const MetadataType& m); - - /// Set/get the dark/white data, used in Data Exchange currently - void setDarkData(vtkSmartPointer image); - - /// Set/get the dark/white data, used in Data Exchange currently - vtkImageData* darkData() const; - - /// Set/get the dark/white data, used in Data Exchange currently - void setWhiteData(vtkSmartPointer image); - - /// Set/get the dark/white data, used in Data Exchange currently - vtkImageData* whiteData() const; - - // Used to track if a volume visualization module was added - bool volumeModuleAutoAdded() const; - void setVolumeModuleAutoAdded(bool b); - - /// Check to see if the data was subsampled while reading - bool wasSubsampled() const; - - /// Set whether the data was subsampled while reading - void setWasSubsampled(bool b); - - /// Get the strides used to generate the subsample - void subsampleStrides(int s[3]) const; - - /// Set the strides used to generate the subsample - void setSubsampleStrides(int s[3]); - - /// Get the volume bounds used to generate the subsample - void subsampleVolumeBounds(int bs[6]) const; - - /// Set the volume bounds used to generate the subsample - void setSubsampleVolumeBounds(int bs[6]); - - /// Can we reload and resample the original dataset? - bool canReloadAndResample() const; - - // Reload and resample the original dataset - bool reloadAndResample(); - - /// Set the label for the data source. - void setLabel(const QString& label); - - /// Returns the name of the filename used from the originalDataSource. - QString label() const; - - /// Returns the type of data in this DataSource - DataSourceType type() const; - /// Sets the type of data in the DataSource - void setType(DataSourceType t); - - /// Whether or not the DataSource has time series steps - bool hasTimeSteps() const { return numTimeSeriesSteps() != 0; } - - /// The number of time series steps that the DataSource has - int numTimeSeriesSteps() const; - - /// The index of the time series we are currently using - int currentTimeSeriesIndex() const; - - /// Switch to a different time series step - void switchTimeSeriesStep(int i); - - /// Set the time series steps - void setTimeSeriesSteps(const QList& steps); - - /// Append a list of time series steps - void addTimeSeriesSteps(const QList& steps); - - /// Add a time series step - void addTimeSeriesStep(const TimeSeriesStep& step); - - /// Get the current time series step - TimeSeriesStep currentTimeSeriesStep(); - - /// Get a copy of all time series steps - QList timeSeriesSteps() const; - - /// Remove all time series steps - void clearTimeSeriesSteps(); - - /// Are we in the middle of changing time steps? - bool isChangingTimeStep() const { return m_changingTimeStep; } - - /// Returns the color map for the DataSource. - vtkSMProxy* colorMap() const; - vtkSMProxy* opacityMap() const; - vtkPiecewiseFunction* gradientOpacityMap() const; - vtkImageData* transferFunction2D() const; - vtkRectd* transferFunction2DBox() const; - - /// Indicates whether the DataSource has a label map of the voxels. - bool hasLabelMap(); - - /// Crop the data to the given volume - void crop(int bounds[6]); - - /// Returns true if the dataset already has a tilt angles array - /// --- this CAN return true even if the dataset is currently a - /// volume. This is to tell if switching the dataset to a tilt - /// series also needs to set the tilt angles. - bool hasTiltAngles(); - - /// Get a copy of the current tilt angles - QVector getTiltAngles() const; - - /// Set the tilt angles to the values in the given QVector - void setTiltAngles(const QVector& angles); - - /// Remove the tilt angles from the data source - void clearTiltAngles(); - - /// Returns true if the dataset has scan IDs - bool hasScanIDs(); - - /// Get scan IDs (if available - otherwise an empty vector is returned) - QVector getScanIDs() const; - - /// Set the scan IDs - void setScanIDs(const QVector& scanIDs); - - /// Remove scan IDs - void clearScanIDs(); - - /// Moves the displayPosition of the DataSource by deltaPosition - void translate(const double deltaPosition[3]); - - /// Gets the display position of the data source - const double* displayPosition() const; - void displayPosition(double pos[3]) const; - - /// Sets the display position of the data source - void setDisplayPosition(const double newPosition[3]); - - /// Gets the display orientation of the data source - const double* displayOrientation() const; - - /// Sets the display orientation of the data source - void setDisplayOrientation(const double newOrientation[3]); - - /// Returns the extent of the transformed dataset - void getExtent(int extent[6]) const; - /// Returns the physical extent (bounds) of the transformed dataset - void getBounds(double bounds[6]); - /// Returns the range of the transformed dataset - void getRange(double range[2]) { getRange(imageData(), range); } - static void getRange(vtkImageData* data, double range[2]); - /// Returns the spacing of the transformed dataset - void getSpacing(double spacing[3]) const; - const double* getSpacing() const; - - /// Sets the scale factor (ratio between units and spacing) - /// one component per axis - void setSpacing(const double scaleFactor[3], bool markModified = true); - - /// Get the physical dimensions - /// Computed via spacing[i] * (extent[2 * i + 1] - extent[2 * i] + 1) - void getPhysicalDimensions(double lengths[3]) const; - - /// Set the active scalars by array name. - void setActiveScalars(const QString& arrayName); - QString activeScalars() const; - /// Set the active scalars by component index. - void setActiveScalars(int arrayIdx); - int activeScalarsIdx() const; - /// Get the scalars name for a given index. - QString scalarsName(int arrayIdx) const; - - /// Get the scalars idx for a given name. - int scalarsIdx(const QString& arrayName) const; - - /// Get the scalars list - QStringList listScalars() const; - - // Get pointer to scalar array - vtkDataArray* getScalarsArray(const QString& arrayName) const; - - /// Returns the number of components in the dataset. - unsigned int getNumberOfComponents(); - - /// Returns a string describing the units for the data - QString getUnits(); - /// Set the string describing the units - void setUnits(const QString& units, bool markModified = true); - - /// Set the persistence state - void setPersistenceState(PersistenceState state); - - /// Returns the persistence state - PersistenceState persistenceState() const; - - Pipeline* pipeline() const; - - /// Create copy of current data object, caller is responsible for ownership - vtkDataObject* copyData(); - - /// If there are arrays in the data, ensure one of them is active. - void ensureActiveArray(); - - /// Set data output of trivial producer to new data object, the trivial - /// producer takes over ownership of the data object. - void setData(vtkDataObject* newData); - - /// Copy data from a data object to the existing data. - void copyData(vtkDataObject* newData); - - bool unitsModified(); - - // Returns true if the data source is not associated with a file, false - // otherwise. - bool isTransient() const; - - /// Returns true if child operators can be added to this data source, false - // otherwise. - bool forkable(); - void setForkable(bool forkable); - - static void setType(vtkDataObject* image, DataSourceType t); - - static bool hasTiltAngles(vtkDataObject* image); - static QVector getTiltAngles(vtkDataObject* image); - static void setTiltAngles(vtkDataObject* image, - const QVector& angles); - static void clearTiltAngles(vtkDataObject* image); - - static bool hasScanIDs(vtkDataObject* image); - static QVector getScanIDs(vtkDataObject* image); - static void setScanIDs(vtkDataObject* image, const QVector& scanIDs); - static void clearScanIDs(vtkDataObject* image); - - /// Check to see if the data was subsampled while reading - static bool wasSubsampled(vtkDataObject* image); - - /// Set whether the data was subsampled while reading - static void setWasSubsampled(vtkDataObject* image, bool b); - - /// Get the strides used to generate the subsample - static void subsampleStrides(vtkDataObject* image, int s[3]); - - /// Set the strides used to generate the subsample - static void setSubsampleStrides(vtkDataObject* image, int s[3]); - - /// Get the volume bounds used to generate the subsample - static void subsampleVolumeBounds(vtkDataObject* image, int bs[6]); - - /// Set the volume bounds used to generate the subsample - static void setSubsampleVolumeBounds(vtkDataObject* image, int bs[6]); - - /// Get a simple proxy for the data source to simplify Python wrapping. - DataSourceBase* pythonProxy() const { return m_pythonProxy; } - -signals: - /// This signal is fired to notify the world that the DataSource may have - /// new/updated data. - void dataChanged(); - - /// This signal is fired to notify the world that the data's properties may - /// have changed. - void dataPropertiesChanged(); - - /// Fired when active scalars change - void activeScalarsChanged(); - - /// This signal is fired every time a new operator is added to this - /// DataSource. - void operatorAdded(Operator*); - - void operatorRemoved(Operator*); - - /// This signal is fired every time the display position is changed - /// Any actors based on this DataSource's data should update the position - /// on their actors to match this so the effect of setting the position is - /// to translate the dataset. - void displayPositionChanged(double newX, double newY, double newZ); - - /// This signal is fired every time the display orientation is changed - /// Any actors based on this DataSource's data should update the orientation - /// on their actors to match this so the effect of setting the orientation is - /// to rotate the dataset. - void displayOrientationChanged(double newX, double newY, double newZ); - - /// Indicates the component names have been modified - void componentNamesModified(); - - /// Indicates that the current time step has been changed - void timeStepChanged(); - - /// Indicate that the time steps have been modified - void timeStepsModified(); - -public slots: - void dataModified(); - void renameScalarsArray(const QString& oldName, const QString& newName); - -protected slots: - /// update the color map range. - void updateColorMap(); - - /// What to do when the time is changed... - void onTimeChanged(); - -private: - /// Private method to initialize the data source. - void init(vtkImageData* dataSource, DataSourceType dataType, - PersistenceState persistState); - - vtkAlgorithm* algorithm() const; - - Q_DISABLE_COPY(DataSource) - - class DSInternals; - const QScopedPointer Internals; - - /// This is a simple proxy class for forwarding Python calls. - DataSourceBase* m_pythonProxy = nullptr; - - QJsonObject m_json; - MetadataType m_metadata; - - bool m_changingTimeStep = false; - bool m_volumeModuleAutoAdded = false; -}; - -inline void DataSource::clearTiltAngles() -{ - clearTiltAngles(dataObject()); -} - -inline bool DataSource::wasSubsampled() const -{ - return wasSubsampled(dataObject()); -} - -inline void DataSource::setWasSubsampled(bool b) -{ - setWasSubsampled(dataObject(), b); -} - -inline void DataSource::subsampleStrides(int s[3]) const -{ - subsampleStrides(dataObject(), s); -} - -inline void DataSource::setSubsampleStrides(int s[3]) -{ - setSubsampleStrides(dataObject(), s); -} - -inline void DataSource::subsampleVolumeBounds(int bs[6]) const -{ - subsampleVolumeBounds(dataObject(), bs); -} - -inline void DataSource::setSubsampleVolumeBounds(int bs[6]) -{ - setSubsampleVolumeBounds(dataObject(), bs); -} - -inline bool DataSource::volumeModuleAutoAdded() const -{ - return m_volumeModuleAutoAdded; -} - -inline void DataSource::setVolumeModuleAutoAdded(bool b) -{ - m_volumeModuleAutoAdded = b; -} - -} // namespace tomviz - -#endif diff --git a/tomviz/DataTransformMenu.cxx b/tomviz/DataTransformMenu.cxx index 967cb14cd..ee166ab8b 100644 --- a/tomviz/DataTransformMenu.cxx +++ b/tomviz/DataTransformMenu.cxx @@ -7,7 +7,6 @@ #include #include -#include "AddExpressionReaction.h" #include "AddPythonTransformReaction.h" #include "ArrayWranglerReaction.h" #include "CloneDataReaction.h" @@ -35,70 +34,99 @@ void DataTransformMenu::buildTransforms() QMenu* menu = m_transformMenu; menu->clear(); - // Build the Data Transforms menu auto customPythonAction = menu->addAction("Custom Transform"); + menu->addSeparator(); - auto cropDataAction = menu->addAction("Crop"); - auto convertDataAction = menu->addAction("Convert to Float"); - auto arrayWranglerAction = menu->addAction("Convert Type"); - auto transposeDataAction = menu->addAction("Transpose Data"); - auto removeArraysAction = menu->addAction("Remove Arrays"); + // === Data Management submenu === + QMenu* dataManagement = menu->addMenu("Data Management"); + auto cropDataAction = dataManagement->addAction("Crop"); + auto cylindricalCropAction = dataManagement->addAction("Cylindrical Crop"); + auto convertDataAction = dataManagement->addAction("Convert to Float"); + auto arrayWranglerAction = dataManagement->addAction("Convert Type"); + auto transposeDataAction = dataManagement->addAction("Transpose Data"); + auto removeArraysAction = dataManagement->addAction("Remove Arrays"); auto reinterpretSignedToUnignedAction = - menu->addAction("Reinterpret Signed to Unsigned"); - menu->addSeparator(); - auto manualManipulationAction = menu->addAction("Manual Manipulation"); - auto shiftUniformAction = menu->addAction("Shift Volume"); - auto deleteSliceAction = menu->addAction("Delete Slices"); - auto padVolumeAction = menu->addAction("Pad Volume"); - auto downsampleByTwoAction = menu->addAction("Bin Volume x2"); - auto resampleAction = menu->addAction("Resample"); - auto rotateAction = menu->addAction("Rotate"); - auto clearAction = menu->addAction("Clear Subvolume"); - auto swapAction = menu->addAction("Swap Axes"); - auto registrationAction = menu->addAction("Registration"); - menu->addSeparator(); - auto setNegativeVoxelsToZeroAction = - menu->addAction("Set Negative Voxels To Zero"); - auto addConstantAction = menu->addAction("Add Constant"); - auto invertDataAction = menu->addAction("Invert Data"); - auto squareRootAction = menu->addAction("Square Root Data"); - auto cropEdgesAction = menu->addAction("Clip Edges"); - auto hannWindowAction = menu->addAction("Hann Window"); - auto fftAbsLogAction = menu->addAction("FFT (abs log)"); - auto gradientMagnitudeSobelAction = menu->addAction("Gradient Magnitude"); - auto unsharpMaskAction = menu->addAction("Unsharp Mask"); - auto laplaceFilterAction = menu->addAction("Laplace Sharpen"); - auto gaussianFilterAction = menu->addAction("Gaussian Blur"); - auto wienerAction = menu->addAction("Wiener Filter"); - auto TVminAction = menu->addAction("Remove Stripes, Curtaining, Scratches"); - auto peronaMalikeAnisotropicDiffusionAction = - menu->addAction("Perona-Malik Anisotropic Diffusion"); - auto medianFilterAction = menu->addAction("Median Filter"); - auto circleMaskAction = menu->addAction("Circle Mask"); - auto moleculeAction = menu->addAction("Add Molecule"); - menu->addSeparator(); - auto tortuosityAction = menu->addAction("Tortuosity"); - auto poreSizeAction = menu->addAction("Pore Size Distribution"); - menu->addSeparator(); - auto psdAction = menu->addAction("Power Spectrum Density"); - auto fscAction = menu->addAction("Fourier Shell Correlation"); - auto deconvolutionDenoiseAction = menu->addAction("Deconvolution Denoise"); - auto similarityMetricsAction = menu->addAction("Similarity Metrics"); - menu->addSeparator(); - auto cloneAction = menu->addAction("Clone"); - auto deleteDataAction = menu->addAction( + dataManagement->addAction("Reinterpret Signed to Unsigned"); + dataManagement->addSeparator(); + auto cloneAction = dataManagement->addAction("Clone"); + auto deleteDataAction = dataManagement->addAction( QIcon(":/QtWidgets/Icons/pqDelete.svg"), "Delete Data and Modules"); deleteDataAction->setToolTip("Delete Data"); + // === Volume Manipulation submenu === + QMenu* volumeManip = menu->addMenu("Volume Manipulation"); + // FIXME: staged for removal + auto manualManipulationAction = + volumeManip->addAction("Manual Manipulation"); + manualManipulationAction->setVisible(false); + auto shiftUniformAction = volumeManip->addAction("Shift Volume"); + auto deleteSliceAction = volumeManip->addAction("Delete Slices"); + auto padVolumeAction = volumeManip->addAction("Pad Volume"); + auto downsampleByTwoAction = volumeManip->addAction("Bin Volume x2"); + auto resampleAction = volumeManip->addAction("Resample"); + auto rotateAction = volumeManip->addAction("Rotate"); + auto clearAction = volumeManip->addAction("Clear Subvolume"); + auto swapAction = volumeManip->addAction("Swap Axes"); + // FIXME: staged for removal + auto registrationAction = volumeManip->addAction("Registration"); + registrationAction->setVisible(false); + + // === Math Operations submenu === + QMenu* mathOps = menu->addMenu("Math Operations"); + auto setNegativeVoxelsToZeroAction = + mathOps->addAction("Set Negative Voxels To Zero"); + auto addConstantAction = mathOps->addAction("Add Constant"); + auto invertDataAction = mathOps->addAction("Invert Data"); + auto squareRootAction = mathOps->addAction("Square Root Data"); + auto cropEdgesAction = mathOps->addAction("Clip Edges"); + auto hannWindowAction = mathOps->addAction("Hann Window"); + auto fftAbsLogAction = mathOps->addAction("FFT (abs log)"); + + // === Filters & Smoothing submenu === + QMenu* filters = menu->addMenu("Filters && Smoothing"); + auto gradientMagnitudeSobelAction = + filters->addAction("Gradient Magnitude"); + auto unsharpMaskAction = filters->addAction("Unsharp Mask"); + auto laplaceFilterAction = filters->addAction("Laplace Sharpen"); + auto gaussianFilterAction = filters->addAction("Gaussian Blur"); + auto wienerAction = filters->addAction("Wiener Filter"); + auto TVminAction = + filters->addAction("Remove Stripes, Curtaining, Scratches"); + auto peronaMalikeAnisotropicDiffusionAction = + filters->addAction("Perona-Malik Anisotropic Diffusion"); + auto medianFilterAction = filters->addAction("Median Filter"); + auto circleMaskAction = filters->addAction("Circle Mask"); + + // === Material Analysis submenu === + QMenu* materialAnalysis = menu->addMenu("Material Analysis"); + auto tortuosityAction = materialAnalysis->addAction("Tortuosity"); + auto poreSizeAction = + materialAnalysis->addAction("Pore Size Distribution"); + auto moleculeAction = materialAnalysis->addAction("Add Molecule"); + + // === Metrics & Spectral submenu === + QMenu* metrics = menu->addMenu("Metrics && Spectral"); + auto psdAction = metrics->addAction("Power Spectrum Density"); + auto fscAction = metrics->addAction("Fourier Shell Correlation"); + auto deconvolutionDenoiseAction = + metrics->addAction("Deconvolution Denoise"); + auto similarityMetricsAction = metrics->addAction("Similarity Metrics"); + // Add our Python script reactions, these compose Python into menu entries. - new AddExpressionReaction(customPythonAction); + new AddPythonTransformReaction( + customPythonAction, "Custom Transform", + readInPythonScript("DefaultCustomTransform")); new CropReaction(cropDataAction, mainWindow); + new AddPythonTransformReaction( + cylindricalCropAction, "Cylindrical Crop", + readInPythonScript("CylindricalCrop"), + readInJSONDescription("CylindricalCrop")); new ConvertToFloatReaction(convertDataAction); new ArrayWranglerReaction(arrayWranglerAction, mainWindow); new TransposeDataReaction(transposeDataAction, mainWindow); new AddPythonTransformReaction( removeArraysAction, "Remove Arrays", - readInPythonScript("RemoveArrays"), false, false, false, + readInPythonScript("RemoveArrays"), readInJSONDescription("RemoveArrays")); new AddPythonTransformReaction( reinterpretSignedToUnignedAction, "Reinterpret Signed to Unsigned", @@ -106,49 +134,48 @@ void DataTransformMenu::buildTransforms() new AddPythonTransformReaction( manualManipulationAction, "Manual Manipulation", - readInPythonScript("ManualManipulation"), false, false, false, + readInPythonScript("ManualManipulation"), readInJSONDescription("ManualManipulation")); new AddPythonTransformReaction( shiftUniformAction, "Shift Volume", - readInPythonScript("Shift_Stack_Uniformly"), false, false, false, + readInPythonScript("Shift_Stack_Uniformly"), readInJSONDescription("Shift_Stack_Uniformly")); new AddPythonTransformReaction(deleteSliceAction, "Delete Slices", readInPythonScript("DeleteSlices"), - false, false, false, readInJSONDescription("DeleteSlices")); new AddPythonTransformReaction(padVolumeAction, "Pad Volume", - readInPythonScript("Pad_Data"), false, false, - false, readInJSONDescription("Pad_Data")); + readInPythonScript("Pad_Data"), + readInJSONDescription("Pad_Data")); new AddPythonTransformReaction(downsampleByTwoAction, "Bin Volume x2", readInPythonScript("BinVolumeByTwo")); new AddPythonTransformReaction(resampleAction, "Resample", - readInPythonScript("Resample"), false, false, - false, readInJSONDescription("Resample")); + readInPythonScript("Resample"), + readInJSONDescription("Resample")); new AddPythonTransformReaction(rotateAction, "Rotate", - readInPythonScript("Rotate3D"), false, false, - false, readInJSONDescription("Rotate3D")); + readInPythonScript("Rotate3D"), + readInJSONDescription("Rotate3D")); new AddPythonTransformReaction(clearAction, "Clear Volume", readInPythonScript("ClearVolume")); new AddPythonTransformReaction(swapAction, "Swap Axes", - readInPythonScript("SwapAxes"), false, false, - false, readInJSONDescription("SwapAxes")); + readInPythonScript("SwapAxes"), + readInJSONDescription("SwapAxes")); new AddPythonTransformReaction(registrationAction, "Registration", readInPythonScript("ElastixRegistration"), - false, false, false, readInJSONDescription("ElastixRegistration")); new AddPythonTransformReaction(setNegativeVoxelsToZeroAction, "Set Negative Voxels to Zero", readInPythonScript("SetNegativeVoxelsToZero")); new AddPythonTransformReaction( addConstantAction, "Add a Constant", readInPythonScript("AddConstant"), - false, false, false, readInJSONDescription("AddConstant")); + readInJSONDescription("AddConstant")); new AddPythonTransformReaction(invertDataAction, "Invert Data", - readInPythonScript("InvertData")); + readInPythonScript("InvertData"), + readInJSONDescription("InvertData")); new AddPythonTransformReaction(squareRootAction, "Square Root Data", readInPythonScript("Square_Root_Data")); new AddPythonTransformReaction(cropEdgesAction, "Clip Edges", - readInPythonScript("ClipEdges"), false, true, - false, readInJSONDescription("ClipEdges")); + readInPythonScript("ClipEdges"), + readInJSONDescription("ClipEdges")); new AddPythonTransformReaction(hannWindowAction, "Hann Window", readInPythonScript("HannWindow3D")); new AddPythonTransformReaction(fftAbsLogAction, "FFT (ABS LOG)", @@ -157,65 +184,62 @@ void DataTransformMenu::buildTransforms() "Gradient Magnitude", readInPythonScript("GradientMagnitude_Sobel")); new AddPythonTransformReaction( - unsharpMaskAction, "Unsharp Mask", readInPythonScript("UnsharpMask"), false, - false, false, readInJSONDescription("UnsharpMask")); + unsharpMaskAction, "Unsharp Mask", readInPythonScript("UnsharpMask"), + readInJSONDescription("UnsharpMask")); new AddPythonTransformReaction(laplaceFilterAction, "Laplace Sharpen", readInPythonScript("LaplaceFilter")); new AddPythonTransformReaction( - wienerAction, "Wiener Filter", readInPythonScript("WienerFilter"), false, - false, false, readInJSONDescription("WienerFilter")); + wienerAction, "Wiener Filter", readInPythonScript("WienerFilter"), + readInJSONDescription("WienerFilter")); new AddPythonTransformReaction(TVminAction, "TV_Filter", - readInPythonScript("TV_Filter"), false, false, - false, readInJSONDescription("TV_Filter")); + readInPythonScript("TV_Filter"), + readInJSONDescription("TV_Filter")); new AddPythonTransformReaction( gaussianFilterAction, "Gaussian Blur", readInPythonScript("GaussianFilter"), - false, false, false, readInJSONDescription("GaussianFilter")); + readInJSONDescription("GaussianFilter")); new AddPythonTransformReaction( peronaMalikeAnisotropicDiffusionAction, "Perona-Malik Anisotropic Diffusion", - readInPythonScript("PeronaMalikAnisotropicDiffusion"), false, false, false, + readInPythonScript("PeronaMalikAnisotropicDiffusion"), readInJSONDescription("PeronaMalikAnisotropicDiffusion")); new AddPythonTransformReaction( medianFilterAction, "Median Filter", readInPythonScript("MedianFilter"), - false, false, false, readInJSONDescription("MedianFilter")); + readInJSONDescription("MedianFilter")); new AddPythonTransformReaction(circleMaskAction, "Circle Mask", - readInPythonScript("CircleMask"), false, false, - false, readInJSONDescription("CircleMask")); + readInPythonScript("CircleMask"), + readInJSONDescription("CircleMask")); new AddPythonTransformReaction( - moleculeAction, "Add Molecule", readInPythonScript("DummyMolecule"), false, - false, false, readInJSONDescription("DummyMolecule")); + moleculeAction, "Add Molecule", readInPythonScript("DummyMolecule"), + readInJSONDescription("DummyMolecule")); new AddPythonTransformReaction( - tortuosityAction, "Tortuosity", readInPythonScript("Tortuosity"), false, - false, false, readInJSONDescription("Tortuosity")); + tortuosityAction, "Tortuosity", readInPythonScript("Tortuosity"), + readInJSONDescription("Tortuosity")); new AddPythonTransformReaction( poreSizeAction, "Pore Size Distribution", - readInPythonScript("PoreSizeDistribution"), false, false, false, + readInPythonScript("PoreSizeDistribution"), readInJSONDescription("PoreSizeDistribution")); new AddPythonTransformReaction( psdAction, "Power Spectrum Density", - readInPythonScript("PowerSpectrumDensity"), false, false, false, + readInPythonScript("PowerSpectrumDensity"), readInJSONDescription("PowerSpectrumDensity")); new AddPythonTransformReaction( fscAction, "Fourier Shell Correlation", - readInPythonScript("FourierShellCorrelation"), false, false, false, + readInPythonScript("FourierShellCorrelation"), readInJSONDescription("FourierShellCorrelation")); new AddPythonTransformReaction( deconvolutionDenoiseAction, "Deconvolution Denoise", - readInPythonScript("DeconvolutionDenoise"), true, false, false, + readInPythonScript("DeconvolutionDenoise"), readInJSONDescription("DeconvolutionDenoise")); new AddPythonTransformReaction( similarityMetricsAction, "Similarity Metrics", - readInPythonScript("SimilarityMetrics"), false, false, false, + readInPythonScript("SimilarityMetrics"), readInJSONDescription("SimilarityMetrics")); new CloneDataReaction(cloneAction); new DeleteDataReaction(deleteDataAction); - // TODO - enable/disable menu actions depending on whether the selected - // DataSource has - // the properties required by the Data Transform } void DataTransformMenu::buildSegmentation() @@ -224,77 +248,104 @@ void DataTransformMenu::buildSegmentation() menu->clear(); auto customPythonITKAction = menu->addAction("Custom ITK Transform"); - menu->addSeparator(); - auto binaryThresholdAction = menu->addAction("Binary Threshold"); - auto otsuMultipleThresholdAction = menu->addAction("Otsu Multiple Threshold"); - auto connectedComponentsAction = menu->addAction("Connected Components"); - menu->addSeparator(); - auto binaryDilateAction = menu->addAction("Binary Dilate"); - auto binaryErodeAction = menu->addAction("Binary Erode"); - auto binaryOpenAction = menu->addAction("Binary Open"); - auto binaryCloseAction = menu->addAction("Binary Close"); + + // === Thresholding submenu === + QMenu* thresholding = menu->addMenu("Thresholding"); + auto binaryThresholdAction = thresholding->addAction("Binary Threshold"); + auto otsuMultipleThresholdAction = + thresholding->addAction("Otsu Multiple Threshold"); + auto connectedComponentsAction = + thresholding->addAction("Connected Components"); + + // === Morphology submenu === + QMenu* morphology = menu->addMenu("Morphology"); + auto binaryDilateAction = morphology->addAction("Binary Dilate"); + auto binaryErodeAction = morphology->addAction("Binary Erode"); + auto binaryOpenAction = morphology->addAction("Binary Open"); + auto binaryCloseAction = morphology->addAction("Binary Close"); auto binaryMinMaxCurvatureFlowAction = - menu->addAction("Binary MinMax Curvature Flow"); - menu->addSeparator(); - auto labelObjectAttributesAction = menu->addAction("Label Object Attributes"); + morphology->addAction("Binary MinMax Curvature Flow"); + + // === Label Analysis submenu === + QMenu* labelAnalysis = menu->addMenu("Label Analysis"); + auto labelObjectAttributesAction = + labelAnalysis->addAction("Label Object Attributes"); auto labelObjectPrincipalAxesAction = - menu->addAction("Label Object Principal Axes"); + labelAnalysis->addAction("Label Object Principal Axes"); auto distanceFromAxisAction = - menu->addAction("Label Object Distance From Principal Axis"); - menu->addSeparator(); - auto segmentParticlesAction = menu->addAction("Segment Particles"); - auto segmentPoresAction = menu->addAction("Segment Pores"); + labelAnalysis->addAction("Label Object Distance From Principal Axis"); + + // === Segmentation Workflows submenu === + QMenu* segWorkflows = menu->addMenu("Segmentation Workflows"); + auto segmentParticlesAction = segWorkflows->addAction("Segment Particles"); + auto segmentPoresAction = segWorkflows->addAction("Segment Pores"); - new AddExpressionReaction(customPythonITKAction); + // === Machine Learning submenu === + QMenu* machineLearning = menu->addMenu("Machine Learning"); + auto sam2SegmentAction = + machineLearning->addAction("SAM 2 Segmentation (3D)"); + auto sam3SegmentAction = + machineLearning->addAction("SAM 3 Segmentation (3D)"); + + new AddPythonTransformReaction( + customPythonITKAction, "Custom ITK Transform", + readInPythonScript("DefaultITKTransform")); new AddPythonTransformReaction(binaryThresholdAction, "Binary Threshold", - readInPythonScript("BinaryThreshold"), false, - false, false, + readInPythonScript("BinaryThreshold"), readInJSONDescription("BinaryThreshold")); new AddPythonTransformReaction( otsuMultipleThresholdAction, "Otsu Multiple Threshold", - readInPythonScript("OtsuMultipleThreshold"), false, false, false, + readInPythonScript("OtsuMultipleThreshold"), readInJSONDescription("OtsuMultipleThreshold")); new AddPythonTransformReaction( connectedComponentsAction, "Connected Components", - readInPythonScript("ConnectedComponents"), false, false, false, + readInPythonScript("ConnectedComponents"), readInJSONDescription("ConnectedComponents")); new AddPythonTransformReaction( binaryDilateAction, "Binary Dilate", readInPythonScript("BinaryDilate"), - false, false, false, readInJSONDescription("BinaryDilate")); + readInJSONDescription("BinaryDilate")); new AddPythonTransformReaction( - binaryErodeAction, "Binary Erode", readInPythonScript("BinaryErode"), false, - false, false, readInJSONDescription("BinaryErode")); + binaryErodeAction, "Binary Erode", readInPythonScript("BinaryErode"), + readInJSONDescription("BinaryErode")); new AddPythonTransformReaction(binaryOpenAction, "Binary Open", - readInPythonScript("BinaryOpen"), false, false, - false, readInJSONDescription("BinaryOpen")); + readInPythonScript("BinaryOpen"), + readInJSONDescription("BinaryOpen")); new AddPythonTransformReaction( - binaryCloseAction, "Binary Close", readInPythonScript("BinaryClose"), false, - false, false, readInJSONDescription("BinaryClose")); + binaryCloseAction, "Binary Close", readInPythonScript("BinaryClose"), + readInJSONDescription("BinaryClose")); new AddPythonTransformReaction( binaryMinMaxCurvatureFlowAction, "Binary MinMax Curvature Flow", - readInPythonScript("BinaryMinMaxCurvatureFlow"), false, false, false, + readInPythonScript("BinaryMinMaxCurvatureFlow"), readInJSONDescription("BinaryMinMaxCurvatureFlow")); new AddPythonTransformReaction( labelObjectAttributesAction, "Label Object Attributes", - readInPythonScript("LabelObjectAttributes"), false, false, false, + readInPythonScript("LabelObjectAttributes"), readInJSONDescription("LabelObjectAttributes")); new AddPythonTransformReaction( labelObjectPrincipalAxesAction, "Label Object Principal Axes", - readInPythonScript("LabelObjectPrincipalAxes"), false, false, false, + readInPythonScript("LabelObjectPrincipalAxes"), readInJSONDescription("LabelObjectPrincipalAxes")); new AddPythonTransformReaction( distanceFromAxisAction, "Label Object Distance From Principal Axis", - readInPythonScript("LabelObjectDistanceFromPrincipalAxis"), false, false, - false, readInJSONDescription("LabelObjectDistanceFromPrincipalAxis")); + readInPythonScript("LabelObjectDistanceFromPrincipalAxis"), + readInJSONDescription("LabelObjectDistanceFromPrincipalAxis")); new AddPythonTransformReaction(segmentParticlesAction, "Segment Particles", - readInPythonScript("SegmentParticles"), false, - false, false, + readInPythonScript("SegmentParticles"), readInJSONDescription("SegmentParticles")); new AddPythonTransformReaction( segmentPoresAction, "Segment Pores", readInPythonScript("SegmentPores"), - false, false, false, readInJSONDescription("SegmentPores")); + readInJSONDescription("SegmentPores")); + + new AddPythonTransformReaction( + sam2SegmentAction, "SAM 2 Segmentation (3D)", + readInPythonScript("SAM2Segment3D"), + readInJSONDescription("SAM2Segment3D")); + new AddPythonTransformReaction( + sam3SegmentAction, "SAM 3 Segmentation (3D)", + readInPythonScript("SAM3Segment3D"), + readInJSONDescription("SAM3Segment3D")); } void DataTransformMenu::updateActions() {} diff --git a/tomviz/DeleteDataReaction.cxx b/tomviz/DeleteDataReaction.cxx index 8378132ea..ed16e5d70 100644 --- a/tomviz/DeleteDataReaction.cxx +++ b/tomviz/DeleteDataReaction.cxx @@ -4,68 +4,37 @@ #include "DeleteDataReaction.h" #include "ActiveObjects.h" -#include "ModuleManager.h" -#include "Pipeline.h" + +// TODO: Re-implement delete data using the new pipeline API. +// The old implementation relied on ActiveObjects::activeDataSource(), +// DataSource, Pipeline, and ModuleManager which have been removed or changed. namespace tomviz { DeleteDataReaction::DeleteDataReaction(QAction* parentObject) : pqReaction(parentObject) { - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &DeleteDataReaction::activeDataSourceChanged); - m_activeDataSource = ActiveObjects::instance().activeDataSource(); - updateEnableState(); + parentAction()->setEnabled(false); } void DeleteDataReaction::updateEnableState() { - bool enabled = (m_activeDataSource != nullptr); - if (enabled) { - enabled = m_activeDataSource->pipeline() && - !m_activeDataSource->pipeline()->isRunning(); - } - parentAction()->setEnabled(enabled); + parentAction()->setEnabled(false); } void DeleteDataReaction::onTriggered() { - auto source = ActiveObjects::instance().activeDataSource(); - Q_ASSERT(source); - DeleteDataReaction::deleteDataSource(source); - ActiveObjects::instance().renderAllViews(); + // TODO: implement with new pipeline } -void DeleteDataReaction::deleteDataSource(DataSource* source) +void DeleteDataReaction::deleteDataSource(DataSource*) { - Q_ASSERT(source); - - auto& mmgr = ModuleManager::instance(); - mmgr.removeAllModules(source); - mmgr.removeDataSource(source); + // TODO: implement with new pipeline } void DeleteDataReaction::activeDataSourceChanged() { - auto source = ActiveObjects::instance().activeDataSource(); - if (m_activeDataSource != source) { - if (m_activeDataSource && m_activeDataSource.data()->pipeline()) { - disconnect(m_activeDataSource.data()->pipeline(), &Pipeline::started, - this, nullptr); - disconnect(m_activeDataSource.data()->pipeline(), &Pipeline::finished, - this, nullptr); - } - m_activeDataSource = source; - if (m_activeDataSource && m_activeDataSource.data()->pipeline()) { - connect(m_activeDataSource.data()->pipeline(), &Pipeline::started, this, - &DeleteDataReaction::updateEnableState); - connect(m_activeDataSource.data()->pipeline(), &Pipeline::finished, this, - &DeleteDataReaction::updateEnableState); - } - } - updateEnableState(); + // TODO: implement with new pipeline } } // end of namespace tomviz diff --git a/tomviz/DockerExecutor.cxx b/tomviz/DockerExecutor.cxx deleted file mode 100644 index 1f880bfe8..000000000 --- a/tomviz/DockerExecutor.cxx +++ /dev/null @@ -1,343 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "DockerExecutor.h" - -#include "DockerUtilities.h" -#include "ProgressDialog.h" -#include "Utilities.h" - -#include - -namespace tomviz { - -DockerPipelineExecutor::DockerPipelineExecutor(Pipeline* pipeline) - : ExternalPipelineExecutor(pipeline), m_statusCheckTimer(new QTimer(this)) -{ - m_statusCheckTimer->setInterval(5000); - connect(m_statusCheckTimer, &QTimer::timeout, this, - &DockerPipelineExecutor::checkContainerStatus); -} - -DockerPipelineExecutor::~DockerPipelineExecutor() = default; - -docker::DockerRunInvocation* DockerPipelineExecutor::run( - const QString& image, const QStringList& args, - const QMap& bindMounts) -{ - auto runInvocation = docker::run(image, QString(), args, bindMounts); - connect(runInvocation, &docker::DockerRunInvocation::error, this, - &DockerPipelineExecutor::error); - connect(runInvocation, &docker::DockerRunInvocation::finished, runInvocation, - [this, runInvocation](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (exitCode) { - displayError("Docker Error", - QString("Docker run failed with: %1\n\n%2") - .arg(exitCode) - .arg(runInvocation->stdErr())); - } else { - m_containerId = runInvocation->containerId(); - // Start to monitor the status of the container - m_statusCheckTimer->start(); - // Print out stdout and stderr as it appears - followLogs(); - } - runInvocation->deleteLater(); - }); - - return runInvocation; -} - -void DockerPipelineExecutor::remove(const QString& containerId, bool force) -{ - auto removeInvocation = docker::remove(containerId, force); - connect(removeInvocation, &docker::DockerRunInvocation::error, this, - &DockerPipelineExecutor::error); - connect( - removeInvocation, &docker::DockerRunInvocation::finished, removeInvocation, - [this, removeInvocation](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (exitCode) { - displayError("Docker Error", - QString("Docker remove failed with: %1\n\n%2") - .arg(exitCode) - .arg(removeInvocation->stdErr())); - } - removeInvocation->deleteLater(); - }); -} - -docker::DockerStopInvocation* DockerPipelineExecutor::stop( - const QString& containerId) -{ - auto stopInvocation = docker::stop(containerId, 0); - connect(stopInvocation, &docker::DockerStopInvocation::error, this, - &DockerPipelineExecutor::error); - connect( - stopInvocation, &docker::DockerStopInvocation::finished, stopInvocation, - [this, stopInvocation](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (exitCode) { - displayError("Docker Error", - QString("Docker stop failed with: %1\n\n%2") - .arg(exitCode) - .arg(stopInvocation->stdErr())); - stopInvocation->deleteLater(); - return; - } - - PipelineSettings settings; - if (settings.dockerRemove() && !m_containerId.isEmpty()) { - // Remove the container - remove(m_containerId, true); - } - - stopInvocation->deleteLater(); - }); - - return stopInvocation; -} - -Pipeline::Future* DockerPipelineExecutor::execute(vtkDataObject* data, - QList operators, - int start, int end) -{ - - auto future = ExternalPipelineExecutor::execute(data, operators, start, end); - - // We are now ready to run the pipeline - auto args = executorArgs(start); - QMap bindMounts; - bindMounts[m_temporaryDir->path()] = CONTAINER_MOUNT; - - PipelineSettings settings; - QString image = settings.dockerImage(); - auto startContainer = [this, image, args, bindMounts]() { - auto msg = QString("Starting docker container."); - auto progress = new ProgressDialog("Docker run", msg, tomviz::mainWidget()); - progress->show(); - auto runInvocation = run(image, args, bindMounts); - connect(runInvocation, &docker::DockerPullInvocation::finished, - runInvocation, - [progress](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitCode) - Q_UNUSED(exitStatus) - progress->hide(); - progress->deleteLater(); - }); - }; - - // Pull the latest version of the image, if haven't already - if (settings.dockerPull() && m_pullImage) { - auto msg = QString("Pulling docker image: %1").arg(image); - auto progress = - new ProgressDialog("Docker Pull", msg, tomviz::mainWidget()); - progress->show(); - m_pullImage = false; - auto pullInvocation = docker::pull(image); - connect(pullInvocation, &docker::DockerPullInvocation::error, this, - &DockerPipelineExecutor::error); - connect(pullInvocation, &docker::DockerPullInvocation::finished, - pullInvocation, - [this, image, args, bindMounts, pullInvocation, progress, - startContainer](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - progress->hide(); - progress->deleteLater(); - if (exitCode) { - displayError("Docker Error", - QString("Docker pull failed with: %1\n\n%2") - .arg(exitCode) - .arg(pullInvocation->stdErr())); - } else { - startContainer(); - } - pullInvocation->deleteLater(); - }); - } else { - startContainer(); - } - - return future; -} - -void DockerPipelineExecutor::cancel(std::function canceled) -{ - // Call reset to stop progress updates, status checking and clean - // update state. - reset(); - auto stopInvocation = stop(m_containerId); - connect(stopInvocation, &docker::DockerStopInvocation::finished, - stopInvocation, - [this, canceled](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (!exitCode) { - canceled(); - } - }); -} - -bool DockerPipelineExecutor::cancel(Operator* op) -{ - Q_UNUSED(op); - - if (m_containerId.isEmpty()) { - return false; - } - - // Cancel status checks - m_statusCheckTimer->stop(); - - // Stop the progress reader - m_progressReader->stop(); - - // Simply stop the container. - stop(m_containerId); - - // Clean update state. - reset(); - - // We can't cancel an individual operator so we return false, so the caller - // knows - return false; -} - -bool DockerPipelineExecutor::isRunning() -{ - return !m_containerId.isEmpty(); -} - -void DockerPipelineExecutor::error(QProcess::ProcessError error) -{ - auto invocation = qobject_cast(sender()); - displayError("Execution Error", - QString("An error occurred executing '%1', '%2'") - .arg(invocation->commandLine()) - .arg(error)); -} - -void DockerPipelineExecutor::containerError(int containerExitCode) -{ - auto logsInvocation = docker::logs(m_containerId); - connect(logsInvocation, &docker::DockerRunInvocation::error, this, - &DockerPipelineExecutor::error); - connect(logsInvocation, &docker::DockerRunInvocation::finished, - logsInvocation, [this, logsInvocation, containerExitCode]( - int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (exitCode) { - displayError("Docker Error", - QString("Docker logs failed with: %1\n\n%2") - .arg(exitCode) - .arg(logsInvocation->stdErr())); - logsInvocation->deleteLater(); - return; - } else { - auto logs = logsInvocation->logs(); - displayError( - "Pipeline Error", - QString("Docker container exited with non-zero exit code: %1." - "\n\nSee message logs for Docker logs.") - .arg(containerExitCode)); - qCritical() << logs; - } - logsInvocation->deleteLater(); - PipelineSettings settings; - if (settings.dockerRemove() && !m_containerId.isEmpty()) { - remove(m_containerId); - } - }); -} - -void DockerPipelineExecutor::checkContainerStatus() -{ - auto inspectInvocation = docker::inspect(m_containerId); - connect(inspectInvocation, &docker::DockerInspectInvocation::error, this, - &DockerPipelineExecutor::error); - connect(inspectInvocation, &docker::DockerInspectInvocation::finished, - inspectInvocation, [this, inspectInvocation]( - int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (exitCode) { - displayError("Docker Error", - QString("Docker inspect failed with: %1\n\n%2") - .arg(exitCode) - .arg(inspectInvocation->stdErr())); - } else { - // Check we haven't exited with an error. - auto status = inspectInvocation->status(); - if (status == "exited") { - if (inspectInvocation->exitCode()) { - containerError(inspectInvocation->exitCode()); - } - // Cancel the status checks we are done. - m_statusCheckTimer->stop(); - } - } - inspectInvocation->deleteLater(); - }); -} - -void DockerPipelineExecutor::pipelineStarted() -{ - qDebug("Pipeline started in docker container!"); -} - -void DockerPipelineExecutor::reset() -{ - // Cancel status checks - m_statusCheckTimer->stop(); - - ExternalPipelineExecutor::reset(); - - PipelineSettings settings; - if (settings.dockerRemove() && !m_containerId.isEmpty()) { - // Remove the container - remove(m_containerId, true); - } - - m_containerId = QString(); -} - -QString DockerPipelineExecutor::executorWorkingDir() -{ - return CONTAINER_MOUNT; -} - -void DockerPipelineExecutor::followLogs() -{ - if (m_containerId.isEmpty()) { - // Nothing to do - return; - } - - auto logsInvocation = docker::logs(m_containerId, true); - connect(logsInvocation, &docker::DockerLogsInvocation::error, this, - &DockerPipelineExecutor::error); - connect( - logsInvocation, &docker::DockerLogsInvocation::finished, logsInvocation, - [this, logsInvocation](int exitCode, QProcess::ExitStatus exitStatus) { - Q_UNUSED(exitStatus) - if (exitCode) { - displayError("Docker Error", - QString("Docker logs failed with: %1\n\n%2") - .arg(exitCode) - .arg(logsInvocation->stdErr())); - } - logsInvocation->deleteLater(); - }); - - auto printFunc = [](QString s) { - // Since qDebug() will already print a newline, get rid of the - // newline from the string if it is there. - auto regexp = QRegularExpression("(?:\r\n|\n)$"); - s = s.remove(regexp); - qDebug().noquote() << s; - }; - - connect(logsInvocation, &docker::DockerInvocation::stdOutReceived, printFunc); - connect(logsInvocation, &docker::DockerInvocation::stdErrReceived, printFunc); -} - -} // namespace tomviz diff --git a/tomviz/DockerExecutor.h b/tomviz/DockerExecutor.h deleted file mode 100644 index c5f3949cc..000000000 --- a/tomviz/DockerExecutor.h +++ /dev/null @@ -1,65 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizDockerExecutor_h -#define tomvizDockerExecutor_h - -#include - -#include "PipelineExecutor.h" - -namespace tomviz { -class DataSource; -class Operator; -class Pipeline; - -namespace docker { -class DockerStopInvocation; -class DockerRunInvocation; -} - - -/// -/// Executor that orchestrates the execution of pipelines in Docker containers, -/// providing a pristine container-based pipeline environment. -/// -class DockerPipelineExecutor : public ExternalPipelineExecutor -{ - Q_OBJECT - -public: - DockerPipelineExecutor(Pipeline* pipeline); - ~DockerPipelineExecutor(); - Pipeline::Future* execute(vtkDataObject* data, QList operators, - int start = 0, int end = -1) override; - void cancel(std::function canceled) override; - bool cancel(Operator* op) override; - bool isRunning() override; - -protected: - QString executorWorkingDir() override; - void pipelineStarted() override; - void reset() override; - -private slots: - docker::DockerRunInvocation* run(const QString& image, - const QStringList& args, - const QMap& bindMounts); - void remove(const QString& containerId, bool force = false); - docker::DockerStopInvocation* stop(const QString& containerId); - void containerError(int exitCode); - void error(QProcess::ProcessError error); - -private: - bool m_pullImage = true; - QString m_containerId; - - QTimer* m_statusCheckTimer; - - void checkContainerStatus(); - void followLogs(); -}; - -} // namespace tomviz - -#endif // tomvizDockerExecutor_h diff --git a/tomviz/DockerUtilities.cxx b/tomviz/DockerUtilities.cxx deleted file mode 100644 index 7368a685c..000000000 --- a/tomviz/DockerUtilities.cxx +++ /dev/null @@ -1,281 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "DockerUtilities.h" -//#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -namespace docker { - -DockerInvocation::DockerInvocation(const QString& command, - const QStringList& args) - : m_command(command), m_args(args) -{ -} - -QString DockerInvocation::commandLine() const -{ - return QString("docker %1 %2").arg(m_command).arg(m_args.join(" ")); -} - -DockerInvocation* DockerInvocation::run() -{ - m_process = new QProcess(this); - - connect(m_process, &QProcess::readyReadStandardOutput, this, - &DockerInvocation::onStdOutReceived); - connect(m_process, &QProcess::readyReadStandardError, this, - &DockerInvocation::onStdErrReceived); - - // We emit the signals rather than connect signal to signal so we get the - // right sender. - connect(m_process, &QProcess::errorOccurred, - [this](QProcess::ProcessError error) { emit this->error(error); }); - - connect(m_process, static_cast( - &QProcess::finished), - [this](int exitCode, QProcess::ExitStatus exitStatus) { - emit this->finished(exitCode, exitStatus); - }); - m_process->setProgram("docker"); - QStringList dockerArgs; - dockerArgs.append(m_command); - dockerArgs.append(m_args); - - m_process->setArguments(dockerArgs); - - // Start in the next event loop so signals can be wired up. - QTimer::singleShot(0, [this]() { m_process->start(); }); - - return this; -} - -void DockerInvocation::onStdOutReceived() -{ - QString s = m_process->readAllStandardOutput(); - m_stdOutReceived += s; - - emit stdOutReceived(s); -} - -void DockerInvocation::onStdErrReceived() -{ - QString s = m_process->readAllStandardError(); - m_stdErrReceived += s; - - emit stdErrReceived(s); -} - -void DockerInvocation::init(const QString& command, const QStringList& args) -{ - m_command = command; - m_args = args; -} - -DockerRunInvocation::DockerRunInvocation( - const QString& image, const QString& entryPoint, - const QStringList& containerArgs, const QMap& bindMounts) - : m_image(image), m_entryPoint(entryPoint), m_containerArgs(containerArgs), - m_bindMounts(bindMounts) -{ - - QStringList args; - args.append("-d"); - if (!entryPoint.isNull()) { - args.append("--entrypoint"); - args.append(entryPoint); - } - - if (!bindMounts.isEmpty()) { - for (QMap::const_iterator iter = bindMounts.begin(); - iter != bindMounts.end(); ++iter) { - args.append("-v"); - args.append(QString("%1:%2").arg(iter.key()).arg(iter.value())); - } - } - - args.append(image); - args.append(containerArgs); - - init("run", args); -} - -DockerRunInvocation* DockerRunInvocation::run() -{ - return qobject_cast(DockerInvocation::run()); -} - -QString DockerRunInvocation::containerId() -{ - return stdOut().trimmed(); -} - -DockerPullInvocation::DockerPullInvocation(const QString& image) - : m_image(image) -{ - QStringList args; - args.append(image); - - init("pull", args); -} - -DockerPullInvocation* DockerPullInvocation::run() -{ - return qobject_cast(DockerInvocation::run()); -} - -DockerLogsInvocation::DockerLogsInvocation(const QString& containerId, - bool follow) - : m_containerId(containerId) -{ - QStringList args; - args.append(containerId); - - if (follow) - args.append("-f"); - - init("logs", args); -} - -DockerLogsInvocation* DockerLogsInvocation::run() -{ - return qobject_cast(DockerInvocation::run()); -} - -QString DockerLogsInvocation::logs() -{ - return stdErr() + stdOut(); -} - -DockerStopInvocation::DockerStopInvocation(const QString& containerId, - int timeout = 10) - : m_containerId(containerId), m_timeout(timeout) -{ - QStringList args; - args.append(containerId); - args.append("-t"); - args.append(QString::number(timeout)); - - init("stop", args); -} - -DockerStopInvocation* DockerStopInvocation::run() -{ - return qobject_cast(DockerInvocation::run()); -} - -DockerInspectInvocation::DockerInspectInvocation(const QString& containerId) - : m_containerId(containerId) -{ - QStringList args; - args.append(containerId); - - init("inspect", args); -} - -QString DockerInspectInvocation::status() -{ - if (m_status.isNull()) { - auto document = QJsonDocument::fromJson(stdOut().toLatin1()); - auto inspect = document.array()[0].toObject(); - auto state = inspect["State"].toObject(); - auto status = state["Status"]; - - if (status.isString()) { - m_status = status.toString(); - } - } - - return m_status; -} - -int DockerInspectInvocation::exitCode() -{ - if (m_exitCode < 0) { - auto document = QJsonDocument::fromJson(stdOut().toLatin1()); - auto inspect = document.array()[0].toObject(); - auto state = inspect["State"].toObject(); - auto exitCode = state["ExitCode"]; - - if (exitCode.isDouble()) { - m_exitCode = exitCode.toInt(); - } - } - - return m_exitCode; -} - -DockerInspectInvocation* DockerInspectInvocation::run() -{ - return qobject_cast(DockerInvocation::run()); -} - -DockerRemoveInvocation::DockerRemoveInvocation(const QString& containerId, - bool force) - : m_containerId(containerId) -{ - QStringList args; - args.append(containerId); - if (force) { - args.append("-f"); - } - - init("rm", args); -} - -DockerRemoveInvocation* DockerRemoveInvocation::run() -{ - return qobject_cast(DockerInvocation::run()); -} - -DockerRunInvocation* run(const QString& image, const QString& entryPoint, - const QStringList& containerArgs, - const QMap& bindMounts) -{ - auto invocation = - new DockerRunInvocation(image, entryPoint, containerArgs, bindMounts); - - return invocation->run(); -} - -DockerPullInvocation* pull(const QString& image) -{ - auto invocation = new DockerPullInvocation(image); - - return invocation->run(); -} - -DockerStopInvocation* stop(const QString& containerId, int wait) -{ - auto invocation = new DockerStopInvocation(containerId, wait); - return invocation->run(); -} - -DockerRemoveInvocation* remove(const QString& containerId, bool force) -{ - auto invocation = new DockerRemoveInvocation(containerId, force); - return invocation->run(); -} - -DockerLogsInvocation* logs(const QString& containerId, bool follow) -{ - auto invocation = new DockerLogsInvocation(containerId, follow); - return invocation->run(); -} - -DockerInspectInvocation* inspect(const QString& containerId) -{ - auto invocation = new DockerInspectInvocation(containerId); - return invocation->run(); -} -} - -} // namespace tomviz diff --git a/tomviz/DockerUtilities.h b/tomviz/DockerUtilities.h deleted file mode 100644 index 7238da9c9..000000000 --- a/tomviz/DockerUtilities.h +++ /dev/null @@ -1,154 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizDockerUtilties_h -#define tomvizDockerUtilties_h - -// Collection of utilities for interacting with docker. - -#include -#include -#include -#include -#include - -namespace tomviz { - -namespace docker { - -class DockerInvocation : public QObject -{ - Q_OBJECT - -public: - DockerInvocation(){}; - DockerInvocation(const QString& command, const QStringList& args); - - QString commandLine() const; - DockerInvocation* run(); - QString stdOut() { return m_stdOutReceived; } - QString stdErr() { return m_stdErrReceived; } - -signals: - void error(QProcess::ProcessError error); - void finished(int exitCode, QProcess::ExitStatus exitStatus); - - void stdOutReceived(const QString& s); - void stdErrReceived(const QString& s); - -protected: - void init(const QString& command, const QStringList& args); - -private slots: - void onStdOutReceived(); - void onStdErrReceived(); - -private: - QString m_command; - QStringList m_args; - QString m_stdOutReceived; - QString m_stdErrReceived; - QProcess* m_process; -}; - -class DockerRunInvocation : public DockerInvocation -{ - Q_OBJECT -public: - DockerRunInvocation(const QString& image, const QString& entryPoint, - const QStringList& containerArgs, - const QMap& bindMounts); - - DockerRunInvocation* run(); - QString containerId(); - -private: - QString m_image; - QString m_entryPoint; - QStringList m_containerArgs; - QMap m_bindMounts; -}; - -class DockerPullInvocation : public DockerInvocation -{ - Q_OBJECT -public: - DockerPullInvocation(const QString& image); - - DockerPullInvocation* run(); - -private: - QString m_image; -}; - -class DockerLogsInvocation : public DockerInvocation -{ - Q_OBJECT -public: - DockerLogsInvocation(const QString& containerId, bool follow = false); - - DockerLogsInvocation* run(); - QString logs(); - -private: - QString m_containerId; - QString m_logs; -}; - -class DockerStopInvocation : public DockerInvocation -{ - Q_OBJECT -public: - DockerStopInvocation(const QString& containerId, int timeout); - - DockerStopInvocation* run(); - -private: - QString m_containerId; - int m_timeout; -}; - -class DockerInspectInvocation : public DockerInvocation -{ - Q_OBJECT -public: - DockerInspectInvocation(const QString& containerId); - - DockerInspectInvocation* run(); - QString status(); - int exitCode(); - -private: - QString m_containerId; - int m_inspectResult; - QString m_status; - int m_exitCode = -1; -}; - -class DockerRemoveInvocation : public DockerInvocation -{ - Q_OBJECT -public: - DockerRemoveInvocation(const QString& containerId, bool force = false); - - DockerRemoveInvocation* run(); - -private: - QString m_containerId; -}; - -DockerRunInvocation* run( - const QString& image, const QString& entryPoint = QString(), - const QStringList& containerArgs = QStringList(), - const QMap& bindMounts = QMap()); - -DockerPullInvocation* pull(const QString& image); -DockerStopInvocation* stop(const QString& containerId, int wait = 10); -DockerRemoveInvocation* remove(const QString& containerId, bool force = false); -DockerLogsInvocation* logs(const QString& containerId, bool follow = false); -DockerInspectInvocation* inspect(const QString& containerId); - -} // namespace docker -} // namespace tomviz - -#endif diff --git a/tomviz/DuplicateModuleReaction.cxx b/tomviz/DuplicateModuleReaction.cxx deleted file mode 100644 index e14c0575a..000000000 --- a/tomviz/DuplicateModuleReaction.cxx +++ /dev/null @@ -1,56 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "DuplicateModuleReaction.h" - -#include "ActiveObjects.h" -#include "Module.h" -#include "ModuleFactory.h" -#include "ModuleManager.h" - -#include - -namespace tomviz { - -DuplicateModuleReaction::DuplicateModuleReaction(QAction* action) - : pqReaction(action) -{ - connect(&ActiveObjects::instance(), &ActiveObjects::moduleChanged, this, - &DuplicateModuleReaction::updateEnableState); - updateEnableState(); -} - -void DuplicateModuleReaction::updateEnableState() -{ - parentAction()->setEnabled(ActiveObjects::instance().activeModule() != - nullptr); -} - -void DuplicateModuleReaction::onTriggered() -{ - auto module = ActiveObjects::instance().activeModule(); - auto dataSource = module->dataSource(); - auto operatorResult = module->operatorResult(); - auto moleculeSource = module->moleculeSource(); - auto view = ActiveObjects::instance().activeView(); - auto moduleType = ModuleFactory::moduleType(module); - // Copy the module - Module* copy; - if (ModuleFactory::moduleApplicable(moduleType, dataSource, view)) { - copy = ModuleFactory::createModule(moduleType, dataSource, view); - } else if (ModuleFactory::moduleApplicable(moduleType, moleculeSource, - view)) { - copy = ModuleFactory::createModule(moduleType, moleculeSource, view); - } else { - copy = ModuleFactory::createModule(moduleType, operatorResult, view); - } - - if (copy) { - // Copy its settings - QJsonObject json = module->serialize(); - copy->deserialize(json); - ModuleManager::instance().addModule(copy); - } -} - -} // end namespace tomviz diff --git a/tomviz/DuplicateModuleReaction.h b/tomviz/DuplicateModuleReaction.h deleted file mode 100644 index 376494163..000000000 --- a/tomviz/DuplicateModuleReaction.h +++ /dev/null @@ -1,30 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizDuplicateModuleReaction_h -#define tomvizDuplicateModuleReaction_h - -#include - -namespace tomviz { - -/// SaveDataReaction handles the "Save Data" action in tomviz. On trigger, -/// this will save the data file. -class DuplicateModuleReaction : public pqReaction -{ - Q_OBJECT - -public: - DuplicateModuleReaction(QAction* parentAction); - -protected: - /// Called when the data changes to enable/disable the menu item - void updateEnableState() override; - - /// Called when the action is triggered. - void onTriggered() override; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/EmdFormat.cxx b/tomviz/EmdFormat.cxx index 8ffd22cab..3d4cb53f1 100644 --- a/tomviz/EmdFormat.cxx +++ b/tomviz/EmdFormat.cxx @@ -3,7 +3,7 @@ #include "EmdFormat.h" -#include "DataSource.h" +#include "pipeline/data/VolumeData.h" #include "GenericHDF5Format.h" #include "Utilities.h" @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -155,8 +156,20 @@ bool EmdFormat::readNode(h5::H5ReadWrite& reader, const std::string& emdNode, } else { // No deep copying of the data needed. Just relabel the X and Z axes. relabelXAndZAxes(image); - DataSource::setTiltAngles(image, angles); - DataSource::setType(image, DataSource::TiltSeries); + pipeline::VolumeData::setTiltAngles(image, angles); + pipeline::VolumeData::setType( + image, pipeline::VolumeData::DataType::TiltSeries); + } + + // /data carries the first scalar in insertion order; the true + // active is in active_scalar_name when it differs. Files without + // the attr leave the active as /data. + if (reader.hasAttribute(emdNode, "active_scalar_name")) { + auto activeName = + reader.attribute(emdNode, "active_scalar_name", &ok); + if (ok && !activeName.empty()) { + image->GetPointData()->SetActiveScalars(activeName.c_str()); + } } // Read scan IDs if present @@ -169,18 +182,13 @@ bool EmdFormat::readNode(h5::H5ReadWrite& reader, const std::string& emdNode, for (auto& id : scanIdsData) { scanIDs.push_back(id); } - DataSource::setScanIDs(image, scanIDs); + pipeline::VolumeData::setScanIds(image, scanIDs); } } return true; } -bool EmdFormat::write(const std::string& fileName, DataSource* source) -{ - return write(fileName, source->imageData()); -} - bool EmdFormat::write(const std::string& fileName, vtkImageData* image) { using h5::H5ReadWrite; @@ -205,10 +213,10 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, writer.setAttribute(path, "emd_group_type", 1u); // See if we have tilt angles - auto hasTiltAngles = DataSource::hasTiltAngles(image); + auto hasTiltAngles = pipeline::VolumeData::hasTiltAngles(image); vtkNew permutedImage; - if (DataSource::hasTiltAngles(image)) { + if (pipeline::VolumeData::hasTiltAngles(image)) { // No deep copies of data needed. Just re-label the axes. permutedImage->ShallowCopy(image); relabelXAndZAxes(permutedImage); @@ -237,7 +245,7 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, std::vector imageDimDataZ(dimensions[2]); if (hasTiltAngles) { - auto angles = DataSource::getTiltAngles(permutedImage); + auto angles = pipeline::VolumeData::getTiltAngles(permutedImage); imageDimDataX.reserve(angles.size()); for (int i = 0; i < angles.size(); ++i) { imageDimDataX[i] = static_cast(angles[i]); @@ -256,7 +264,7 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, // Create the 3 dim sets too... std::vector side(1); - side[0] = imageDimDataX.size(); + side[0] = static_cast(imageDimDataX.size()); writer.writeData(path, "dim1", side, imageDimDataX); if (hasTiltAngles) { writer.setAttribute(path + "/dim1", "name", "angles"); @@ -266,12 +274,12 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, writer.setAttribute(path + "/dim1", "units", "[n_m]"); } - side[0] = imageDimDataY.size(); + side[0] = static_cast(imageDimDataY.size()); writer.writeData(path, "dim2", side, imageDimDataY); writer.setAttribute(path + "/dim2", "name", "y"); writer.setAttribute(path + "/dim2", "units", "[n_m]"); - side[0] = imageDimDataZ.size(); + side[0] = static_cast(imageDimDataZ.size()); writer.writeData(path, "dim3", side, imageDimDataZ); if (hasTiltAngles) { writer.setAttribute(path + "/dim3", "name", "x"); @@ -285,8 +293,8 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, writeExtraScalars(writer, path, permutedImage); // Write scan IDs if present - if (DataSource::hasScanIDs(image)) { - auto scanIDs = DataSource::getScanIDs(image); + if (pipeline::VolumeData::hasScanIds(image)) { + auto scanIDs = pipeline::VolumeData::getScanIds(image); std::vector scanIdsVec(scanIDs.begin(), scanIDs.end()); std::vector dims(1, static_cast(scanIdsVec.size())); writer.writeData(path, "scan_ids", dims, scanIdsVec); diff --git a/tomviz/EmdFormat.h b/tomviz/EmdFormat.h index e8978b520..8b727a401 100644 --- a/tomviz/EmdFormat.h +++ b/tomviz/EmdFormat.h @@ -16,14 +16,11 @@ class H5ReadWrite; namespace tomviz { -class DataSource; - class EmdFormat { public: static bool read(const std::string& fileName, vtkImageData* data, const QVariantMap& options = QVariantMap()); - static bool write(const std::string& fileName, DataSource* source); static bool write(const std::string& fileName, vtkImageData* image); // Read EMD data from a specified node in the HDF5 file diff --git a/tomviz/ExportDataReaction.cxx b/tomviz/ExportDataReaction.cxx index ef1979d0b..207b35174 100644 --- a/tomviz/ExportDataReaction.cxx +++ b/tomviz/ExportDataReaction.cxx @@ -4,10 +4,10 @@ #include "ExportDataReaction.h" #include "ActiveObjects.h" -#include "ConvertToFloatOperator.h" +#include #include "DataExchangeFormat.h" #include "EmdFormat.h" -#include "Module.h" +#include "legacy/modules/Module.h" #include "Utilities.h" #include @@ -43,18 +43,21 @@ namespace tomviz { ExportDataReaction::ExportDataReaction(QAction* parentAction, Module* module) : pqReaction(parentAction), m_module(module) { - connect(&ActiveObjects::instance(), &ActiveObjects::moduleChanged, this, + // TODO: migrate to new pipeline + // Old code connected to ActiveObjects::moduleChanged + connect(&ActiveObjects::instance(), &ActiveObjects::activeNodeChanged, this, &ExportDataReaction::updateEnableState); updateEnableState(); } void ExportDataReaction::updateEnableState() { - if (!m_module) { - parentAction()->setEnabled(ActiveObjects::instance().activeModule() != - nullptr); - } else { + // TODO: migrate to new pipeline + // Old code checked ActiveObjects::activeModule() != nullptr + if (m_module) { parentAction()->setEnabled(true); + } else { + parentAction()->setEnabled(false); } } @@ -62,9 +65,8 @@ void ExportDataReaction::onTriggered() { Module* module = m_module; if (!module) { - module = ActiveObjects::instance().activeModule(); - } - if (!module) { + // TODO: migrate to new pipeline + // Old code used ActiveObjects::activeModule() return; } QString exportType = module->exportDataTypeString(); @@ -100,7 +102,7 @@ void ExportDataReaction::onTriggered() return; } - QFileDialog dialog(nullptr); + QFileDialog dialog(tomviz::mainWidget()); dialog.setFileMode(QFileDialog::AnyFile); dialog.setNameFilters(filters); dialog.setObjectName("FileOpenDialog-tomviz"); // avoid name collision? @@ -249,8 +251,22 @@ bool ExportDataReaction::exportData(const QString& filename) if (strcmp(writerName, "vtkTIFFWriter") == 0 && imageType == VTK_DOUBLE) { vtkNew fImage; fImage->DeepCopy(imageData); - ConvertToFloatOperator convertFloat; - convertFloat.applyTransform(fImage); + // Convert double scalars to float for TIFF export + auto* scalars = fImage->GetPointData()->GetScalars(); + vtkNew floatArray; + floatArray->SetNumberOfComponents(scalars->GetNumberOfComponents()); + floatArray->SetNumberOfTuples(scalars->GetNumberOfTuples()); + floatArray->SetName(scalars->GetName()); + for (vtkIdType i = 0; + i < scalars->GetNumberOfComponents() * + scalars->GetNumberOfTuples(); + ++i) { + static_cast(floatArray->GetVoidPointer(0))[i] = + static_cast( + static_cast(scalars->GetVoidPointer(0))[i]); + } + fImage->GetPointData()->RemoveArray(scalars->GetName()); + fImage->GetPointData()->SetScalars(floatArray); trivialProducer->SetOutput(fImage); trivialProducer->UpdateInformation(); diff --git a/tomviz/ExportDataReaction.h b/tomviz/ExportDataReaction.h deleted file mode 100644 index 5266537d7..000000000 --- a/tomviz/ExportDataReaction.h +++ /dev/null @@ -1,43 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizExportDataReaction_h -#define tomvizExportDataReaction_h - -#include - -class vtkImageData; -class vtkSMSourceProxy; - -namespace tomviz { -class Module; - -/// ExportDataReaction handles the "Export as ..." action in tomviz. On trigger, -/// this will save the data from the active module (or the module that is set) -/// to a file -class ExportDataReaction : public pqReaction -{ - Q_OBJECT - -public: - ExportDataReaction(QAction* parentAction, Module* module = nullptr); - - /// Save the file - bool exportData(const QString& filename); - bool exportColoredSlice(vtkImageData* imageData, vtkSMSourceProxy* writer, - const QString& filename); - -protected: - /// Called when the data changes to enable/disable the menu item - void updateEnableState() override; - - /// Called when the action is triggered. - void onTriggered() override; - -private: - Module* m_module; - - Q_DISABLE_COPY(ExportDataReaction) -}; -} // namespace tomviz -#endif diff --git a/tomviz/ExternalPythonExecutor.cxx b/tomviz/ExternalPythonExecutor.cxx deleted file mode 100644 index 1a71516ef..000000000 --- a/tomviz/ExternalPythonExecutor.cxx +++ /dev/null @@ -1,210 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include - -#include "ExternalPythonExecutor.h" -#include "DataSource.h" -#include "EmdFormat.h" -#include "Operator.h" -#include "OperatorPython.h" -#include "Pipeline.h" -#include "PipelineWorker.h" - -namespace tomviz { - -ExternalPythonExecutor::ExternalPythonExecutor(Pipeline* pipeline) - : ExternalPipelineExecutor(pipeline) -{ -} - -ExternalPythonExecutor::~ExternalPythonExecutor() = default; - -Pipeline::Future* ExternalPythonExecutor::execute(vtkDataObject* data, - QList operators, - int start, int end) -{ - m_receivedStdOut.clear(); - m_receivedStdErr.clear(); - - auto future = ExternalPipelineExecutor::execute(data, operators, start, end); - - // We are now ready to run the pipeline - QStringList args = executorArgs(start); - - PipelineSettings settings; - auto pythonExecutable = settings.externalPythonExecutablePath(); - - // Find the tomviz-pipeline executable - auto pythonExecutableFile = QFileInfo(pythonExecutable); - - if (!pythonExecutableFile.exists()) { - displayError("External Python Error", - QString("The external python executable doesn't exist: %1\n") - .arg(pythonExecutable)); - - return Pipeline::emptyFuture(); - } - - auto baseDir = pythonExecutableFile.dir(); - auto tomvizPipelineExecutable = - QFileInfo(baseDir.filePath("tomviz-pipeline")); - if (!tomvizPipelineExecutable.exists()) { - displayError( - "External Python Error", - QString("Unable to find tomviz-pipeline executable, please ensure " - "tomviz package has been installed in python environment." - "Click the Help button for more details on setting up your " - "Python environment.")); - - return Pipeline::emptyFuture(); - } - - m_process.reset(new QProcess(this)); - - connect(m_process.data(), &QProcess::readyReadStandardOutput, this, - &ExternalPythonExecutor::onStdOutReceived); - connect(m_process.data(), &QProcess::readyReadStandardError, this, - &ExternalPythonExecutor::onStdErrReceived); - - connect(m_process.data(), &QProcess::errorOccurred, this, - &ExternalPythonExecutor::error); - connect( - m_process.data(), - QOverload::of(&QProcess::finished), - [this](int exitCode, QProcess::ExitStatus exitStatus) { - if (exitStatus == QProcess::CrashExit) { - displayError("External Python Error", - QString("The external python process crash: %1\n\n " - "stderr:\n%2 \n\n stdout:\n%3 \n") - .arg(commandLine(this->m_process.data())) - .arg(m_receivedStdErr) - .arg(m_receivedStdOut)); - - } else if (exitCode != 0) { - displayError( - "External Python Error", - QString("The external python returned a non-zero exit code: %1\n\n " - "command: %2 \n\n stderr:\n%3 \n\n stdout:\n%4 \n") - .arg(exitCode) - .arg(commandLine(this->m_process.data())) - .arg(m_receivedStdErr) - .arg(m_receivedStdOut)); - } - }); - - // We have to get the process environment and unset TOMVIZ_APPLICATION and - // set that as the process environment for the process, otherwise the - // python package will think its running in the application. - auto processEnv = QProcessEnvironment::systemEnvironment(); - processEnv.remove("TOMVIZ_APPLICATION"); - // Remove vars related to python environment - processEnv.remove("PYTHONHOME"); - processEnv.remove("PYTHONPATH"); - - // Normally, python uses a buffer for stdout and stderr. However, - // we want everything printed from python to immediately appear in the - // tomviz messages box (rather than waiting until the program is finished). - // Unbuffer the python output so the messages get printed immediately. - processEnv.insert("PYTHONUNBUFFERED", "ON"); - - m_process->setProcessEnvironment(processEnv); - - m_process->start(tomvizPipelineExecutable.filePath(), args); - - return future; -} - -void ExternalPythonExecutor::cancel(std::function canceled) -{ - - reset(); - - m_process->kill(); - - canceled(); -} - -bool ExternalPythonExecutor::cancel(Operator* op) -{ - Q_UNUSED(op); - - // Stop the progress reader - m_progressReader->stop(); - - m_process->kill(); - - // Clean update state. - reset(); - - // We can't cancel an individual operator so we return false, so the caller - // knows - return false; -} - -bool ExternalPythonExecutor::isRunning() -{ - return !m_process.isNull() && m_process->state() != QProcess::NotRunning; -} - -void ExternalPythonExecutor::error(QProcess::ProcessError error) -{ - auto process = qobject_cast(sender()); - auto invocation = commandLine(process); - - displayError("Execution Error", - QString("An error occurred executing '%1', '%2'") - .arg(invocation) - .arg(error)); -} - -void ExternalPythonExecutor::pipelineStarted() -{ - qDebug("Pipeline started in external python!"); -} - -void ExternalPythonExecutor::reset() -{ - ExternalPipelineExecutor::reset(); - - m_process->waitForFinished(); - m_process.reset(); -} - -QString ExternalPythonExecutor::executorWorkingDir() -{ - return workingDir(); -} - -QString ExternalPythonExecutor::commandLine(QProcess* process) -{ - return QString("%1 %2") - .arg(process->program()) - .arg(process->arguments().join(" ")); -} - -void ExternalPythonExecutor::onStdOutReceived() -{ - QString s = m_process->readAllStandardOutput(); - m_receivedStdOut += s; - - // Since qDebug() will already print a newline, get rid of the - // newline from the string if it is there. - auto regexp = QRegularExpression("(?:\r\n|\n)$"); - s = s.remove(regexp); - qDebug().noquote() << s; -} - -void ExternalPythonExecutor::onStdErrReceived() -{ - QString s = m_process->readAllStandardError(); - m_receivedStdErr += s; - - // Since qDebug() will already print a newline, get rid of the - // newline from the string if it is there. - auto regexp = QRegularExpression("(?:\r\n|\n)$"); - s = s.remove(regexp); - qDebug().noquote() << s; -} - -} // namespace tomviz diff --git a/tomviz/ExternalPythonExecutor.h b/tomviz/ExternalPythonExecutor.h deleted file mode 100644 index ee9f83ebf..000000000 --- a/tomviz/ExternalPythonExecutor.h +++ /dev/null @@ -1,56 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizExternalPythonExecutor_h -#define tomvizExternalPythonExecutor_h - -#include "PipelineExecutor.h" - -#include -#include -#include - -namespace tomviz { - -class DataSource; -class Operator; -class Pipeline; - -/// -/// Executor that executes the pipeline in a specified external Python -/// environment in order to enable GPU acceleration, custom packages, etc. -/// -class ExternalPythonExecutor : public ExternalPipelineExecutor -{ - Q_OBJECT - -public: - ExternalPythonExecutor(Pipeline* pipeline); - ~ExternalPythonExecutor(); - Pipeline::Future* execute(vtkDataObject* data, QList operators, - int start = 0, int end = -1) override; - void cancel(std::function canceled) override; - bool cancel(Operator* op) override; - bool isRunning() override; - -protected: - QString executorWorkingDir() override; - -private slots: - void onStdOutReceived(); - void onStdErrReceived(); - void error(QProcess::ProcessError error); - -private: - void pipelineStarted() override; - void reset() override; - QString commandLine(QProcess* process); - - QScopedPointer m_process; - QString m_receivedStdOut; - QString m_receivedStdErr; -}; - -} // namespace tomviz - -#endif // tomvizExternalPythonExecutor_h diff --git a/tomviz/FileReader.cxx b/tomviz/FileReader.cxx new file mode 100644 index 000000000..a3060399f --- /dev/null +++ b/tomviz/FileReader.cxx @@ -0,0 +1,256 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "FileReader.h" + +#include "ActiveObjects.h" +#include "DataExchangeFormat.h" +#include "EmdFormat.h" +#include "FileFormatManager.h" +#include "FxiFormat.h" +#include "GenericHDF5Format.h" +#include "LoadDataReaction.h" +#include "PythonReader.h" +#include "Utilities.h" +#include "vtkOMETiffReader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace tomviz { + +namespace { + +QString fileExtension(const QStringList& fileNames) +{ + if (fileNames.isEmpty()) { + return {}; + } + return QFileInfo(fileNames.first()).suffix().toLower(); +} + +/// Read .tif/.tiff/.png/.jpg/.jpeg/.vti/.mrc directly via VTK without +/// going through ParaView's file-load machinery — so no dialogs. +vtkSmartPointer readWithVTK(const QStringList& fileNames) +{ + if (fileNames.isEmpty()) { + return nullptr; + } + QString ext = fileExtension(fileNames); + std::string path = fileNames.first().toStdString(); + + if (ext == "tif" || ext == "tiff") { + auto reader = vtkSmartPointer::New(); + if (fileNames.size() > 1) { + auto sa = vtkSmartPointer::New(); + sa->SetNumberOfValues(static_cast(fileNames.size())); + for (int i = 0; i < fileNames.size(); ++i) { + sa->SetValue(static_cast(i), + fileNames[i].toStdString()); + } + reader->SetFileNames(sa); + } else { + reader->SetFileName(path.c_str()); + } + reader->Update(); + return reader->GetOutput(); + } + if (ext == "png") { + auto reader = vtkSmartPointer::New(); + reader->SetFileName(path.c_str()); + reader->Update(); + return reader->GetOutput(); + } + if (ext == "jpg" || ext == "jpeg") { + auto reader = vtkSmartPointer::New(); + reader->SetFileName(path.c_str()); + reader->Update(); + return reader->GetOutput(); + } + if (ext == "vti") { + auto reader = vtkSmartPointer::New(); + reader->SetFileName(path.c_str()); + reader->Update(); + return reader->GetOutput(); + } + if (ext == "mrc") { + auto reader = vtkSmartPointer::New(); + reader->SetFileName(path.c_str()); + reader->Update(); + return reader->GetOutput(); + } + return nullptr; +} + +QVariantMap hdf5OptionsFromJson(const QJsonObject& options) +{ + QVariantMap hdf5Options; + if (options.contains("subsampleSettings")) { + hdf5Options["subsampleStrides"] = + options.value("subsampleSettings").toObject()["strides"].toVariant(); + hdf5Options["subsampleVolumeBounds"] = + options.value("subsampleSettings") + .toObject()["volumeBounds"] + .toVariant(); + hdf5Options["askForSubsample"] = false; + } + return hdf5Options; +} + +} // namespace + +ReadResult readImageData(const QStringList& fileNames, + const QJsonObject& options) +{ + ReadResult result; + if (fileNames.isEmpty()) { + return result; + } + + const QString fileName = fileNames.first(); + QFileInfo info(fileName); + const QString suffix = info.suffix().toLower(); + const QString completeSuffix = info.completeSuffix().toLower(); + + // .tvh5 — requires an internal node path. + if (suffix == "tvh5") { + QString path = options.value("tvh5NodePath").toString(); + if (path.isEmpty()) { + return result; + } + QVariantMap emdOptions = { { "askForSubsample", false } }; + vtkNew image; + if (EmdFormat::readNode(fileName.toStdString(), path.toStdString(), + image, emdOptions)) { + result.imageData = image; + result.tvh5NodePath = path; + } + return result; + } + + // .emd — simple EMD reader. + if (suffix == "emd") { + QVariantMap emdOptions = hdf5OptionsFromJson(options); + vtkNew imageData; + if (EmdFormat::read(fileName.toLatin1().data(), imageData, emdOptions)) { + result.imageData = imageData; + } + return result; + } + + // .hspy / .h5 — DataExchange / FXI / generic HDF5. + if (suffix == "hspy" || suffix == "h5") { + QVariantMap hdf5Options = hdf5OptionsFromJson(options); + const auto fnStd = fileName.toStdString(); + if (GenericHDF5Format::isDataExchange(fnStd)) { + DataExchangeFormat format; + auto r = format.readAll(fileName.toLatin1().data(), hdf5Options); + if (r.imageData) { + result.imageData = r.imageData; + result.tiltAngles = r.tiltAngles; + result.darkData = r.darkData; + result.whiteData = r.whiteData; + } + return result; + } + if (GenericHDF5Format::isFxi(fnStd)) { + FxiFormat format; + auto r = format.readAll(fileName.toLatin1().data(), hdf5Options); + if (r.imageData) { + result.imageData = r.imageData; + result.tiltAngles = r.tiltAngles; + result.darkData = r.darkData; + result.whiteData = r.whiteData; + } + return result; + } + vtkNew imageData; + GenericHDF5Format hdf5Format; + if (hdf5Format.read(fileName.toLatin1().data(), imageData, hdf5Options)) { + result.imageData = imageData; + } + return result; + } + + // *.ome.tif[f] — multi-channel OME-TIFF. + if (completeSuffix.endsWith("ome.tif") || + completeSuffix.endsWith("ome.tiff")) { + vtkNew reader; + reader->SetFileName(fileName.toLocal8Bit().constData()); + reader->Update(); + result.imageData = reader->GetOutput(); + QJsonObject rprops; + rprops["name"] = "OMETIFFReader"; + result.readerProperties = rprops; + return result; + } + + // Simple VTK-readable formats — direct, no dialog. + if (suffix == "tif" || suffix == "tiff" || suffix == "png" || + suffix == "jpg" || suffix == "jpeg" || suffix == "vti" || + suffix == "mrc") { + result.imageData = readWithVTK(fileNames); + return result; + } + + // Python-registered reader by extension. + if (FileFormatManager::instance().pythonReaderFactory(suffix) != nullptr) { + auto factory = FileFormatManager::instance().pythonReaderFactory(suffix); + auto reader = factory->createReader(); + auto imageData = reader.read(fileNames[0]); + if (imageData) { + result.imageData = imageData; + } + return result; + } + + // ParaView reader — requires an explicit descriptor from the caller + // (no dialog here). The descriptor carries the reader's XML name and + // property values, as emitted by LoadDataReaction::readerProperties(). + if (options.contains("reader")) { + auto props = options.value("reader").toObject(); + auto name = props.value("name").toString(); + auto* pxm = ActiveObjects::instance().proxyManager(); + if (!pxm || name.isEmpty()) { + return result; + } + vtkSmartPointer reader; + reader.TakeReference(pxm->NewProxy("sources", name.toLatin1().data())); + if (!reader) { + return result; + } + tomviz::setProperties(props, reader); + LoadDataReaction::setFileNameProperties(props, reader); + reader->UpdateVTKObjects(); + if (auto* src = vtkSMSourceProxy::SafeDownCast(reader)) { + src->UpdatePipelineInformation(); + src->UpdatePipeline(); + auto* algo = vtkAlgorithm::SafeDownCast(src->GetClientSideObject()); + if (algo) { + auto* image = + vtkImageData::SafeDownCast(algo->GetOutputDataObject(0)); + if (image) { + result.imageData = image; + result.readerProperties = props; + } + } + } + return result; + } + + // No headless path — caller must fall back to interactive load. + return result; +} + +} // namespace tomviz diff --git a/tomviz/FileReader.h b/tomviz/FileReader.h new file mode 100644 index 000000000..ad7cd3e47 --- /dev/null +++ b/tomviz/FileReader.h @@ -0,0 +1,63 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizFileReader_h +#define tomvizFileReader_h + +#include +#include +#include +#include + +#include + +class vtkImageData; + +namespace tomviz { + +/// Result of a headless file read via readImageData(). +struct ReadResult +{ + /// Populated on success, null on failure. + vtkSmartPointer imageData; + + /// Non-empty for formats that carry them (DataExchange, FXI). + QVector tiltAngles; + + /// Non-null for formats with reference frames (DataExchange). + vtkSmartPointer darkData; + vtkSmartPointer whiteData; + + /// For ParaView-reader-backed reads, the reader's XML name and + /// properties so a source node can round-trip through state files. + QJsonObject readerProperties; + + /// For tvh5 reads, the internal /tomviz_datasources/ path used. + QString tvh5NodePath; +}; + +/// Read image data from @a fileNames headlessly. No dialogs are shown, +/// no nodes are created, ActiveObjects is only touched when a ParaView +/// reader is explicitly requested via @a options["reader"]. +/// +/// Format dispatch: +/// .tvh5 -> EmdFormat::readNode (options["tvh5NodePath"] required) +/// .emd -> EmdFormat::read +/// .h5 / .hspy -> DataExchange / FXI / GenericHDF5Format (auto-detect) +/// *.ome.tif[f] -> vtkOMETiffReader +/// .tif/.tiff/.png/.jpg/.jpeg/.vti/.mrc -> direct VTK readers +/// other -> FileFormatManager Python reader if registered +/// fallback -> ParaView reader if options["reader"] is supplied +/// (otherwise ReadResult.imageData is null) +/// +/// @a options keys used: +/// "tvh5NodePath" (string) — required for .tvh5 +/// "subsampleSettings" (object) — forwarded to HDF5 readers +/// "reader" (object) — ParaView reader descriptor +/// ({"name": "...", ...properties}) +ReadResult readImageData(const QStringList& fileNames, + const QJsonObject& options = QJsonObject()); + +} // namespace tomviz + +#endif diff --git a/tomviz/FxiFormat.cxx b/tomviz/FxiFormat.cxx index 3b6897151..bd8064639 100644 --- a/tomviz/FxiFormat.cxx +++ b/tomviz/FxiFormat.cxx @@ -3,7 +3,6 @@ #include "FxiFormat.h" -#include "DataSource.h" #include "GenericHDF5Format.h" #include "Utilities.h" @@ -44,66 +43,56 @@ bool FxiFormat::read(const std::string& fileName, vtkImageData* image, return readDataSet(fileName, path, image, options); } -bool FxiFormat::read(const std::string& fileName, DataSource* dataSource, - const QVariantMap& options) +HDF5ReadResult FxiFormat::readAll(const std::string& fileName, + const QVariantMap& options) { + HDF5ReadResult result; + vtkNew image; if (!read(fileName, image, options)) { std::cerr << "Failed to read data in: " + fileName + "\n"; - return false; + return result; } - dataSource->setData(image); - - // Use the same strides and volume bounds for the dark and white data, - // except for the tilt axis. - QVariantMap darkWhiteOptions = options; - int strides[3]; - int bs[6]; - dataSource->subsampleStrides(strides); - dataSource->subsampleVolumeBounds(bs); + result.imageData = image; - QVariantList stridesList = { 1, strides[1], strides[2] }; - QVariantList boundsList = { 0, 1, bs[2], bs[3], bs[4], bs[5] }; - - darkWhiteOptions["subsampleStrides"] = stridesList; - darkWhiteOptions["subsampleVolumeBounds"] = boundsList; - darkWhiteOptions["askForSubsample"] = false; - - // Read in the dark and white image data as well + // Read dark and white data vtkNew darkImage, whiteImage; - readDark(fileName, darkImage, darkWhiteOptions); - if (darkImage->GetPointData()->GetNumberOfArrays() != 0) - dataSource->setDarkData(std::move(darkImage)); + readDark(fileName, darkImage, options); + if (darkImage->GetPointData()->GetNumberOfArrays() != 0) { + result.darkData = darkImage; + } - readWhite(fileName, whiteImage, darkWhiteOptions); - if (whiteImage->GetPointData()->GetNumberOfArrays() != 0) - dataSource->setWhiteData(std::move(whiteImage)); + readWhite(fileName, whiteImage, options); + if (whiteImage->GetPointData()->GetNumberOfArrays() != 0) { + result.whiteData = whiteImage; + } - QVector angles = readTheta(fileName, options); + result.tiltAngles = readTheta(fileName, options); - if (angles.isEmpty()) { + if (result.tiltAngles.isEmpty()) { // Re-order the data to Fortran ordering GenericHDF5Format::reorderData(image, ReorderMode::CToFortran); - GenericHDF5Format::reorderData(dataSource->darkData(), - ReorderMode::CToFortran); - GenericHDF5Format::reorderData(dataSource->whiteData(), - ReorderMode::CToFortran); + if (result.darkData) { + GenericHDF5Format::reorderData(result.darkData, ReorderMode::CToFortran); + } + if (result.whiteData) { + GenericHDF5Format::reorderData(result.whiteData, + ReorderMode::CToFortran); + } } else { // No re-order needed. Just re-label the axes. relabelXAndZAxes(image); - relabelXAndZAxes(dataSource->darkData()); - relabelXAndZAxes(dataSource->whiteData()); - dataSource->setTiltAngles(angles); - dataSource->setType(DataSource::TiltSeries); + if (result.darkData) { + relabelXAndZAxes(result.darkData); + } + if (result.whiteData) { + relabelXAndZAxes(result.whiteData); + } + result.isTiltSeries = true; } - auto metadata = readMetadata(fileName, options); - dataSource->setMetadata(metadata); - - dataSource->dataModified(); - - return true; + return result; } bool FxiFormat::readDark(const std::string& fileName, vtkImageData* image, diff --git a/tomviz/FxiFormat.h b/tomviz/FxiFormat.h index 297483591..26b78ee4d 100644 --- a/tomviz/FxiFormat.h +++ b/tomviz/FxiFormat.h @@ -8,14 +8,13 @@ #include +#include "HDF5ReadResult.h" #include "core/Variant.h" class vtkImageData; namespace tomviz { -class DataSource; - /** * Format used by the FXI beamline at BNL. */ @@ -26,10 +25,9 @@ class FxiFormat // This will only read /exchange/data, nothing else bool read(const std::string& fileName, vtkImageData* data, const QVariantMap& options = QVariantMap()); - // This will read the data as well as dark, white, and the - // theta angles, and it will swap x and z for tilt series. - bool read(const std::string& fileName, DataSource* source, - const QVariantMap& options = QVariantMap()); + // Read everything into an HDF5ReadResult (no DataSource needed) + HDF5ReadResult readAll(const std::string& fileName, + const QVariantMap& options = QVariantMap()); private: // Read the dark dataset into the image data diff --git a/tomviz/GenericHDF5Format.cxx b/tomviz/GenericHDF5Format.cxx index b7f96f359..4953f52b9 100644 --- a/tomviz/GenericHDF5Format.cxx +++ b/tomviz/GenericHDF5Format.cxx @@ -3,8 +3,9 @@ #include "GenericHDF5Format.h" +#include "pipeline/data/VolumeData.h" + #include -#include #include #include @@ -124,8 +125,8 @@ void GenericHDF5Format::reorderData(vtkImageData* in, vtkImageData* out, auto* name = inPd->GetArrayName(i); auto* array = inPd->GetArray(name); - vtkSmartPointer newArray = - vtkDataArray::CreateDataArray(array->GetDataType()); + auto newArray = vtkSmartPointer::Take( + vtkDataArray::CreateDataArray(array->GetDataType())); newArray->SetName(name); reorderDataArray(array, newArray, in->GetDimensions(), mode); outPd->AddArray(newArray); @@ -166,8 +167,8 @@ QVector GenericHDF5Format::readAngles(h5::H5ReadWrite& reader, return angles; } - vtkSmartPointer array = - vtkDataArray::CreateDataArray(vtkDataType); + auto array = vtkSmartPointer::Take( + vtkDataArray::CreateDataArray(vtkDataType)); array->SetNumberOfTuples(dims[0]); if (!reader.readData(path, type, array->GetVoidPointer(0))) { std::cerr << "Failed to read the angles\n"; @@ -240,12 +241,12 @@ bool GenericHDF5Format::addScalarArray(h5::H5ReadWrite& reader, // Set up the strides and counts int strides[3] = { 1, 1, 1 }; size_t start[3], counts[3]; - if (DataSource::wasSubsampled(image)) { + if (pipeline::VolumeData::wasSubsampled(image)) { // If the main image was subsampled, we need to use the same // subsampling for the scalars - DataSource::subsampleStrides(image, strides); + pipeline::VolumeData::subsampleStrides(image, strides); int bs[6]; - DataSource::subsampleVolumeBounds(image, bs); + pipeline::VolumeData::subsampleVolumeBounds(image, bs); for (int i = 0; i < 3; ++i) { start[i] = static_cast(bs[i * 2]); @@ -261,7 +262,7 @@ bool GenericHDF5Format::addScalarArray(h5::H5ReadWrite& reader, // vtk requires the counts to be an int array int vtkCounts[3]; for (int i = 0; i < 3; ++i) - vtkCounts[i] = counts[i]; + vtkCounts[i] = static_cast(counts[i]); // Make sure the dimensions match those of the image, or else // we will probably experience a crash later... @@ -270,7 +271,7 @@ bool GenericHDF5Format::addScalarArray(h5::H5ReadWrite& reader, for (int i = 0; i < 3; ++i) { if (vtkCounts[i] != imageDims[i]) { std::stringstream ss; - if (DataSource::wasSubsampled(image)) { + if (pipeline::VolumeData::wasSubsampled(image)) { ss << "Subsampled dimensions of "; } else { ss << "Dimensions of "; @@ -282,14 +283,15 @@ bool GenericHDF5Format::addScalarArray(h5::H5ReadWrite& reader, std::cerr << "Error in GenericHDF5Format::addScalarArray():\n" << ss.str() << std::endl; - QMessageBox::critical(nullptr, "Dimensions do not match", - ss.str().c_str()); + // No modal dialog here: the caller may run under a BQC during + // intermediate updates, and a nested QDialog reenters paint/ + // layout from inside a format-reader stack frame and crashes. return false; } } - vtkSmartPointer array = - vtkAbstractArray::CreateArray(vtkDataType); + auto array = vtkSmartPointer::Take( + vtkAbstractArray::CreateArray(vtkDataType)); array->SetNumberOfTuples(counts[0] * counts[1] * counts[2]); array->SetName(name.c_str()); @@ -352,8 +354,8 @@ bool GenericHDF5Format::readVolume(h5::H5ReadWrite& reader, QVariantList list = options["subsampleVolumeBounds"].toList(); for (int i = 0; i < list.size() && i < 6; ++i) bs[i] = list[i].toInt(); - DataSource::setWasSubsampled(image, true); - DataSource::setSubsampleVolumeBounds(image, bs); + pipeline::VolumeData::setWasSubsampled(image, true); + pipeline::VolumeData::setSubsampleVolumeBounds(image, bs); } else { // Set it to the defaults for (int i = 0; i < 3; ++i) { @@ -371,8 +373,8 @@ bool GenericHDF5Format::readVolume(h5::H5ReadWrite& reader, strides[i] = 1; } - DataSource::setWasSubsampled(image, true); - DataSource::setSubsampleStrides(image, strides); + pipeline::VolumeData::setWasSubsampled(image, true); + pipeline::VolumeData::setSubsampleStrides(image, strides); } bool askForSubsample = false; @@ -401,12 +403,12 @@ bool GenericHDF5Format::readVolume(h5::H5ReadWrite& reader, Hdf5SubsampleWidget widget(dimensions, size); layout.addWidget(&widget); - if (DataSource::wasSubsampled(image)) { + if (pipeline::VolumeData::wasSubsampled(image)) { // If it was previously subsampled, start with the previous values - DataSource::subsampleStrides(image, strides); + pipeline::VolumeData::subsampleStrides(image, strides); widget.setStrides(strides); - DataSource::subsampleVolumeBounds(image, bs); + pipeline::VolumeData::subsampleVolumeBounds(image, bs); widget.setBounds(bs); } @@ -427,9 +429,9 @@ bool GenericHDF5Format::readVolume(h5::H5ReadWrite& reader, widget.bounds(bs); widget.strides(strides); - DataSource::setWasSubsampled(image, true); - DataSource::setSubsampleStrides(image, strides); - DataSource::setSubsampleVolumeBounds(image, bs); + pipeline::VolumeData::setWasSubsampled(image, true); + pipeline::VolumeData::setSubsampleStrides(image, strides); + pipeline::VolumeData::setSubsampleVolumeBounds(image, bs); } // Do one final check to make sure all bounds are valid @@ -449,7 +451,7 @@ bool GenericHDF5Format::readVolume(h5::H5ReadWrite& reader, if (changed) { // Update the volume bounds that were used - DataSource::setSubsampleVolumeBounds(image, bs); + pipeline::VolumeData::setSubsampleVolumeBounds(image, bs); } // Set up the strides and counts @@ -462,7 +464,7 @@ bool GenericHDF5Format::readVolume(h5::H5ReadWrite& reader, // vtk requires the counts to be an int array int vtkCounts[3]; for (int i = 0; i < 3; ++i) - vtkCounts[i] = counts[i]; + vtkCounts[i] = static_cast(counts[i]); image->SetDimensions(&vtkCounts[0]); image->AllocateScalars(vtkDataType, 1); @@ -572,7 +574,7 @@ bool GenericHDF5Format::read(const std::string& fileName, vtkImageData* image, if (selectedDatasets.empty()) { QString msg = "At least one volume must be selected"; std::cerr << msg.toStdString() << std::endl; - QMessageBox::critical(nullptr, "Invalid Selection", msg); + QMessageBox::critical(tomviz::mainWidget(), "Invalid Selection", msg); return false; } @@ -583,7 +585,7 @@ bool GenericHDF5Format::read(const std::string& fileName, vtkImageData* image, auto msg = QString("Failed to read the data at: ") + selectedDatasets[0].c_str(); std::cerr << msg.toStdString() << std::endl; - QMessageBox::critical(nullptr, "Read Failed", msg); + QMessageBox::critical(tomviz::mainWidget(), "Read Failed", msg); return false; } @@ -597,7 +599,7 @@ bool GenericHDF5Format::read(const std::string& fileName, vtkImageData* image, if (!addScalarArray(reader, path, image, path)) { auto msg = QString("Failed to read or add the data of: ") + path.c_str(); std::cerr << msg.toStdString() << std::endl; - QMessageBox::critical(nullptr, "Failure", msg); + QMessageBox::critical(tomviz::mainWidget(), "Failure", msg); return false; } } @@ -619,8 +621,8 @@ bool GenericHDF5Format::read(const std::string& fileName, vtkImageData* image, } else { // No deep copying of the data needed. Just relabel the X and Z axes. relabelXAndZAxes(image); - DataSource::setTiltAngles(image, angles); - DataSource::setType(image, DataSource::TiltSeries); + pipeline::VolumeData::setTiltAngles(image, angles); + pipeline::VolumeData::setType(image, pipeline::VolumeData::DataType::TiltSeries); } // Made it to the end... diff --git a/tomviz/HDF5ReadResult.h b/tomviz/HDF5ReadResult.h new file mode 100644 index 000000000..ada251678 --- /dev/null +++ b/tomviz/HDF5ReadResult.h @@ -0,0 +1,25 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizHDF5ReadResult_h +#define tomvizHDF5ReadResult_h + +#include + +#include +#include + +namespace tomviz { + +struct HDF5ReadResult +{ + vtkSmartPointer imageData; + vtkSmartPointer darkData; + vtkSmartPointer whiteData; + QVector tiltAngles; + bool isTiltSeries = false; +}; + +} // namespace tomviz + +#endif // tomvizHDF5ReadResult_h diff --git a/tomviz/HistogramManager.cxx b/tomviz/HistogramManager.cxx index 007737286..35a5045bd 100644 --- a/tomviz/HistogramManager.cxx +++ b/tomviz/HistogramManager.cxx @@ -14,6 +14,7 @@ #include #include +#include #include Q_DECLARE_METATYPE(vtkSmartPointer) @@ -39,8 +40,19 @@ void PopulateHistogram(vtkImageData* input, vtkTable* output) return; } - // The bin values are the centers, extending +/- half an inc either side - arrayPtr->GetFiniteRange(minmax, -1); + // The bin values are the centers, extending +/- half an inc either side. + // Compute the range from the buffer rather than via arrayPtr->GetFiniteRange: + // GetFiniteRange caches its result in the array's vtkInformation, and that + // write races with the main (render) thread's use of the same shared array + // (see tomviz::ComputeFiniteRange). + switch (arrayPtr->GetDataType()) { + vtkTemplateMacro(tomviz::ComputeFiniteRange( + reinterpret_cast(arrayPtr->GetVoidPointer(0)), + arrayPtr->GetNumberOfTuples(), arrayPtr->GetNumberOfComponents(), + /*useMagnitude=*/true, minmax)); + default: + break; + } if (minmax[0] == minmax[1]) { minmax[1] = minmax[0] + 1.0; } @@ -107,11 +119,17 @@ void Populate2DHistogram(vtkImageData* input, vtkImageData* output) return; } - // The bin values are the centers, extending +/- half an inc either side - for (int i = 0; i < arrayPtr->GetNumberOfComponents(); ++i) { - double* tmp = arrayPtr->GetFiniteRange(i); - minmax[0] = std::min(minmax[0], tmp[0]); - minmax[1] = std::max(minmax[1], tmp[1]); + // The bin values are the centers, extending +/- half an inc either side. + // Compute the range from the buffer rather than via arrayPtr->GetFiniteRange + // so the background thread stays read-only with respect to the shared array + // (see tomviz::ComputeFiniteRange). + switch (arrayPtr->GetDataType()) { + vtkTemplateMacro(tomviz::ComputeFiniteRange( + reinterpret_cast(arrayPtr->GetVoidPointer(0)), + arrayPtr->GetNumberOfTuples(), arrayPtr->GetNumberOfComponents(), + /*useMagnitude=*/false, minmax)); + default: + break; } if (minmax[0] == minmax[1]) { @@ -171,8 +189,6 @@ public slots: void HistogramMaker::makeHistogram(vtkSmartPointer input, vtkSmartPointer output) { - // make the histogram and notify observers (the main thread) that it - // is done. if (input && output) { PopulateHistogram(input, output); } @@ -229,7 +245,41 @@ void HistogramManager::finalize() m_worker = nullptr; m_histogramCache.clear(); m_histogram2DCache.clear(); + m_histogramCacheLRU.clear(); + m_histogram2DCacheLRU.clear(); +} + +void HistogramManager::clearCaches() +{ + // Don't touch the in-progress lists: the background thread still holds + // vtkSmartPointer refs to those inputs, and the ready-slot will repopulate + // the cache when it fires. That late insert is harmless — the next reset + // (or app exit) will drop it. + m_histogramCache.clear(); + m_histogram2DCache.clear(); + m_histogramCacheLRU.clear(); + m_histogram2DCacheLRU.clear(); +} + +namespace { +// LRU cap on the number of cached histograms. Live operator +// reconstructions can produce hundreds of intermediate vtkImageDatas; +// the cache keeps each one alive (smart pointer key) so we cap how +// many we hold to bound memory. +constexpr int kCacheLimit = 16; + +template +void touchLRU(QList>& order, CacheT& cache, + const vtkSmartPointer& key) +{ + order.removeOne(key); + order.prepend(key); + while (order.size() > kCacheLimit) { + auto evict = order.takeLast(); + cache.remove(evict); + } } +} // namespace HistogramManager& HistogramManager::instance() { @@ -243,10 +293,12 @@ vtkSmartPointer HistogramManager::getHistogram( if (m_histogramCache.contains(image)) { auto cachedTable = m_histogramCache[image]; if (cachedTable->GetMTime() > image->GetMTime()) { + touchLRU(m_histogramCacheLRU, m_histogramCache, image); return cachedTable; } else { // Need to recalculate, clear the plots, and remove the cached data. m_histogramCache.remove(image); + m_histogramCacheLRU.removeOne(image); } } if (m_histogramsInProgress.contains(image)) { @@ -255,14 +307,13 @@ vtkSmartPointer HistogramManager::getHistogram( } auto table = vtkSmartPointer::New(); m_histogramsInProgress.append(image); - vtkSmartPointer const imageSP = image; // This fakes a Qt signal to the background thread (without exposing the // class internals as a signal). The background thread will then call // makeHistogram on the HistogramMaker object with the parameters we // gave here. QMetaObject::invokeMethod(m_histogramGen, "makeHistogram", - Q_ARG(vtkSmartPointer, imageSP), + Q_ARG(vtkSmartPointer, image), Q_ARG(vtkSmartPointer, table)); // The histogram cannot be returned for use while the background thread is @@ -276,10 +327,12 @@ vtkSmartPointer HistogramManager::getHistogram2D( if (m_histogram2DCache.contains(image)) { auto cachedHistogram = m_histogram2DCache[image]; if (cachedHistogram->GetMTime() > image->GetMTime()) { + touchLRU(m_histogram2DCacheLRU, m_histogram2DCache, image); return cachedHistogram; } else { // Need to recalculate, clear the plots, and remove the cached data. m_histogram2DCache.remove(image); + m_histogram2DCacheLRU.removeOne(image); } } if (m_histogram2DsInProgress.contains(image)) { @@ -288,14 +341,13 @@ vtkSmartPointer HistogramManager::getHistogram2D( } auto histogram = vtkSmartPointer::New(); m_histogram2DsInProgress.append(image); - vtkSmartPointer const imageSP = image; // This fakes a Qt signal to the background thread (without exposing the // class internals as a signal). The background thread will then call // makeHistogram on the HistogramMaker object with the parameters we // gave here. QMetaObject::invokeMethod(m_histogramGen, "makeHistogram2D", - Q_ARG(vtkSmartPointer, imageSP), + Q_ARG(vtkSmartPointer, image), Q_ARG(vtkSmartPointer, histogram)); // The histogram cannot be returned for use while the background thread is // populating it. @@ -306,6 +358,7 @@ void HistogramManager::histogramReadyInternal( vtkSmartPointer image, vtkSmartPointer histogram) { m_histogramCache[image] = histogram; + touchLRU(m_histogramCacheLRU, m_histogramCache, image); m_histogramsInProgress.removeAll(image); emit this->histogramReady(image, histogram); } @@ -314,6 +367,7 @@ void HistogramManager::histogram2DReadyInternal( vtkSmartPointer image, vtkSmartPointer histogram) { m_histogram2DCache[image] = histogram; + touchLRU(m_histogram2DCacheLRU, m_histogram2DCache, image); m_histogram2DsInProgress.removeAll(image); emit this->histogram2DReady(image, histogram); } diff --git a/tomviz/HistogramManager.h b/tomviz/HistogramManager.h index 3087e9746..4aed61f3c 100644 --- a/tomviz/HistogramManager.h +++ b/tomviz/HistogramManager.h @@ -28,6 +28,11 @@ class HistogramManager : public QObject void finalize(); + /// Drop all cached 1D/2D histograms. The caches keep their input + /// imageData alive (vtkSmartPointer keys) — call this on pipeline + /// reset to release the memory they're pinning. + void clearCaches(); + vtkSmartPointer getHistogram(vtkSmartPointer image); vtkSmartPointer getHistogram2D( vtkSmartPointer image); @@ -47,10 +52,19 @@ private slots: HistogramManager(); ~HistogramManager(); - QMap> m_histogramCache; - QMap> m_histogram2DCache; - QList m_histogramsInProgress; - QList m_histogram2DsInProgress; + // Cache and in-progress lists hold vtkSmartPointer keys so the + // imageData objects they reference can't be destroyed (and their + // addresses re-used by an unrelated allocation) while we're still + // talking about them. Bounded by `kCacheLimit` via LRU eviction so + // long live-update sessions don't pin arbitrary amounts of memory. + QMap, vtkSmartPointer> + m_histogramCache; + QMap, vtkSmartPointer> + m_histogram2DCache; + QList> m_histogramCacheLRU; + QList> m_histogram2DCacheLRU; + QList> m_histogramsInProgress; + QList> m_histogram2DsInProgress; HistogramMaker* m_histogramGen; QThread* m_worker; }; diff --git a/tomviz/HistogramWidget.cxx b/tomviz/HistogramWidget.cxx index 8aebfc38a..137ed33b4 100644 --- a/tomviz/HistogramWidget.cxx +++ b/tomviz/HistogramWidget.cxx @@ -7,13 +7,18 @@ #include "BrightnessContrastWidget.h" #include "ColorMap.h" #include "ColorMapSettingsWidget.h" -#include "DataSource.h" #include "DoubleSliderWidget.h" -#include "ModuleContour.h" -#include "ModuleManager.h" #include "PresetDialog.h" #include "QVTKGLWidget.h" #include "Utilities.h" +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/Node.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PortType.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/ContourSink.h" #include "vtkChartHistogramColorOpacityEditor.h" @@ -48,8 +53,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -150,11 +157,14 @@ HistogramWidget::HistogramWidget(QWidget* parent) connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::viewChanged), this, [this](vtkSMViewProxy*) { updateUI(); }); + // TODO: activeNodeChanged provides pipeline::Node*, need to extract + // DataSource context for updateColorMapDialogs when needed. connect(&ActiveObjects::instance(), - QOverload::of(&ActiveObjects::dataSourceChanged), this, - &HistogramWidget::updateColorMapDialogs); - connect(&ModuleManager::instance(), &ModuleManager::dataSourceRemoved, - this, [this](DataSource*) { updateUI(); }); + &ActiveObjects::activeNodeChanged, this, + [this](pipeline::Node*) { updateColorMapDialogs(); }); + // TODO: ModuleManager::dataSourceRemoved signal no longer available. + // Need to listen for node removal from the pipeline instead. + // connect to pipeline's nodeRemoved signal when available. connect(this, &HistogramWidget::colorMapUpdated, this, &HistogramWidget::updateUI); setLayout(hLayout); @@ -198,19 +208,22 @@ void HistogramWidget::setLUTProxy(vtkSMProxy* proxy) vtkDiscretizableColorTransferFunction::SafeDownCast( proxy->GetClientSideObject()); setLUT(lut); - - auto view = ActiveObjects::instance().activeView(); - - // Update widget to reflect scalar bar visibility. - if (m_LUTProxy) { - auto sbProxy = getScalarBarRepresentation(view); - if (sbProxy) { - bool visible = - vtkSMPropertyHelper(sbProxy, "Visibility").GetAsInt() == 1; - m_colorLegendToolButton->setChecked(visible); - } - } + } else if (!proxy) { + // Nothing to edit (selection with no colormap). Keep the chart's + // last LUT — the histogram data is cleared separately — but drop + // the proxy so the edit buttons disable. + m_LUTProxy = nullptr; } + // Every selection path funnels through here (via + // CentralWidget::setActiveVolumeData / setActiveSinkNode), so + // refresh the button state even when the proxy is unchanged — + // updateUI() is otherwise only triggered by view changes. + updateUI(); +} + +void HistogramWidget::setVolumeData(pipeline::VolumeDataPtr volumeData) +{ + m_volumeData = std::move(volumeData); } void HistogramWidget::updateLUTProxy() @@ -239,7 +252,6 @@ void HistogramWidget::updateLUTProxy() void HistogramWidget::updateColorMapDialogs() { - auto* ds = ActiveObjects::instance().activeDataSource(); auto* lut = m_LUT.Get(); if (m_colorMapSettingsWidget) { @@ -248,7 +260,7 @@ void HistogramWidget::updateColorMapDialogs() } if (m_brightnessContrastWidget) { - m_brightnessContrastWidget->setDataSource(ds); + m_brightnessContrastWidget->setVolumeData(m_volumeData); m_brightnessContrastWidget->setLut(lut); m_brightnessContrastWidget->updateGui(); } @@ -289,7 +301,7 @@ vtkSMProxy* HistogramWidget::getScalarBarRepresentation(vtkSMProxy* view) vtkSMPropertyHelper(sbProxy, "Enabled").Set(0); vtkSMPropertyHelper(sbProxy, "Title").Set(""); vtkSMPropertyHelper(sbProxy, "ComponentTitle").Set(""); - vtkSMPropertyHelper(sbProxy, "RangeLabelFormat").Set("%g"); + vtkSMPropertyHelper(sbProxy, "RangeLabelFormat").Set("{:g}"); sbProxy->UpdateVTKObjects(); } @@ -302,16 +314,33 @@ void HistogramWidget::onColorFunctionChanged() // Avoid infinite recursion return; } - m_updatingColorFunction = true; - updateLUTProxy(); - if (m_LUT) { - m_LUT->Build(); - renderViews(); - emit colorMapUpdated(); + // This slot is wired to the color transfer function's ModifiedEvent, which + // can fire reentrantly from deep inside another mutation of the *same* + // function -- e.g. VolumeData::rescaleColorMap() -> + // vtkSMProxy::UpdateVTKObjects() -> vtkColorTransferFunction::UpdateRange(). + // Rebuilding the LUT here (Build() -> SetAnnotations()) while that outer + // mutation is still on the stack corrupts the function's internal arrays and + // crashes in ~vtkVariant. Defer the rebuild to the next event-loop turn so + // it runs after the mutation unwinds; the pending flag coalesces the + // multi-Hz churn from live operator updates. + if (m_colorFunctionUpdatePending) { + return; } + m_colorFunctionUpdatePending = true; + QTimer::singleShot(0, this, [this]() { + m_colorFunctionUpdatePending = false; + m_updatingColorFunction = true; - m_updatingColorFunction = false; + updateLUTProxy(); + if (m_LUT) { + m_LUT->Build(); + renderViews(); + emit colorMapUpdated(); + } + + m_updatingColorFunction = false; + }); } void HistogramWidget::onScalarOpacityFunctionChanged() @@ -371,40 +400,80 @@ void HistogramWidget::onCurrentPointEditEvent() void HistogramWidget::histogramClicked(vtkObject*) { - auto activeDataSource = ActiveObjects::instance().activeDataSource(); - Q_ASSERT(activeDataSource); - - auto view = ActiveObjects::instance().activeView(); + auto& ao = ActiveObjects::instance(); + auto* view = ao.activeView(); if (!view) { return; } - // Use active ModuleContour is possible. Otherwise, find the first existing - // ModuleContour instance or just create a new one, if none exists. - typedef ModuleContour ModuleContourType; - auto isoValue = m_histogramColorOpacityEditor->GetContourValue(); - auto contour = - qobject_cast(ActiveObjects::instance().activeModule()); + + auto* pip = ao.pipeline(); + if (!pip) { + return; + } + + pipeline::ContourSink* contour = nullptr; + + // Priority 1: the active node is already a ContourSink. + contour = qobject_cast(ao.activeNode()); + + // Priority 2: a ContourSink linked to the current tip output port. + if (!contour) { + auto* tipPort = ao.activeTipOutputPort(); + if (tipPort) { + for (auto* link : tipPort->links()) { + auto* sink = + qobject_cast(link->to()->node()); + if (sink) { + contour = sink; + break; + } + } + } + } + + // Priority 3: any ContourSink in the pipeline. if (!contour) { - QList contours = - ModuleManager::instance().findModules( - activeDataSource, view); - if (contours.size() == 0) { - auto res = createContourDialog(isoValue); - if (!res) { - return; + for (auto* node : pip->nodes()) { + auto* sink = qobject_cast(node); + if (sink) { + contour = sink; + break; } - contour = qobject_cast( - ModuleManager::instance().createAndAddModule("Contour", - activeDataSource, view)); - } else { - contour = contours[0]; } - ActiveObjects::instance().setActiveModule(contour); } - Q_ASSERT(contour); + + // No existing ContourSink — ask the user to create one. + if (!contour) { + if (!createContourDialog(isoValue)) { + return; + } + + auto* tipPort = ao.activeTipOutputPort(); + if (!tipPort) { + return; + } + + auto* newContour = new pipeline::ContourSink(); + newContour->setLabel("Contour"); + newContour->initialize(view); + pip->addNode(newContour); + + auto* input = newContour->inputPorts().value(0); + if (input && + pipeline::isPortTypeCompatible(tipPort->type(), + input->acceptedTypes())) { + pip->createLink(tipPort, input); + } + + contour = newContour; + } + contour->setIsoValue(isoValue); + ao.setActiveNode(contour); + pip->execute(); + tomviz::convert(view)->render(); } @@ -417,9 +486,16 @@ bool HistogramWidget::createContourDialog(double& isoValue) return true; } - auto ds = ActiveObjects::instance().activeDataSource(); - if (!ds) { - return false; + // Obtain the scalar range from the tip output port's VolumeData. + double range[2] = { 0.0, 1.0 }; + auto* tipPort = ActiveObjects::instance().activeTipOutputPort(); + if (tipPort && tipPort->hasData()) { + auto vol = tipPort->data().value(); + if (vol && vol->isValid()) { + auto r = vol->scalarRange(); + range[0] = r[0]; + range[1] = r[1]; + } } QDialog dialog; @@ -434,10 +510,6 @@ bool HistogramWidget::createContourDialog(double& isoValue) QFormLayout formLayout; vLayout.addLayout(&formLayout); - // Get the range of the dataset - double range[2]; - ds->getRange(range); - DoubleSliderWidget w(true); w.setMinimum(range[0]); w.setMaximum(range[1]); @@ -479,12 +551,12 @@ void HistogramWidget::onResetRangeClicked() void HistogramWidget::resetRange() { - auto activeDataSource = ActiveObjects::instance().activeDataSource(); - if (!activeDataSource) + if (!m_volumeData || !m_volumeData->isValid()) { return; + } - double range[2]; - activeDataSource->getRange(range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; resetRange(range); } @@ -497,17 +569,18 @@ void HistogramWidget::resetRange(double range[2]) void HistogramWidget::onCustomRangeClicked() { - // Get the max allowable range - auto activeDataSource = ActiveObjects::instance().activeDataSource(); - if (!activeDataSource) + if (!m_volumeData || !m_volumeData->isValid()) { return; + } - double maxRange[2]; - activeDataSource->getRange(maxRange); + auto sr = m_volumeData->scalarRange(); + double maxRange[2] = { sr[0], sr[1] }; // Get the type of the active scalar - auto scalar = activeDataSource->activeScalars(); - auto array = activeDataSource->getScalarsArray(scalar); + auto* array = m_volumeData->scalars(); + if (!array) { + return; + } auto dataType = array->GetDataType(); int precision = 0; if (dataType == VTK_FLOAT || dataType == VTK_DOUBLE) { @@ -622,6 +695,9 @@ void HistogramWidget::showPresetDialog(const QJsonObject& newPreset) m_presetDialog = new PresetDialog(this); QObject::connect(m_presetDialog, &PresetDialog::applyPreset, this, &HistogramWidget::applyCurrentPreset); + QObject::connect(m_presetDialog, + &PresetDialog::createSegmentationColormapRequested, this, + &HistogramWidget::onCreateSegmentationColormapClicked); } if (!newPreset.isEmpty()) { @@ -674,7 +750,9 @@ void HistogramWidget::onPresetClicked() void HistogramWidget::resetAutoContrastState() { - m_currentAutoContrastThreshold = m_defaultAutoContrastThreshold; + // 0 mimics ImageJ, which zeroes autoThreshold on reset so the next + // auto-contrast starts over at the default threshold. + m_currentAutoContrastThreshold = 0; } void HistogramWidget::onBrightnessAndContrastClicked() @@ -688,9 +766,10 @@ void HistogramWidget::onBrightnessAndContrastClicked() auto& dialog = *m_brightnessContrastDialog; dialog.setLayout(new QVBoxLayout); dialog.setWindowTitle("Brightness and Contrast"); + dialog.resize(500, 160); - auto* ds = ActiveObjects::instance().activeDataSource(); - m_brightnessContrastWidget = new BrightnessContrastWidget(ds, m_LUT, this); + m_brightnessContrastWidget = + new BrightnessContrastWidget(m_volumeData, m_LUT, this); dialog.layout()->addWidget(m_brightnessContrastWidget); auto* widget = m_brightnessContrastWidget.data(); @@ -709,16 +788,11 @@ void HistogramWidget::autoAdjustContrast() { auto* table = m_inputData.Get(); - // For now, auto adjust contrast for the whole data source. We can - // also do it for individual slices in the future (in which case - // we should generate a histogram for an individual slice). - auto* ds = ActiveObjects::instance().activeDataSource(); - - if (!table || !ds || !m_LUT) { + if (!table || !m_volumeData || !m_volumeData->isValid() || !m_LUT) { return; } - auto* imageData = ds->imageData(); + auto* imageData = m_volumeData->imageData(); auto* histogram = vtkDataArray::SafeDownCast(table->GetColumnByName("image_pops")); auto* extents = @@ -737,8 +811,8 @@ void HistogramWidget::autoAdjustContrast(vtkDataArray* histogram, vtkImageData* imageData) { // Gather some information - double range[2]; - DataSource::getRange(imageData, range); + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; int dims[3]; imageData->GetDimensions(dims); @@ -768,7 +842,7 @@ void HistogramWidget::autoAdjustContrast(vtkDataArray* histogram, } int hmin = i; - for (i = 255; i >= 0; --i) { + for (i = static_cast(numBins) - 1; i >= 0; --i) { double count = histogram->GetTuple1(i); count = count > limit ? 0 : count; if (count > threshold) { @@ -782,8 +856,11 @@ void HistogramWidget::autoAdjustContrast(vtkDataArray* histogram, return; } - double min = histMin + hmin * binSize; - double max = histMin + hmax * binSize; + // The extents column holds bin centers; ImageJ maps indices to bin left + // edges, so shift down half a bin to match. + double binStart = histMin - binSize / 2; + double min = binStart + hmin * binSize; + double max = binStart + hmax * binSize; if (min == max) { min = range[0]; max = range[1]; @@ -791,6 +868,38 @@ void HistogramWidget::autoAdjustContrast(vtkDataArray* histogram, rescaleTransferFunction(m_LUTProxy, min, max); } +void HistogramWidget::onCreateSegmentationColormapClicked() +{ + if (!m_volumeData || !m_volumeData->isValid()) { + return; + } + + auto* scalars = m_volumeData->scalars(); + if (!scalars) { + return; + } + + // Up-front diagnostics — the utility returns empty for the same + // failure modes, but we want to tell the user *why* before falling + // through to the silent path. + auto dataType = scalars->GetDataType(); + if (dataType == VTK_FLOAT || dataType == VTK_DOUBLE) { + QMessageBox::warning( + this, "Integer Data Required", + "Segmentation colormaps require integer-valued data. " + "The current dataset has floating-point values."); + return; + } + + auto preset = buildSegmentationPreset(scalars); + if (preset.isEmpty()) { + return; + } + + m_presetDialog->addNewPreset(preset); + applyCurrentPreset(); +} + void HistogramWidget::applyCurrentPreset() { vtkSMProxy* lut = m_LUTProxy; @@ -811,35 +920,26 @@ void HistogramWidget::applyCurrentPreset() void HistogramWidget::updateUI() { + // Enable the colormap editing buttons exactly when there is a valid + // LUT to edit in the active view. Do not gate on the active node: + // selecting a port (or link) clears the active node, but a port + // selection is still a valid colormap-editing context. auto view = ActiveObjects::instance().activeView(); + auto* sbProxy = + m_LUTProxy && view ? getScalarBarRepresentation(view) : nullptr; + bool enable = sbProxy != nullptr; - // Update widget to reflect scalar bar visibility. - if (m_LUTProxy) { - auto sbProxy = getScalarBarRepresentation(view); - if (view && sbProxy) { - QSignalBlocker blocker1(m_colorLegendToolButton); - QSignalBlocker blocker2(m_colorMapSettingsButton); - QSignalBlocker blocker3(m_savePresetButton); - QSignalBlocker blocker4(m_brightnessAndContrastButton); - m_colorLegendToolButton->setEnabled(true); - m_colorMapSettingsButton->setEnabled(true); - m_colorLegendToolButton->setChecked( - vtkSMPropertyHelper(sbProxy, "Visibility").GetAsInt() == 1); - m_savePresetButton->setEnabled(true); - m_brightnessAndContrastButton->setEnabled(true); - } - } - - auto dataSource = ActiveObjects::instance().activeDataSource(); - if (!dataSource) { - QSignalBlocker blocker1(m_colorLegendToolButton); - QSignalBlocker blocker2(m_colorMapSettingsButton); - QSignalBlocker blocker3(m_savePresetButton); - QSignalBlocker blocker4(m_brightnessAndContrastButton); - m_colorLegendToolButton->setEnabled(false); - m_colorMapSettingsButton->setEnabled(false); - m_savePresetButton->setEnabled(false); - m_brightnessAndContrastButton->setEnabled(false); + QSignalBlocker blocker1(m_colorLegendToolButton); + QSignalBlocker blocker2(m_colorMapSettingsButton); + QSignalBlocker blocker3(m_savePresetButton); + QSignalBlocker blocker4(m_brightnessAndContrastButton); + m_colorLegendToolButton->setEnabled(enable); + m_colorMapSettingsButton->setEnabled(enable); + m_savePresetButton->setEnabled(enable); + m_brightnessAndContrastButton->setEnabled(enable); + if (enable) { + m_colorLegendToolButton->setChecked( + vtkSMPropertyHelper(sbProxy, "Visibility").GetAsInt() == 1); } } @@ -859,6 +959,14 @@ void HistogramWidget::rescaleTransferFunction(vtkSMProxy* lutProxy, double min, vtkSMPropertyHelper(m_LUTProxy, "ScalarOpacityFunction").GetAsProxy(); removePlaceholderNodes(); + // RescaleTransferFunction operates on the proxy's control points property, + // not the client-side object we just stripped the placeholder nodes from. + // Sync the client state into the property first; otherwise the placeholder + // nodes still in the property span the full data range, which makes the + // rescale a no-op (or compresses the real window instead of setting it). + // The opacity property needs no sync: onScalarOpacityFunctionChanged + // already mirrors every client-side opacity change into it. + updateLUTProxy(); vtkSMTransferFunctionProxy::RescaleTransferFunction(lutProxy, min, max); vtkSMTransferFunctionProxy::RescaleTransferFunction(opacityMap, min, max); addPlaceholderNodes(); @@ -874,16 +982,22 @@ void HistogramWidget::showEvent(QShowEvent* event) void HistogramWidget::addPlaceholderNodes() { + if (!m_volumeData || !m_volumeData->isValid()) { + return; + } + + auto sr = m_volumeData->scalarRange(); + double range[2] = { sr[0], sr[1] }; + auto* lut = m_LUT.Get(); auto* opacity = m_scalarOpacityFunction.Get(); - auto* ds = ActiveObjects::instance().activeDataSource(); - if (lut && ds) { - tomviz::addPlaceholderNodes(lut, ds); + if (lut) { + tomviz::addPlaceholderNodes(lut, range); } - if (opacity && ds) { - tomviz::addPlaceholderNodes(opacity, ds); + if (opacity) { + tomviz::addPlaceholderNodes(opacity, range); } } diff --git a/tomviz/HistogramWidget.h b/tomviz/HistogramWidget.h index ae09cf039..d93ad7281 100644 --- a/tomviz/HistogramWidget.h +++ b/tomviz/HistogramWidget.h @@ -10,6 +10,8 @@ #include #include +#include + class vtkChartHistogramColorOpacityEditor; class vtkContextView; class vtkDataArray; @@ -27,6 +29,11 @@ class vtkSMProxy; namespace tomviz { +namespace pipeline { +class VolumeData; +using VolumeDataPtr = std::shared_ptr; +} // namespace pipeline + class BrightnessContrastWidget; class ColorMapSettingsWidget; class PresetDialog; @@ -42,6 +49,7 @@ class HistogramWidget : public QWidget void setLUT(vtkDiscretizableColorTransferFunction* lut); void setLUTProxy(vtkSMProxy* proxy); + void setVolumeData(pipeline::VolumeDataPtr volumeData); void setInputData(vtkTable* table, const char* x_, const char* y_); @@ -65,6 +73,7 @@ public slots: void onPresetClicked(); void onSaveToPresetClicked(); void onBrightnessAndContrastClicked(); + void onCreateSegmentationColormapClicked(); void applyCurrentPreset(); void updateUI(); @@ -119,11 +128,20 @@ public slots: QPointer m_brightnessContrastDialog; QPointer m_brightnessContrastWidget; + pipeline::VolumeDataPtr m_volumeData; + // To prevent infinite recursion... bool m_updatingColorFunction = false; + // Coalesces the deferred color-function rebuild (see + // onColorFunctionChanged) so reentrant ModifiedEvents don't pile up. + bool m_colorFunctionUpdatePending = false; + + // Matches ImageJ's ContrastAdjuster: AUTO_THRESHOLD is the starting + // threshold divisor, and the current value must begin below 10 so the + // first "Auto" press uses the full AUTO_THRESHOLD before halving. static const int m_defaultAutoContrastThreshold = 5000; - int m_currentAutoContrastThreshold = 5000; + int m_currentAutoContrastThreshold = 0; }; } // namespace tomviz diff --git a/tomviz/ImageStackDialog.cxx b/tomviz/ImageStackDialog.cxx index dd0754061..8b81d8e6c 100644 --- a/tomviz/ImageStackDialog.cxx +++ b/tomviz/ImageStackDialog.cxx @@ -59,10 +59,8 @@ ImageStackDialog::ImageStackDialog(QWidget* parent) m_ui->loadedContainer->hide(); m_ui->stackTypeCombo->setDisabled(true); - m_ui->stackTypeCombo->insertItem(DataSource::DataSourceType::Volume, - QString("Volume")); - m_ui->stackTypeCombo->insertItem(DataSource::DataSourceType::TiltSeries, - QString("Tilt Series")); + m_ui->stackTypeCombo->insertItem(0, QString("Volume")); + m_ui->stackTypeCombo->insertItem(1, QString("Tilt Series")); // Due to an overloaded signal I am force to use static_cast here. QObject::connect(m_ui->stackTypeCombo, static_cast( @@ -100,12 +98,13 @@ void ImageStackDialog::setStackSummary(const QList& summary, setAcceptDrops(false); } -void ImageStackDialog::setStackType(const DataSource::DataSourceType& stackType) +void ImageStackDialog::setStackType(pipeline::PortType stackType) { if (m_stackType != stackType) { m_stackType = stackType; emit stackTypeChanged(m_stackType); - m_ui->stackTypeCombo->setCurrentIndex(m_stackType); + m_ui->stackTypeCombo->setCurrentIndex( + m_stackType == pipeline::PortType::TiltSeries ? 1 : 0); } } @@ -129,7 +128,7 @@ void ImageStackDialog::openFileDialog(int mode) QStringList filters; filters << "TIFF Image files (*.tiff *.tif)"; - QFileDialog dialog(nullptr); + QFileDialog dialog(this); if (mode == QFileDialog::ExistingFiles) { dialog.setFileMode(QFileDialog::ExistingFiles); dialog.setNameFilters(filters); @@ -171,13 +170,13 @@ void ImageStackDialog::processFiles(const QStringList& fileNames) bool isVolume = false; bool isTilt = false; bool isNumbered = false; - DataSource::DataSourceType stackType = DataSource::DataSourceType::Volume; + pipeline::PortType stackType = pipeline::PortType::Volume; isVolume = detectVolume(fNames, summary); if (!isVolume) { isTilt = detectTilt(fNames, summary); if (isTilt) { - stackType = DataSource::DataSourceType::TiltSeries; + stackType = pipeline::PortType::TiltSeries; } else { isNumbered = detectVolume(fNames, summary, false); if (!isNumbered) { @@ -389,7 +388,7 @@ QList ImageStackDialog::getStackSummary() const return m_summary; } -DataSource::DataSourceType ImageStackDialog::getStackType() const +pipeline::PortType ImageStackDialog::getStackType() const { return m_stackType; } @@ -407,10 +406,10 @@ void ImageStackDialog::onImageToggled(int row, bool value) void ImageStackDialog::onStackTypeChanged(int stackType) { - if (stackType == DataSource::DataSourceType::Volume) { - setStackType(DataSource::DataSourceType::Volume); - } else if (stackType == DataSource::DataSourceType::TiltSeries) { - setStackType(DataSource::DataSourceType::TiltSeries); + if (stackType == 0) { + setStackType(pipeline::PortType::Volume); + } else if (stackType == 1) { + setStackType(pipeline::PortType::TiltSeries); } } diff --git a/tomviz/ImageStackDialog.h b/tomviz/ImageStackDialog.h index 88229020c..bb227a0b1 100644 --- a/tomviz/ImageStackDialog.h +++ b/tomviz/ImageStackDialog.h @@ -6,8 +6,8 @@ #include -#include "DataSource.h" #include "ImageStackModel.h" +#include "pipeline/PortType.h" #include @@ -26,11 +26,11 @@ class ImageStackDialog : public QDialog ~ImageStackDialog() override; void setStackSummary(const QList& summary, bool sort = true); - void setStackType(const DataSource::DataSourceType& stackType); + void setStackType(pipeline::PortType stackType); void processDirectory(const QString& path); void processFiles(const QStringList& fileNames); QList getStackSummary() const; - DataSource::DataSourceType getStackType() const; + pipeline::PortType getStackType() const; bool getImageViewerMode() const; public slots: @@ -43,7 +43,7 @@ public slots: signals: void summaryChanged(const QList&); - void stackTypeChanged(const DataSource::DataSourceType&); + void stackTypeChanged(pipeline::PortType); protected: void dragEnterEvent(QDragEnterEvent* event) override; @@ -53,7 +53,7 @@ public slots: QScopedPointer m_ui; QList m_summary; - DataSource::DataSourceType m_stackType; + pipeline::PortType m_stackType; ImageStackModel m_tableModel; void openFileDialog(int mode); bool detectVolume(QStringList fileNames, QList& summary, diff --git a/tomviz/ImageStackModel.cxx b/tomviz/ImageStackModel.cxx index 7c81cd5fd..0f53f38d4 100644 --- a/tomviz/ImageStackModel.cxx +++ b/tomviz/ImageStackModel.cxx @@ -86,9 +86,9 @@ QVariant ImageStackModel::headerData(int section, Qt::Orientation orientation, } else if (section == c_yCol) { return QString("Y"); } else if (section == c_posCol) { - if (m_stackType == DataSource::DataSourceType::Volume) { + if (m_stackType == pipeline::PortType::Volume) { return QString("Slice"); - } else if (m_stackType == DataSource::DataSourceType::TiltSeries) { + } else if (m_stackType == pipeline::PortType::TiltSeries) { return QString("Angle"); } } @@ -134,7 +134,7 @@ void ImageStackModel::onFilesInfoChanged(QList filesInfo) endResetModel(); } -void ImageStackModel::onStackTypeChanged(DataSource::DataSourceType stackType) +void ImageStackModel::onStackTypeChanged(pipeline::PortType stackType) { beginResetModel(); m_stackType = stackType; diff --git a/tomviz/ImageStackModel.h b/tomviz/ImageStackModel.h index 0e1ad467b..00a8878ae 100644 --- a/tomviz/ImageStackModel.h +++ b/tomviz/ImageStackModel.h @@ -6,7 +6,7 @@ #include -#include "DataSource.h" +#include "pipeline/PortType.h" #include #include @@ -29,22 +29,22 @@ class ImageStackModel : public QAbstractTableModel QVariant headerData(int section, Qt::Orientation orientation, int role) const override; - Qt::ItemFlags flags(const QModelIndex& index) const; + Qt::ItemFlags flags(const QModelIndex& index) const override; bool setData(const QModelIndex& index, const QVariant& value, - int role = Qt::EditRole); + int role = Qt::EditRole) override; QList getFileInfo() const; public slots: void onFilesInfoChanged(QList filesInfo); - void onStackTypeChanged(DataSource::DataSourceType stackType); + void onStackTypeChanged(pipeline::PortType stackType); signals: void toggledSelected(int row, bool value); private: QList m_filesInfo; - DataSource::DataSourceType m_stackType = DataSource::DataSourceType::Volume; + pipeline::PortType m_stackType = pipeline::PortType::Volume; const int c_numCol = 5; const int c_checkCol = 0; const int c_fileCol = 1; diff --git a/tomviz/InteractiveTransformWidget.cxx b/tomviz/InteractiveTransformWidget.cxx new file mode 100644 index 000000000..a97051087 --- /dev/null +++ b/tomviz/InteractiveTransformWidget.cxx @@ -0,0 +1,217 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "InteractiveTransformWidget.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +InteractiveTransformWidget& InteractiveTransformWidget::instance() +{ + static InteractiveTransformWidget theInstance; + return theInstance; +} + +InteractiveTransformWidget::InteractiveTransformWidget() : QObject(nullptr) +{ + m_boxRep->SetPlaceFactor(1.0); + m_boxRep->HandlesOn(); + m_boxRep->SetHandleSize(10); + + m_boxWidget->SetRepresentation(m_boxRep.GetPointer()); + m_boxWidget->SetPriority(1); + + m_eventLink->Connect(m_boxWidget.GetPointer(), vtkCommand::InteractionEvent, + this, SLOT(onInteraction(vtkObject*))); + m_eventLink->Connect(m_boxWidget.GetPointer(), + vtkCommand::EndInteractionEvent, this, + SLOT(onInteraction(vtkObject*))); +} + +InteractiveTransformWidget::~InteractiveTransformWidget() {} + +bool InteractiveTransformWidget::acquire(QObject* user) +{ + if (!user) { + return false; + } + + if (m_currentUser && m_currentUser != user) { + return false; + } + + if (m_currentUser == user) { + return true; + } + + m_currentUser = user; + connect(user, &QObject::destroyed, this, + &InteractiveTransformWidget::onUserDestroyed); + return true; +} + +void InteractiveTransformWidget::release(QObject* user) +{ + if (m_currentUser != user) { + return; + } + + disconnect(m_currentUser, &QObject::destroyed, this, + &InteractiveTransformWidget::onUserDestroyed); + + m_currentUser = nullptr; + m_translationEnabled = false; + m_rotationEnabled = false; + m_scalingEnabled = false; + disableWidget(); + emit widgetReleased(); +} + +void InteractiveTransformWidget::onUserDestroyed() +{ + m_currentUser = nullptr; + m_translationEnabled = false; + m_rotationEnabled = false; + m_scalingEnabled = false; + disableWidget(); + emit widgetReleased(); +} + +void InteractiveTransformWidget::setView(pqView* view) +{ + if (m_view == view) { + return; + } + + if (view && view->getViewProxy() && + view->getViewProxy()->GetRenderWindow()) { + m_boxWidget->SetInteractor( + view->getViewProxy()->GetRenderWindow()->GetInteractor()); + } else { + m_boxWidget->SetInteractor(nullptr); + m_boxWidget->EnabledOff(); + } + + render(); + m_view = view; + updateWidgetState(); +} + +void InteractiveTransformWidget::setTranslationEnabled(bool enabled) +{ + m_translationEnabled = enabled; + updateWidgetState(); +} + +void InteractiveTransformWidget::setRotationEnabled(bool enabled) +{ + m_rotationEnabled = enabled; + updateWidgetState(); +} + +void InteractiveTransformWidget::setScalingEnabled(bool enabled) +{ + m_scalingEnabled = enabled; + updateWidgetState(); +} + +void InteractiveTransformWidget::setBounds(const double bounds[6]) +{ + m_boxRep->PlaceWidget(const_cast(bounds)); + render(); +} + +void InteractiveTransformWidget::setTransform(const double position[3], + const double orientation[3], + const double scale[3]) +{ + vtkNew t; + t->Identity(); + t->Translate(const_cast(position)); + // Rotate in Z-X-Y order (same as vtkProp3D). + t->RotateZ(orientation[2]); + t->RotateX(orientation[0]); + t->RotateY(orientation[1]); + t->Scale(const_cast(scale)); + + m_boxRep->SetTransform(t); + render(); +} + +void InteractiveTransformWidget::onInteraction(vtkObject*) +{ + vtkNew t; + m_boxRep->GetTransform(t); + + // Copy to local arrays — the pointer-returning GetPosition/GetScale/ + // GetOrientation methods on vtkTransform share the same internal buffer, + // so passing them directly as arguments is undefined behavior. + double position[3], orientation[3], scale[3]; + t->GetPosition(position); + t->GetOrientation(orientation); + t->GetScale(scale); + + emit transformChanged(position, orientation, scale); + render(); +} + +void InteractiveTransformWidget::updateWidgetState() +{ + auto* widget = m_boxWidget.Get(); + auto* rep = m_boxRep.Get(); + + if (!m_currentUser || !m_view) { + widget->EnabledOff(); + render(); + return; + } + + bool anyEnabled = + m_translationEnabled || m_rotationEnabled || m_scalingEnabled; + + widget->SetEnabled(anyEnabled); + if (!anyEnabled) { + render(); + return; + } + + widget->SetTranslationEnabled(m_translationEnabled); + widget->SetRotationEnabled(m_rotationEnabled); + widget->SetScalingEnabled(m_scalingEnabled); + widget->SetMoveFacesEnabled(m_scalingEnabled); + + for (int i = 0; i < 6; ++i) { + rep->GetHandle()[i]->SetVisibility(m_scalingEnabled); + } + rep->GetHandle()[6]->SetVisibility(m_translationEnabled); + + render(); +} + +void InteractiveTransformWidget::disableWidget() +{ + m_boxWidget->EnabledOff(); + render(); +} + +void InteractiveTransformWidget::render() +{ + if (!m_view) { + return; + } + m_view->render(); +} + +} // namespace tomviz diff --git a/tomviz/InteractiveTransformWidget.h b/tomviz/InteractiveTransformWidget.h new file mode 100644 index 000000000..86818c606 --- /dev/null +++ b/tomviz/InteractiveTransformWidget.h @@ -0,0 +1,92 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizInteractiveTransformWidget_h +#define tomvizInteractiveTransformWidget_h + +#include +#include + +#include + +class pqView; +class vtkCustomBoxRepresentation; +class vtkBoxWidget2; +class vtkEventQtSlotConnect; +class vtkObject; + +namespace tomviz { + +/// A singleton 3D box widget for interactive translation, rotation, and +/// scaling. Only one consumer may use the widget at a time via acquire/release. +class InteractiveTransformWidget : public QObject +{ + Q_OBJECT + +public: + static InteractiveTransformWidget& instance(); + + /// Acquire exclusive use of the widget. Returns false if already held by + /// another user. Auto-releases if @a user is destroyed. + bool acquire(QObject* user); + + /// Release the widget. No-op if @a user is not the current holder. + void release(QObject* user); + + /// Returns the current holder, or nullptr. + QObject* currentUser() const { return m_currentUser; } + + /// Set the view whose interactor the widget should attach to. + void setView(pqView* view); + + /// Enable/disable individual interaction modes. + void setTranslationEnabled(bool enabled); + void setRotationEnabled(bool enabled); + void setScalingEnabled(bool enabled); + + /// Place the widget using bounding box extents. + void setBounds(const double bounds[6]); + + /// Set the current widget transform (position, orientation, scale). + void setTransform(const double position[3], const double orientation[3], + const double scale[3]); + +signals: + /// Emitted during and at the end of user interaction. + void transformChanged(const double position[3], + const double orientation[3], + const double scale[3]); + + /// Emitted when the widget is released (including auto-release on user + /// destruction). + void widgetReleased(); + +protected: + InteractiveTransformWidget(); + ~InteractiveTransformWidget() override; + +private slots: + void onInteraction(vtkObject* caller); + void onUserDestroyed(); + +private: + void updateWidgetState(); + void disableWidget(); + void render(); + + Q_DISABLE_COPY(InteractiveTransformWidget) + + vtkNew m_boxRep; + vtkNew m_boxWidget; + vtkNew m_eventLink; + QPointer m_view; + QObject* m_currentUser = nullptr; + + bool m_translationEnabled = false; + bool m_rotationEnabled = false; + bool m_scalingEnabled = false; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/InterfaceBuilder.h b/tomviz/InterfaceBuilder.h deleted file mode 100644 index 0d3c026b8..000000000 --- a/tomviz/InterfaceBuilder.h +++ /dev/null @@ -1,60 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizInterfaceBuilder_h -#define tomvizInterfaceBuilder_h - -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -class DataSource; - -/// The InterfaceBuilder creates a Qt widget containing controls defined -/// by a JSON description. -class InterfaceBuilder : public QObject -{ - Q_OBJECT - -public: - InterfaceBuilder(QObject* parent = nullptr, DataSource* ds = nullptr); - - /// Set the JSON description - void setJSONDescription(const QString& description); - void setJSONDescription(const QJsonDocument& description); - - /// Build the interface, returning it in a QWidget. - QLayout* buildInterface() const; - QLayout* buildParameterInterface(QGridLayout* layout, QJsonArray& parameters, - const QString& tag = "") const; - - /// Set the parameter values - void setParameterValues(QMap values); - - static QMap parameterValues(const QObject* parent); - - void updateWidgetValues(const QObject* parent); - - void setupEnableAndVisibleStates(const QObject* parent, - QJsonArray& parameters) const; - void setupEnableStates(const QObject* parent, QJsonArray& parameters, - bool visible) const; - - QWidget* findWidgetByName(const QObject* parent, const QString& name) const; - -private: - Q_DISABLE_COPY(InterfaceBuilder) - - QJsonDocument m_json; - QMap m_parameterValues; - DataSource* m_dataSource; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/LoadDataReaction.cxx b/tomviz/LoadDataReaction.cxx index d040453b8..e9d94e639 100644 --- a/tomviz/LoadDataReaction.cxx +++ b/tomviz/LoadDataReaction.cxx @@ -5,26 +5,35 @@ #include "ActiveObjects.h" #include "DataExchangeFormat.h" -#include "DataSource.h" #include "EmdFormat.h" #include "FileFormatManager.h" +#include "FileReader.h" #include "FxiFormat.h" #include "GenericHDF5Format.h" #include "ImageStackDialog.h" #include "ImageStackModel.h" #include "LoadStackReaction.h" -#include "ModuleManager.h" -#include "MoleculeSource.h" -#include "Pipeline.h" -#include "PipelineManager.h" #include "PythonReader.h" #include "PythonUtilities.h" #include "RAWFileReaderDialog.h" #include "RecentFilesMenu.h" -#include "TimeSeriesStep.h" #include "Utilities.h" #include "vtkOMETiffReader.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/PortUtils.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SourceNode.h" +#include "pipeline/sources/ReaderSourceNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/OutlineSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/VolumeSink.h" +#include "ColorMap.h" + #include #include #include @@ -41,20 +50,20 @@ #include #include -#include #include #include #include #include #include #include -#include +#include #include #include #include #include #include +#include #include @@ -82,7 +91,8 @@ bool hasData(vtkSMProxy* reader) int extent[6]; data->GetExtent(extent); - if (extent[0] > extent[1] || extent[2] > extent[3] || extent[4] > extent[5]) { + if (extent[0] > extent[1] || extent[2] > extent[3] || + extent[4] > extent[5]) { return false; } @@ -96,6 +106,20 @@ bool hasData(vtkSMProxy* reader) } return true; } + +void applyDefaultColorMapPreset(tomviz::pipeline::OutputPort* port) +{ + if (!port) { + return; + } + auto vol = tomviz::pipeline::getOutputData( + port->node(), port->name()); + if (!vol) { + return; + } + tomviz::ColorMap::instance().applyPreset(vol->colorMap()); + vol->rescaleColorMap(); +} } // namespace namespace tomviz { @@ -111,33 +135,70 @@ void LoadDataReaction::onTriggered() loadData(); } -QList LoadDataReaction::loadData(bool isTimeSeries) +pipeline::SourceNode* LoadDataReaction::createSourceFromImageData( + vtkImageData* image, const QString& label, const QStringList& fileNames) +{ + QString nodeLabel = label; + if (nodeLabel.isEmpty() && !fileNames.isEmpty()) { + nodeLabel = QFileInfo(fileNames[0]).completeBaseName(); + } + + auto volumeData = std::make_shared(image); + volumeData->setLabel(nodeLabel); + pipeline::PortType dataType = volumeData->hasTiltAngles() + ? pipeline::PortType::TiltSeries + : pipeline::PortType::Volume; + + // If we have file paths, use a ReaderSourceNode so a future state-file + // load can re-run the reader from the saved paths. Otherwise fall back + // to a bare SourceNode — the data lives purely in memory and can only + // round-trip through a container that embeds voxels (.tvh5). + pipeline::SourceNode* source = nullptr; + if (!fileNames.isEmpty()) { + auto* reader = new pipeline::ReaderSourceNode(); + reader->setFileNames(fileNames); + // Pre-populate the output port with the already-read data so the + // caller doesn't have to re-read immediately. ReaderSourceNode's + // constructor declares "volume" as ImageData; refine to the + // detected effective type (TiltSeries / Volume). + reader->outputPort("volume")->setDeclaredType(dataType); + source = reader; + } else { + source = new pipeline::SourceNode(); + source->addOutput("volume", dataType); + } + source->setLabel(nodeLabel); + source->setOutputData("volume", pipeline::PortData(volumeData, dataType)); + return source; +} + +QList LoadDataReaction::loadData(bool isTimeSeries) { QStringList filters; filters << "Common file types (*.emd *.jpg *.jpeg *.png *.tiff *.tif *.h5 " - "*.raw *.dat *.bin *.txt *.mhd *.mha *.vti *.mrc *.st *.rec *.ali " - "*.xmf *.xdmf)" + "*.hspy *.raw *.dat *.bin *.txt *.mhd *.mha *.vti *.mrc *.st " + "*.rec *.ali *.xmf *.xdmf *.npy *.mat *.dcm)" << "EMD (*.emd)" << "JPeg Image files (*.jpg *.jpeg)" << "PNG Image files (*.png)" << "TIFF Image files (*.tiff *.tif)" - << "HDF5 files (*.h5)" + << "HDF5 files (*.h5 *.hspy)" << "OME-TIFF Image files (*.ome.tif)" << "Raw data files (*.raw *.dat *.bin)" << "Meta Image files (*.mhd *.mha)" << "VTK ImageData Files (*.vti)" << "MRC files (*.mrc *.st *.rec *.ali)" << "XDMF files (*.xmf *.xdmf)" - << "Molecule files (*.xyz)" << "Text files (*.txt)"; - foreach (auto reader, FileFormatManager::instance().pythonReaderFactories()) { + foreach (auto reader, + FileFormatManager::instance().pythonReaderFactories()) { filters << reader->getFileDialogFilter(); } filters << "All files (*.*)"; - QFileDialog dialog(nullptr); + QFileDialog dialog(tomviz::mainWidget()); dialog.setFileMode(QFileDialog::ExistingFiles); dialog.setNameFilters(filters); dialog.setObjectName("FileOpenDialog-tomviz"); // avoid name collision? @@ -155,61 +216,60 @@ QList LoadDataReaction::loadData(bool isTimeSeries) options["createCameraOrbit"] = false; } - QList dataSources; - QString fileName = filenames.size() > 0 ? filenames[0] : ""; - QFileInfo info(fileName); - auto suffix = info.suffix().toLower(); - QStringList moleculeExt = { "xyz" }; - if (moleculeExt.contains(suffix)) { - loadMolecule(filenames); - } else { - for (auto f : filenames) { - dataSources << loadData(f, options); - if (isTimeSeries) { - // After loading the first data source in a time series, don't - // add any more to the pipeline. We'll delete them below. - options["addToPipeline"] = false; - } + QList sources; + for (auto f : filenames) { + sources << loadData(f, options); + if (isTimeSeries) { + // After loading the first source in a time series, don't + // add any more to the pipeline. We'll delete them below. + options["addToPipeline"] = false; } } - if (isTimeSeries) { - // Combine all of the data sources into the first one. - std::vector times; - QList timeSteps; - - for (auto i = 0; i < dataSources.size(); ++i) { - QString label = dataSources[i]->label(); - auto* image = dataSources[i]->imageData(); - double time = i; - - times.push_back(time); - timeSteps.append(TimeSeriesStep(label, image, time)); - - if (i != 0) { - // Delete all data sources other than the first one. These were not - // added to the pipeline. - dataSources[i]->deleteLater(); + if (isTimeSeries && !sources.isEmpty()) { + // Combine all sources into the first one via time steps. + auto firstVol = + pipeline::getOutputData(sources[0]); + if (firstVol) { + std::vector times; + QList timeSteps; + + for (int i = 0; i < sources.size(); ++i) { + auto vol = + pipeline::getOutputData(sources[i]); + if (!vol) { + continue; + } + double t = static_cast(i); + times.push_back(t); + timeSteps.append({ vol->label(), vol->imageData(), t }); + + if (i != 0) { + // Delete all sources other than the first one. These were not + // added to the pipeline. + sources[i]->deleteLater(); + } } - } - dataSources[0]->setTimeSeriesSteps(timeSteps); - dataSources = { dataSources[0] }; + firstVol->setTimeSteps(timeSteps); + sources = { sources[0] }; - // Set the animation time steps and change the play mode to - // "Snap To TimeSteps". - tomviz::snapAnimationToTimeSteps(times); + // Set the animation time steps and change the play mode to + // "Snap To TimeSteps". + tomviz::snapAnimationToTimeSteps(times); - // Also set the number of time steps in the sequence to match - // (this only matters if the user switches to "Sequence" play mode) - tomviz::setAnimationNumberOfFrames(times.size()); + // Also set the number of time steps in the sequence to match + // (this only matters if the user switches to "Sequence" play mode) + tomviz::setAnimationNumberOfFrames(static_cast(times.size())); + } } - return dataSources; + return sources; } -DataSource* LoadDataReaction::loadData(const QString& fileName, - bool defaultModules, bool addToRecent, - bool child, const QJsonObject& options) +pipeline::SourceNode* LoadDataReaction::loadData(const QString& fileName, + bool defaultModules, + bool addToRecent, bool child, + const QJsonObject& options) { QJsonObject opts = options; opts["defaultModules"] = defaultModules; @@ -218,8 +278,8 @@ DataSource* LoadDataReaction::loadData(const QString& fileName, return LoadDataReaction::loadData(fileName, opts); } -DataSource* LoadDataReaction::loadData(const QString& fileName, - const QJsonObject& val) +pipeline::SourceNode* LoadDataReaction::loadData(const QString& fileName, + const QJsonObject& val) { QStringList fileNames; fileNames << fileName; @@ -227,200 +287,135 @@ DataSource* LoadDataReaction::loadData(const QString& fileName, return loadData(fileNames, val); } -DataSource* LoadDataReaction::loadData(const QStringList& fileNames, - const QJsonObject& options) +pipeline::SourceNode* LoadDataReaction::loadData(const QStringList& fileNames, + const QJsonObject& options) { bool defaultModules = options["defaultModules"].toBool(true); bool addToRecent = options["addToRecent"].toBool(true); bool addToPipeline = options["addToPipeline"].toBool(true); bool createCameraOrbit = options["createCameraOrbit"].toBool(true); bool child = options["child"].toBool(false); - bool loadWithParaview = true; - bool loadWithPython = false; - DataSource* dataSource(nullptr); QString fileName; if (fileNames.size() > 0) { fileName = fileNames[0]; } QFileInfo info(fileName); - if (info.suffix().toLower() == "tvh5") { - // Need to specify a path inside the tvh5 file to load - QString path = options["tvh5NodePath"].toString(); - if (path.isEmpty()) { - qCritical() << "A path is required to read tvh5 as a data file"; + + // Try the headless helper first. It handles every format we can read + // without UI (tvh5, emd, h5, ome.tif, tif/png/jpg/vti/mrc, registered + // Python readers, and ParaView readers when a descriptor is supplied + // in options["reader"]). + ReadResult result = readImageData(fileNames, options); + + pipeline::SourceNode* source = nullptr; + if (result.imageData) { + source = createSourceFromImageData(result.imageData, + info.completeBaseName(), fileNames); + if (!source) { return nullptr; } - - loadWithParaview = false; - // Do not prompt the user for subsample settings - QVariantMap emdOptions = { { "askForSubsample", false } }; - vtkNew image; - if (EmdFormat::readNode(fileName.toStdString(), path.toStdString(), image, - emdOptions)) { - DataSource::DataSourceType type = DataSource::hasTiltAngles(image) - ? DataSource::TiltSeries - : DataSource::Volume; - dataSource = new DataSource(image, type); - // Save the node path in case we write the data again in the future - dataSource->setTvh5NodePath(path); + // Tilt angles — copy onto the VolumeData and promote port type. + if (!result.tiltAngles.isEmpty()) { + auto vol = pipeline::getOutputData(source); + if (vol) { + vol->setTiltAngles(result.tiltAngles); + source->outputPort("volume")->setDeclaredType( + pipeline::PortType::TiltSeries); + source->setProperty("dataType", "tiltSeries"); + } + } + // DataExchange / FXI auxiliary frames. + if (result.darkData) { + source->setProperty( + "darkData", QVariant::fromValue( + static_cast(result.darkData.GetPointer()))); } - } else if (info.suffix().toLower() == "emd") { - // Load the file using our simple EMD class. - loadWithParaview = false; - QVariantMap emdOptions; - vtkNew imageData; - if (options.contains("subsampleSettings")) { - // Before we read into the image data, set subsample settings - emdOptions["subsampleStrides"] = - options["subsampleSettings"].toObject()["strides"].toVariant(); - emdOptions["subsampleVolumeBounds"] = - options["subsampleSettings"].toObject()["volumeBounds"].toVariant(); - emdOptions["askForSubsample"] = false; + if (result.whiteData) { + source->setProperty( + "whiteData", + QVariant::fromValue( + static_cast(result.whiteData.GetPointer()))); } - if (EmdFormat::read(fileName.toLatin1().data(), imageData, emdOptions)) { - DataSource::DataSourceType type = DataSource::hasTiltAngles(imageData) - ? DataSource::TiltSeries - : DataSource::Volume; - dataSource = new DataSource(imageData, type); + if (!result.readerProperties.isEmpty()) { + source->setProperty( + "readerProperties", + QVariant::fromValue(result.readerProperties.toVariantMap())); } - } else if (info.suffix().toLower() == "h5") { - loadWithParaview = false; - QVariantMap hdf5Options; - if (options.contains("subsampleSettings")) { - // Before we read into the image data, set subsample settings - hdf5Options["subsampleStrides"] = - options["subsampleSettings"].toObject()["strides"].toVariant(); - hdf5Options["subsampleVolumeBounds"] = - options["subsampleSettings"].toObject()["volumeBounds"].toVariant(); - hdf5Options["askForSubsample"] = false; + if (!result.tvh5NodePath.isEmpty()) { + source->setProperty("tvh5NodePath", result.tvh5NodePath); } - // Check if it looks like data exchange - if (GenericHDF5Format::isDataExchange(fileName.toStdString())) { - dataSource = new DataSource(info.completeBaseName()); - DataExchangeFormat format; - if (!format.read(fileName.toLatin1().data(), dataSource, hdf5Options)) { - delete dataSource; - return nullptr; + // If the node is a ReaderSourceNode, stash the reader descriptor and + // subsample settings so a re-execute after a state-file load can + // invoke readImageData with the same configuration. + if (auto* reader = qobject_cast(source)) { + QJsonObject readerOpts; + if (!result.readerProperties.isEmpty()) { + readerOpts["reader"] = result.readerProperties; } - } else if (GenericHDF5Format::isFxi(fileName.toStdString())) { - dataSource = new DataSource(info.completeBaseName()); - FxiFormat format; - if (!format.read(fileName.toLatin1().data(), dataSource, hdf5Options)) { - delete dataSource; - return nullptr; + if (options.contains("subsampleSettings")) { + readerOpts["subsampleSettings"] = options.value("subsampleSettings"); + } + if (!result.tvh5NodePath.isEmpty()) { + readerOpts["tvh5NodePath"] = result.tvh5NodePath; + } + if (!readerOpts.isEmpty()) { + reader->setReaderOptions(readerOpts); } - } else { - vtkNew imageData; - GenericHDF5Format hdf5Format; - if (!hdf5Format.read(fileName.toLatin1().data(), imageData, hdf5Options)) - return nullptr; - DataSource::DataSourceType type = DataSource::hasTiltAngles(imageData) - ? DataSource::TiltSeries - : DataSource::Volume; - dataSource = new DataSource(imageData, type); - } - } else if (info.completeSuffix().endsWith("ome.tif")) { - loadWithParaview = false; - vtkNew reader; - reader->SetFileName(fileName.toLocal8Bit().constData()); - reader->Update(); - auto* imageData = reader->GetOutput(); - - dataSource = new DataSource(imageData); - QJsonObject readerProperties; - readerProperties["name"] = "OMETIFFReader"; - dataSource->setReaderProperties(readerProperties.toVariantMap()); - } else if (FileFormatManager::instance().pythonReaderFactory( - info.suffix().toLower()) != nullptr) { - loadWithParaview = false; - loadWithPython = true; - } else if (options.contains("reader")) { - loadWithParaview = false; - // Create the ParaView reader and set its properties using the JSON - // configuration. - auto props = options["reader"].toObject(); - auto name = props["name"].toString(); - - auto pxm = ActiveObjects::instance().proxyManager(); - vtkSmartPointer reader; - reader.TakeReference(pxm->NewProxy("sources", name.toLatin1().data())); - - setProperties(props, reader); - setFileNameProperties(props, reader); - reader->UpdateVTKObjects(); - vtkSMSourceProxy::SafeDownCast(reader)->UpdatePipelineInformation(); - - // We'll add it to the pipeline on our own later, if needed - bool addToThePipeline = false; - dataSource = LoadDataReaction::createDataSource(reader, defaultModules, - child, addToThePipeline); - if (dataSource == nullptr) { - return nullptr; } - - dataSource->setReaderProperties(props.toVariantMap()); - } - - if (loadWithParaview) { - // Use ParaView's file load infrastructure. + } else if (!options.contains("reader")) { + // Fallback path — let ParaView's interactive file loader run (this + // may pop a reader-config dialog). Only reached when nothing else + // claimed the extension AND the caller didn't provide a reader + // descriptor (state-file loads / re-executions always do). pqPipelineSource* reader = pqLoadDataReaction::loadData(fileNames); if (!reader) { return nullptr; } - - // We'll add it to the pipeline on our own later, if needed bool addToThePipeline = false; - dataSource = createDataSource(reader->getProxy(), defaultModules, child, - addToThePipeline); - if (dataSource == nullptr) { + source = createFromParaViewReader(reader->getProxy(), defaultModules, + child, addToThePipeline); + if (!source) { return nullptr; } QJsonObject props = readerProperties(reader->getProxy()); props["name"] = reader->getProxy()->GetXMLName(); - - dataSource->setReaderProperties(props.toVariantMap()); + source->setProperty("readerProperties", + QVariant::fromValue(props.toVariantMap())); + // Propagate to ReaderSourceNode so re-executes after a state-file + // load can rebuild the ParaView reader headlessly. + if (auto* readerNode = + qobject_cast(source)) { + QJsonObject readerOpts; + readerOpts["reader"] = props; + readerNode->setReaderOptions(readerOpts); + } vtkNew controller; controller->UnRegisterProxy(reader->getProxy()); - } else if (loadWithPython) { - QString ext = info.suffix().toLower(); - auto factory = FileFormatManager::instance().pythonReaderFactory(ext); - Q_ASSERT(factory != nullptr); - auto reader = factory->createReader(); - auto imageData = reader.read(fileNames[0]); - if (imageData == nullptr) { - return nullptr; - } - dataSource = new DataSource(imageData); } - // It is possible that the dataSource will be null if, for example, loading - // a VTI is cancelled in the array selection dialog. Guard against this. - if (!dataSource) { + if (!source) { return nullptr; } + // Set file names as a node property so the label is available. + source->setProperty("fileNames", fileNames); + if (addToPipeline) { - // Add to the pipeline if needed... - LoadDataReaction::dataSourceAdded(dataSource, defaultModules, child, + LoadDataReaction::sourceNodeAdded(source, defaultModules, child, createCameraOrbit); } - // Now for house keeping, registering elements, etc. - // Always save it as a list, even if there is only one file. - dataSource->setFileNames(fileNames); - - if (addToRecent && dataSource) { - RecentFilesMenu::pushDataReader(dataSource); + if (addToRecent && source) { + RecentFilesMenu::pushDataReader(source); } - return dataSource; + return source; } -DataSource* LoadDataReaction::createDataSource(vtkSMProxy* reader, - bool defaultModules, bool child, - bool addToPipeline) +pipeline::SourceNode* LoadDataReaction::createFromParaViewReader( + vtkSMProxy* reader, bool defaultModules, bool child, bool addToPipeline) { // Prompt user for reader configuration, unless it is TIFF. QScopedPointer dialog(new pqProxyWidgetDialog(reader)); @@ -446,32 +441,30 @@ DataSource* LoadDataReaction::createDataSource(vtkSMProxy* reader, return nullptr; } - auto source = vtkSMSourceProxy::SafeDownCast(reader); - source->UpdatePipeline(); - auto algo = vtkAlgorithm::SafeDownCast(source->GetClientSideObject()); + auto pvSource = vtkSMSourceProxy::SafeDownCast(reader); + pvSource->UpdatePipeline(); + auto algo = + vtkAlgorithm::SafeDownCast(pvSource->GetClientSideObject()); auto data = algo->GetOutputDataObject(0); auto image = vtkImageData::SafeDownCast(data); - DataSource::DataSourceType type = DataSource::hasTiltAngles(image) - ? DataSource::TiltSeries - : DataSource::Volume; + QString readerFileName; auto prop = reader->GetProperty("FileNames"); if (prop != nullptr) { auto jsonProp = toJson(prop); - QString fileName; if (jsonProp.toArray().size() > 0) { - fileName = jsonProp.toArray()[0].toString(); + readerFileName = jsonProp.toArray()[0].toString(); } else { - fileName = jsonProp.toString(); + readerFileName = jsonProp.toString(); } - QFileInfo info(fileName); + QFileInfo fInfo(readerFileName); // Special case: mrc files store spacing in Angstrom QStringList mrcExt; mrcExt << "mrc" << "st" << "rec" << "ali"; - if (mrcExt.contains(info.suffix().toLower())) { + if (mrcExt.contains(fInfo.suffix().toLower())) { double spacing[3]; image->GetSpacing(spacing); for (int i = 0; i < 3; ++i) { @@ -481,40 +474,61 @@ DataSource* LoadDataReaction::createDataSource(vtkSMProxy* reader, } } - DataSource* dataSource = new DataSource(image, type); + QString label; + if (!readerFileName.isEmpty()) { + label = QFileInfo(readerFileName).completeBaseName(); + } + + auto* source = createSourceFromImageData( + image, label, readerFileName.isEmpty() ? QStringList() + : QStringList({ readerFileName })); - // Do whatever we need to do with a new data source. + // Do whatever we need to do with a new source node. if (addToPipeline) { - LoadDataReaction::dataSourceAdded(dataSource, defaultModules, child); + LoadDataReaction::sourceNodeAdded(source, defaultModules, child); } - return dataSource; + return source; } return nullptr; } -void LoadDataReaction::dataSourceAdded(DataSource* dataSource, - bool defaultModules, bool child, - bool createCameraOrbit) +void LoadDataReaction::addSourceToPipeline(pipeline::SourceNode* source) { - if (!dataSource) { + if (!source) { return; } - DataSource* previousActiveDataSource = - ActiveObjects::instance().activeDataSource(); - if (child) { - ModuleManager::instance().addChildDataSource(dataSource); - } else { - auto pipeline = new Pipeline(dataSource); - PipelineManager::instance().addPipeline(pipeline); - // TODO Eventually we shouldn't need to keep track of the data sources, - // the pipeline should do that for us. - ModuleManager::instance().addDataSource(dataSource); - if (defaultModules) { - pipeline->addDefaultModules(dataSource); + auto* pip = ActiveObjects::instance().pipeline(); + if (!pip) { + return; + } + pip->addNode(source); +} + +void LoadDataReaction::completeSourceSetup(pipeline::SourceNode* source, + bool defaultModules) +{ + if (!source) { + return; + } + auto* pip = ActiveObjects::instance().pipeline(); + if (!pip) { + return; + } + + // Apply the default color map preset to every output port. File-loaded + // sources already have data when we get here; sources that produce data + // on first execute get a single-shot listener per port. + for (auto* port : source->outputPorts()) { + if (port->hasData()) { + applyDefaultColorMapPreset(port); + } else { + QObject::connect(port, &pipeline::OutputPort::dataChanged, port, + [port]() { applyDefaultColorMapPreset(port); }, + Qt::SingleShotConnection); } } - // Work through pathological cases as necessary, prefer active view. + // Get view for visualization ActiveObjects::instance().createRenderViewIfNeeded(); auto view = ActiveObjects::instance().activeView(); @@ -523,17 +537,77 @@ void LoadDataReaction::dataSourceAdded(DataSource* dataSource, view = ActiveObjects::instance().activeView(); } - if (!previousActiveDataSource) { - pqRenderView* renderView = - qobject_cast(pqActiveObjects::instance().activeView()); - if (renderView && createCameraOrbit) { - tomviz::setAnimationNumberOfFrames(200); - tomviz::createCameraOrbit(dataSource->proxy(), - renderView->getRenderViewProxy()); + if (defaultModules && view) { + // Only create default sinks for the first output port; additional + // outputs get colormaps but no automatic visualization. + auto* group = new pipeline::SinkGroupNode(); + group->addPassthrough("volume", pipeline::PortType::ImageData); + pip->addNode(group); + pip->createLink(source->outputPorts()[0], group->inputPorts()[0]); + + auto* outline = new pipeline::OutlineSink(); + outline->setLabel("Outline"); + outline->initialize(view); + pip->addNode(outline); + pip->createLink(group->outputPorts()[0], outline->inputPorts()[0]); + + bool isTiltSeries = source->outputPorts()[0]->type() == + pipeline::PortType::TiltSeries; + if (isTiltSeries) { + auto* slice = new pipeline::SliceSink(); + slice->setLabel("Slice"); + slice->initialize(view); + pip->addNode(slice); + pip->createLink(group->outputPorts()[0], slice->inputPorts()[0]); + } else { + auto* volume = new pipeline::VolumeSink(); + volume->setLabel("Volume"); + volume->initialize(view); + pip->addNode(volume); + pip->createLink(group->outputPorts()[0], volume->inputPorts()[0]); } } } +void LoadDataReaction::sourceNodeAdded(pipeline::SourceNode* source, + bool defaultModules, bool /* child */, + bool createCameraOrbit) +{ + auto* pip = ActiveObjects::instance().pipeline(); + bool isFirstSource = pip && pip->nodes().isEmpty(); + + addSourceToPipeline(source); + completeSourceSetup(source, defaultModules); + + // Each pip->addNode() above auto-selects the node (to advance the tip + // output port as nodes are added); opening a file would otherwise leave the + // last sink showing in the properties panel. Clear the selection so nothing + // is selected — the tip is unchanged (a sink has no output port, so it was + // already derived via findTipOutputPort), only the properties panel empties. + ActiveObjects::instance().clearActiveSelection(); + + if (isFirstSource && createCameraOrbit && pip) { + // Create the camera orbit after the first execution completes so the + // camera has been reset to frame the data. + auto conn = std::make_shared(); + *conn = QObject::connect( + pip, &pipeline::Pipeline::executionFinished, pip, [conn]() { + QObject::disconnect(*conn); + tomviz::setAnimationNumberOfFrames(200); + auto* rv = ActiveObjects::instance().activePqRenderView(); + if (rv) { + tomviz::createCameraOrbit(rv->getRenderViewProxy()); + } + }); + } + + // Defer so the event loop can process pending signals before executing. + // ThreadedExecutor handles the case where it's already running. + if (pip) { + QTimer::singleShot(0, pip, [pip]() { pip->execute(); }); + } +} + QJsonObject LoadDataReaction::readerProperties(vtkSMProxy* reader) { QStringList propNames({ "DataScalarType", "DataByteOrder", @@ -559,10 +633,6 @@ QJsonObject LoadDataReaction::readerProperties(vtkSMProxy* reader) props["fileNames"] = fileNames; } // Normalize to fileNames for single value. - // FIXME: this used to always have at least one file name, - // but since the update to ParaView 6.0, it does not. Maybe - // we don't need it? Or maybe we need to use a different - // property name instead than "FileNames". else if (fileNames.size() == 1) { props["fileName"] = fileNames[0]; } @@ -589,7 +659,8 @@ void LoadDataReaction::setFileNameProperties(const QJsonObject& props, prop = reader->GetProperty("FileNames"); if (prop != nullptr) { if (!props.contains("fileNames") && !props.contains("fileName")) { - qCritical() << "Reader doesn't have 'fileName' or 'fileNames' property."; + qCritical() + << "Reader doesn't have 'fileName' or 'fileNames' property."; return; } @@ -611,44 +682,4 @@ void LoadDataReaction::setFileNameProperties(const QJsonObject& props, } } -QList LoadDataReaction::loadMolecule( - const QStringList& fileNames, const QJsonObject& options) -{ - QList moleculeSources; - foreach (auto fileName, fileNames) { - moleculeSources << loadMolecule(fileName, options); - } - return moleculeSources; -} - -MoleculeSource* LoadDataReaction::loadMolecule(const QString& fileName, - const QJsonObject& options) -{ - if (fileName.isEmpty()) { - return nullptr; - } - - bool addToRecent = options["addToRecent"].toBool(true); - bool defaultModules = options["defaultModules"].toBool(true); - - vtkMolecule* molecule = vtkMolecule::New(); - vtkNew reader; - reader->SetFileName(fileName.toLatin1().data()); - reader->SetOutput(molecule); - reader->Update(); - - auto moleculeSource = new MoleculeSource(molecule); - moleculeSource->setFileName(fileName); - ModuleManager::instance().addMoleculeSource(moleculeSource); - if (moleculeSource && defaultModules) { - auto view = ActiveObjects::instance().activeView(); - ModuleManager::instance().createAndAddModule("Molecule", moleculeSource, - view); - } - if (moleculeSource && addToRecent) { - RecentFilesMenu::pushMoleculeReader(moleculeSource); - } - return moleculeSource; -} - } // end of namespace tomviz diff --git a/tomviz/LoadDataReaction.h b/tomviz/LoadDataReaction.h index 02c55fce1..0aa23f367 100644 --- a/tomviz/LoadDataReaction.h +++ b/tomviz/LoadDataReaction.h @@ -14,8 +14,10 @@ class vtkImageData; class vtkSMProxy; namespace tomviz { -class DataSource; -class MoleculeSource; + +namespace pipeline { +class SourceNode; +} class PythonReaderFactory; @@ -31,53 +33,71 @@ class LoadDataReaction : public pqReaction LoadDataReaction(QAction* parentAction); ~LoadDataReaction() override; - static QList loadData(bool isTimeSeries = false); + static QList loadData(bool isTimeSeries = false); /// Convenience method, adds defaultModules, addToRecent, and child to the /// JSON object before passing it to the loadData methods. - static DataSource* loadData(const QString& fileName, bool defaultModules, - bool addToRecent, bool child, - const QJsonObject& options = QJsonObject()); + static pipeline::SourceNode* loadData(const QString& fileName, + bool defaultModules, bool addToRecent, + bool child, + const QJsonObject& options = + QJsonObject()); /// Load a data file from the specified location, options can be used to pass /// additional parameters to the method, such as defaultModules, addToRecent, /// and child, or pvXML to pass to the ParaView reader. - static DataSource* loadData(const QString& fileName, - const QJsonObject& options = QJsonObject()); + static pipeline::SourceNode* loadData( + const QString& fileName, const QJsonObject& options = QJsonObject()); /// Load data files from the specified locations, options can be used to pass /// additional parameters to the method, such as defaultModules, addToRecent, /// and child, or pvXML to pass to the ParaView reader. - static DataSource* loadData(const QStringList& fileNames, - const QJsonObject& options = QJsonObject()); - - static QList loadMolecule( + static pipeline::SourceNode* loadData( const QStringList& fileNames, const QJsonObject& options = QJsonObject()); - static MoleculeSource* loadMolecule( - const QString& fileName, const QJsonObject& options = QJsonObject()); - /// Handle creation of a new data source. - static void dataSourceAdded(DataSource* dataSource, + /// Handle creation of a new source node (data already set on the node). + static void sourceNodeAdded(pipeline::SourceNode* source, bool defaultModules = true, bool child = false, bool createCameraOrbit = true); -protected: - /// Create a raw data source from the reader. - static DataSource* createDataSource(vtkSMProxy* reader, - bool defaultModules = true, - bool child = false, - bool addToPipeline = true); + /// Add a source node to the active pipeline without setting up the + /// default sinks or executing. Useful when a caller needs the source + /// in the pipeline ahead of time (e.g. before opening an insertion + /// dialog that may still cancel and roll back). + static void addSourceToPipeline(pipeline::SourceNode* source); + + /// Finish the work that sourceNodeAdded() does after addNode(): + /// initialize color maps from the source's volume data, build the + /// default sink group + outline + volume/slice sinks (when + /// defaultModules is true), and queue a pipeline execution. The + /// source must already be in the pipeline (typically via + /// addSourceToPipeline()). + static void completeSourceSetup(pipeline::SourceNode* source, + bool defaultModules = true); + + /// Create a SourceNode from a ParaView reader proxy. + static pipeline::SourceNode* createFromParaViewReader( + vtkSMProxy* reader, bool defaultModules = true, bool child = false, + bool addToPipeline = true); + + /// Capture reader-proxy property values for round-tripping. + static QJsonObject readerProperties(vtkSMProxy* reader); + /// Apply filename-related properties from a reader descriptor onto a + /// ParaView proxy. + static void setFileNameProperties(const QJsonObject& props, + vtkSMProxy* reader); + +protected: /// Called when the action is triggered. void onTriggered() override; private: Q_DISABLE_COPY(LoadDataReaction) - static void addDefaultModules(DataSource* dataSource); - static QJsonObject readerProperties(vtkSMProxy* reader); - static void setFileNameProperties(const QJsonObject& props, - vtkSMProxy* reader); + static pipeline::SourceNode* createSourceFromImageData( + vtkImageData* image, const QString& label, + const QStringList& fileNames = {}); }; } // namespace tomviz diff --git a/tomviz/LoadStackReaction.cxx b/tomviz/LoadStackReaction.cxx index 27dde5730..39be98957 100644 --- a/tomviz/LoadStackReaction.cxx +++ b/tomviz/LoadStackReaction.cxx @@ -4,13 +4,17 @@ #include "LoadStackReaction.h" #include "ActiveObjects.h" -#include "DataSource.h" #include "ImageStackDialog.h" #include "LoadDataReaction.h" -#include "Pipeline.h" -#include "SetTiltAnglesOperator.h" #include "Utilities.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PortType.h" +#include "pipeline/PortUtils.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/transforms/SetTiltAnglesTransform.h" + #include #include #include @@ -29,27 +33,29 @@ void LoadStackReaction::onTriggered() loadData(); } -DataSource* LoadStackReaction::loadData(const QStringList& fileNames) +pipeline::SourceNode* LoadStackReaction::loadData( + const QStringList& fileNames) { ImageStackDialog dialog(tomviz::mainWidget()); dialog.processFiles(fileNames); return execStackDialog(dialog); } -DataSource* LoadStackReaction::loadData(const QString& directory) +pipeline::SourceNode* LoadStackReaction::loadData(const QString& directory) { ImageStackDialog dialog(tomviz::mainWidget()); dialog.processDirectory(directory); return execStackDialog(dialog); } -DataSource* LoadStackReaction::loadData() +pipeline::SourceNode* LoadStackReaction::loadData() { ImageStackDialog dialog(tomviz::mainWidget()); return execStackDialog(dialog); } -DataSource* LoadStackReaction::execStackDialog(ImageStackDialog& dialog) +pipeline::SourceNode* LoadStackReaction::execStackDialog( + ImageStackDialog& dialog) { int result = dialog.exec(); if (result == QDialog::Accepted) { @@ -59,11 +65,15 @@ DataSource* LoadStackReaction::execStackDialog(ImageStackDialog& dialog) if (fNames.size() < 1) { return nullptr; } - DataSource* dataSource = LoadDataReaction::loadData(fNames); - DataSource::DataSourceType stackType = dialog.getStackType(); + auto* source = LoadDataReaction::loadData(fNames); + if (!source) { + return nullptr; + } + + pipeline::PortType stackType = dialog.getStackType(); bool imageViewerMode = dialog.getImageViewerMode(); - if (stackType == DataSource::DataSourceType::TiltSeries) { - auto op = new SetTiltAnglesOperator; + if (stackType == pipeline::PortType::TiltSeries) { + // Build tilt angles from the stack summary QMap angles; int j = 0; for (int i = 0; i < summary.size(); ++i) { @@ -71,20 +81,28 @@ DataSource* LoadStackReaction::execStackDialog(ImageStackDialog& dialog) angles[j++] = summary[i].pos; } } - op->setTiltAngles(angles); - dataSource->addOperator(op); - - // After the operator completes, update the image viewer mode - auto* pipeline = dataSource->pipeline(); - connect(pipeline, &Pipeline::finished, &ActiveObjects::instance(), - [imageViewerMode]() { - ActiveObjects::instance().setImageViewerMode(imageViewerMode); - }); + + // Set tilt angles directly on the VolumeData + auto vol = + pipeline::getOutputData(source); + if (vol) { + QVector tiltAngles(angles.size(), 0.0); + for (auto it = angles.constBegin(); it != angles.constEnd(); + ++it) { + if (static_cast(it.key()) < tiltAngles.size()) { + tiltAngles[static_cast(it.key())] = it.value(); + } + } + vol->setTiltAngles(tiltAngles); + source->setProperty("dataType", "tiltSeries"); + } + + ActiveObjects::instance().setImageViewerMode(imageViewerMode); } else { ActiveObjects::instance().setImageViewerMode(imageViewerMode); } - return dataSource; + return source; } else { return nullptr; } @@ -102,17 +120,16 @@ QStringList LoadStackReaction::summaryToFileNames( return fileNames; } -QList LoadStackReaction::loadTiffStack(const QStringList& fileNames) +QList LoadStackReaction::loadTiffStack( + const QStringList& fileNames) { QList summary; vtkNew reader; int n = -1; int m = -1; int dims[3]; - int i = -1; bool consistent; foreach (QString file, fileNames) { - i++; consistent = true; reader->SetFileName(file.toLatin1().data()); reader->Update(); diff --git a/tomviz/LoadStackReaction.h b/tomviz/LoadStackReaction.h index eda41fc55..0495dbce0 100644 --- a/tomviz/LoadStackReaction.h +++ b/tomviz/LoadStackReaction.h @@ -9,16 +9,17 @@ #include "ImageStackModel.h" namespace tomviz { -class DataSource; class ImageStackDialog; +namespace pipeline { +class SourceNode; +} + /// LoadStackReaction handles the "Load Stack" action in tomviz. On trigger, /// this will open a dialog where the user can drag-n-drop or open multiple -/// files -/// or a folder. After selecting the files in the stack, options will be -/// presented -/// to include or exclude each file, and to label which type of stack it is -/// +/// files or a folder. After selecting the files in the stack, options will be +/// presented to include or exclude each file, and to label which type of stack +/// it is. class LoadStackReaction : public pqReaction { Q_OBJECT @@ -27,9 +28,9 @@ class LoadStackReaction : public pqReaction LoadStackReaction(QAction* parentAction); ~LoadStackReaction() override; - static DataSource* loadData(); - static DataSource* loadData(const QStringList& fileNames); - static DataSource* loadData(const QString& directory); + static pipeline::SourceNode* loadData(); + static pipeline::SourceNode* loadData(const QStringList& fileNames); + static pipeline::SourceNode* loadData(const QString& directory); static QList loadTiffStack(const QStringList& fileNames); protected: @@ -39,7 +40,7 @@ class LoadStackReaction : public pqReaction private: Q_DISABLE_COPY(LoadStackReaction) - static DataSource* execStackDialog(ImageStackDialog& dialog); + static pipeline::SourceNode* execStackDialog(ImageStackDialog& dialog); static QStringList summaryToFileNames(const QList& summary); }; } // namespace tomviz diff --git a/tomviz/LoadTimeSeriesReaction.cxx b/tomviz/LoadTimeSeriesReaction.cxx index 2b90728b0..6b105ff66 100644 --- a/tomviz/LoadTimeSeriesReaction.cxx +++ b/tomviz/LoadTimeSeriesReaction.cxx @@ -19,7 +19,7 @@ void LoadTimeSeriesReaction::onTriggered() loadData(); } -QList LoadTimeSeriesReaction::loadData() +QList LoadTimeSeriesReaction::loadData() { bool isTimeSeries = true; return LoadDataReaction::loadData(isTimeSeries); diff --git a/tomviz/LoadTimeSeriesReaction.h b/tomviz/LoadTimeSeriesReaction.h index b37f009cb..654ee96bc 100644 --- a/tomviz/LoadTimeSeriesReaction.h +++ b/tomviz/LoadTimeSeriesReaction.h @@ -7,7 +7,9 @@ #include namespace tomviz { -class DataSource; +namespace pipeline { +class SourceNode; +} /// LoadTimeSeriesReaction handles the "Open Time Series" action in tomviz. On /// trigger, this will open the data files and perform the necessary subsequent @@ -20,7 +22,7 @@ class LoadTimeSeriesReaction : public pqReaction LoadTimeSeriesReaction(QAction* parentAction); ~LoadTimeSeriesReaction() override; - static QList loadData(); + static QList loadData(); protected: /// Called when the action is triggered. diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index f85c7b352..9594ee111 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -15,8 +15,16 @@ #include #include #include +#include +#include #include +#include +#include +#include +#include +#include + #include "AboutDialog.h" #include "AcquisitionWidget.h" #include "ActiveObjects.h" @@ -30,65 +38,80 @@ #include "DataBroker.h" #include "DataBrokerLoadReaction.h" #include "DataBrokerSaveReaction.h" -#include "DataPropertiesPanel.h" #include "DataTransformMenu.h" #include "FileFormatManager.h" #include "LoadDataReaction.h" #include "LoadPaletteReaction.h" #include "LoadStackReaction.h" #include "LoadTimeSeriesReaction.h" -#include "ModuleManager.h" -#include "ModuleMenu.h" -#include "ModulePropertiesPanel.h" -#include "OperatorFactory.h" -#include "OperatorProxy.h" -#include "PassiveAcquisitionWidget.h" -#include "Pipeline.h" -#include "PipelineManager.h" -#include "PipelineProxy.h" -#include "PipelineSettingsDialog.h" +#include "PipelineModuleMenu.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineExecutor.h" +#include "pipeline/ThreadedExecutor.h" +#include "pipeline/PipelineControlsWidget.h" +#include "pipeline/PipelineStripWidget.h" +#include "pipeline/Node.h" +#include "pipeline/TransformNode.h" +#include "pipeline/OutputPort.h" +#include "pipeline/PassthroughOutputPort.h" +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/PortData.h" +#include "pipeline/PortDataMetadata.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/NodeEditDialog.h" +#include "pipeline/NodePropertiesPanel.h" +#include "pipeline/LinkPropertiesWidget.h" +#include "pipeline/SinkGroupPropertiesWidget.h" +#include "pipeline/SourceNode.h" +#include "pipeline/SinkNode.h" +#include "pipeline/VolumePropertiesWidget.h" +#include "MoleculeProperties.h" +#include "CentralWidget.h" +#include "OperatorSearchDialog.h" #include "ProgressDialogManager.h" #include "PtychoRunner.h" #include "PyXRFRunner.h" -#include "PythonGeneratedDatasetReaction.h" +#include "AddPythonSourceReaction.h" #include "PythonUtilities.h" #include "RecentFilesMenu.h" #include "ReconstructionReaction.h" -#include "RegexGroupSubstitution.h" #include "ResetReaction.h" +#include "SaveDataDialog.h" #include "SaveDataReaction.h" #include "SaveLoadStateReaction.h" #include "SaveLoadTemplateReaction.h" #include "SaveScreenshotReaction.h" #include "SaveWebReaction.h" #include "SetDataTypeReaction.h" -#include "SetTiltAnglesOperator.h" #include "SetTiltAnglesReaction.h" #include "Utilities.h" #include "ViewMenuManager.h" -#include "VolumeManager.h" #include "WelcomeDialog.h" #include "tomvizConfig.h" -#include "PipelineModel.h" - #include #include #include +#include +#include +#include #include -#include #include -#include #include +#include #include +#include #include #include +#include #include #include #include #include #include -#include namespace { QString getAutosaveFile() @@ -102,24 +125,50 @@ QString getAutosaveFile() } return dataDir.absoluteFilePath(".tomviz_autosave.tvsm"); } + +/// Append the trailing "Save Data" section shared by the node and port +/// context menus: a separator, then the action, greyed out when the +/// dialog would have nothing to offer. @a target is whatever +/// SaveDataDialog can be restricted to — a Node or an OutputPort. +template +void addSaveDataAction(QMenu& menu, Target* target, QWidget* parent, + bool enabled) +{ + using tomviz::SaveDataDialog; + + if (!menu.isEmpty()) { + menu.addSeparator(); + } + + auto* action = menu.addAction("Save Data", [target, parent]() { + SaveDataDialog dialog(target, parent); + if (dialog.exec() == QDialog::Accepted) { + SaveDataDialog::writeEntries(dialog.selectedEntries(), parent); + } + }); + action->setEnabled( + enabled && !SaveDataDialog::candidatePorts( + target, SaveDataDialog::Scope::AllPersisted) + .isEmpty()); +} } // namespace class Connection; namespace tomviz { +MainWindow* MainWindow::instance() +{ + for (auto* w : QApplication::topLevelWidgets()) { + auto* mw = qobject_cast(w); + if (mw) + return mw; + } + return nullptr; +} + MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) : QMainWindow(parent, flags), m_ui(new Ui::MainWindow) { - VolumeManager::instance(); - connect(&ModuleManager::instance(), &ModuleManager::enablePythonConsole, this, - &MainWindow::setEnabledPythonConsole); - - connect(&ModuleManager::instance(), &ModuleManager::mouseOverVoxel, this, - &MainWindow::onMouseOverVoxel); - - connect(&ModuleManager::instance(), - &ModuleManager::mostRecentStateFileChanged, this, - &MainWindow::updateSaveStateEnableState); // Update back light azimuth default on view. connect(pqApplicationCore::instance()->getServerManagerModel(), @@ -133,6 +182,24 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // checkOpenGL(); m_ui->setupUi(this); + // Allow docks in the same area to be split in both directions (e.g. the + // Pipelines and Properties docks side by side with a horizontal split, not + // only stacked vertically or tabbed). + setDockNestingEnabled(true); + // Give the dock splitter a visible 1px line (most styles draw it nearly + // invisible). The pipeline scroll area rules reassert its white background: + // setting any stylesheet on the main window otherwise stops the palette-set + // background from being honored, turning the area grey. Target the scroll + // area and its content container by name; a blanket "#pipelineScroll + // QWidget" rule would also match the context menus parented to the strip + // widget, replacing their native rendering with a flat white one. + setStyleSheet(styleSheet() + + QStringLiteral( + "QMainWindow::separator { background: palette(mid);" + " width: 1px; height: 1px; }" + "#pipelineScroll, #pipelineScrollContainer" + " { background: white; }")); + setAcceptDrops(true); // Force full messages to be shown m_ui->outputWidget->showFullMessages(true); m_timer = new QTimer(this); @@ -158,13 +225,6 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // shell. pqPythonShell::setPreamble(QStringList()); - connect(m_ui->pythonConsole, &pqPythonShell::executing, - [this](bool executing) { - if (!executing) { - this->syncPythonToApp(); - } - }); - // Hide these dock widgets when tomviz is first opened. If they are later // opened and remain open while tomviz is shut down, their visibility and // geometry state will be saved out to the settings file. The dock widgets @@ -191,42 +251,385 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) connect(m_ui->outputWidget, &pqOutputWidget::messageDisplayed, this, &MainWindow::handleMessage); - // Link the histogram in the central widget to the active data source. - m_ui->centralWidget->connect( - &ActiveObjects::instance(), &ActiveObjects::transformedDataSourceActivated, - m_ui->centralWidget, &CentralWidget::setActiveColorMapDataSource); - connect(&ActiveObjects::instance(), &ActiveObjects::moduleActivated, - m_ui->centralWidget, &CentralWidget::setActiveModule); - connect(&ActiveObjects::instance(), &ActiveObjects::colorMapChanged, - m_ui->centralWidget, &CentralWidget::setActiveColorMapDataSource); - connect(m_ui->dataPropertiesPanel, &DataPropertiesPanel::colorMapUpdated, - m_ui->centralWidget, &CentralWidget::onColorMapUpdated); - connect(&ActiveObjects::instance(), &ActiveObjects::operatorActivated, - m_ui->centralWidget, &CentralWidget::setActiveOperator); - - m_ui->treeWidget->setModel(new PipelineModel(this)); - m_ui->treeWidget->initLayout(); - - // Ensure that items are expanded by default, can be collapsed at will. - connect(m_ui->treeWidget->model(), &QAbstractItemModel::rowsInserted, - m_ui->treeWidget, &QTreeView::expandAll); - connect(m_ui->treeWidget->model(), &QAbstractItemModel::modelReset, - m_ui->treeWidget, &QTreeView::expandAll); + // The color map / histogram updates are now driven by onNodeSelected() + // and onPortSelected() which call CentralWidget::setActiveSinkNode() + // and CentralWidget::setActiveVolumeData(). + + // Create pipeline controls and strip widgets in the left dock + m_pipelineControls = new pipeline::PipelineControlsWidget(this); + m_ui->pipelineContainerLayout->addWidget(m_pipelineControls); + m_pipelineStrip = new pipeline::PipelineStripWidget(this); + m_pipelineStrip->setSortOrder(pipeline::SortOrder::DepthFirst); + auto* pipelineScroll = new QScrollArea(this); + pipelineScroll->setObjectName("pipelineScroll"); + pipelineScroll->setWidgetResizable(true); + pipelineScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + // pipelineScroll->setFrameShape(QFrame::NoFrame); + QPalette scrollPal = pipelineScroll->palette(); + scrollPal.setColor(QPalette::Window, Qt::white); + pipelineScroll->setPalette(scrollPal); + // Wrap the strip widget in a container with padding so the scroll area + // provides insets on the top, right, and bottom. + auto* scrollContainer = new QWidget(); + scrollContainer->setObjectName("pipelineScrollContainer"); + scrollContainer->setAutoFillBackground(true); + scrollContainer->setPalette(scrollPal); + auto* scrollLayout = new QVBoxLayout(scrollContainer); + scrollLayout->setContentsMargins(0, 4, 4, 4); + scrollLayout->addWidget(m_pipelineStrip); + pipelineScroll->setWidget(scrollContainer); + m_ui->pipelineContainerLayout->addWidget(pipelineScroll); + connect(m_pipelineControls, + &pipeline::PipelineControlsWidget::dimmingToggled, + m_pipelineStrip, + &pipeline::PipelineStripWidget::setDimmingEnabled); + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::nodeSelected, + this, &MainWindow::onNodeSelected); + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::portSelected, + this, &MainWindow::onPortSelected); + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::linkSelected, + this, &MainWindow::onLinkSelected); + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::selectionCleared, + &ActiveObjects::instance(), &ActiveObjects::clearActiveSelection); + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::deleteNodeRequested, + this, [this](pipeline::Node* node) { + auto* p = pipeline(); + if (p && !p->isExecuting()) { + p->removeNode(node); + } + }); + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::deleteLinkRequested, + this, [this](pipeline::Link* link) { + auto* p = pipeline(); + if (p && !p->isExecuting()) { + p->removeLink(link); + } + }); + + // Sync ActiveObjects changes back to the strip widget and properties panel. + // This ensures programmatic setActiveNode/Port/Link calls are reflected. + connect(&ActiveObjects::instance(), &ActiveObjects::activeNodeChanged, + this, &MainWindow::onActiveNodeChanged); + connect(&ActiveObjects::instance(), &ActiveObjects::activePortChanged, + this, &MainWindow::onActivePortChanged); + connect(&ActiveObjects::instance(), &ActiveObjects::activeLinkChanged, + this, &MainWindow::onActiveLinkChanged); + + // Double-click on a node with an editor opens the edit dialog + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::nodeDoubleClicked, + this, [this](pipeline::Node* node) { + if (node && node->hasPropertiesWidget()) { + auto* dlg = new pipeline::NodeEditDialog( + node, pipeline(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->setWindowTitle( + QString("Edit - %1").arg(node->label())); + dlg->show(); + } + }); + + // Link validator: type-compatible, different nodes. If the target input + // already has a link, the existing one will be replaced when the drag + // completes. + m_pipelineStrip->setLinkValidator( + [](pipeline::OutputPort* from, pipeline::InputPort* to) -> bool { + if (from->node() == to->node()) { + return false; + } + if (!pipeline::isPortTypeCompatible(from->type(), + to->acceptedTypes())) { + return false; + } + if (!from->canAcceptLink(to)) { + return false; + } + return true; + }); + + // Create the link when the user completes a drag between ports. + // If the destination is a TransformNode with a custom properties UI and all + // its inputs are now connected, show the dialog before executing. Cancel + // removes the newly created link; OK/Apply executes the pipeline. + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::linkRequested, + this, [this](pipeline::OutputPort* from, pipeline::InputPort* to) { + auto* p = pipeline(); + if (!p) { + return; + } + + // If the target already has a link (e.g. stealing a sink into + // a group), remove the existing link first. + if (to->link()) { + p->removeLink(to->link()); + } + + auto* link = p->createLink(from, to); + if (!link) { + return; + } + + // Check if all inputs on the destination node are connected. + // Don't execute until they are. + auto* destNode = to->node(); + bool allConnected = true; + for (auto* input : destNode->inputPorts()) { + if (!input->link()) { + allConnected = false; + break; + } + } + + if (!allConnected) { + return; + } + + // Auto-open the edit dialog only for nodes the user just + // dropped — i.e. NodeState::New. Reconnecting an existing + // transform (Stale/Current), or wiring up a template-loaded + // transform whose parameters are already set, should not + // pop the dialog. + if (destNode && destNode->hasPropertiesWidget() && + destNode->state() == pipeline::NodeState::New) { + auto* dialog = new pipeline::NodeEditDialog( + destNode, p, this); + connect(dialog, &QDialog::rejected, this, + [p, link]() { p->removeLink(link); }); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); + return; + } + + p->execute(); + }); + + // Leave-group: relink the member to the group's upstream port + connect(m_pipelineStrip, &pipeline::PipelineStripWidget::leaveGroupRequested, + this, &MainWindow::leaveGroup); + + // Context menu on links: delete action + m_pipelineStrip->setLinkMenuProvider( + [this](pipeline::Link* link, QMenu& menu) { + auto* action = menu.addAction("Delete Link", [link]() { + auto* p = qobject_cast(link->parent()); + if (p) { + p->removeLink(link); + } + }); + if (pipeline() && pipeline()->isExecuting()) { + action->setEnabled(false); + } + }); + + // Context menu on nodes: type-specific actions + m_pipelineStrip->setNodeMenuProvider( + [this](pipeline::Node* node, QMenu& menu) { + auto* p = pipeline(); + if (!p) { + return; + } + bool locked = p->isExecuting(); + + // SinkNode not inside a group: offer "Create Group" + auto* sink = qobject_cast(node); + bool inGroup = false; + if (sink) { + for (auto* inPort : sink->inputPorts()) { + if (inPort->link() && + qobject_cast( + inPort->link()->from()->node())) { + inGroup = true; + break; + } + } + } + if (sink && !inGroup) { + auto* action = menu.addAction("Create Group", [p, sink]() { + // Create a SinkGroupNode with matching port types. + auto* group = new pipeline::SinkGroupNode(); + for (auto* inPort : sink->inputPorts()) { + // Use the first accepted type flag as the passthrough type. + pipeline::PortType pt = pipeline::PortType::ImageData; + for (auto t : pipeline::kAllPortTypes) { + if (inPort->acceptedTypes().testFlag(t)) { + pt = t; + break; + } + } + group->addPassthrough(inPort->name(), pt); + } + p->addNode(group); + + // Relink: route sink through the group. If the sink had an + // upstream connection, break it and reconnect via the group. + for (int i = 0; i < sink->inputPorts().size(); ++i) { + auto* sinkInput = sink->inputPorts()[i]; + if (sinkInput->link() && i < group->inputPorts().size()) { + auto* upstream = sinkInput->link()->from(); + p->removeLink(sinkInput->link()); + p->createLink(upstream, group->inputPorts()[i]); + } + // Always connect the sink to the group's output. + if (i < group->outputPorts().size()) { + p->createLink(group->outputPorts()[i], sinkInput); + } + } + p->execute(); + }); + if (locked) { + action->setEnabled(false); + } + } + + // Node inside a group: offer "Leave Group" + for (auto* inPort : node->inputPorts()) { + if (!inPort->link()) { + continue; + } + auto* group = qobject_cast( + inPort->link()->from()->node()); + if (!group) { + continue; + } + auto* action = menu.addAction("Leave Group", [p, node, group]() { + for (auto* inp : node->inputPorts()) { + if (!inp->link()) { + continue; + } + auto* groupPort = inp->link()->from(); + if (groupPort->node() != group) { + continue; + } + int idx = group->outputPorts().indexOf(groupPort); + pipeline::OutputPort* upstream = nullptr; + if (idx >= 0 && idx < group->inputPorts().size()) { + auto* gi = group->inputPorts()[idx]; + if (gi->link()) { + upstream = gi->link()->from(); + } + } + p->removeLink(inp->link()); + if (upstream) { + p->createLink(upstream, inp); + } + break; + } + p->execute(); + }); + if (locked) { + action->setEnabled(false); + } + break; + } + + // Delete node + auto* deleteAction = + menu.addAction("Delete", [p, node]() { p->removeNode(node); }); + if (locked) { + deleteAction->setEnabled(false); + } + + // Save this node's own persisted ports. Kept last, in its own + // trailing section, so it reads as a separate kind of action from + // the graph edits above it. + if (qobject_cast(node) || + qobject_cast(node)) { + addSaveDataAction(menu, node, this, !locked); + } + }); + + // Context menu on ports: per-port persistence override, grouped + // under a "Persistency" submenu so the top-level menu has room for + // upcoming port-level actions. + m_pipelineStrip->setPortMenuProvider( + [this](pipeline::OutputPort* port, QMenu& menu) { + if (!port) { + return; + } + // Passthrough ports (sink-group outputs) forward upstream data + // and pin isPersistent() to false; a persistence choice here + // would silently do nothing, so don't offer one. + if (qobject_cast(port)) { + return; + } + auto* p = pipeline(); + auto* persistencyMenu = menu.addMenu(QStringLiteral("Persistency")); + auto addModeAction = + [persistencyMenu, port, p](const QString& iconPath, + const QString& label, bool persistent, + pipeline::PersistenceMode mode) { + auto* action = persistencyMenu->addAction( + QIcon(iconPath), label, [port, persistent, mode, p]() { + // Enabling persistence: set the medium first so the + // single reconcile triggered by setPersistent runs with + // the target mode already in place. Disabling: skip the + // mode entirely (transient ports ignore it, and setting + // it first would trigger an unnecessary InMemory-branch + // reload on a port that we're about to evict anyway). + if (persistent) { + port->setPersistenceMode(mode); + port->setPersistent(true); + } else { + port->setPersistent(false); + } + // If the user just asked to retain this port's data but + // the data was already deallocated (e.g. previously + // transient and consumed), the only way to obtain it is + // to re-run the producer. Trigger that here at the UI + // layer so the pipeline core stays free of policy. + if (persistent && p && port->node() && + !port->hasData()) { + p->execute(port->node()); + } + }); + action->setCheckable(true); + // Mark the currently-active mode so the user sees what's set. + bool isCurrent = + (port->isPersistent() == persistent) && + (!persistent || port->persistenceMode() == mode); + action->setChecked(isCurrent); + }; + addModeAction(QStringLiteral(":/pipeline/port_persistent_ram.svg"), + QStringLiteral("Persist in Memory"), true, + pipeline::PersistenceMode::InMemory); + addModeAction(QStringLiteral(":/pipeline/port_persistent_disk.svg"), + QStringLiteral("Persist on Disk"), true, + pipeline::PersistenceMode::OnDisk); + addModeAction(QStringLiteral(":/pipeline/port_transient.svg"), + QStringLiteral("Transient"), false, + pipeline::PersistenceMode::InMemory); + + addSaveDataAction(menu, port, this, !(p && p->isExecuting())); + }); + + // Sync tip output port from ActiveObjects to the strip widget and colormap + connect(&ActiveObjects::instance(), + &ActiveObjects::activeTipOutputPortChanged, + m_pipelineStrip, + &pipeline::PipelineStripWidget::setTipOutputPort); + connect(&ActiveObjects::instance(), + &ActiveObjects::activeTipOutputPortChanged, + this, [this](pipeline::OutputPort* port) { + disconnect(m_tipDataChangedConn); + disconnect(m_tipMetadataChangedConn); + if (port) { + m_tipDataChangedConn = connect( + port, &pipeline::OutputPort::dataChanged, + this, &MainWindow::scheduleColorMapDisplayUpdate); + m_tipMetadataChangedConn = connect( + port, &pipeline::OutputPort::metadataChanged, + this, &MainWindow::scheduleColorMapDisplayUpdate); + } + updateColorMapDisplay(); + }); + + // Create the single application pipeline + initPipeline(); // connect quit. connect(m_ui->actionExit, &QAction::triggered, this, &MainWindow::close); - // Connect up the module/data changed to the appropriate slots. - connect(&ActiveObjects::instance(), &ActiveObjects::dataSourceActivated, this, - &MainWindow::dataSourceChanged); - connect(&ActiveObjects::instance(), &ActiveObjects::moleculeSourceActivated, - this, &MainWindow::moleculeSourceChanged); - connect(&ActiveObjects::instance(), &ActiveObjects::moduleActivated, this, - &MainWindow::moduleChanged); - connect(&ActiveObjects::instance(), &ActiveObjects::operatorActivated, this, - &MainWindow::operatorChanged); - connect(&ActiveObjects::instance(), &ActiveObjects::resultActivated, this, - &MainWindow::operatorResultChanged); + // Panel switching is now handled by onNodeSelected() / onPortSelected() + // which are connected to the PipelineStripWidget signals above. // Connect the about dialog up too. connect(m_ui->actionAbout, &QAction::triggered, this, @@ -250,12 +653,12 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) auto dataBrokerSaveReaction = new DataBrokerSaveReaction(m_ui->actionExportToDataBroker, this); - // Workflows menu + // Sources menu auto pyXRFRunner = new PyXRFRunner(this); - connect(m_ui->actionPyXRFWorkflow, &QAction::triggered, pyXRFRunner, + connect(m_ui->actionPyXRFSource, &QAction::triggered, pyXRFRunner, &PyXRFRunner::start); auto ptychoRunner = new PtychoRunner(this); - connect(m_ui->actionPtychoWorkflow, &QAction::triggered, ptychoRunner, + connect(m_ui->actionPtychoSource, &QAction::triggered, ptychoRunner, &PtychoRunner::start); // Build Data Transforms menu @@ -263,9 +666,10 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // Create the custom transforms menu m_customTransformsMenu = new QMenu("Custom Transforms", this); - m_customTransformsMenu->setEnabled(false); m_ui->menubar->insertMenu(m_ui->menuModules->menuAction(), m_customTransformsMenu); + connect(m_customTransformsMenu, &QMenu::aboutToShow, this, + [this]() { registerCustomOperators(findCustomOperators()); }); // Create the pipeline templates menu m_pipelineTemplates = new QMenu("Pipeline templates", this); @@ -274,10 +678,6 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // Populate the menu with templates findPipelineTemplates(); - // Register our factories for Python wrapping. - OperatorProxyFactory::registerWithFactory(); - PipelineProxyFactory::registerWithFactory(); - // Build Tomography menu // ################################################################ QAction* setVolumeDataTypeAction = @@ -292,203 +692,239 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) m_ui->menuTomography->addAction("Set Tilt Angles"); m_ui->menuTomography->addSeparator(); - QAction* dataProcessingLabel = - m_ui->menuTomography->addAction("Pre-processing:"); - dataProcessingLabel->setEnabled(false); + // === Pre-processing submenu === + QMenu* preprocessingMenu = m_ui->menuTomography->addMenu("Pre-processing"); QAction* downsampleByTwoAction = - m_ui->menuTomography->addAction("Bin Tilt Images x2"); + preprocessingMenu->addAction("Bin Tilt Images x2"); QAction* removeBadPixelsAction = - m_ui->menuTomography->addAction("Remove Bad Pixels"); + preprocessingMenu->addAction("Remove Bad Pixels"); QAction* gaussianFilterAction = - m_ui->menuTomography->addAction("Gaussian Filter"); + preprocessingMenu->addAction("Gaussian Filter"); QAction* autoSubtractBackgroundAction = - m_ui->menuTomography->addAction("Background Subtraction (Auto)"); + preprocessingMenu->addAction("Background Subtraction (Auto)"); QAction* subtractBackgroundAction = - m_ui->menuTomography->addAction("Background Subtraction (Manual)"); + preprocessingMenu->addAction("Background Subtraction (Manual)"); QAction* normalizationAction = - m_ui->menuTomography->addAction("Normalize Average Image Intensity"); + preprocessingMenu->addAction("Normalize Average Image Intensity"); QAction* gradientMagnitude2DSobelAction = - m_ui->menuTomography->addAction("2D Gradient Magnitude"); - QAction* ctfCorrectAction = m_ui->menuTomography->addAction("CTF Correction"); + preprocessingMenu->addAction("2D Gradient Magnitude"); + QAction* ctfCorrectAction = + preprocessingMenu->addAction("CTF Correction"); - m_ui->menuTomography->addSeparator(); - QAction* alignmentLabel = m_ui->menuTomography->addAction("Alignment:"); - alignmentLabel->setEnabled(false); - QAction* autoAlignCCAction = m_ui->menuTomography->addAction( + // === Alignment submenu === + QMenu* alignmentMenu = m_ui->menuTomography->addMenu("Alignment"); + QAction* autoAlignCCAction = alignmentMenu->addAction( "Image Alignment (Auto: Cross Correlation)"); QAction* autoAlignCOMAction = - m_ui->menuTomography->addAction("Image Alignment (Auto: Center of Mass)"); + alignmentMenu->addAction("Image Alignment (Auto: Center of Mass)"); QAction* autoAlignPyStackRegAction = - m_ui->menuTomography->addAction("Image Alignment (Auto: PyStackReg)"); + alignmentMenu->addAction("Image Alignment (Auto: PyStackReg)"); QAction* alignAction = - m_ui->menuTomography->addAction("Image Alignment (Manual)"); + alignmentMenu->addAction("Image Alignment (Manual)"); + alignmentMenu->addSeparator(); QAction* autoRotateAlignAction = - m_ui->menuTomography->addAction("Tilt Axis Rotation Alignment (Auto)"); + alignmentMenu->addAction("Tilt Axis Rotation Alignment (Auto)"); QAction* autoRotateAlignShiftAction = - m_ui->menuTomography->addAction("Tilt Axis Shift Alignment (Auto)"); + alignmentMenu->addAction("Tilt Axis Shift Alignment (Auto)"); QAction* rotateAlignAction = - m_ui->menuTomography->addAction("Tilt Axis Alignment (Manual)"); + alignmentMenu->addAction("Tilt Axis Alignment (Manual)"); QAction* shiftRotationCenterAction = - m_ui->menuTomography->addAction("Shift Rotation Center (Manual)"); - m_ui->menuTomography->addSeparator(); + alignmentMenu->addAction("Shift Rotation Center (Manual)"); - QAction* reconLabel = m_ui->menuTomography->addAction("Reconstruction:"); - reconLabel->setEnabled(false); + // === Reconstruction submenu === + QMenu* reconstructionMenu = m_ui->menuTomography->addMenu("Reconstruction"); QAction* reconDFMAction = - m_ui->menuTomography->addAction("Direct Fourier Method"); + reconstructionMenu->addAction("Direct Fourier Method"); QAction* reconWBPAction = - m_ui->menuTomography->addAction("Weighted Back Projection"); + reconstructionMenu->addAction("Weighted Back Projection"); QAction* reconWBP_CAction = - m_ui->menuTomography->addAction("Simple Back Projection (C++)"); - QAction* reconARTAction = - m_ui->menuTomography->addAction("Algebraic Reconstruction Technique (ART)"); - QAction* reconSIRTAction = m_ui->menuTomography->addAction( + reconstructionMenu->addAction("Simple Back Projection (C++)"); + QAction* reconARTAction = reconstructionMenu->addAction( + "Algebraic Reconstruction Technique (ART)"); + QAction* reconSIRTAction = reconstructionMenu->addAction( "Simultaneous Iterative Recon. Technique (SIRT)"); QAction* reconDFMConstraintAction = - m_ui->menuTomography->addAction("Constraint-based Direct Fourier Method"); + reconstructionMenu->addAction("Constraint-based Direct Fourier Method"); QAction* reconTVMinimizationAction = - m_ui->menuTomography->addAction("TV Minimization Method"); + reconstructionMenu->addAction("TV Minimization Method"); QAction* reconTomoPyGridRecAction = - m_ui->menuTomography->addAction("TomoPy Reconstruction"); - m_ui->menuTomography->addSeparator(); + reconstructionMenu->addAction("TomoPy Reconstruction"); - QAction* simulationLabel = - m_ui->menuTomography->addAction("Simulation and Demonstrations:"); - simulationLabel->setEnabled(false); + // === Simulation submenu === + QMenu* simulationMenu = + m_ui->menuTomography->addMenu("Simulation && Demonstrations"); QAction* generateTiltSeriesAction = - m_ui->menuTomography->addAction("Project Tilt Series from Volume"); - + simulationMenu->addAction("Project Tilt Series from Volume"); QAction* randomShiftsAction = - m_ui->menuTomography->addAction("Shift Tilt Series Randomly"); + simulationMenu->addAction("Shift Tilt Series Randomly"); QAction* reconRealTimeAction = - m_ui->menuTomography->addAction("Initialize Real-Time Tomography"); + simulationMenu->addAction("Initialize Real-Time Tomography"); QAction* addPoissonNoiseAction = - m_ui->menuTomography->addAction("Add Poisson Noise"); + simulationMenu->addAction("Add Poisson Noise"); // Set up reactions for Tomography Menu //################################################################# - new SetDataTypeReaction(setVolumeDataTypeAction, this, DataSource::Volume); - new SetDataTypeReaction(setTiltDataTypeAction, this, DataSource::TiltSeries); - // new SetDataTypeReaction(setFibDataTypeAction, this, DataSource::FIB); + new SetDataTypeReaction(setVolumeDataTypeAction, this, + pipeline::PortType::Volume); + new SetDataTypeReaction(setTiltDataTypeAction, this, + pipeline::PortType::TiltSeries); new SetTiltAnglesReaction(setTiltAnglesAction, this); new AddPythonTransformReaction( generateTiltSeriesAction, "Generate Tilt Series", - readInPythonScript("GenerateTiltSeries"), false, true, false, + readInPythonScript("GenerateTiltSeries"), readInJSONDescription("GenerateTiltSeries")); new AddAlignReaction(alignAction); new AddPythonTransformReaction(downsampleByTwoAction, "Bin Tilt Image x2", - readInPythonScript("BinTiltSeriesByTwo"), - false, false, false); + readInPythonScript("BinTiltSeriesByTwo")); new AddPythonTransformReaction( removeBadPixelsAction, "Remove Bad Pixels", - readInPythonScript("RemoveBadPixelsTiltSeries"), false, false, false); + readInPythonScript("RemoveBadPixelsTiltSeries")); new AddPythonTransformReaction( gaussianFilterAction, "Gaussian Filter Tilt Series", - readInPythonScript("GaussianFilterTiltSeries"), false, false, false, + readInPythonScript("GaussianFilterTiltSeries"), readInJSONDescription("GaussianFilterTiltSeries")); new AddPythonTransformReaction( autoSubtractBackgroundAction, "Background Subtraction (Auto)", - readInPythonScript("Subtract_TiltSer_Background_Auto"), false, false, - false); + readInPythonScript("Subtract_TiltSer_Background_Auto")); new AddPythonTransformReaction( subtractBackgroundAction, "Background Subtraction (Manual)", - readInPythonScript("Subtract_TiltSer_Background"), false, false, false); + readInPythonScript("Subtract_TiltSer_Background")); new AddPythonTransformReaction(normalizationAction, "Normalize Tilt Series", - readInPythonScript("NormalizeTiltSeries"), - false, false, false); + readInPythonScript("NormalizeTiltSeries")); new AddPythonTransformReaction( gradientMagnitude2DSobelAction, "Gradient Magnitude 2D", - readInPythonScript("GradientMagnitude2D_Sobel"), false, false, false); + readInPythonScript("GradientMagnitude2D_Sobel")); new AddPythonTransformReaction(ctfCorrectAction, "CTF Correction", - readInPythonScript("ctf_correct"), true, false, - false, readInJSONDescription("ctf_correct")); + readInPythonScript("ctf_correct"), + readInJSONDescription("ctf_correct")); new AddPythonTransformReaction( rotateAlignAction, "Tilt Axis Alignment (manual)", - readInPythonScript("RotationAlign"), true, false, false, + readInPythonScript("RotationAlign"), readInJSONDescription("RotationAlign")); new AddPythonTransformReaction( autoRotateAlignAction, "Auto Tilt Axis Align", - readInPythonScript("AutoTiltAxisRotationAlignment"), true, false, false, + readInPythonScript("AutoTiltAxisRotationAlignment"), readInJSONDescription("AutoTiltAxisRotationAlignment")); new AddPythonTransformReaction( autoRotateAlignShiftAction, "Auto Tilt Axis Shift Align", - readInPythonScript("AutoTiltAxisShiftAlignment"), true, false, false, + readInPythonScript("AutoTiltAxisShiftAlignment"), readInJSONDescription("AutoTiltAxisShiftAlignment")); new AddPythonTransformReaction( autoAlignCCAction, "Auto Tilt Image Align (XCORR)", - readInPythonScript("AutoCrossCorrelationTiltImageAlignment"), false, false, - false, readInJSONDescription("AutoCrossCorrelationTiltImageAlignment")); + readInPythonScript("AutoCrossCorrelationTiltImageAlignment"), + readInJSONDescription("AutoCrossCorrelationTiltImageAlignment")); new AddPythonTransformReaction( autoAlignCOMAction, "Auto Tilt Image Align (CoM)", - readInPythonScript("AutoCenterOfMassTiltImageAlignment"), false, false, - false, readInJSONDescription("AutoCenterOfMassTiltImageAlignment")); + readInPythonScript("AutoCenterOfMassTiltImageAlignment"), + readInJSONDescription("AutoCenterOfMassTiltImageAlignment")); new AddPythonTransformReaction( autoAlignPyStackRegAction, "Auto Tilt Image Align (PyStackReg)", - readInPythonScript("PyStackRegImageAlignment"), false, false, - false, readInJSONDescription("PyStackRegImageAlignment")); + readInPythonScript("PyStackRegImageAlignment"), + readInJSONDescription("PyStackRegImageAlignment")); new AddPythonTransformReaction( shiftRotationCenterAction, "Shift Rotation Center", - readInPythonScript("ShiftRotationCenter_tomopy"), true, false, false, + readInPythonScript("ShiftRotationCenter_tomopy"), readInJSONDescription("ShiftRotationCenter_tomopy")); new AddPythonTransformReaction(reconDFMAction, "Reconstruct (Direct Fourier)", - readInPythonScript("Recon_DFT"), true, false, - false, readInJSONDescription("Recon_DFT")); + readInPythonScript("Recon_DFT"), + readInJSONDescription("Recon_DFT")); new AddPythonTransformReaction(reconWBPAction, "Reconstruct (Back Projection)", - readInPythonScript("Recon_WBP"), true, false, - false, readInJSONDescription("Recon_WBP")); + readInPythonScript("Recon_WBP"), + readInJSONDescription("Recon_WBP")); new AddPythonTransformReaction(reconARTAction, "Reconstruct (ART)", - readInPythonScript("Recon_ART"), true, false, - false, readInJSONDescription("Recon_ART")); + readInPythonScript("Recon_ART"), + readInJSONDescription("Recon_ART")); new AddPythonTransformReaction(reconSIRTAction, "Reconstruct (SIRT)", - readInPythonScript("Recon_SIRT"), true, false, - false, readInJSONDescription("Recon_SIRT")); + readInPythonScript("Recon_SIRT"), + readInJSONDescription("Recon_SIRT")); new AddPythonTransformReaction( reconDFMConstraintAction, "Reconstruct (Constraint-based Direct Fourier)", - readInPythonScript("Recon_DFT_constraint"), true, false, false, + readInPythonScript("Recon_DFT_constraint"), readInJSONDescription("Recon_DFT_constraint")); new AddPythonTransformReaction( reconTVMinimizationAction, "Reconstruct (TV Minimization)", - readInPythonScript("Recon_TV_minimization"), true, false, false, + readInPythonScript("Recon_TV_minimization"), readInJSONDescription("Recon_TV_minimization")); new AddPythonTransformReaction( reconTomoPyGridRecAction, "Reconstruct (TomoPy)", - readInPythonScript("Recon_tomopy"), true, false, false, + readInPythonScript("Recon_tomopy"), readInJSONDescription("Recon_tomopy")); new ReconstructionReaction(reconWBP_CAction); new AddPythonTransformReaction( randomShiftsAction, "Shift Tilt Series Randomly", - readInPythonScript("ShiftTiltSeriesRandomly"), true, false, false, + readInPythonScript("ShiftTiltSeriesRandomly"), readInJSONDescription("ShiftTiltSeriesRandomly")); new AddPythonTransformReaction( reconRealTimeAction, "Initialize Real-Time Tomography", - readInPythonScript("Recon_real_time_tomography"), true, false, false, + readInPythonScript("Recon_real_time_tomography"), readInJSONDescription("Recon_real_time_tomography")); new AddPythonTransformReaction(addPoissonNoiseAction, "Add Poisson Noise", - readInPythonScript("AddPoissonNoise"), true, - false, false, + readInPythonScript("AddPoissonNoise"), readInJSONDescription("AddPoissonNoise")); //################################################################# - new ModuleMenu(m_ui->modulesToolbar, m_ui->menuModules, this); + + // Set up operator search dialog + m_operatorSearchDialog = new OperatorSearchDialog(this); + m_operatorSearchDialog->collectActionsFromMenu(m_ui->menuData, + "Data Transforms"); + m_operatorSearchDialog->collectActionsFromMenu(m_ui->menuSegmentation, + "Segmentation"); + m_operatorSearchDialog->collectActionsFromMenu(m_ui->menuTomography, + "Tomography"); + + // Add "Search Operators..." to each operator menu (like ParaView does for + // Sources, Filters, and Extractors) + auto showSearch = [this]() { + m_operatorSearchDialog->show(); + m_operatorSearchDialog->raise(); + m_operatorSearchDialog->activateWindow(); + }; + + for (auto* menu : + { m_ui->menuData, m_ui->menuSegmentation, m_ui->menuTomography }) { + // Show "Ctrl+Space" text on all three, but don't set a real shortcut + // to avoid ambiguity. The global shortcut below handles the actual key. + auto* searchAction = new QAction("Search Operators...", menu); + searchAction->setShortcut(QKeySequence("Ctrl+Space")); + searchAction->setShortcutContext(Qt::WidgetShortcut); + connect(searchAction, &QAction::triggered, this, showSearch); + QAction* firstAction = menu->actions().isEmpty() ? nullptr + : menu->actions().first(); + menu->insertAction(firstAction, searchAction); + menu->insertSeparator(firstAction); + } + + // Global shortcut for Ctrl+Space to open the search dialog + auto* globalSearchShortcut = new QShortcut(QKeySequence("Ctrl+Space"), this); + connect(globalSearchShortcut, &QShortcut::activated, this, showSearch); + + new PipelineModuleMenu(m_ui->modulesToolbar, m_ui->menuModules, this); new RecentFilesMenu(*m_ui->menuRecentlyOpened, m_ui->menuRecentlyOpened); new SaveDataReaction(m_ui->actionSaveData); new SaveScreenshotReaction(m_ui->actionSaveScreenshot, this); new pqSaveAnimationReaction(m_ui->actionSaveMovie); - new SaveWebReaction(m_ui->actionSaveWeb, this); + // FIXME: staged for removal + m_ui->actionSaveWeb->setVisible(false); new SaveLoadStateReaction(m_ui->actionLoadState, /*load*/ true); new SaveLoadStateReaction(m_ui->actionSaveStateAs); connect(m_ui->actionSaveState, &QAction::triggered, this, &MainWindow::saveState); + connect(m_ui->actionLoadTemplate, &QAction::triggered, this, + []() { SaveLoadTemplateReaction::loadTemplateWithDialog(); }); + connect(m_ui->actionSaveTemplateAs, &QAction::triggered, this, + []() { SaveLoadTemplateReaction::saveTemplateAs(); }); + auto reaction = new ResetReaction(m_ui->actionReset); connect(m_ui->menu_File, &QMenu::aboutToShow, reaction, &ResetReaction::updateEnableState); @@ -503,27 +939,46 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) connect(userGuideAction, &QAction::triggered, this, &MainWindow::openUserGuide); QAction* introAction = m_ui->menuHelp->addAction("Intro to 3D Visualization"); connect(introAction, &QAction::triggered, this, &MainWindow::openVisIntro); -#ifdef TOMVIZ_DATA - QAction* reconAction = - sampleDataMenu->addAction("Star Nanoparticle (Reconstruction)"); - QAction* tiltAction = - sampleDataMenu->addAction("Star Nanoparticle (Tilt Series)"); - connect(reconAction, &QAction::triggered, this, &MainWindow::openRecon); - connect(tiltAction, &QAction::triggered, this, &MainWindow::openTilt); - sampleDataMenu->addSeparator(); -#endif + // Only show the bundled sample data entries if the data files are actually + // present. The conda-forge package ships without them, while the packaged + // installers (DMG/MSI) include them, so this lets a single binary work both + // ways without a compile-time flag. + QString dataDir = + QApplication::applicationDirPath() + "/../share/tomviz/Data"; + bool haveRecon = + QFileInfo(dataDir + "/Recon_NanoParticle_doi_10.1021-nl103400a.emd") + .exists(); + bool haveTilt = + QFileInfo(dataDir + "/TiltSeries_NanoParticle_doi_10.1021-nl103400a.emd") + .exists(); + if (haveRecon) { + QAction* reconAction = + sampleDataMenu->addAction("Star Nanoparticle (Reconstruction)"); + connect(reconAction, &QAction::triggered, this, &MainWindow::openRecon); + } + if (haveTilt) { + QAction* tiltAction = + sampleDataMenu->addAction("Star Nanoparticle (Tilt Series)"); + connect(tiltAction, &QAction::triggered, this, &MainWindow::openTilt); + } + if (haveRecon || haveTilt) { + sampleDataMenu->addSeparator(); + } QAction* constantDataAction = sampleDataMenu->addAction("Generate Constant Dataset"); - new PythonGeneratedDatasetReaction(constantDataAction, "Constant Dataset", - readInPythonScript("ConstantDataset")); + new AddPythonSourceReaction(constantDataAction, + readInPythonScript("ConstantDataset"), + readInJSONDescription("ConstantDataset")); QAction* randomParticlesAction = sampleDataMenu->addAction("Generate Random Particles"); - new PythonGeneratedDatasetReaction(randomParticlesAction, "Random Particles", - readInPythonScript("RandomParticles")); + new AddPythonSourceReaction(randomParticlesAction, + readInPythonScript("RandomParticles"), + readInJSONDescription("RandomParticles")); QAction* probeShapeAction = sampleDataMenu->addAction("Generate Electron Beam Shape"); - new PythonGeneratedDatasetReaction(probeShapeAction, "Electron Beam Shape", - readInPythonScript("STEM_probe")); + new AddPythonSourceReaction(probeShapeAction, + readInPythonScript("STEM_probe"), + readInJSONDescription("STEM_probe")); sampleDataMenu->addSeparator(); QAction* sampleDataLinkAction = sampleDataMenu->addAction("Download More Datasets"); @@ -543,12 +998,9 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) AxesReaction::addAllActionsToToolBar(m_ui->utilitiesToolbar); ResetReaction::reset(); - // Initialize worker manager - new ProgressDialogManager(this); // Add the acquisition client experimentally. m_ui->actionAcquisition->setEnabled(false); - m_ui->actionPassiveAcquisition->setEnabled(false); connect(m_ui->actionAcquisition, &QAction::triggered, this, [this]() { openDialog(&m_acquisitionWidget); }); @@ -557,73 +1009,55 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) openDialog(&m_animationHelperDialog); }); - connect(m_ui->actionPassiveAcquisition, &QAction::triggered, this, [this]() { - openDialog(&m_passiveAcquisitionDialog); - }); - - auto pipelineSettingsDialog = new PipelineSettingsDialog(this); - connect(m_ui->actionPipelineSettings, &QAction::triggered, - pipelineSettingsDialog, &QWidget::show); - // Prepopulate the previously seen python readers/writers // This operation is fast since it fetches the readers description // from the settings, without really invoking python FileFormatManager::instance().prepopulatePythonReaders(); FileFormatManager::instance().prepopulatePythonWriters(); - // Async initialize python - statusBar()->showMessage("Initializing python..."); - auto pythonWatcher = new QFutureWatcher>; - connect(pythonWatcher, &QFutureWatcherBase::finished, this, - [this, pyXRFRunner, ptychoRunner, pythonWatcher, dataBrokerSaveReaction]() { - m_ui->actionAcquisition->setEnabled(true); - m_ui->actionPassiveAcquisition->setEnabled(true); - registerCustomOperators(pythonWatcher->result()); - // Check if we have DataBroker and enable menu if we do - auto dataBroker = new DataBroker(this); - m_ui->actionImportFromDataBroker->setEnabled( - dataBroker->installed()); - m_ui->actionExportToDataBroker->setEnabled( - dataBroker->installed() && - ActiveObjects::instance().activeDataSource() != nullptr); - dataBrokerSaveReaction->setDataBrokerInstalled( - dataBroker->installed()); - dataBroker->deleteLater(); - - bool installed = pyXRFRunner->isInstalled(); - m_ui->actionPyXRFWorkflow->setEnabled(installed); - if (!installed) { - // Grab the import error and show it in the tooltip - QString tooltip = "Failed to import required modules. " - "Error message was:\n\n" + - pyXRFRunner->importError(); - m_ui->actionPyXRFWorkflow->setToolTip(tooltip); - } + // Initialize python synchronously (splash screen stays up until done) + auto operators = initPython(); - installed = ptychoRunner->isInstalled(); - m_ui->actionPtychoWorkflow->setEnabled(installed); - if (!installed) { - // Grab the import error and show it in the tooltip - QString tooltip = "Failed to import required modules. " - "Error message was:\n\n" + - ptychoRunner->importError(); - m_ui->actionPtychoWorkflow->setToolTip(tooltip); - } + m_ui->actionAcquisition->setEnabled(true); + registerCustomOperators(operators); - delete pythonWatcher; - statusBar()->showMessage("Initialization complete", 1500); - }); + auto dataBroker = new DataBroker(this); + m_ui->actionImportFromDataBroker->setEnabled(dataBroker->installed()); + m_ui->actionExportToDataBroker->setEnabled( + dataBroker->installed() && + ActiveObjects::instance().activeNode() != nullptr); + dataBrokerSaveReaction->setDataBrokerInstalled(dataBroker->installed()); + dataBroker->deleteLater(); + + // The PyXRF and Ptycho python modules are deliberately not imported at + // startup. Their runners check their requirements when the actions are + // triggered, so broken optional dependencies cannot break launch. - auto pythonFuture = QtConcurrent::run(initPython); - pythonWatcher->setFuture(pythonFuture); + // Snapshot existing dock widgets before loading plugin dock widgets + auto currentDocks = findChildren(); + QSet existingDocks(currentDocks.begin(), currentDocks.end()); - // Add plugin dock widgets when a plugin is loaded + // Add plugin dock widgets when a plugin is loaded. new pqPluginDockWidgetsBehavior(this); + + // On first launch (no saved window state), ParaView plugin dock widgets + // (e.g., Node Editor) appear visible by default. Hide any that were added + // by plugins. pqPersistentMainWindowStateBehavior will restore their + // visibility on subsequent launches if the user opened them. + QTimer::singleShot(0, this, [this, existingDocks]() { + QSettings* settings = pqApplicationCore::instance()->settings(); + if (!settings->contains("MainWindow/Geometry")) { + for (auto* dock : findChildren()) { + if (!existingDocks.contains(dock)) { + dock->hide(); + } + } + } + }); } MainWindow::~MainWindow() { - ModuleManager::instance().reset(); QString autosaveFile = getAutosaveFile(); if (QFile::exists(autosaveFile) && !QFile::remove(autosaveFile)) { std::cerr << "Failed to remove autosave file." << std::endl; @@ -634,7 +1068,6 @@ std::vector MainWindow::initPython() { Python::initialize(); Connection::registerType(); - RegexGroupSubstitution::registerType(); auto operators = findCustomOperators(); FileFormatManager::instance().registerPythonReaders(); FileFormatManager::instance().registerPythonWriters(); @@ -737,115 +1170,45 @@ void MainWindow::openVisIntro() openUrl(link); } -void MainWindow::dataSourceChanged(DataSource* dataSource) -{ - m_ui->propertiesPanelStackedWidget->setCurrentWidget( - m_ui->dataPropertiesScrollArea); - if (dataSource) { - bool canAdd = !dataSource->pipeline()->editingOperators(); - m_ui->menuData->setEnabled(canAdd); - m_ui->menuSegmentation->setEnabled(canAdd); - m_ui->menuTomography->setEnabled(canAdd); - m_customTransformsMenu->setEnabled(canAdd); - foreach(QAction* action, m_pipelineTemplates->actions()) { - action->setEnabled(canAdd); - } - } -} - -void MainWindow::moleculeSourceChanged(MoleculeSource*) -{ - m_ui->propertiesPanelStackedWidget->setCurrentWidget( - m_ui->moleculePropertiesScrollArea); -} +// Panel switching is now handled by onNodeSelected() / onPortSelected() -void MainWindow::moduleChanged(Module*) -{ - m_ui->propertiesPanelStackedWidget->setCurrentWidget( - m_ui->modulePropertiesScrollArea); -} - -void MainWindow::operatorChanged(Operator*) +void MainWindow::showEvent(QShowEvent* e) { - m_ui->propertiesPanelStackedWidget->setCurrentWidget( - m_ui->operatorPropertiesScrollArea); + QMainWindow::showEvent(e); + if (m_isFirstShow) { + m_isFirstShow = false; + QTimer::singleShot(1, this, &MainWindow::onFirstWindowShow); + } } -void MainWindow::operatorResultChanged(OperatorResult* res) +void MainWindow::dragEnterEvent(QDragEnterEvent* e) { - if (res) { - m_ui->propertiesPanelStackedWidget->setCurrentWidget( - m_ui->operatorResultPropertiesScrollArea); + if (e->mimeData()->hasUrls()) { + e->acceptProposedAction(); } } -void MainWindow::importCustomTransform() +void MainWindow::dropEvent(QDropEvent* e) { - QStringList filters; - filters << "Python (*.py)"; - - QFileDialog dialog(this); - dialog.setFileMode(QFileDialog::ExistingFile); - dialog.setNameFilters(filters); - dialog.setObjectName("ImportCustomTransform-tomviz"); - dialog.setAcceptMode(QFileDialog::AcceptOpen); - - if (dialog.exec() == QDialog::Accepted) { - QStringList filePaths = dialog.selectedFiles(); - QString format = dialog.selectedNameFilter(); - QFileInfo fileInfo(filePaths[0]); - QString filePath = fileInfo.absolutePath(); - QString fileBaseName = fileInfo.baseName(); - QString pythonSourcePath = QString("%1%2%3.py") - .arg(filePath) - .arg(QDir::separator()) - .arg(fileBaseName); - QString jsonSourcePath = QString("%1%2%3.json") - .arg(filePath) - .arg(QDir::separator()) - .arg(fileBaseName); - - // Get the path to Tomviz - QString path = tomviz::userDataPath(); - if (path.isEmpty()) { - return; + for (const auto& url : e->mimeData()->urls()) { + if (!url.isLocalFile()) { + continue; } - - // Copy the Python file to the tomviz directory if it exists - QFileInfo pythonFileInfo(pythonSourcePath); - if (pythonFileInfo.exists()) { - QString pythonDestPath = - QString("%1%2%3.py").arg(path).arg(QDir::separator()).arg(fileBaseName); - QFile::copy(pythonSourcePath, pythonDestPath); - - // Copy the JSON file if it exists. - QFileInfo jsonFileInfo(jsonSourcePath); - if (jsonFileInfo.exists()) { - QString jsonDestPath = QString("%1%2%3.json") - .arg(path) - .arg(QDir::separator()) - .arg(fileBaseName); - QFile::copy(jsonSourcePath, jsonDestPath); - } - - // Register custom operators again. - registerCustomOperators(findCustomOperators()); + QString path = url.toLocalFile(); + QString suffix = QFileInfo(path).suffix().toLower(); + if (suffix == "tvsm" || suffix == "tvh5") { + SaveLoadStateReaction::loadState(path); + } else { + LoadDataReaction::loadData(path); } } -} - -void MainWindow::showEvent(QShowEvent* e) -{ - QMainWindow::showEvent(e); - if (m_isFirstShow) { - m_isFirstShow = false; - QTimer::singleShot(1, this, &MainWindow::onFirstWindowShow); - } + e->acceptProposedAction(); } void MainWindow::closeEvent(QCloseEvent* e) { - if (ModuleManager::instance().hasRunningOperators()) { + auto* activePipeline = ActiveObjects::instance().pipeline(); + if (activePipeline && activePipeline->isExecuting()) { QMessageBox::StandardButton response = QMessageBox::question(this, "Close tomviz?", "You have transforms that are not completed " @@ -856,7 +1219,7 @@ void MainWindow::closeEvent(QCloseEvent* e) e->ignore(); return; } - } else if (ModuleManager::instance().hasDataSources()) { + } else if (activePipeline && !activePipeline->nodes().isEmpty()) { QMessageBox::StandardButton response = QMessageBox::question(this, "Close?", "Are you sure you want to exit?"); if (response == QMessageBox::No) { @@ -867,10 +1230,9 @@ void MainWindow::closeEvent(QCloseEvent* e) // This is a little hackish, but we must ensure all PV proxy unregister calls // happen early enough in application destruction that the ParaView proxy // management code can still run without segfaulting. - PipelineManager::instance().removeAllPipelines(); - ModuleManager::instance().removeAllModules(); - ModuleManager::instance().removeAllDataSources(); - ModuleManager::instance().removeAllMoleculeSources(); + if (activePipeline) { + activePipeline->clear(); + } e->accept(); } @@ -931,7 +1293,16 @@ void MainWindow::autosave() QString MainWindow::mostRecentStateFile() const { - return ModuleManager::instance().mostRecentStateFile(); + return m_mostRecentStateFile; +} + +void MainWindow::setMostRecentStateFile(const QString& fileName) +{ + if (m_mostRecentStateFile == fileName) { + return; + } + m_mostRecentStateFile = fileName; + updateSaveStateEnableState(); } void MainWindow::updateSaveStateEnableState() @@ -1032,79 +1403,104 @@ void MainWindow::handleMessage(const QString&, int type) void MainWindow::registerCustomOperators( std::vector operators) { - // Always create the Custom Transforms menu so that it is possible to import - // new operators. m_customTransformsMenu->clear(); - QAction* importCustomTransformAction = - m_customTransformsMenu->addAction("Import Custom Transform..."); - m_customTransformsMenu->addSeparator(); - connect(importCustomTransformAction, &QAction::triggered, this, - &MainWindow::importCustomTransform); - - if (!operators.empty()) { - for (const OperatorDescription& op : operators) { - QAction* action = m_customTransformsMenu->addAction(op.label); - action->setEnabled(op.valid); - if (!op.loadError.isNull()) { - qWarning().noquote() - << QString("An error occurred trying to load an operator from '%1':") - .arg(op.pythonPath); - qWarning().noquote() << op.loadError; - continue; - } else if (!op.valid) { - qWarning().noquote() - << QString("'%1' doesn't contain a valid operator definition.") - .arg(op.pythonPath); - continue; - } + std::vector sources; + std::vector transforms; + for (const auto& op : operators) { + if (op.type == OperatorDescription::Type::Source) { + sources.push_back(&op); + } else { + transforms.push_back(&op); + } + } - QString source; - QString json; - // Read the Python source - QFile pythonFile(op.pythonPath); - if (pythonFile.open(QIODevice::ReadOnly)) { - source = pythonFile.readAll(); + auto addEntry = [this](const OperatorDescription& op) { + QAction* action = m_customTransformsMenu->addAction(op.label); + action->setEnabled(op.valid); + if (!op.loadError.isNull()) { + qWarning().noquote() + << QString("An error occurred trying to load an operator from '%1':") + .arg(op.pythonPath); + qWarning().noquote() << op.loadError; + return; + } else if (!op.valid) { + qWarning().noquote() + << QString("'%1' doesn't contain a valid operator definition.") + .arg(op.pythonPath); + return; + } + + QString source; + QString json; + QFile pythonFile(op.pythonPath); + if (pythonFile.open(QIODevice::ReadOnly)) { + source = pythonFile.readAll(); + } else { + qCritical() << QString("Unable to read '%1'.").arg(op.pythonPath); + } + if (!op.jsonPath.isNull()) { + QFile jsonFile(op.jsonPath); + if (jsonFile.open(QIODevice::ReadOnly)) { + json = jsonFile.readAll(); } else { - qCritical() << QString("Unable to read '%1'.").arg(op.pythonPath); + qCritical() << QString("Unable to read '%1'.").arg(op.jsonPath); } - // Read the JSON if we have any - if (!op.jsonPath.isNull()) { - QFile jsonFile(op.jsonPath); - if (jsonFile.open(QIODevice::ReadOnly)) { - json = jsonFile.readAll(); - } else { - qCritical() << QString("Unable to read '%1'.").arg(op.jsonPath); - } - } - new AddPythonTransformReaction(action, op.label, source, false, false, - false, json); + } + + if (op.type == OperatorDescription::Type::Source) { + new AddPythonSourceReaction(action, source, json); + } else { + new AddPythonTransformReaction(action, op.label, source, json); + } + }; + + if (!sources.empty()) { + m_customTransformsMenu->addSection("Sources"); + for (const auto* op : sources) { + addEntry(*op); + } + } + if (!transforms.empty()) { + m_customTransformsMenu->addSection("Transforms"); + for (const auto* op : transforms) { + addEntry(*op); } } - m_customTransformsMenu->setEnabled(true); } std::vector MainWindow::findCustomOperators() { QStringList paths; - // Search in /.tomviz - foreach (QString home, - QStandardPaths::standardLocations(QStandardPaths::HomeLocation)) { - QString path = QString("%1%2.tomviz").arg(home).arg(QDir::separator()); - if (QFile(path).exists()) { - paths.append(path); + QByteArray envOverride = qgetenv("TOMVIZ_CUSTOM_TRANSFORMS_PATH"); + if (!envOverride.isEmpty()) { + for (const QString& path : QString::fromLocal8Bit(envOverride) + .split(QDir::listSeparator(), + Qt::SkipEmptyParts)) { + if (QFileInfo(path).isDir()) { + paths.append(path); + } } - path = QString("%1%2tomviz").arg(home).arg(QDir::separator()); - if (QFile(path).exists()) { - paths.append(path); + } else { + // Search in /.tomviz + foreach (QString home, + QStandardPaths::standardLocations(QStandardPaths::HomeLocation)) { + QString path = QString("%1%2.tomviz").arg(home).arg(QDir::separator()); + if (QFile(path).exists()) { + paths.append(path); + } + path = QString("%1%2tomviz").arg(home).arg(QDir::separator()); + if (QFile(path).exists()) { + paths.append(path); + } } - } - // Search in data locations. - // For example on window C:/Users//AppData/Local/tomviz - for (QString path: - QStandardPaths::standardLocations(QStandardPaths::AppDataLocation)) { - if (QFile(path).exists()) { - paths.append(path); + // Search in data locations. + // For example on window C:/Users//AppData/Local/tomviz + for (QString path : + QStandardPaths::standardLocations(QStandardPaths::AppDataLocation)) { + if (QFile(path).exists()) { + paths.append(path); + } } } @@ -1141,51 +1537,554 @@ void MainWindow::onMouseOverVoxel(const vtkVector3i& ijk, double v) 5000); } -void MainWindow::syncPythonToApp() +void MainWindow::findPipelineTemplates() { + m_pipelineTemplates->clear(); + + // Always include the bundled 'share' directory. + QList locations; + locations.append(QDir(QApplication::applicationDirPath() + + "/../share/tomviz/templates/")); + + QByteArray envOverride = qgetenv("TOMVIZ_PIPELINE_TEMPLATES_PATH"); + if (!envOverride.isEmpty()) { + for (const QString& path : QString::fromLocal8Bit(envOverride) + .split(QDir::listSeparator(), + Qt::SkipEmptyParts)) { + if (QFileInfo(path).isDir()) { + locations.append(QDir(path)); + } + } + } else { + locations.append(QDir(tomviz::userDataPath() + "/templates")); + } + + foreach (QDir dir, locations) { + foreach (QFileInfo file, dir.entryInfoList()) { + if (file.isFile() && file.suffix() == "tvsm") { + QString menuName = file.completeBaseName().replace("_", " "); + QAction* action = m_pipelineTemplates->addAction(menuName); + new SaveLoadTemplateReaction(action, true, file.absoluteFilePath()); + } + } + } + m_pipelineTemplates->addSeparator(); + QAction* actionSaveTemplate = m_pipelineTemplates->addAction("Save Template"); + new SaveLoadTemplateReaction(actionSaveTemplate); + connect(actionSaveTemplate, &QAction::triggered, this, &MainWindow::findPipelineTemplates); +} + +void MainWindow::initPipeline() +{ + auto* p = new pipeline::Pipeline(this); + p->setExecutor(new pipeline::ThreadedExecutor(p)); + m_pipeline = p; + m_pipelineStrip->setPipeline(p); + m_pipelineControls->setPipeline(p); + ActiveObjects::instance().setPipeline(p); + m_progressDialogManager = new ProgressDialogManager(this); + m_progressDialogManager->setPipeline(p); + + // Wire renderNeeded() → pqView::render() for all sink nodes. + // pqView::render() coalesces multiple calls via an internal timer + // (pqView.h: "Multiple calls are collapsed into one."), matching the old + // Module → ModuleManager::render() → pqView::render() pattern. + auto connectSinkRender = [](pipeline::Node* node) { + auto* sink = dynamic_cast(node); + if (sink && sink->view()) { + auto* pqview = tomviz::convert(sink->view()); + if (pqview) { + connect(sink, &pipeline::LegacyModuleSink::renderNeeded, + pqview, &pqView::render); + } + } + }; + connect(p, &pipeline::Pipeline::nodeAdded, this, connectSinkRender); + + // Render all views when a port pushes intermediate data (live updates). + connect(p, &pipeline::Pipeline::nodeAdded, this, + [](pipeline::Node* node) { + for (auto* port : node->outputPorts()) { + connect(port, &pipeline::OutputPort::intermediateDataApplied, + []() { pqApplicationCore::instance()->render(); }); + } + }); + + // Initialize/rescale color maps on intermediate updates so the + // volume renders correctly during the first execution and across + // re-runs. Safe under setIntermediateData's BQC — the worker is + // paused while we mutate SM proxies. Sink-side rebinding is in + // LegacyModuleSink::postConsume. + connect(p, &pipeline::Pipeline::nodeAdded, this, + [this](pipeline::Node* node) { + for (auto* port : node->outputPorts()) { + if (!pipeline::isVolumeType(port->type())) { + continue; + } + connect(port, &pipeline::OutputPort::intermediateDataApplied, + this, [this, node, port]() { + if (ensureColorMapForPort(node, port)) { + updateColorMapDisplay(); + } + }); + } + }); + + // Select newly added nodes so the tip output port updates + connect(p, &pipeline::Pipeline::nodeAdded, this, + &MainWindow::onNodeSelected); + + // Per-node rescale of existing color maps. rescaleColorMap() pushes + // SM proxy state through the ParaView session, which is not + // thread-safe against the worker thread's node execution. The + // ThreadedExecutor parks its worker at a per-node barrier until this + // handler returns (see ExecutionWorker::run), so no operator runs + // concurrently with the rescale below — this previously crashed with + // a SIGBUS inside vtkSMProxy::UpdateVTKObjects. + connect(p->executor(), &pipeline::PipelineExecutor::nodeExecutionFinished, + this, [](pipeline::Node* node, bool success) { + if (!success) { + return; + } + for (auto* port : node->outputPorts()) { + if (!pipeline::isVolumeType(port->type()) || !port->hasData()) { + continue; + } + pipeline::VolumeDataPtr vol; + try { + vol = port->data().value(); + } catch (const std::bad_any_cast&) { + continue; + } + if (vol && vol->hasColorMap()) { + vol->rescaleColorMap(); + } + } + }); + + // Backstop for the per-port intermediate path: covers nodes whose + // first intermediate never fires (no progress.data) and the first + // run of a freshly loaded pipeline. Topological order makes sure + // upstream color maps exist before downstream's copyColorMapFrom. + connect(p, &pipeline::Pipeline::executionFinished, this, [this, p]() { + bool anyNewColorMaps = false; + for (auto* node : p->topologicalSort()) { + for (auto* port : node->outputPorts()) { + if (ensureColorMapForPort(node, port)) { + anyNewColorMaps = true; + } + } + } + + // Refresh sinks that skipped updateColorMap during execution + // (proxy didn't exist yet) plus the central histogram widget. + if (anyNewColorMaps) { + for (auto* node : p->nodes()) { + auto* sink = dynamic_cast(node); + if (sink && sink->isColorMapNeeded()) { + sink->updateColorMap(); + } + } + updateColorMapDisplay(); + } + }); + + // Lock all mutation UI while the pipeline is executing. + connect(p, &pipeline::Pipeline::executionStarted, + this, [this]() { setPipelineMutationEnabled(false); }); + connect(p, &pipeline::Pipeline::executionFinished, + this, [this]() { setPipelineMutationEnabled(true); }); +} + +void MainWindow::setPipelineMutationEnabled(bool enabled) { - Python python; - auto tomvizState = python.import("tomviz.state"); - if (!tomvizState.isValid()) { - qCritical() << "Failed to import tomviz.state"; + m_pipelineStrip->setInteractionLocked(!enabled); + m_ui->menuData->setEnabled(enabled); + m_ui->menuTomography->setEnabled(enabled); + m_ui->menuSegmentation->setEnabled(enabled); + m_ui->menuModules->setEnabled(enabled); + if (m_customTransformsMenu) { + m_customTransformsMenu->setEnabled(enabled); + } + if (m_pipelineTemplates) { + m_pipelineTemplates->setEnabled(enabled); + } +} + +pipeline::Pipeline* MainWindow::pipeline() const +{ + return m_pipeline; +} + +void MainWindow::onNodeSelected(pipeline::Node* node) +{ + // Forward to ActiveObjects; mutual exclusion of node/port/link is + // enforced there. Properties panel, strip sync, and colormap are + // handled by onActiveNodeChanged / onActivePortChanged. + ActiveObjects::instance().setActiveNode(node); +} + +void MainWindow::onPortSelected(pipeline::OutputPort* port) +{ + ActiveObjects::instance().setActivePort(port); +} + +void MainWindow::onLinkSelected(pipeline::Link* link) +{ + ActiveObjects::instance().setActiveLink(link); +} + +void MainWindow::clearDynamicPropertiesWidget() +{ + if (m_dynamicPropertiesWidget) { + auto* w = m_dynamicPropertiesWidget.data(); + m_dynamicPropertiesWidget = nullptr; + m_ui->propertiesPanelStackedWidget->removeWidget(w); + w->deleteLater(); + } +} + + +void MainWindow::showPropertiesPanel(QWidget* content, const QString& title) +{ + auto* container = new QWidget(m_ui->propertiesPanelStackedWidget); + auto* layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto* titleLabel = new QLabel(title, container); + QFont f = titleLabel->font(); + f.setBold(true); + titleLabel->setFont(f); + titleLabel->setContentsMargins(8, 6, 8, 6); + layout->addWidget(titleLabel); + + auto* separator = new QFrame(container); + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + layout->addWidget(separator); + + // A node with no editable properties (e.g. some sources) still gets a + // title — just no body. + if (content) { + content->setParent(container); + layout->addWidget(content, 1); + } else { + layout->addStretch(1); + } + + m_dynamicPropertiesWidget = container; + m_ui->propertiesPanelStackedWidget->addWidget(container); + m_ui->propertiesPanelStackedWidget->setCurrentWidget(container); +} + +void MainWindow::leaveGroup(pipeline::Node* member, + pipeline::SinkGroupNode* group) +{ + auto* p = pipeline(); + if (!p) { return; } + for (auto* inPort : member->inputPorts()) { + if (!inPort->link()) { + continue; + } + auto* groupPort = inPort->link()->from(); + if (groupPort->node() != group) { + continue; + } + // Find the upstream port feeding the group's matching input. + int idx = group->outputPorts().indexOf(groupPort); + pipeline::OutputPort* upstream = nullptr; + if (idx >= 0 && idx < group->inputPorts().size()) { + auto* groupInput = group->inputPorts()[idx]; + if (groupInput->link()) { + upstream = groupInput->link()->from(); + } + } + p->removeLink(inPort->link()); + if (upstream) { + p->createLink(upstream, inPort); + } + break; + } + p->execute(); +} - auto sync = tomvizState.findFunction("sync"); - if (!sync.isValid()) { - qCritical() << "Unable to locate sync."; +void MainWindow::onActiveNodeChanged(pipeline::Node* node) +{ + m_pipelineStrip->setSelectedNode(node); + disconnect(m_sinkColorMapChangedConn); + disconnect(m_editingChangedConn); + clearDynamicPropertiesWidget(); + + if (!node) { + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); return; } - Python::Tuple args(0); - Python::Dict kwargs; - sync.call(args, kwargs); + // Hide the properties panel while an edit dialog is open for the active + // node, and restore it when the dialog is dismissed. + // + // Both NodeEditDialog and NodePropertiesPanel manage the + // parametersApplied → execute() auto-wiring. To avoid them stepping on + // each other we must guarantee ordering: + // editing=true → destroy the panel *immediately* so its destructor + // restores auto-wiring before the dialog's constructor + // re-disconnects it. + // editing=false → *defer* panel creation so the dialog's destructor + // restores auto-wiring before the new panel suppresses it. + m_editingChangedConn = connect( + node, &pipeline::Node::editingChanged, this, [this](bool editing) { + if (editing) { + if (m_dynamicPropertiesWidget) { + auto* w = m_dynamicPropertiesWidget.data(); + m_dynamicPropertiesWidget = nullptr; + m_ui->propertiesPanelStackedWidget->removeWidget(w); + delete w; + } + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + } else { + QTimer::singleShot(0, this, [this]() { + onActiveNodeChanged(ActiveObjects::instance().activeNode()); + }); + } + }); + + // Title header declaring the type of the selected node. + QString title; + if (qobject_cast(node)) { + title = tr("Visualizations"); + } else if (qobject_cast(node)) { + title = tr("Source Node"); + } else if (qobject_cast(node)) { + title = tr("Transform Node"); + } else if (qobject_cast(node)) { + title = tr("Sink Node"); + } else { + title = tr("Node"); + } + + QWidget* propsWidget = nullptr; + + if (auto* group = qobject_cast(node)) { + auto* w = new pipeline::SinkGroupPropertiesWidget( + group, pipeline(), m_ui->propertiesPanelStackedWidget); + connect(w, &pipeline::SinkGroupPropertiesWidget::leaveGroupRequested, this, + [this, group](pipeline::SinkNode* member) { + leaveGroup(member, group); + }); + connect(w, &pipeline::SinkGroupPropertiesWidget::deleteRequested, this, + [this](pipeline::SinkNode* member) { + auto* p = pipeline(); + if (p && !p->isExecuting()) { + p->removeNode(member); + } + }); + propsWidget = w; + } else if (auto* sink = dynamic_cast(node)) { + if (!node->isEditing()) { + propsWidget = sink->createSinkPropertiesWidget( + m_ui->propertiesPanelStackedWidget); + } + // React to detached colormap toggling while this sink is selected + m_sinkColorMapChangedConn = connect( + sink, &pipeline::LegacyModuleSink::colorMapChanged, + this, [this, sink]() { + if (sink->useDetachedColorMap()) { + m_ui->centralWidget->setActiveSinkNode(sink); + } else { + updateColorMapDisplay(); + } + }); + } else if (node && node->hasPropertiesWidget() && !node->isEditing()) { + propsWidget = new pipeline::NodePropertiesPanel( + node, pipeline(), m_ui->propertiesPanelStackedWidget); + } + + // Always show the title for a selected node, even when it has no + // editable properties (title-only panel). + showPropertiesPanel(propsWidget, title); + + // Sink with detached colormap: display it; otherwise the tip port + // drives the colormap via updateColorMapDisplay(). + if (auto* sink = dynamic_cast(node)) { + if (sink->useDetachedColorMap()) { + m_ui->centralWidget->setActiveSinkNode(sink); + return; + } + } + updateColorMapDisplay(); } -void MainWindow::findPipelineTemplates() { - m_pipelineTemplates->clear(); +void MainWindow::onActivePortChanged(pipeline::OutputPort* port) +{ + m_pipelineStrip->setSelectedPort(port); + clearDynamicPropertiesWidget(); + if (!port) { + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + return; + } - // Look in 'share' directory for default templates - QDir provided (QApplication::applicationDirPath() + "/../share/tomviz/templates/"); - // Look for user created templates - QDir created (tomviz::userDataPath() + "/templates"); + if (pipeline::isVolumeType(port->type())) { + auto* propsWidget = + new pipeline::VolumePropertiesWidget(nullptr); + propsWidget->setOutputPort(port); + connect(propsWidget->showTimeSeriesLabelCheckBox(), &QCheckBox::toggled, + &ActiveObjects::instance(), + &ActiveObjects::setShowTimeSeriesLabel); + connect(&ActiveObjects::instance(), + &ActiveObjects::showTimeSeriesLabelChanged, + propsWidget->showTimeSeriesLabelCheckBox(), + &QCheckBox::setChecked); + propsWidget->setupInteractiveTransform(); + auto* scrollArea = new QScrollArea(m_ui->propertiesPanelStackedWidget); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidgetResizable(true); + scrollArea->setWidget(propsWidget); + showPropertiesPanel(scrollArea, tr("Output Port")); + } else if (port->type() == pipeline::PortType::Molecule && port->hasData()) { + try { + auto molecule = + port->data().value>(); + if (molecule) { + auto* propsWidget = new MoleculeProperties( + molecule, m_ui->propertiesPanelStackedWidget); + showPropertiesPanel(propsWidget, tr("Output Port")); + } else { + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + } + } catch (const std::bad_any_cast&) { + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + } + } else { + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + } +} - QList locations = { provided, created }; - foreach (QDir dir, locations) { - foreach (QFileInfo file, dir.entryInfoList()) { - QString menuName = file.completeBaseName().replace("_", " "); - if (file.isFile()) { - QAction* action = m_pipelineTemplates->addAction(menuName); - new SaveLoadTemplateReaction(action, true, file.completeBaseName()); - if (!ModuleManager::instance().hasDataSources()) { - action->setEnabled(false); +void MainWindow::onActiveLinkChanged(pipeline::Link* link) +{ + m_pipelineStrip->setSelectedLink(link); + if (!link) { + // A null link also arrives as a side effect of selecting a node/port, + // whose own handler repopulates the panel. Only tear the panel down + // here if it is actually showing a link's properties (e.g. on a full + // clearActiveSelection while a link was the active object). The tracked + // widget is the title wrapper, so look for the inner widget. + if (m_dynamicPropertiesWidget && + m_dynamicPropertiesWidget->findChild()) { + clearDynamicPropertiesWidget(); + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + } + return; + } + clearDynamicPropertiesWidget(); + + auto* propsWidget = + new pipeline::LinkPropertiesWidget(link, m_ui->propertiesPanelStackedWidget); + connect(propsWidget, &pipeline::LinkPropertiesWidget::deleteRequested, this, + [this](pipeline::Link* l) { + auto* p = pipeline(); + if (p && !p->isExecuting()) { + p->removeLink(l); + } + ActiveObjects::instance().clearActiveSelection(); + }); + showPropertiesPanel(propsWidget, tr("Link")); +} + +bool MainWindow::ensureColorMapForPort(pipeline::Node* node, + pipeline::OutputPort* port) +{ + if (!port || !port->hasData() || + !pipeline::isVolumeType(port->type())) { + return false; + } + pipeline::VolumeDataPtr vol; + try { + vol = port->data().value(); + } catch (const std::bad_any_cast&) { + return false; + } + if (!vol) { + return false; + } + + // LabelMap ports never inherit a colormap: each execution gets a + // freshly-built segmentation preset based on its own label set. Skip + // rescale too — the segmentation preset's node positions are already + // in data-coordinate space and rescaling would shift them. + if (port->type() == pipeline::PortType::LabelMap) { + bool created = !vol->hasColorMap(); + pipeline::applySegmentationColorMap(*vol); + return created; + } + + pipeline::VolumeDataPtr upstream; + if (node) { + for (auto* in : node->inputPorts()) { + if (in->hasData() && pipeline::isVolumeType(in->data().type())) { + try { + upstream = in->data().value(); + } catch (const std::bad_any_cast&) { + } + if (upstream) { + break; } } } } - m_pipelineTemplates->addSeparator(); - QAction* actionSaveTemplate = m_pipelineTemplates->addAction("Save Template"); - new SaveLoadTemplateReaction(actionSaveTemplate); - connect(actionSaveTemplate, &QAction::triggered, this, &MainWindow::findPipelineTemplates); + // Pass-through node: shared color map, nothing to do. + if (vol == upstream) { + return false; + } + + bool created = false; + if (!vol->hasColorMap()) { + vol->initColorMap(); + if (upstream && upstream->hasColorMap()) { + vol->copyColorMapFrom(*upstream); + } + created = true; + } + vol->rescaleColorMap(); + return created; +} + +void MainWindow::scheduleColorMapDisplayUpdate() +{ + // Coalesce — drop if a refresh is already scheduled. Live operator + // updates fire dataChanged at multi-Hz rates; without this each + // would re-bind the color-map / histogram machinery. + if (m_colorMapUpdatePending) { + return; + } + m_colorMapUpdatePending = true; + static constexpr int kColorMapUpdateThrottleMs = 200; + QTimer::singleShot(kColorMapUpdateThrottleMs, this, [this]() { + m_colorMapUpdatePending = false; + updateColorMapDisplay(); + }); +} + +void MainWindow::updateColorMapDisplay() +{ + auto* tipPort = ActiveObjects::instance().activeTipOutputPort(); + if (!tipPort || !pipeline::isVolumeType(tipPort->type()) || + !tipPort->hasData()) { + // Nothing valid to display — clear the histogram + gradient + // opacity widgets so a Reset (or any state-clear) doesn't leave + // stale curves from the previous pipeline on screen. + m_ui->centralWidget->setActiveVolumeData(nullptr); + return; + } + pipeline::VolumeDataPtr vol; + try { + vol = tipPort->data().value(); + } catch (const std::bad_any_cast&) { + m_ui->centralWidget->setActiveVolumeData(nullptr); + return; + } + m_ui->centralWidget->setActiveVolumeData(vol); } } // namespace tomviz diff --git a/tomviz/MainWindow.h b/tomviz/MainWindow.h index ed8179589..626469dc9 100644 --- a/tomviz/MainWindow.h +++ b/tomviz/MainWindow.h @@ -8,6 +8,7 @@ #include +#include #include class QMenu; @@ -20,13 +21,22 @@ class MainWindow; namespace tomviz { class AboutDialog; -class DataPropertiesPanel; class DataSource; -class MoleculeSource; class Module; -class Operator; struct OperatorDescription; -class OperatorResult; +class OperatorSearchDialog; +class ProgressDialogManager; + +namespace pipeline { +class Link; +class Node; +class OutputPort; +class Pipeline; +class PipelineControlsWidget; +class PipelineStripWidget; +class SinkGroupNode; +class VolumePropertiesWidget; +} // namespace pipeline /// The main window for the tomviz application. class MainWindow : public QMainWindow @@ -38,9 +48,17 @@ class MainWindow : public QMainWindow ~MainWindow() override; void openFiles(int argc, char** argv); + static MainWindow* instance(); + + pipeline::Pipeline* pipeline() const; + + void setMostRecentStateFile(const QString& fileName); + protected: void showEvent(QShowEvent* event) override; void closeEvent(QCloseEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; /// Check the system at runtime to see for an appropriate OpenGL version. bool checkOpenGL(); @@ -49,30 +67,18 @@ public slots: void openRecon(); private slots: + void onNodeSelected(pipeline::Node* node); + void onPortSelected(pipeline::OutputPort* port); + void onLinkSelected(pipeline::Link* link); + void onActiveNodeChanged(pipeline::Node* node); + void onActivePortChanged(pipeline::OutputPort* port); + void onActiveLinkChanged(pipeline::Link* link); void openTilt(); void openDataLink(); void openReadTheDocs(); void openUserGuide(); void openVisIntro(); - /// Change the active data source in the UI. - void dataSourceChanged(DataSource* source); - - /// Change the active molecule source in the UI. - void moleculeSourceChanged(MoleculeSource* moleculeSource); - - /// Change the active module displayed in the UI. - void moduleChanged(Module* module); - - /// Change the active module displayed in the properties panel. - void operatorChanged(Operator* op); - - /// Change the active result displayed in the properties panel. - void operatorResultChanged(OperatorResult* result); - - /// Load a custom operator from a file - void importCustomTransform(); - void onFirstWindowShow(); void autosave(); @@ -99,21 +105,52 @@ private slots: static std::vector findCustomOperators(); void registerCustomOperators(std::vector operators); static std::vector initPython(); - void syncPythonToApp(); void updateSaveStateEnableState(); QString mostRecentStateFile() const; + void initPipeline(); + void clearDynamicPropertiesWidget(); + /// Install @a content as the dynamic properties panel, wrapped with a + /// title header declaring the type of the selected object. + void showPropertiesPanel(QWidget* content, const QString& title); + /// Relink a group member sink to the group's upstream port (removing it + /// from the group). + void leaveGroup(pipeline::Node* member, pipeline::SinkGroupNode* group); + void updateColorMapDisplay(); + /// Coalescing wrapper around updateColorMapDisplay — at most one + /// refresh per kColorMapUpdateThrottleMs window. + void scheduleColorMapDisplayUpdate(); + /// Ensure @a port's VolumeData has a color map (init + copy from + /// upstream on first use) and rescale it. Returns true on first + /// creation so the caller can refresh sinks. + bool ensureColorMapForPort(pipeline::Node* node, + pipeline::OutputPort* port); + void setPipelineMutationEnabled(bool enabled); QScopedPointer m_ui; QMenu* m_customTransformsMenu = nullptr; QMenu* m_pipelineTemplates = nullptr; + OperatorSearchDialog* m_operatorSearchDialog = nullptr; QTimer* m_timer = nullptr; bool m_isFirstShow = true; + // New pipeline infrastructure + pipeline::PipelineControlsWidget* m_pipelineControls = nullptr; + pipeline::PipelineStripWidget* m_pipelineStrip = nullptr; + pipeline::Pipeline* m_pipeline = nullptr; + ProgressDialogManager* m_progressDialogManager = nullptr; + QMetaObject::Connection m_tipDataChangedConn; + QMetaObject::Connection m_tipMetadataChangedConn; + bool m_colorMapUpdatePending = false; + QMetaObject::Connection m_sinkColorMapChangedConn; + QMetaObject::Connection m_editingChangedConn; + QPointer m_dynamicPropertiesWidget; + + QString m_mostRecentStateFile; + // Lazily loaded dialogs QWidget* m_aboutDialog = nullptr; QWidget* m_acquisitionWidget = nullptr; QWidget* m_animationHelperDialog = nullptr; - QWidget* m_passiveAcquisitionDialog = nullptr; template void openDialog(QWidget**); diff --git a/tomviz/MainWindow.ui b/tomviz/MainWindow.ui index d0e8e4539..cb3713676 100644 --- a/tomviz/MainWindow.ui +++ b/tomviz/MainWindow.ui @@ -63,6 +63,9 @@ + + + @@ -80,8 +83,6 @@ - - @@ -110,15 +111,15 @@ Segmentation - + - Workflows + Sources - - + + - + @@ -132,7 +133,7 @@ 250 - 300 + 50 @@ -156,22 +157,21 @@ 0 - - - true - - - QAbstractItemView::DragOnly - - - true - - - true - - - false - + + + + 0 + + + 0 + + + 0 + + + 0 + + @@ -232,7 +232,7 @@ 250 - 300 + 50 @@ -263,111 +263,6 @@ 0 - - - QFrame::NoFrame - - - Qt::ScrollBarAlwaysOff - - - true - - - - - 0 - 0 - 100 - 30 - - - - - - - QFrame::NoFrame - - - Qt::ScrollBarAlwaysOff - - - true - - - - - 0 - 0 - 100 - 30 - - - - - - - QFrame::NoFrame - - - Qt::ScrollBarAlwaysOff - - - true - - - - - 0 - 0 - 100 - 30 - - - - - - - QFrame::NoFrame - - - Qt::ScrollBarAlwaysOff - - - true - - - - - 0 - 0 - 100 - 30 - - - - - - - QFrame::NoFrame - - - Qt::ScrollBarAlwaysOff - - - true - - - - - 0 - 0 - 100 - 30 - - - - @@ -410,14 +305,6 @@ Ctrl+Q - - - &Passive Acquisition - - - Passive Acquisition - - &Save Data @@ -483,14 +370,6 @@ Export data to Web - - - Pipeline Settings - - - Pipeline Settings - - Acquisition @@ -512,6 +391,16 @@ Save State + + + Load Template + + + + + Save Template As + + &Data @@ -532,15 +421,15 @@ Animation Helper - + PyXRF - PyXRF Workflow + PyXRF Source - + Ptycho @@ -564,41 +453,6 @@ QToolBar
pqVCRToolbar.h
- - tomviz::PipelineView - QTreeView -
PipelineView.h
-
- - tomviz::DataPropertiesPanel - QWidget -
DataPropertiesPanel.h
- 1 -
- - tomviz::MoleculePropertiesPanel - QWidget -
MoleculePropertiesPanel.h
- 1 -
- - tomviz::ModulePropertiesPanel - QWidget -
ModulePropertiesPanel.h
- 1 -
- - tomviz::OperatorPropertiesPanel - QWidget -
OperatorPropertiesPanel.h
- 1 -
- - tomviz::OperatorResultPropertiesPanel - QWidget -
OperatorResultPropertiesPanel.h
- 1 -
pqOutputWidget QWidget diff --git a/tomviz/ManualManipulationWidget.cxx b/tomviz/ManualManipulationWidget.cxx index b85338364..5f279a8f3 100644 --- a/tomviz/ManualManipulationWidget.cxx +++ b/tomviz/ManualManipulationWidget.cxx @@ -4,624 +4,141 @@ #include "ManualManipulationWidget.h" #include "ui_ManualManipulationWidget.h" -#include "ActiveObjects.h" -#include "DataSource.h" -#include "ModuleManager.h" -#include "OperatorPython.h" -#include "Utilities.h" - -#include -#include -#include +#include "pipeline/data/VolumeData.h" #include -#include -#include -#include -#include -#include -#include + +#include namespace tomviz { -class ManualManipulationWidget::Internal : public QObject +class ManualManipulationWidget::Internal { public: Ui::ManualManipulationWidget ui; - QPointer op; vtkSmartPointer image; - QPointer parent; - QPointer dataSource; - QPointer referenceData; - double savedReferencePosition[3] = { 0, 0, 0 }; - vtkSmartPointer originalOutlineSource; - vtkSmartPointer originalOutlineRepresentation; - vtkNew pipelineController; - double cachedBounds[6] = { 0, 0, 0, 0, 0, 0 }; - Internal(Operator* o, vtkSmartPointer img, - ManualManipulationWidget* p) - : op(o), image(img), parent(p) + Internal(vtkSmartPointer img, ManualManipulationWidget* parent) + : image(img) { - // Must call setupUi() before using p in any way - ui.setupUi(p); - setParent(p); + ui.setupUi(parent); - if (op->childDataSource() && !op->isEditing()) { - dataSource = op->childDataSource(); - } else if (op->dataSource()) { - dataSource = op->dataSource(); - } else { - dataSource = ActiveObjects::instance().activeDataSource(); - } + // Hide groups that require DataSource/ModuleManager infrastructure + ui.interactionGroup->hide(); + ui.referenceDataGroup->hide(); - if (op->isEditing() && op->childDataSource()) { - // Mark the units of the child data source as modified so that when - // this widget modifies the spacing of the parent data source, it won't - // propagate down to the child. - auto* cds = op->childDataSource(); - cds->setSpacing(cds->getSpacing(), true); + // Initialize scale spinboxes from image spacing + if (image) { + double spacing[3]; + image->GetSpacing(spacing); + ui.scaleX->setValue(spacing[0]); + ui.scaleY->setValue(spacing[1]); + ui.scaleZ->setValue(spacing[2]); } - - // Make sure this is the active data source - ActiveObjects::instance().setActiveDataSource(dataSource); - fixInteractionDataSource(); - - populateReferenceDataCheckBoxes(); - createOriginalOutline(); - setupConnections(); - enableAllInteraction(); - updateGui(); } - ~Internal() + QList scaling() const { - restoreReferenceDataPosition(); - unfixInteractionDataSource(); - removeOriginalOutline(); - disableAllInteraction(); + return { ui.scaleX->value(), ui.scaleY->value(), ui.scaleZ->value() }; } - void setupConnections() + QList rotation() const { - connect(dataSource, &DataSource::dataPropertiesChanged, this, - &Internal::onDataSourcePropertiesChanged); - - ActiveObjects& activeObjects = ActiveObjects::instance(); - - connect(&activeObjects, &ActiveObjects::translationStateChanged, this, - &Internal::updateInteractionCheckboxes); - connect(&activeObjects, &ActiveObjects::rotationStateChanged, this, - &Internal::updateInteractionCheckboxes); - connect(&activeObjects, &ActiveObjects::scalingStateChanged, this, - &Internal::updateInteractionCheckboxes); - - connect(ui.interactTranslate, &QCheckBox::clicked, &activeObjects, - &ActiveObjects::enableTranslation); - connect(ui.interactRotate, &QCheckBox::clicked, &activeObjects, - &ActiveObjects::enableRotation); - connect(ui.interactScale, &QCheckBox::clicked, &activeObjects, - &ActiveObjects::enableScaling); - - connect(dataSource, &DataSource::displayPositionChanged, this, - &Internal::updateGui); - connect(dataSource, &DataSource::displayOrientationChanged, this, - &Internal::updateGui); - - QList shiftWidgets = { ui.shiftX, ui.shiftY, ui.shiftZ }; - QList rotateWidgets = { ui.rotateX, ui.rotateY, - ui.rotateZ }; - QList scaleWidgets = { ui.scaleX, ui.scaleY, ui.scaleZ }; - - for (int axis = 0; axis < 3; ++axis) { - connect(shiftWidgets[axis], &QDoubleSpinBox::editingFinished, this, - [this, axis, shiftWidgets]() { - this->setShiftValue(axis, shiftWidgets[axis]->value()); - }); - connect(rotateWidgets[axis], &QDoubleSpinBox::editingFinished, this, - [this, axis, rotateWidgets]() { - this->setRotationValue(axis, rotateWidgets[axis]->value()); - }); - connect(scaleWidgets[axis], &QDoubleSpinBox::editingFinished, this, - [this, axis, scaleWidgets]() { - this->setScalingValue(axis, scaleWidgets[axis]->value()); - }); - } - - connect(ui.selectedReferenceData, - QOverload::of(&QComboBox::currentIndexChanged), this, - &Internal::onSelectedReferenceDataChanged); - connect(ui.alignVoxelsWithReference, &QCheckBox::toggled, this, - &Internal::onAlignVoxelsWithReferenceToggled); + return { ui.rotateX->value(), ui.rotateY->value(), ui.rotateZ->value() }; } - QList scaling() + QList physicalShift() const { - auto* scaling = dataSource->getSpacing(); - return { scaling[0], scaling[1], scaling[2] }; + return { ui.shiftX->value(), ui.shiftY->value(), ui.shiftZ->value() }; } - QList rotation() + QList voxelShift() const { - const auto* orientation = dataSource->displayOrientation(); - return { orientation[0], orientation[1], orientation[2] }; - } - - QList shift() - { - // Compute the center - // Since we rotate about the center, this is the one point that won't - // move due to rotation. We'll transform its coordinates and then - // compute the difference to figure out what our shift should be. - double center[3]; - computeCenter(center); - - vtkNew t; - - // Translate - t->Translate(dataSource->displayPosition()); - - // Rotate - const auto* orientation = dataSource->displayOrientation(); - t->RotateZ(orientation[2]); - t->RotateX(orientation[0]); - t->RotateY(orientation[1]); - - // Transform it to the new coordinates - double physicalShift[3]; - t->TransformPoint(center, physicalShift); - - // Now get the difference - for (auto i = 0; i < 3; ++i) { - physicalShift[i] -= center[i]; + if (!image) { + return { 0, 0, 0 }; } - return { physicalShift[0], physicalShift[1], physicalShift[2] }; - } - - QList voxelShift() - { - // Get the shift as voxel shift values - double physicalShift[3]; - auto physicalShiftList = shift(); - for (int i = 0; i < 3; ++i) { - physicalShift[i] = physicalShiftList[i].toDouble(); - } + double spacing[3]; + image->GetSpacing(spacing); + const int* dims = image->GetDimensions(); - double lengths[3]; - dataSource->getPhysicalDimensions(lengths); - const auto* dims = dataSource->imageData()->GetDimensions(); int shifts[3]; - for (auto i = 0; i < 3; ++i) { - shifts[i] = std::round(physicalShift[i] / lengths[i] * dims[i]); - } - - return { shifts[0], shifts[1], shifts[2] }; - } - - void setScaling(double* scale) { dataSource->setSpacing(scale); } - - void setRotation(double* rotation) - { - double physicalShift[3]; - auto shiftList = shift(); - for (int i = 0; i < 3; ++i) { - physicalShift[i] = shiftList[i].toDouble(); - } - dataSource->setDisplayOrientation(rotation); - // Change the data positions so that the shift stays fixed - setShift(physicalShift); - } - - void setShift(double* physicalShift) - { - double center[3]; - computeCenter(center); - - // Determine where the data will be after its current rotations - vtkNew t; - - // Rotate - const auto* orientation = dataSource->displayOrientation(); - t->RotateZ(orientation[2]); - t->RotateX(orientation[0]); - t->RotateY(orientation[1]); - - double rotatedCenter[3]; - t->TransformPoint(center, rotatedCenter); - - // Set the display position to the difference - double newPosition[3]; for (int i = 0; i < 3; ++i) { - newPosition[i] = physicalShift[i] - rotatedCenter[i] + center[i]; - } - - dataSource->setDisplayPosition(newPosition); - } - - void setVoxelShift(int* shift) - { - double physicalShift[3]; - double lengths[3]; - dataSource->getPhysicalDimensions(lengths); - const auto* dims = dataSource->imageData()->GetDimensions(); - for (auto i = 0; i < 3; ++i) { - physicalShift[i] = shift[i] * lengths[i] / dims[i]; + double length = spacing[i] * dims[i]; + double physVal = physicalShift()[i].toDouble(); + shifts[i] = + (length > 0) ? static_cast(std::round(physVal / length * dims[i])) + : 0; } - setShift(physicalShift); - } - - bool alignWithReference() { return ui.alignVoxelsWithReference->isChecked(); } - - void setAlignWithReference(bool b) - { - ui.alignVoxelsWithReference->setChecked(b); - } - - QList referenceSpacing() - { - QList ret; - QList widgets = { ui.referenceSpacingX, - ui.referenceSpacingY, - ui.referenceSpacingZ }; - for (auto* w : widgets) { - ret.append(w->value()); - } - return ret; - } - - void setReferenceSpacing(const double* spacing) - { - QList widgets = { ui.referenceSpacingX, - ui.referenceSpacingY, - ui.referenceSpacingZ }; - for (int i = 0; i < 3; ++i) { - widgets[i]->setValue(spacing[i]); - } - } - - QList referenceShape() - { - QList ret; - QList widgets = { ui.referenceShapeX, ui.referenceShapeY, - ui.referenceShapeZ }; - - for (auto* w : widgets) { - ret.append(w->value()); - } - - return ret; - } - - void setReferenceShape(const int* shape) - { - QList widgets = { ui.referenceShapeX, ui.referenceShapeY, - ui.referenceShapeZ }; - for (int i = 0; i < 3; ++i) { - widgets[i]->setValue(shape[i]); - } + return { shifts[0], shifts[1], shifts[2] }; } - void createOriginalOutline() + void setScaling(const double* scale) { - auto* vtkView = ActiveObjects::instance().activeView(); - - auto pxm = dataSource->proxy()->GetSessionProxyManager(); - - // Create the outline filter. - vtkSmartPointer proxy; - proxy.TakeReference(pxm->NewProxy("sources", "OutlineSource")); - - dataSource->getBounds(cachedBounds); - - originalOutlineSource = vtkSMSourceProxy::SafeDownCast(proxy); - pipelineController->PreInitializeProxy(originalOutlineSource); - vtkSMPropertyHelper(originalOutlineSource, "Bounds").Set(cachedBounds, 6); - pipelineController->PostInitializeProxy(originalOutlineSource); - pipelineController->RegisterPipelineProxy(originalOutlineSource); - - // Create the representation for it. - originalOutlineRepresentation = - pipelineController->Show(originalOutlineSource, 0, vtkView); - - // Set the color - static const double color[3] = { 1, 0, 0 }; - vtkSMPropertyHelper(originalOutlineRepresentation, "DiffuseColor") - .Set(color, 3); - vtkSMPropertyHelper(originalOutlineRepresentation, "LineWidth").Set(5); - - originalOutlineRepresentation->UpdateVTKObjects(); - - // Give the proxy a friendly name for the GUI/Python world. - if (auto p = convert(proxy)) { - p->rename("OriginalPositionOutline"); - } - - render(); + ui.scaleX->setValue(scale[0]); + ui.scaleY->setValue(scale[1]); + ui.scaleZ->setValue(scale[2]); } - void removeOriginalOutline() + void setRotation(const double* rot) { - if (originalOutlineRepresentation) { - pipelineController->UnRegisterProxy(originalOutlineRepresentation); - originalOutlineRepresentation = nullptr; - } - - if (originalOutlineSource) { - pipelineController->UnRegisterProxy(originalOutlineSource); - originalOutlineSource = nullptr; - } - - render(); + ui.rotateX->setValue(rot[0]); + ui.rotateY->setValue(rot[1]); + ui.rotateZ->setValue(rot[2]); } - void render() + void setVoxelShift(const int* shift) { - auto* view = ActiveObjects::instance().activeView(); - if (!view) { + if (!image) { return; } - view->StillRender(); - } - - void onDataSourcePropertiesChanged() - { - const double tol = 1.e-8; - - double bounds[6]; - dataSource->getBounds(bounds); - bool boundsChanged = false; - for (int i = 0; i < 6; ++i) { - if (std::abs(bounds[i] - cachedBounds[i]) > tol) { - boundsChanged = true; - break; - } - } - - if (boundsChanged) { - onBoundsChanged(); - } - updateGui(); - } - void onBoundsChanged() - { - double bounds[6]; - dataSource->getBounds(bounds); - - for (int i = 0; i < 6; ++i) { - cachedBounds[i] = bounds[i]; - } - - vtkSMPropertyHelper(originalOutlineSource, "Bounds").Set(bounds, 6); - originalOutlineSource->UpdateVTKObjects(); - - alignReferenceDataPosition(); - render(); - } - - void updateGui() - { - auto shifts = shift(); - auto rotations = rotation(); - auto scales = scaling(); - - QList shiftWidgets = { ui.shiftX, ui.shiftY, ui.shiftZ }; - QList rotateWidgets = { ui.rotateX, ui.rotateY, - ui.rotateZ }; - QList scaleWidgets = { ui.scaleX, ui.scaleY, ui.scaleZ }; + double spacing[3]; + image->GetSpacing(spacing); + const int* dims = image->GetDimensions(); for (int i = 0; i < 3; ++i) { - shiftWidgets[i]->setValue(shifts[i].toDouble()); - rotateWidgets[i]->setValue(rotations[i].toDouble()); - scaleWidgets[i]->setValue(scales[i].toDouble()); + double length = spacing[i] * dims[i]; + double physVal = (dims[i] > 0) ? shift[i] * length / dims[i] : 0.0; + QList widgets = { ui.shiftX, ui.shiftY, ui.shiftZ }; + widgets[i]->setValue(physVal); } } - void setScalingValue(int axis, double value) + QList referenceSpacing() const { - double scales[3]; - dataSource->getSpacing(scales); - - scales[axis] = value; - setScaling(scales); - } - - void setShiftValue(int axis, double value) - { - double shifts[3]; - auto previousShifts = shift(); - for (int i = 0; i < 3; ++i) { - shifts[i] = previousShifts[i].toDouble(); - } - - shifts[axis] = value; - setShift(shifts); - } - - void setRotationValue(int axis, double value) - { - double orientation[3]; - for (int i = 0; i < 3; ++i) { - orientation[i] = dataSource->displayOrientation()[i]; - } - orientation[axis] = value; - setRotation(orientation); - } - - void enableAllInteraction() - { - ActiveObjects& activeObjects = ActiveObjects::instance(); - - activeObjects.enableTranslation(true); - activeObjects.enableRotation(true); - activeObjects.enableScaling(true); - } - - void disableAllInteraction() - { - ActiveObjects& activeObjects = ActiveObjects::instance(); - - activeObjects.enableTranslation(false); - activeObjects.enableRotation(false); - activeObjects.enableScaling(false); - } - - void fixInteractionDataSource() - { - ActiveObjects::instance().setFixedInteractionDataSource(dataSource); - } - - void unfixInteractionDataSource() - { - ActiveObjects::instance().setFixedInteractionDataSource(nullptr); - } - - void computeCenter(double* center, DataSource* ds = nullptr) - { - if (!ds) { - ds = dataSource; - } - - double lengths[3]; - ds->getPhysicalDimensions(lengths); - - for (auto i = 0; i < 3; ++i) { - center[i] = lengths[i] / 2; - } - } - - void updateInteractionCheckboxes() - { - ActiveObjects& activeObjects = ActiveObjects::instance(); - - bool translate = activeObjects.translationEnabled(); - bool rotate = activeObjects.rotationEnabled(); - bool scale = activeObjects.scalingEnabled(); - - ui.interactTranslate->setChecked(translate); - ui.interactRotate->setChecked(rotate); - ui.interactScale->setChecked(scale); - } - - void populateReferenceDataCheckBoxes() - { - auto allDataSources = ModuleManager::instance().allDataSourcesDepthFirst(); - - // Do not include this data source - allDataSources.removeAll(dataSource); - - auto* cb = ui.selectedReferenceData; - cb->clear(); - - // Make the first item null - QVariant firstItemData; - firstItemData.setValue(static_cast(nullptr)); - cb->addItem("None", firstItemData); - - auto labels = ModuleManager::createUniqueLabels(allDataSources); - for (int i = 0; i < allDataSources.size(); ++i) { - auto* ds = allDataSources[i]; - auto label = labels[i]; - - QVariant data; - data.setValue(ds); - cb->addItem(label, data); - } - } - - DataSource* selectedReferenceData() const - { - // We know we can convert to DataSource*, even for the nullptr - return ui.selectedReferenceData->currentData().value(); - } - - void onSelectedReferenceDataChanged() - { - if (referenceData) { - restoreReferenceDataPosition(); - } - - referenceData = selectedReferenceData(); - updateReferenceValues(); - saveReferenceDataPosition(); - alignReferenceDataPosition(); - updateReferenceEnableStates(); - } - - void onAlignVoxelsWithReferenceToggled() { updateReferenceEnableStates(); } - - void updateReferenceValues() - { - if (!referenceData) { - return; - } - - const double* spacing = referenceData->getSpacing(); - int* dimensions = referenceData->imageData()->GetDimensions(); - - setReferenceSpacing(spacing); - setReferenceShape(dimensions); - } - - void updateReferenceEnableStates() - { - bool enableValuesWidget = alignWithReference() && referenceData == nullptr; - ui.referenceDataValuesWidget->setEnabled(enableValuesWidget); - } - - void saveReferenceDataPosition() - { - if (!referenceData) { - return; - } - - referenceData->displayPosition(savedReferencePosition); - } - - void alignReferenceDataPosition() - { - if (!referenceData || !dataSource) { - return; - } - - double center[3]; - computeCenter(center, dataSource); - - double referenceCenter[3]; - computeCenter(referenceCenter, referenceData); - - // Find the difference - double newPosition[3]; - for (int i = 0; i < 3; ++i) { - newPosition[i] = center[i] - referenceCenter[i]; + if (image) { + double spacing[3]; + image->GetSpacing(spacing); + return { spacing[0], spacing[1], spacing[2] }; } - - referenceData->setDisplayPosition(newPosition); + return { 1.0, 1.0, 1.0 }; } - void restoreReferenceDataPosition() + QList referenceShape() const { - if (!referenceData) { - return; + if (image) { + const int* dims = image->GetDimensions(); + return { dims[0], dims[1], dims[2] }; } - - referenceData->setDisplayPosition(savedReferencePosition); + return { 1, 1, 1 }; } }; ManualManipulationWidget::ManualManipulationWidget( - Operator* op, vtkSmartPointer image, QWidget* p) - : CustomPythonOperatorWidget(p) -{ - m_internal.reset(new Internal(op, image, this)); -} - -CustomPythonOperatorWidget* ManualManipulationWidget::New( - QWidget* p, Operator* op, vtkSmartPointer data) + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent) { - return new ManualManipulationWidget(op, data, p); + vtkSmartPointer image; + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + image = vol->imageData(); + } + } + m_internal.reset(new Internal(image, this)); } ManualManipulationWidget::~ManualManipulationWidget() = default; @@ -631,7 +148,7 @@ void ManualManipulationWidget::getValues(QMap& map) map.insert("scaling", m_internal->scaling()); map.insert("rotation", m_internal->rotation()); map.insert("shift", m_internal->voxelShift()); - map.insert("align_with_reference", m_internal->alignWithReference()); + map.insert("align_with_reference", false); map.insert("reference_spacing", m_internal->referenceSpacing()); map.insert("reference_shape", m_internal->referenceShape()); } @@ -639,53 +156,31 @@ void ManualManipulationWidget::getValues(QMap& map) void ManualManipulationWidget::setValues(const QMap& map) { if (map.contains("scaling")) { - double scaling[3]; auto array = map["scaling"].toList(); - for (int i = 0; i < 3; ++i) { - scaling[i] = array[i].toDouble(); + if (array.size() >= 3) { + double scaling[3] = { array[0].toDouble(), array[1].toDouble(), + array[2].toDouble() }; + m_internal->setScaling(scaling); } - m_internal->setScaling(scaling); } + if (map.contains("rotation")) { - double rotation[3]; auto array = map["rotation"].toList(); - for (int i = 0; i < 3; ++i) { - rotation[i] = array[i].toDouble(); + if (array.size() >= 3) { + double rotation[3] = { array[0].toDouble(), array[1].toDouble(), + array[2].toDouble() }; + m_internal->setRotation(rotation); } - m_internal->setRotation(rotation); } + if (map.contains("shift")) { - int shift[3]; auto array = map["shift"].toList(); - for (int i = 0; i < 3; ++i) { - shift[i] = array[i].toInt(); + if (array.size() >= 3) { + int shift[3] = { array[0].toInt(), array[1].toInt(), + array[2].toInt() }; + m_internal->setVoxelShift(shift); } - m_internal->setVoxelShift(shift); - } - - if (map.contains("align_with_reference")) { - m_internal->setAlignWithReference(map["align_with_reference"].toBool()); } - - if (map.contains("reference_spacing")) { - double referenceSpacing[3]; - auto array = map["reference_spacing"].toList(); - for (int i = 0; i < 3; ++i) { - referenceSpacing[i] = array[i].toDouble(); - } - m_internal->setReferenceSpacing(referenceSpacing); - } - - if (map.contains("reference_shape")) { - int referenceShape[3]; - auto array = map["reference_shape"].toList(); - for (int i = 0; i < 3; ++i) { - referenceShape[i] = array[i].toInt(); - } - m_internal->setReferenceShape(referenceShape); - } - - m_internal->updateGui(); } } // namespace tomviz diff --git a/tomviz/ManualManipulationWidget.h b/tomviz/ManualManipulationWidget.h index 0b0c03c31..c4f4d6780 100644 --- a/tomviz/ManualManipulationWidget.h +++ b/tomviz/ManualManipulationWidget.h @@ -4,37 +4,37 @@ #ifndef tomvizManualManipulationWidget_h #define tomvizManualManipulationWidget_h -#include "CustomPythonOperatorWidget.h" +#include "CustomPythonNodeWidget.h" +#include "PortData.h" -#include "vtkSmartPointer.h" +#include +#include #include +#include class vtkImageData; +class vtkSMProxy; namespace tomviz { -class Operator; -class ManualManipulationWidget : public CustomPythonOperatorWidget +class ManualManipulationWidget : public pipeline::CustomPythonNodeWidget { Q_OBJECT public: - ManualManipulationWidget(Operator* op, vtkSmartPointer image, + ManualManipulationWidget(const QMap& inputs, QWidget* parent = nullptr); ~ManualManipulationWidget(); - static CustomPythonOperatorWidget* New(QWidget* p, Operator* op, - vtkSmartPointer data); - void getValues(QMap& map) override; void setValues(const QMap& map) override; private: Q_DISABLE_COPY(ManualManipulationWidget) - class Internal; QScopedPointer m_internal; }; + } // namespace tomviz #endif diff --git a/tomviz/MergeImagesDialog.cxx b/tomviz/MergeImagesDialog.cxx deleted file mode 100644 index 561aedf33..000000000 --- a/tomviz/MergeImagesDialog.cxx +++ /dev/null @@ -1,25 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "MergeImagesDialog.h" -#include "ui_MergeImagesDialog.h" - -namespace tomviz { - -MergeImagesDialog::MergeImagesDialog(QWidget* parent) - : QDialog(parent), m_ui(new Ui::MergeImagesDialog) -{ - m_ui->setupUi(this); - m_ui->MergeImageArraysRadioButton->setChecked(true); - m_ui->MergeImageArraysRadioButton->setChecked(false); - m_ui->MergeArrayComponentsWidget->hide(); -} - -MergeImagesDialog::~MergeImagesDialog() {} - -MergeImagesDialog::MergeMode MergeImagesDialog::getMode() -{ - return (m_ui->MergeImageArraysRadioButton->isChecked() ? Arrays : Components); -} - -} // namespace tomviz diff --git a/tomviz/MergeImagesDialog.h b/tomviz/MergeImagesDialog.h deleted file mode 100644 index 3bafcec91..000000000 --- a/tomviz/MergeImagesDialog.h +++ /dev/null @@ -1,38 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizMergeImagesDialog_h -#define tomvizMergeImagesDialog_h - -#include -#include - -namespace Ui { -class MergeImagesDialog; -} - -namespace tomviz { - -class MergeImagesDialog : public QDialog -{ - Q_OBJECT - -public: - MergeImagesDialog(QWidget* parent = nullptr); - ~MergeImagesDialog() override; - - enum MergeMode : int - { - Arrays, - Components - }; - - MergeMode getMode(); - -private: - Q_DISABLE_COPY(MergeImagesDialog) - QScopedPointer m_ui; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/MergeImagesDialog.ui b/tomviz/MergeImagesDialog.ui deleted file mode 100644 index 81482d850..000000000 --- a/tomviz/MergeImagesDialog.ui +++ /dev/null @@ -1,202 +0,0 @@ - - - MergeImagesDialog - - - - 0 - 0 - 400 - 268 - - - - Dialog - - - - - - - 0 - 0 - - - - - 4 - - - 4 - - - 4 - - - 4 - - - 4 - - - - - - 75 - true - - - - - - - Merge image arrays - - - true - - - - - - - Merge all arrays from the selected images into a new image, but keep the arrays separate. - - - true - - - - - - - - 75 - true - - - - - - - Merge array components - - - - - - - Merge arrays from the selected images into a new image, but merge all components into one array. - - - true - - - - - - - - - - - 0 - 0 - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - MergeImagesDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - MergeImagesDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - MergeImageArraysRadioButton - clicked() - MergeArrayComponentsWidget - hide() - - - 199 - 22 - - - 199 - 314 - - - - - MergeArrayComponentsRadioButton - clicked() - MergeArrayComponentsWidget - show() - - - 199 - 78 - - - 199 - 314 - - - - - diff --git a/tomviz/MergeImagesReaction.cxx b/tomviz/MergeImagesReaction.cxx deleted file mode 100644 index 79868cf57..000000000 --- a/tomviz/MergeImagesReaction.cxx +++ /dev/null @@ -1,180 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "MergeImagesReaction.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "LoadDataReaction.h" -#include "MergeImagesDialog.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace tomviz { - -MergeImagesReaction::MergeImagesReaction(QAction* parentObject) - : pqReaction(parentObject) -{ - updateEnableState(); -} - -void MergeImagesReaction::onTriggered() -{ - // Post a dialog box to select what kind of merging to do, either merging - // arrays or merging components in a single array. - MergeImagesDialog dialog; - - // TODO - build list widget of arrays and their inputs in the dialog, - // allow for reordering - - int result = dialog.exec(); - if (result == QDialog::Rejected) { - return; - } - - DataSource* newSource = nullptr; - if (dialog.getMode() == MergeImagesDialog::Arrays) { - newSource = mergeArrays(); - } else { - newSource = mergeComponents(); - } - - if (newSource) { - LoadDataReaction::dataSourceAdded(newSource); - } -} - -void MergeImagesReaction::updateDataSources(QSet sources) -{ - m_dataSources = sources; - updateEnableState(); -} - -void MergeImagesReaction::updateEnableState() -{ - bool enabled = m_dataSources.size() > 1; - - // Check for compatibility of the DataSource extents. Ignore overlap in - // physical space for now. - if (enabled) { - QList sourceList = m_dataSources.values(); - auto info = sourceList[0]->proxy()->GetDataInformation(); - int refExtent[6]; - info->GetExtent(refExtent); - - // Check against other DataSource extents - for (int i = 0; i < m_dataSources.size(); ++i) { - info = sourceList[i]->proxy()->GetDataInformation(); - int thisExtent[6]; - info->GetExtent(thisExtent); - enabled = enabled && std::equal(refExtent, refExtent + 6, thisExtent); - } - } - - parentAction()->setEnabled(enabled); -} - -DataSource* MergeImagesReaction::mergeArrays() -{ - if (m_dataSources.size() < 1) { - return nullptr; - } - - QList sourceList = m_dataSources.values(); - - // Eventually, we'll offer the option to merge components in a single array. - // For now, we will simply append the point data arrays. - vtkSMSessionProxyManager* pxm = ActiveObjects::instance().proxyManager(); - vtkSMSourceProxy* filter = vtkSMSourceProxy::SafeDownCast( - pxm->NewProxy("filters", "AppendAttributes")); - Q_ASSERT(filter); - - for (int i = 0; i < sourceList.size(); ++i) { - vtkSMPropertyHelper(filter, "Input").Add(sourceList[i]->proxy(), 0); - } - - filter->UpdateVTKObjects(); - filter->UpdatePipeline(); - - DataSource* newSource = new DataSource(filter); - QString mergedFilename(QFileInfo(sourceList[0]->fileName()).baseName()); - for (int i = 1; i < sourceList.size(); ++i) { - mergedFilename.append(" + "); - mergedFilename.append(QFileInfo(sourceList[i]->fileName()).baseName()); - } - newSource->setFileName(mergedFilename); - filter->Delete(); - - return newSource; -} - -DataSource* MergeImagesReaction::mergeComponents() -{ - if (m_dataSources.size() < 1) { - return nullptr; - } - - QList sourceList = m_dataSources.values(); - QStringList sourceLabels; - - vtkSMSessionProxyManager* pxm = ActiveObjects::instance().proxyManager(); - vtkSMSourceProxy* filter = vtkSMSourceProxy::SafeDownCast( - pxm->NewProxy("filters", "PythonCalculator")); - Q_ASSERT(filter); - - std::stringstream expression; - expression << "np.transpose(np.vstack(("; - - for (int i = 0; i < sourceList.size(); ++i) { - vtkSMPropertyHelper(filter, "Input").Add(sourceList[i]->proxy(), 0); - - auto info = sourceList[0]->proxy()->GetDataInformation(); - auto pointData = info->GetPointDataInformation(); - for (int j = 0; j < pointData->GetNumberOfArrays(); ++j) { - auto arrayInfo = pointData->GetArrayInformation(j); - std::string arrayName(arrayInfo->GetName()); - expression << "inputs[" << i << "].PointData['" << arrayName << "']"; - if (i < sourceList.size() - 1 || j < pointData->GetNumberOfArrays() - 1) { - expression << ", "; - } - } - - sourceLabels.append(sourceList[i]->label()); - } - - // Arrange columns - expression << ")))"; - - std::cout << "expression: " << expression.str() << std::endl; - - // Set the Python expression for arranging the components in the output - vtkSMPropertyHelper(filter, "ArrayAssociation").Set(0); - vtkSMPropertyHelper(filter, "CopyArrays").Set(0); - vtkSMPropertyHelper(filter, "Expression").Set(expression.str().c_str()); - vtkSMPropertyHelper(filter, "ArrayName").Set("Merged"); - - filter->UpdateVTKObjects(); - filter->UpdatePipeline(); - - DataSource* newSource = new DataSource(filter); - newSource->setFileName("Merged Image"); - filter->Delete(); - - // Give the components names based off the labels of the data sources - // that were used to generate them. - newSource->setComponentNames(sourceLabels); - return newSource; -} - -} // namespace tomviz diff --git a/tomviz/MergeImagesReaction.h b/tomviz/MergeImagesReaction.h deleted file mode 100644 index 5f23bb59a..000000000 --- a/tomviz/MergeImagesReaction.h +++ /dev/null @@ -1,39 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizMergeImagesReaction_h -#define tomvizMergeImagesReaction_h - -#include - -#include - -namespace tomviz { - -class DataSource; - -class MergeImagesReaction : pqReaction -{ - Q_OBJECT - -public: - MergeImagesReaction(QAction* action); - -public slots: - void updateDataSources(QSet); - -protected: - void onTriggered() override; - void updateEnableState() override; - - DataSource* mergeArrays(); - DataSource* mergeComponents(); - -private: - Q_DISABLE_COPY(MergeImagesReaction) - - QSet m_dataSources; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/MoleculeProperties.cxx b/tomviz/MoleculeProperties.cxx index 5629b39f6..74889f168 100644 --- a/tomviz/MoleculeProperties.cxx +++ b/tomviz/MoleculeProperties.cxx @@ -32,7 +32,7 @@ MoleculeProperties::MoleculeProperties(vtkMolecule* molecule, QWidget* parent) it.next(); formula += QString("%1%2 ").arg(it.key()).arg(it.value()); } - auto formulaBox = new QGroupBox("Formula:"); + auto formulaBox = new QGroupBox("Molecule Formula:"); auto formulaLabel = new QLabel(formula); auto vbox = new QVBoxLayout; vbox->addWidget(formulaLabel); @@ -58,12 +58,15 @@ MoleculeProperties::MoleculeProperties(vtkMolecule* molecule, QWidget* parent) table->setVisible(toggle); }); + formulaBox->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + layout->addWidget(formulaBox); layout->addWidget(saveButton); layout->addWidget(showButton); layout->addWidget(table); + layout->addStretch(); - layout->setContentsMargins(0, 0, 0, 0); + layout->setContentsMargins(4, 4, 4, 4); this->setLayout(layout); } diff --git a/tomviz/MoleculePropertiesPanel.cxx b/tomviz/MoleculePropertiesPanel.cxx deleted file mode 100644 index 0a780241c..000000000 --- a/tomviz/MoleculePropertiesPanel.cxx +++ /dev/null @@ -1,69 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "MoleculePropertiesPanel.h" - -#include "ActiveObjects.h" -#include "MoleculeProperties.h" -#include "MoleculeSource.h" - -#include - -#include -#include -#include - -namespace tomviz { - -MoleculePropertiesPanel::MoleculePropertiesPanel(QWidget* parent) - : QWidget(parent) -{ - - m_layout = new QVBoxLayout(); - QWidget* separator = pqProxyWidget::newGroupLabelWidget("Filename", this); - m_label = new QLineEdit(); - m_label->setAutoFillBackground(true); - m_label->setFrame(false); - m_label->setReadOnly(true); - m_layout->addWidget(separator); - m_layout->addWidget(m_label); - m_layout->addStretch(); - this->setLayout(m_layout); - - connect(&ActiveObjects::instance(), - &ActiveObjects::moleculeSourceChanged, this, - &MoleculePropertiesPanel::setMoleculeSource); - update(); -} - -MoleculePropertiesPanel::~MoleculePropertiesPanel() -{ -} - -void MoleculePropertiesPanel::setMoleculeSource(MoleculeSource* source) -{ - m_currentMoleculeSource = source; - update(); -} - -void MoleculePropertiesPanel::update() -{ - if (m_moleculeProperties) { - m_layout->removeWidget(m_moleculeProperties); - delete m_moleculeProperties; - m_moleculeProperties = nullptr; - } - - if (m_currentMoleculeSource) { - auto fileName = m_currentMoleculeSource->fileName(); - m_label->setText(fileName); - auto molecule = m_currentMoleculeSource->molecule(); - m_moleculeProperties = new MoleculeProperties(molecule); - m_layout->insertWidget(m_layout->count() - 1, m_moleculeProperties); - - } else { - m_label->setText(QString()); - } -} - -} // tomviz diff --git a/tomviz/MoleculePropertiesPanel.h b/tomviz/MoleculePropertiesPanel.h deleted file mode 100644 index a10c31f30..000000000 --- a/tomviz/MoleculePropertiesPanel.h +++ /dev/null @@ -1,42 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizMoleculePropertiesPanel_h -#define tomvizMoleculePropertiesPanel_h - -#include - -#include - -class QVBoxLayout; -class QLineEdit; - -namespace tomviz { - -class MoleculeSource; -class MoleculeProperties; - -class MoleculePropertiesPanel : public QWidget -{ - Q_OBJECT - -public: - explicit MoleculePropertiesPanel(QWidget* parent = nullptr); - ~MoleculePropertiesPanel() override; - -private slots: - void setMoleculeSource(MoleculeSource*); - -private: - Q_DISABLE_COPY(MoleculePropertiesPanel) - - void update(); - - QPointer m_currentMoleculeSource; - QVBoxLayout* m_layout; - QLineEdit* m_label; - MoleculeProperties* m_moleculeProperties = nullptr; -}; -} - -#endif diff --git a/tomviz/MoleculeSource.cxx b/tomviz/MoleculeSource.cxx deleted file mode 100644 index b740c21aa..000000000 --- a/tomviz/MoleculeSource.cxx +++ /dev/null @@ -1,102 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "MoleculeSource.h" - -#include "ModuleFactory.h" -#include "ModuleManager.h" -#include "Pipeline.h" - -#include -#include - -#include - -namespace tomviz { - -MoleculeSource::MoleculeSource(vtkMolecule* molecule, QObject* parent) - : QObject(parent), m_molecule(vtkSmartPointer::Take(molecule)) -{ -} - -MoleculeSource::~MoleculeSource() = default; - -QJsonObject MoleculeSource::serialize() const -{ - QJsonObject json = m_json; - // Serialize the modules... - auto modules = ModuleManager::instance().findModulesGeneric(this, nullptr); - QJsonArray jModules; - foreach (Module* module, modules) { - QJsonObject jModule = module->serialize(); - jModule["type"] = ModuleFactory::moduleType(module); - jModule["viewId"] = static_cast(module->view()->GetGlobalID()); - - jModules.append(jModule); - } - if (!jModules.isEmpty()) { - json["modules"] = jModules; - } - - return json; -} - -bool MoleculeSource::deserialize(const QJsonObject& state) -{ - // Check for modules on the data source first. - if (state.contains("modules") && state["modules"].isArray()) { - auto moduleArray = state["modules"].toArray(); - for (int i = 0; i < moduleArray.size(); ++i) { - auto moduleObj = moduleArray[i].toObject(); - auto viewId = moduleObj["viewId"].toInt(); - auto viewProxy = ModuleManager::instance().lookupView(viewId); - auto type = moduleObj["type"].toString(); - auto m = - ModuleManager::instance().createAndAddModule(type, this, viewProxy); - m->deserialize(moduleObj); - } - } - return true; -} - -void MoleculeSource::setFileName(const QString fileName) -{ - auto reader = m_json.value("reader").toObject(QJsonObject()); - QJsonValue file(fileName); - reader["fileName"] = file; - m_json["reader"] = reader; -} - -/// Returns the name of the file used to load the data source. -QString MoleculeSource::fileName() const -{ - auto reader = m_json.value("reader").toObject(QJsonObject()); - QString file; - if (reader.contains("fileName")) { - file = reader["fileName"].toString(); - } - return file; -} - -/// Set the label for the data source. -void MoleculeSource::setLabel(const QString& label) -{ - m_json["label"] = label; -} - -/// Returns the name of the filename used from the originalDataSource. -QString MoleculeSource::label() const -{ - if (m_json.contains("label")) { - return m_json["label"].toString(); - } else { - return QFileInfo(fileName()).baseName(); - } -} - -vtkMolecule* MoleculeSource::molecule() const -{ - return m_molecule.GetPointer(); -} - -} // namespace tomviz diff --git a/tomviz/MoleculeSource.h b/tomviz/MoleculeSource.h deleted file mode 100644 index 0ba7d9ef1..000000000 --- a/tomviz/MoleculeSource.h +++ /dev/null @@ -1,52 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizMoleculeSource_h -#define tomvizMoleculeSource_h - -#include - -#include - -#include - -class vtkMolecule; - -namespace tomviz { -class Pipeline; - -class MoleculeSource : public QObject -{ - Q_OBJECT - -public: - MoleculeSource(vtkMolecule* molecule, QObject* parent = nullptr); - ~MoleculeSource() override; - - /// Save the state out. - QJsonObject serialize() const; - bool deserialize(const QJsonObject& state); - - /// Set the file name. - void setFileName(QString label); - - /// Returns the file used to load the atoms. - QString fileName() const; - - /// Set the label for the data source. - void setLabel(const QString& label); - - /// Returns the name of the filename used from the originalDataSource. - QString label() const; - - /// Returns a pointer to the vtkMolecule - vtkMolecule* molecule() const; - -private: - QJsonObject m_json; - vtkSmartPointer m_molecule; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/MoveActiveObject.cxx b/tomviz/MoveActiveObject.cxx deleted file mode 100644 index 94f26e87e..000000000 --- a/tomviz/MoveActiveObject.cxx +++ /dev/null @@ -1,267 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "MoveActiveObject.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "Module.h" -#include "ModuleManager.h" -#include "Pipeline.h" -#include "Utilities.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace tomviz { - -MoveActiveObject::MoveActiveObject(QObject* p) : Superclass(p) -{ - ActiveObjects& activeObjs = ActiveObjects::instance(); - this->BoxRep->SetPlaceFactor(1.0); - this->BoxRep->HandlesOn(); - this->BoxRep->SetHandleSize(10); - - this->BoxWidget->SetRepresentation(this->BoxRep.GetPointer()); - this->BoxWidget->SetPriority(1); - - this->connect(&activeObjs, &ActiveObjects::dataSourceActivated, - this, &MoveActiveObject::dataSourceActivated); - this->connect(&activeObjs, - QOverload::of(&ActiveObjects::viewChanged), - this, &MoveActiveObject::onViewChanged); - - this->connect(&activeObjs, &ActiveObjects::interactionDataSourceFixed, this, - &MoveActiveObject::onInteractionDataSourceFixed); - - this->connect(&activeObjs, &ActiveObjects::translationStateChanged, this, - &MoveActiveObject::updateInteractionStates); - - this->connect(&activeObjs, &ActiveObjects::rotationStateChanged, this, - &MoveActiveObject::updateInteractionStates); - - this->connect(&activeObjs, &ActiveObjects::scalingStateChanged, this, - &MoveActiveObject::updateInteractionStates); - - this->EventLink->Connect(this->BoxWidget.GetPointer(), - vtkCommand::InteractionEvent, this, - SLOT(interactionEnd(vtkObject*))); - this->EventLink->Connect(this->BoxWidget.GetPointer(), - vtkCommand::EndInteractionEvent, this, - SLOT(interactionEnd(vtkObject*))); -} - -MoveActiveObject::~MoveActiveObject() {} - -void MoveActiveObject::onViewChanged(vtkSMViewProxy* view) -{ - pqView* pqview = tomviz::convert(view); - if (this->View == pqview) { - return; - } - - if (view && view->GetRenderWindow()) { - this->BoxWidget->SetInteractor(view->GetRenderWindow()->GetInteractor()); - } else { - this->BoxWidget->SetInteractor(nullptr); - this->BoxWidget->EnabledOff(); - } - - // Render the old view and then the new one - render(); - this->View = pqview; - updateInteractionStates(); -} - -void MoveActiveObject::interactionEnd(vtkObject* caller) -{ - Q_UNUSED(caller); - - vtkNew t; - this->BoxRep->GetTransform(t); - - QScopedValueRollback rollback(this->Interacting, true); - auto ds = this->currentDataSource; - ds->setDisplayPosition(t->GetPosition()); - ds->setDisplayOrientation(t->GetOrientation()); - ds->setSpacing(t->GetScale()); - render(); -} - -void MoveActiveObject::dataSourceActivated(DataSource* ds) -{ - auto* fixedDs = ActiveObjects::instance().fixedInteractionDataSource(); - if (fixedDs && ds != fixedDs) { - // Don't allow a different data source to be activated if we are using - // a fixed data source. - return; - } - - if (ds == this->currentDataSource) { - return; - } - - if (this->currentDataSource) { - this->disconnect(this->currentDataSource); - } - - this->currentDataSource = ds; - resetWidgetPlacement(); - - if (ds) { - connect(ds, &DataSource::displayPositionChanged, this, - &MoveActiveObject::onDataPositionChanged); - connect(ds, &DataSource::displayOrientationChanged, this, - &MoveActiveObject::onDataOrientationChanged); - connect(ds, &DataSource::dataPropertiesChanged, this, - &MoveActiveObject::onDataPropertiesChanged); - } - - updateInteractionStates(); -} - -void MoveActiveObject::resetWidgetPlacement() -{ - auto source = this->currentDataSource; - if (!source) { - return; - } - - // Use the extents for the widget bounds. Need to convert to double. - double bounds[6]; - auto* extent = source->imageData()->GetExtent(); - for (int i = 0; i < 6; ++i) { - bounds[i] = static_cast(extent[i]); - } - - this->BoxRep->PlaceWidget(bounds); - this->updateWidgetTransform(); -} - -void MoveActiveObject::onDataPropertiesChanged() -{ - if (this->Interacting) { - return; - } - - updateWidgetTransform(); - render(); -} - -void MoveActiveObject::updateWidgetTransform() -{ - auto ds = this->currentDataSource; - if (!ds) { - return; - } - - vtkNew t; - t->Identity(); - - // Translate - t->Translate(ds->displayPosition()); - - // Rotate - // Do as vtkProp3D does: rotate Z first, then X, then Y. - auto* orientation = ds->displayOrientation(); - t->RotateZ(orientation[2]); - t->RotateX(orientation[0]); - t->RotateY(orientation[1]); - - // Scale - t->Scale(ds->getSpacing()); - - this->BoxRep->SetTransform(t); -} - -void MoveActiveObject::onDataPositionChanged(double, double, double) -{ - this->onDataPropertiesChanged(); -} - -void MoveActiveObject::onDataOrientationChanged(double, double, double) -{ - this->onDataPropertiesChanged(); -} - -void MoveActiveObject::onInteractionDataSourceFixed(DataSource* ds) -{ - // This has the logic we need - this->dataSourceActivated(ds); -} - -void MoveActiveObject::updateInteractionStates() -{ - auto* ds = this->currentDataSource; - auto* widget = this->BoxWidget.Get(); - auto* rep = this->BoxRep.Get(); - - if (!ds || !this->View || activePipelineIsRunning()) { - widget->EnabledOff(); - render(); - return; - } - - auto& activeObjects = ActiveObjects::instance(); - bool translate = activeObjects.translationEnabled(); - bool rotate = activeObjects.rotationEnabled(); - bool scale = activeObjects.scalingEnabled(); - - bool anyTransforms = translate || rotate || scale; - - widget->SetEnabled(anyTransforms); - if (!anyTransforms) { - render(); - return; - } - - widget->SetTranslationEnabled(translate); - widget->SetRotationEnabled(rotate); - widget->SetScalingEnabled(scale); - widget->SetMoveFacesEnabled(scale); - - for (int i = 0; i < 6; ++i) { - rep->GetHandle()[i]->SetVisibility(scale); - } - - rep->GetHandle()[6]->SetVisibility(translate); - - render(); -} - -void MoveActiveObject::render() -{ - if (!this->View) { - return; - } - - this->View->render(); -} - -bool MoveActiveObject::activePipelineIsRunning() const -{ - auto* pipeline = ActiveObjects::instance().activePipeline(); - return pipeline && pipeline->isRunning(); -} - -} // namespace tomviz diff --git a/tomviz/MoveActiveObject.h b/tomviz/MoveActiveObject.h deleted file mode 100644 index 2dab3f4f5..000000000 --- a/tomviz/MoveActiveObject.h +++ /dev/null @@ -1,66 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizMoveActiveObject_h -#define tomvizMoveActiveObject_h - -#include - -#include -#include - -#include -#include - -class pqDataRepresentation; -class vtkCustomBoxRepresentation; -class vtkBoxWidget2; -class vtkEventQtSlotConnect; -class vtkObject; -class vtkSMViewProxy; -class pqView; - -namespace tomviz { -class DataSource; - -class MoveActiveObject : public QObject -{ - Q_OBJECT - typedef QObject Superclass; - -public: - MoveActiveObject(QObject* parent); - ~MoveActiveObject(); - -private slots: - void dataSourceActivated(DataSource* ds); - void onDataPropertiesChanged(); - void onDataPositionChanged(double x, double y, double z); - void onDataOrientationChanged(double x, double y, double z); - - void onViewChanged(vtkSMViewProxy* newView); - void interactionEnd(vtkObject* obj); - - void updateInteractionStates(); - - void onInteractionDataSourceFixed(DataSource* ds); - -private: - void render(); - - bool activePipelineIsRunning() const; - - void resetWidgetPlacement(); - void updateWidgetTransform(); - - Q_DISABLE_COPY(MoveActiveObject) - vtkNew BoxRep; - vtkNew BoxWidget; - vtkNew EventLink; - QPointer View; - DataSource* currentDataSource; - bool Interacting = false; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/OperatorSearchDialog.cxx b/tomviz/OperatorSearchDialog.cxx new file mode 100644 index 000000000..5f77e7978 --- /dev/null +++ b/tomviz/OperatorSearchDialog.cxx @@ -0,0 +1,303 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "OperatorSearchDialog.h" + +#include "Utilities.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +OperatorSearchDialog::OperatorSearchDialog(QWidget* parent) + : QDialog(parent, Qt::Dialog | Qt::FramelessWindowHint) +{ + setWindowTitle("Search Operators"); + setMinimumSize(450, 550); + + auto* layout = new QVBoxLayout(this); + + m_searchBox = new QLineEdit(this); + m_searchBox->setPlaceholderText("Type to search"); + m_searchBox->setClearButtonEnabled(true); + layout->addWidget(m_searchBox); + + m_createButton = new QPushButton(this); + m_createButton->setText("(no selection)"); + m_createButton->setToolTip("Create the selected operator"); + layout->addWidget(m_createButton); + + m_availableList = new QListWidget(this); + m_availableList->setSizePolicy(QSizePolicy::Expanding, + QSizePolicy::Expanding); + layout->addWidget(m_availableList); + + m_descriptionLabel = new QLabel(this); + m_descriptionLabel->setWordWrap(true); + m_descriptionLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); + m_descriptionLabel->setMinimumHeight(40); + layout->addWidget(m_descriptionLabel); + + m_unavailableLabel = new QLabel("Unavailable:", this); + m_unavailableLabel->setToolTip( + "Select one to see its description. These operators require " + "an active data source to be enabled."); + layout->addWidget(m_unavailableLabel); + + m_unavailableList = new QListWidget(this); + m_unavailableList->setSizePolicy(QSizePolicy::Expanding, + QSizePolicy::Maximum); + m_unavailableList->setMaximumHeight(150); + layout->addWidget(m_unavailableList); + + m_instructionsLabel = new QLabel( + "Press Enter to create.\nEsc to cancel.", this); + layout->addWidget(m_instructionsLabel); + + connect(m_searchBox, &QLineEdit::textChanged, this, + &OperatorSearchDialog::filterOperators); + connect(m_createButton, &QPushButton::clicked, this, + &OperatorSearchDialog::activateCurrentProxy); + connect(m_availableList, &QListWidget::currentItemChanged, this, + &OperatorSearchDialog::availableItemChanged); + connect(m_unavailableList, &QListWidget::currentItemChanged, this, + &OperatorSearchDialog::unavailableItemChanged); + connect(m_availableList, &QListWidget::itemDoubleClicked, this, + [this](QListWidgetItem*) { activateCurrentProxy(); }); +} + +void OperatorSearchDialog::addOperatorAction(QAction* action, + const QString& category, + const QString& description) +{ + OperatorEntry entry; + entry.action = action; + entry.name = action->text().remove('&'); + entry.category = category; + entry.description = description; + m_operators.append(entry); +} + +void OperatorSearchDialog::collectActionsFromMenu(QMenu* menu, + const QString& prefix) +{ + for (auto* action : menu->actions()) { + if (action->menu()) { + QString submenuTitle = action->menu()->title().remove('&'); + QString newPrefix = prefix.isEmpty() + ? submenuTitle + : prefix + " > " + submenuTitle; + collectActionsFromMenu(action->menu(), newPrefix); + } else if (!action->isSeparator()) { + // Prefer statusTip (set from JSON description) over toolTip + QString desc = action->statusTip(); + if (desc.isEmpty()) { + QString tip = action->toolTip(); + // Qt often auto-sets toolTip to the action text; skip those + if (tip != action->text().remove('&')) { + desc = tip; + } + } + addOperatorAction(action, prefix, desc); + } + } +} + +void OperatorSearchDialog::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + m_searchBox->clear(); + m_searchBox->setFocus(); + rebuildLists(); +} + +void OperatorSearchDialog::filterOperators(const QString& text) +{ + m_availableList->clear(); + m_unavailableList->clear(); + m_createButton->setText("(no selection)"); + m_createButton->setEnabled(false); + m_descriptionLabel->clear(); + + QString filter = text.trimmed(); + + // Build name counts for duplicate detection + QHash nameCounts; + for (const auto& entry : m_operators) { + QString name = entry.action->text().remove('&'); + nameCounts[name]++; + } + + for (int i = 0; i < m_operators.size(); ++i) { + const auto& entry = m_operators[i]; + QString currentName = entry.action->text().remove('&'); + if (!filter.isEmpty() && + !currentName.contains(filter, Qt::CaseInsensitive) && + !entry.category.contains(filter, Qt::CaseInsensitive)) { + continue; + } + + // Only disambiguate with category if there are duplicate names + QString displayText = currentName; + if (nameCounts.value(currentName) > 1 && !entry.category.isEmpty()) { + displayText = currentName + " (" + entry.category + ")"; + } + + auto* item = new QListWidgetItem(displayText); + item->setData(Qt::UserRole, i); + + if (entry.action->isEnabled()) { + m_availableList->addItem(item); + } else { + item->setForeground(palette().color(QPalette::Disabled, QPalette::Text)); + m_unavailableList->addItem(item); + } + } + + // Select first available item, or first unavailable if none available + if (m_availableList->count() > 0) { + m_availableList->setCurrentRow(0); + } else if (m_unavailableList->count() > 0) { + m_unavailableList->setCurrentRow(0); + } +} + +void OperatorSearchDialog::availableItemChanged(QListWidgetItem* current, + QListWidgetItem*) +{ + if (current) { + // Clear selection in the other list + m_unavailableList->clearSelection(); + m_unavailableList->setCurrentItem(nullptr); + updateSelection(current); + m_createButton->setEnabled(true); + } +} + +void OperatorSearchDialog::unavailableItemChanged(QListWidgetItem* current, + QListWidgetItem*) +{ + if (current) { + // Clear selection in the other list + m_availableList->clearSelection(); + m_availableList->setCurrentItem(nullptr); + updateSelection(current); + m_createButton->setEnabled(false); + } +} + +void OperatorSearchDialog::updateSelection(QListWidgetItem* item) +{ + if (!item) { + return; + } + + int index = item->data(Qt::UserRole).toInt(); + if (index < 0 || index >= m_operators.size()) { + return; + } + + const auto& entry = m_operators[index]; + QString currentName = entry.action->text().remove('&'); + m_createButton->setText(currentName); + + // Prefer live statusTip (set from JSON), fall back to stored description + QString desc = entry.action->statusTip(); + if (desc.isEmpty()) { + desc = entry.description; + } + m_descriptionLabel->setText(desc); +} + +void OperatorSearchDialog::activateCurrentProxy() +{ + auto* item = m_availableList->currentItem(); + if (!item) { + return; + } + + int index = item->data(Qt::UserRole).toInt(); + if (index >= 0 && index < m_operators.size() && + m_operators[index].action->isEnabled()) { + accept(); + m_operators[index].action->trigger(); + } +} + +QListWidget* OperatorSearchDialog::currentList() +{ + if (m_unavailableList->currentItem() && + !m_availableList->currentItem()) { + return m_unavailableList; + } + return m_availableList; +} + +void OperatorSearchDialog::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + activateCurrentProxy(); + return; + } else if (event->key() == Qt::Key_Escape) { + if (m_searchBox->text().isEmpty()) { + reject(); + } else { + m_searchBox->clear(); + } + return; + } else if (event->key() == Qt::Key_Down) { + QListWidget* list = currentList(); + int row = list->currentRow(); + if (row < list->count() - 1) { + list->setCurrentRow(row + 1); + } else if (list == m_availableList && m_unavailableList->count() > 0) { + // Move from available to unavailable list + m_unavailableList->setCurrentRow(0); + } + return; + } else if (event->key() == Qt::Key_Up) { + QListWidget* list = currentList(); + int row = list->currentRow(); + if (row > 0) { + list->setCurrentRow(row - 1); + } else if (list == m_unavailableList && m_availableList->count() > 0) { + // Move from unavailable back to available list + m_availableList->setCurrentRow(m_availableList->count() - 1); + } + return; + } else if (event->key() == Qt::Key_Tab) { + // Toggle between lists + QListWidget* list = currentList(); + if (list == m_availableList && m_unavailableList->count() > 0) { + m_unavailableList->setCurrentRow(0); + } else if (list == m_unavailableList && m_availableList->count() > 0) { + m_availableList->setCurrentRow(0); + } + return; + } + + // Forward unhandled text keys to the search box + if (!event->text().isEmpty() && event->text().at(0).isPrint()) { + m_searchBox->setFocus(); + m_searchBox->setText(m_searchBox->text() + event->text()); + return; + } + + QDialog::keyPressEvent(event); +} + +void OperatorSearchDialog::rebuildLists() +{ + filterOperators(m_searchBox->text()); +} + +} // namespace tomviz diff --git a/tomviz/OperatorSearchDialog.h b/tomviz/OperatorSearchDialog.h new file mode 100644 index 000000000..9d70ca51a --- /dev/null +++ b/tomviz/OperatorSearchDialog.h @@ -0,0 +1,80 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizOperatorSearchDialog_h +#define tomvizOperatorSearchDialog_h + +#include +#include +#include + +class QAction; +class QKeyEvent; +class QLabel; +class QLineEdit; +class QListWidget; +class QListWidgetItem; +class QMenu; +class QPushButton; +class QShowEvent; + +namespace tomviz { + +/// A ParaView-style quick-launch dialog for searching and activating operators. +/// +/// Layout (top to bottom): +/// - Search text field +/// - Button showing selected operator name (click to activate) +/// - Available operators list +/// - Description label for selected operator +/// - "Unavailable:" label + disabled operators list +/// - Instructions label +class OperatorSearchDialog : public QDialog +{ + Q_OBJECT + +public: + explicit OperatorSearchDialog(QWidget* parent = nullptr); + + /// Register an action with a category and optional description. + void addOperatorAction(QAction* action, const QString& category, + const QString& description = QString()); + + /// Recursively collect all actions from a menu hierarchy. + void collectActionsFromMenu(QMenu* menu, const QString& prefix); + + void showEvent(QShowEvent* event) override; + +private slots: + void filterOperators(const QString& text); + void availableItemChanged(QListWidgetItem* current, QListWidgetItem* prev); + void unavailableItemChanged(QListWidgetItem* current, QListWidgetItem* prev); + void activateCurrentProxy(); + +private: + void keyPressEvent(QKeyEvent* event) override; + void rebuildLists(); + void updateSelection(QListWidgetItem* item); + QListWidget* currentList(); + + struct OperatorEntry + { + QAction* action; + QString name; + QString category; + QString description; + }; + + QLineEdit* m_searchBox; + QPushButton* m_createButton; + QListWidget* m_availableList; + QLabel* m_descriptionLabel; + QLabel* m_unavailableLabel; + QListWidget* m_unavailableList; + QLabel* m_instructionsLabel; + QList m_operators; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/Pipeline.cxx b/tomviz/Pipeline.cxx deleted file mode 100644 index 77ea03adb..000000000 --- a/tomviz/Pipeline.cxx +++ /dev/null @@ -1,700 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "Pipeline.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "DockerExecutor.h" -#include "DockerUtilities.h" -#include "EmdFormat.h" -#include "ExternalPythonExecutor.h" -#include "ModuleManager.h" -#include "Operator.h" -#include "ThreadedExecutor.h" -#include "Utilities.h" - -#include - -#include -#include -#include -#include -#include - -namespace tomviz { - -PipelineSettings::PipelineSettings() -{ - m_settings = pqApplicationCore::instance()->settings(); -} - -void PipelineSettings::setExecutionMode(Pipeline::ExecutionMode executor) -{ - auto metaEnum = QMetaEnum::fromType(); - setExecutionMode(QString(metaEnum.valueToKey(executor))); -} - -void PipelineSettings::setExecutionMode(const QString& executor) -{ - m_settings->setValue("pipeline/mode", executor); -} - -Pipeline::ExecutionMode PipelineSettings::executionMode() -{ - if (!m_settings->contains("pipeline/mode")) { - return Pipeline::ExecutionMode::Threaded; - } - auto metaEnum = QMetaEnum::fromType(); - - return static_cast(metaEnum.keyToValue( - m_settings->value("pipeline/mode").toString().toLatin1().data())); -} - -QString PipelineSettings::dockerImage() -{ - return m_settings->value("pipeline/docker.image").toString(); -} - -bool PipelineSettings::dockerPull() -{ - return m_settings->value("pipeline/docker.pull", true).toBool(); -} - -bool PipelineSettings::dockerRemove() -{ - return m_settings->value("pipeline/docker.remove", true).toBool(); -} - -QString PipelineSettings::externalPythonExecutablePath() -{ - return m_settings->value("pipeline/external.executable").toString(); -} - -void PipelineSettings::setDockerImage(const QString& image) -{ - m_settings->setValue("pipeline/docker.image", image); -} - -void PipelineSettings::setDockerPull(bool pull) -{ - m_settings->setValue("pipeline/docker.pull", pull); -} - -void PipelineSettings::setDockerRemove(bool remove) -{ - m_settings->setValue("pipeline/docker.remove", remove); -} - -void PipelineSettings::setExternalPythonExecutablePath( - const QString& executable) -{ - m_settings->setValue("pipeline/external.executable", executable); -} - -Pipeline::Pipeline(DataSource* dataSource, QObject* parent) : QObject(parent) -{ - m_data = dataSource; - m_data->setParent(this); - - addDataSource(dataSource); - - PipelineSettings settings; - auto executor = settings.executionMode(); - setExecutionMode(executor); -} - -Pipeline::~Pipeline() = default; - -Pipeline::Future* Pipeline::execute() -{ - return execute(m_data); -} - -class PipelineFutureInternal : public Pipeline::Future -{ - Q_OBJECT -public: - PipelineFutureInternal(Pipeline* pipeline, QList operators, - Pipeline::Future* future, bool recurse = true, - QObject* parent = nullptr); - -private: - Pipeline* m_pipeline; - QScopedPointer m_currentBranchFuture; - bool m_recurse = false; - - void setCurrentFuture(Pipeline::Future* future); -}; - -PipelineFutureInternal::PipelineFutureInternal(Pipeline* pipeline, - QList operators, - Pipeline::Future* future, - bool recurse, QObject* parent) - : Pipeline::Future(operators, parent), m_pipeline(pipeline), - m_recurse(recurse) -{ - setCurrentFuture(future); -} - -void PipelineFutureInternal::setCurrentFuture(Pipeline::Future* future) -{ - - m_currentBranchFuture.reset(future); - - connect(future, &Pipeline::Future::finished, future, [future, this]() { - auto operators = future->operators(); - // operators will be empty if we are returning the original data set ( i.e - // no operators have been run. - if (operators.isEmpty()) { - setResult(future->result()); - emit finished(); - return; - } - - auto lastOp = future->operators().last(); - - // Do we have another branch to execute - if (m_recurse && lastOp->childDataSource() != nullptr && - !lastOp->childDataSource()->operators().isEmpty()) { - auto child = lastOp->childDataSource(); - auto newFuture = m_pipeline->executor()->execute(child->dataObject(), - child->operators()); - this->setCurrentFuture(newFuture); - // Ensure the pipeline has ownership of the transformed data source. - lastOp->childDataSource()->setParent(m_pipeline); - } - // The pipeline execution is finished - else { - setResult(future->result()); - emit finished(); - } - }); -} - -Pipeline::Future* Pipeline::execute(DataSource* dataSource) -{ - if (beingEdited(dataSource)) { - return emptyFuture(); - } - - auto operators = dataSource->operators(); - if (operators.size() < 1) { - return emptyFuture(); - } - - Operator* firstModifiedOperator = operators.first(); - if (!isModified(dataSource, &firstModifiedOperator)) { - return emptyFuture(); - } - - return executeRange(dataSource, firstModifiedOperator, nullptr, true); -} - -Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start) -{ - return executeRange(ds, start, nullptr, true); -} - -Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, - Operator* end) -{ - return executeRange(ds, start, end, false); -} - -Pipeline::Future* Pipeline::executeRange(DataSource* ds, Operator* start, - Operator* end, - bool checkBreakpoints) -{ - if (paused()) { - return emptyFuture(); - } - - if (ds == nullptr) { - ds = m_data; - } - - m_operatorsDeleted = false; - - emit started(); - - auto operators = ds->operators(); - if (operators.isEmpty()) { - emit finished(); - auto future = new Pipeline::Future(); - // Delay emitting signal until next event loop - QTimer::singleShot(0, [future, ds] { emit future->finished(); }); - return future; - } - int startIndex = 0; - if (start == nullptr) { - start = operators.first(); - } - if (start == ds->operators().last() && start->isNew()) { - // See if we have any canceled operators in the pipeline, if so we have to - // re-run the anyway pipeline. - bool haveCanceled = false; - for (auto itr = operators.begin(); *itr != start; ++itr) { - auto currentOp = *itr; - if (currentOp->isCanceled()) { - currentOp->resetState(); - haveCanceled = true; - break; - } - } - - if (!haveCanceled) { - startIndex = operators.indexOf(start); - // Use transformed data source - ds = transformedDataSource(ds); - } - } - // If start is not the first operator and we haven't already adjusted - // startIndex (e.g. when resuming from a breakpoint), start from the - // correct position using the already-transformed intermediate data. - // but can only use the already transformed intermediate data if the - // previous operator is the one that created it, otherwise operators - // could be applied multiple times to already transformed data. - else if (start != operators.first() && startIndex == 0) { - startIndex = operators.indexOf(start); - if (startIndex > 0) { - auto prevOp = operators[startIndex - 1]; - // We can only use intermediate data if: - // 1. The previous op completed (so its output is valid) - // 2. The current op hasn't run yet (Queued) - // 3. No operators after start have already finished (would mean - // mid-chain insertion/edit, not a breakpoint resume) - bool canUseIntermediateData = - prevOp->isCompleted() && start->isQueued(); - if (canUseIntermediateData) { - for (int i = startIndex + 1; i < operators.size(); ++i) { - if (operators[i]->isFinished()) { - canUseIntermediateData = false; - break; - } - } - } - if (canUseIntermediateData) { - ds = transformedDataSource(ds); - } else { - startIndex = 0; - } - } - } - - // If we have been asked to run until the new operator we can just return - // the transformed data. - if (!operators.isEmpty() && end != nullptr && end->isNew()) { - auto transformed = transformedDataSource(); - auto dataObject = vtkImageData::SafeDownCast(transformed->copyData()); - auto future = new Pipeline::Future(dataObject); - dataObject->FastDelete(); - // Delay emitting signal until next event loop - QTimer::singleShot(0, [=] { emit future->finished(); }); - - return future; - } - - auto endIndex = -1; - if (end != nullptr) { - endIndex = operators.indexOf(end); - } - - // Search for breakpoints within the actual execution range. This happens - // AFTER startIndex determination so that the search covers the real range - // (which may start earlier than the modified operator). - Operator* breakpointOp = nullptr; - if (checkBreakpoints) { - int searchEnd = (endIndex == -1) ? operators.size() : endIndex; - for (int i = startIndex; i < searchEnd; ++i) { - if (operators[i]->hasBreakpoint()) { - breakpointOp = operators[i]; - break; - } - } - if (breakpointOp) { - int bpIdx = operators.indexOf(breakpointOp); - for (int i = bpIdx; i < operators.size(); ++i) { - operators[i]->resetState(); - } - endIndex = bpIdx; - } - } - - auto branchFuture = - m_executor->execute(ds->dataObject(), operators, startIndex, endIndex); - connect(branchFuture, &Pipeline::Future::finished, this, - &Pipeline::branchFinished); - - auto pipelineFuture = new PipelineFutureInternal( - this, branchFuture->operators(), branchFuture, operators.last() == end); - connect(pipelineFuture, &Pipeline::Future::finished, this, - &Pipeline::finished); - - if (breakpointOp) { - connect(pipelineFuture, &Pipeline::Future::finished, this, - [this, breakpointOp]() { emit breakpointReached(breakpointOp); }); - } - - return pipelineFuture; -} - -void Pipeline::startedEditingOp(Operator* op) -{ - ++m_editingOperators; - op->setEditing(); -} - -void Pipeline::finishedEditingOp(Operator* op) -{ - if (op->isModified()) { - op->resetState(); - } else { - op->setComplete(); - } - - if (m_editingOperators > 0) { - --m_editingOperators; - if (m_editingOperators == 0 && !isRunning()) { - execute(op->dataSource())->deleteWhenFinished(); - } - } -} - -bool Pipeline::beingEdited(DataSource* ds) const -{ - // If any operators in the pipeline are in editing state, - // don't execute the pipeline - if (ds == nullptr) { - return false; - } - auto operators = ds->operators(); - for (auto itr = operators.begin(); itr != operators.end(); ++itr) { - auto currentOp = *itr; - if (currentOp->isEditing()) { - return true; - } - // Also check if the operators in a branch are being edited. - if (beingEdited(currentOp->childDataSource())) { - return true; - } - } - return false; -} - -bool Pipeline::isModified(DataSource* datasource, Operator** start) const -{ - // If the m_operatorsDeleted flag is tripped - // (i.e. if one or more operator were deleted since the last execution) - // we should execute the pipeline even if no operators are in a modified state - if (m_operatorsDeleted) { - return true; - } - // If no operators are in a modified state, - // there is no need to execute the pipeline - if (datasource == nullptr) { - return false; - } - auto operators = datasource->operators(); - for (auto itr = operators.begin(); itr != operators.end(); ++itr) { - auto currentOp = *itr; - if (currentOp->isModified()) { - *start = currentOp; - return true; - } - // Also check if the operators in a branch are being edited. - if (isModified(currentOp->childDataSource(), start)) { - return true; - } - } - return false; -} - -void Pipeline::branchFinished() -{ - auto future = qobject_cast(sender()); - auto operators = future->operators(); - // operators will be empty if the original data source was returned as the - // result. i.e. no operators have been run. - if (operators.isEmpty()) { - return; - } - auto start = future->operators().first()->dataSource(); - auto newData = future->result(); - // We only add the transformed child data source if the last operator - // doesn't already have an explicit child data source i.e. - // hasChildDataSource is true. - auto lastOp = start->operators().last(); - if (!lastOp->isCompleted()) { - // The DataSource's last operator hasn't completed. This can happen when - // execution stopped early (e.g. at a breakpoint). If the last actually - // executed operator completed, update the visualization with the - // intermediate result; otherwise bail out. - if (operators.isEmpty() || !operators.last()->isCompleted()) { - return; - } - } - - if (!lastOp->hasChildDataSource()) { - DataSource* newChildDataSource = nullptr; - if (lastOp->childDataSource() == nullptr) { - newChildDataSource = new DataSource("Output"); - newChildDataSource->setPersistenceState( - tomviz::DataSource::PersistenceState::Transient); - newChildDataSource->setForkable(false); - newChildDataSource->setParent(this); - lastOp->setChildDataSource(newChildDataSource); - auto rootDataSource = dataSource(); - // connect signal to flow units and spacing to child data source. - connect(rootDataSource, &DataSource::dataPropertiesChanged, - newChildDataSource, - [rootDataSource, newChildDataSource]() { - // Only flow the properties if no user modifications have been - // made. - if (!newChildDataSource->unitsModified()) { - newChildDataSource->setUnits(rootDataSource->getUnits(), - false); - double spacing[3]; - rootDataSource->getSpacing(spacing); - newChildDataSource->setSpacing(spacing, false); - } - }); - } - - // Update the type if necessary - DataSource::DataSourceType type = DataSource::hasTiltAngles(newData) - ? DataSource::TiltSeries - : DataSource::Volume; - lastOp->childDataSource()->setData(newData); - lastOp->childDataSource()->setType(type); - lastOp->childDataSource()->dataModified(); - - if (newChildDataSource != nullptr) { - emit lastOp->newChildDataSource(newChildDataSource); - // Move modules from root data source. - moveModulesDown(newChildDataSource); - } - } - else { - // If this is the only operator, make sure the modules are moved down. - if (start->operators().size() == 1) - moveModulesDown(lastOp->childDataSource()); - } -} - -void Pipeline::pause() -{ - m_paused = true; -} - -bool Pipeline::paused() const -{ - return m_paused; -} - -void Pipeline::resume() -{ - m_paused = false; -} - -void Pipeline::cancel(std::function canceled) -{ - m_executor->cancel(canceled); -} - -bool Pipeline::isRunning() -{ - return m_executor->isRunning(); -} - -DataSource* Pipeline::findTransformedDataSource(DataSource* dataSource) -{ - auto op = findTransformedDataSourceOperator(dataSource); - if (op != nullptr) { - return op->childDataSource(); - } - - return nullptr; -} - -Operator* Pipeline::findTransformedDataSourceOperator(DataSource* dataSource) -{ - if (dataSource == nullptr) { - return nullptr; - } - - auto operators = dataSource->operators(); - for (auto itr = operators.rbegin(); itr != operators.rend(); ++itr) { - auto op = *itr; - if (op->childDataSource() != nullptr) { - auto child = op->childDataSource(); - // If the child has operators we need to go deeper - if (!child->operators().isEmpty()) { - return findTransformedDataSourceOperator(child); - } - - return op; - } - } - - return nullptr; -} - -void Pipeline::addDataSource(DataSource* dataSource) -{ - connect(dataSource, &DataSource::operatorAdded, [this](Operator* op) { - execute(op->dataSource())->deleteWhenFinished(); - }); - // Wire up transformModified to execute pipeline - connect(dataSource, &DataSource::operatorAdded, [this](Operator* op) { - // Extract out source and execute all. - connect(op, &Operator::transformModified, this, - [this]() { execute()->deleteWhenFinished(); }); - - // Ensure that new child data source signals are correctly wired up. - connect(op, - static_cast( - &Operator::newChildDataSource), - [this](DataSource* ds) { addDataSource(ds); }); - - // We need to ensure we move add datasource to the end of the branch, - // but only if the new operator is the last one (appended). For mid-chain - // insertions, the child DataSource should stay where it is. - auto operators = op->dataSource()->operators(); - if (operators.size() > 1 && op == operators.last()) { - auto transformedDataSourceOp = - findTransformedDataSourceOperator(op->dataSource()); - if (transformedDataSourceOp != nullptr) { - auto transformedDataSource = transformedDataSourceOp->childDataSource(); - transformedDataSourceOp->setChildDataSource(nullptr); - op->setChildDataSource(transformedDataSource); - emit operatorAdded(op, transformedDataSource); - } else { - emit operatorAdded(op); - } - } else { - emit operatorAdded(op); - } - }); - // Wire up operatorRemoved. TODO We need to check the branch of the - // pipeline we are currently executing. - connect(dataSource, &DataSource::operatorRemoved, [this](Operator* op) { - // If an operator has been removed, there's a chance that none of the - // remaining operators are in a modified state. - // But the pipeline should still be executed to reflect changes - if (!op->isNew()) { - m_operatorsDeleted = true; - } - if (op->childDataSource() != nullptr) { - auto transformedDataSource = op->childDataSource(); - auto operators = op->dataSource()->operators(); - // We have an operator to move it to. - if (!operators.isEmpty()) { - auto newOp = operators.last(); - op->setChildDataSource(nullptr); - newOp->setChildDataSource(transformedDataSource); - emit newOp->dataSourceMoved(transformedDataSource); - } - // Clean it up - else { - transformedDataSource->removeAllOperators(); - transformedDataSource->deleteLater(); - } - } - - // If pipeline is running see if we can safely remove the operator - if (isRunning()) { - // If we can't safely cancel the execution then trigger the rerun of the - // pipeline. - if (!m_executor->cancel(op)) { - execute(op->dataSource())->deleteWhenFinished(); - } - } else { - // Trigger the pipeline to run - execute(op->dataSource())->deleteWhenFinished(); - } - }); -} - -void Pipeline::addDefaultModules(DataSource* dataSource) -{ - // Note: In the future we can pull this out into a setting. - QStringList defaultModules = { "Outline", "Slice" }; - auto view = ActiveObjects::instance().activeView(); - - if (view == nullptr || !view->IsA("vtkSMRenderViewProxy")) { - return; - } - - Module* module = nullptr; - foreach (QString name, defaultModules) { - module = - ModuleManager::instance().createAndAddModule(name, dataSource, view); - } - ActiveObjects::instance().setActiveModule(module); - - auto pqview = tomviz::convert(view); - pqview->resetDisplay(); - pqview->render(); -} - -DataSource* Pipeline::transformedDataSource(DataSource* ds) -{ - if (ds == nullptr) { - ds = dataSource(); - } - - auto transformed = findTransformedDataSource(ds); - if (transformed != nullptr) { - return transformed; - } - - // Default to dataSource at being of pipeline - return ds; -} - -void Pipeline::setExecutionMode(ExecutionMode executor) -{ - m_executionMode = executor; - if (executor == ExecutionMode::Docker) { - m_executor.reset(new DockerPipelineExecutor(this)); - } else if (executor == ExecutionMode::Threaded) { - m_executor.reset(new ThreadPipelineExecutor(this)); - } else if (executor == ExecutionMode::ExternalPython) { - m_executor.reset(new ExternalPythonExecutor(this)); - } else { - qCritical() << "Unknown executor type: " << executor; - } -} - -void Pipeline::Future::deleteWhenFinished() -{ - connect(this, &Pipeline::Future::finished, this, [this]() { deleteLater(); }); -} - -Pipeline::Future* Pipeline::emptyFuture() -{ - auto future = new Pipeline::Future(); - // Delay emitting signal until next event loop - QTimer::singleShot(0, [future] { emit future->finished(); }); - - return future; -} - -void Pipeline::moveModulesDown(DataSource* newChildDataSource) -{ - foreach (Module* module, ModuleManager::instance().findModules( - dataSource(), nullptr)) { - // TODO: We should really copy the module properties as well. - auto newModule = ModuleManager::instance().createAndAddModule( - module->label(), newChildDataSource, module->view()); - // Copy over properties using the serialization code. - newModule->deserialize(module->serialize()); - ModuleManager::instance().removeModule(module); - } -} - -} // namespace tomviz -#include "Pipeline.moc" - - diff --git a/tomviz/Pipeline.h b/tomviz/Pipeline.h deleted file mode 100644 index af0da0a40..000000000 --- a/tomviz/Pipeline.h +++ /dev/null @@ -1,211 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipeline_h -#define tomvizPipeline_h - -#include - -#include "PipelineWorker.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -class pqSettings; - -namespace tomviz { -class DataSource; -class Operator; -class Pipeline; -class PipelineExecutor; - -namespace docker { -class DockerStopInvocation; -class DockerRunInvocation; -} - -class Pipeline : public QObject -{ - Q_OBJECT -public: - enum ExecutionMode - { - Threaded, - Docker, - ExternalPython - }; - Q_ENUM(ExecutionMode) - - class Future; - - Pipeline(DataSource* dataSource, QObject* parent = nullptr); - ~Pipeline() override; - - // Pause the automatic execution of the pipeline - void pause(); - - // Returns true if pipeline is currently paused, false otherwise. - bool paused() const; - - // Returns true if edit dialogs of operators in the pipeline are open - bool editingOperators() const { return m_editingOperators > 0; } - - // Resume the automatic execution of the pipeline. - void resume(); - - // Cancel execution of the pipeline. canceled is a optional callback - // that will be called when the pipeline has been successfully canceled. - void cancel(std::function canceled = nullptr); - - /// Return true if the pipeline is currently being executed. - bool isRunning(); - - /// Add default modules to this pipeline. - void addDefaultModules(DataSource* dataSource); - - /// The data source a the root of the pipeline. - DataSource* dataSource() { return m_data; } - - /// Returns that transformed data source associated with a given - /// data source. If no data source is provided the pipeline's root data source - /// will be used. - DataSource* transformedDataSource(DataSource* dataSource = nullptr); - - /// Set the execution mode to use when executing the pipeline. - void setExecutionMode(ExecutionMode executor); - - ExecutionMode executionMode() { return m_executionMode; }; - PipelineExecutor* executor() { return m_executor.data(); }; - - static Future* emptyFuture(); - -public slots: - /// Execute the entire pipeline, starting at the root data source. Note the - /// returned Future instance needs to be cleaned up. deleteWhenFinished() can - /// be called to ensure its cleanup when the pipeline execution is finished. - Future* execute(); - /// Execute the pipeline, starting at the given data source. Note the - /// returned Future instance needs to be cleaned up. deleteWhenFinished() can - /// be called to ensure its cleanup when the pipeline execution is finished. - Future* execute(DataSource* dataSource); - /// Execute the pipeline, starting at the operator provided. Note the - /// returned Future instance needs to be cleaned up. deleteWhenFinished() can - /// be called to ensure its cleanup when the pipeline execution is finished. - Future* execute(DataSource* dataSource, Operator* start); - /// Execute the pipeline, starting at 'start' end just before 'end'. If end is - /// nullptr the execution will run to the end of the pipeline. Note the - /// returned Future instance needs to be cleaned up. deleteWhenFinished() can - /// be called to ensure its cleanup when the pipeline execution is finished. - Future* execute(DataSource* dataSource, Operator* start, Operator* end); - - /// The user has started/finished editing an operator - void startedEditingOp(Operator* op); - void finishedEditingOp(Operator* op); - -signals: - /// This signal is when the execution of the pipeline starts. - void started(); - - /// This signal is fired the execution of the pipeline finishes. - void finished(); - - /// This signal is fired when execution stops at a breakpoint operator. - void breakpointReached(Operator* op); - - /// This signal is fired when an operator is added. The second argument - /// is the datasource that should be moved to become its output in the - /// pipeline view (or null if there isn't one). - void operatorAdded(Operator* op, DataSource* outputDS = nullptr); - -private slots: - void branchFinished(); - -private: - DataSource* findTransformedDataSource(DataSource* dataSource); - Operator* findTransformedDataSourceOperator(DataSource* dataSource); - // Move modules down below the new data source - void moveModulesDown(DataSource* newChildDataSource); - void addDataSource(DataSource* dataSource); - bool beingEdited(DataSource* dataSource) const; - bool isModified(DataSource* dataSource, Operator** firstModified) const; - Future* executeRange(DataSource* ds, Operator* start, Operator* end, - bool checkBreakpoints); - - DataSource* m_data; - bool m_paused = false; - bool m_operatorsDeleted = false; - QScopedPointer m_executor; - ExecutionMode m_executionMode = Threaded; - int m_editingOperators = 0; -}; - -/// Return from getCopyOfImagePriorTo for caller to track async operation. -class Pipeline::Future : public QObject -{ - Q_OBJECT - -public: - friend class ThreadPipelineExecutor; - - Future(vtkImageData* result, QObject* parent = nullptr) - : QObject(parent), m_imageData(result){}; - Future(vtkImageData* result, QList operators, - QObject* parent = nullptr) - : QObject(parent), m_imageData(result), m_operators(operators){}; - Future(QList operators, QObject* parent = nullptr) - : QObject(parent), m_operators(operators){}; - Future(QObject* parent = nullptr) : QObject(parent){}; - virtual ~Future() override{}; - - vtkSmartPointer result() { return m_imageData; } - void setResult(vtkSmartPointer result) { m_imageData = result; } - void setResult(vtkImageData* result) { m_imageData = result; } - QList operators() { return m_operators; } - void deleteWhenFinished(); - -signals: - void finished(); - void canceled(); - -private: - vtkSmartPointer m_imageData; - QList m_operators; -}; - -class PipelineSettings -{ -public: - PipelineSettings(); - Pipeline::ExecutionMode executionMode(); - QString dockerImage(); - bool dockerPull(); - bool dockerRemove(); - QString externalPythonExecutablePath(); - - void setExecutionMode(Pipeline::ExecutionMode executor); - void setExecutionMode(const QString& executor); - void setDockerImage(const QString& image); - void setDockerPull(bool pull); - void setDockerRemove(bool remove); - void setExternalPythonExecutablePath(const QString& executable); - -private: - pqSettings* m_settings; -}; - -} // namespace tomviz - -#endif // tomvizPipeline_h diff --git a/tomviz/PipelineExecutor.cxx b/tomviz/PipelineExecutor.cxx deleted file mode 100644 index e69c00ccd..000000000 --- a/tomviz/PipelineExecutor.cxx +++ /dev/null @@ -1,539 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "Pipeline.h" - -#include "ActiveObjects.h" -#include "DataExchangeFormat.h" -#include "DataSource.h" -#include "EmdFormat.h" -#include "ModuleManager.h" -#include "Operator.h" -#include "OperatorPython.h" -#include "Pipeline.h" -#include "PipelineExecutor.h" -#include "PipelineWorker.h" -#include "ProgressDialog.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include - -Q_DECLARE_METATYPE(vtkSmartPointer); - -namespace tomviz { -PipelineExecutor::PipelineExecutor(Pipeline* pipeline) : QObject(pipeline) -{ -} - -Pipeline* PipelineExecutor::pipeline() -{ - return qobject_cast(parent()); -} - -bool PipelineExecutor::cancel(Operator* op) -{ - Q_UNUSED(op) - // Default implementation doesn't allow canceling operators during execution. - return false; -} - -class ExternalPipelineFuture : public Pipeline::Future -{ -public: - ExternalPipelineFuture(QList operators, QObject* parent = nullptr); -}; - -ExternalPipelineFuture::ExternalPipelineFuture(QList operators, - QObject* parent) - : Pipeline::Future(operators, parent) -{ -} - -const char* ExternalPipelineExecutor::ORIGINAL_FILENAME = "original"; -const char* ExternalPipelineExecutor::TRANSFORM_FILENAME = "transformed.emd"; -const char* ExternalPipelineExecutor::STATE_FILENAME = "state.tvsm"; -const char* ExternalPipelineExecutor::CONTAINER_MOUNT = "/tomviz"; -const char* ExternalPipelineExecutor::PROGRESS_PATH = "progress"; - -Pipeline::Future* ExternalPipelineExecutor::execute(vtkDataObject* data, - QList operators, - int, int end) -{ - if (end == -1) { - end = operators.size(); - } - - m_temporaryDir.reset(new QTemporaryDir()); - if (!m_temporaryDir->isValid()) { - displayError("Directory Error", "Unable to create temporary directory."); - return Pipeline::emptyFuture(); - ; - } - - QString origFileName = originalFileName(); - - // First generate a state file for this pipeline - QJsonObject state; - QJsonObject dataSource; - QJsonObject reader; - QJsonArray fileNames; - fileNames.append(QDir(executorWorkingDir()).filePath(origFileName)); - reader["fileNames"] = fileNames; - dataSource["reader"] = reader; - QJsonArray pipelineOps; - foreach (Operator* op, operators) { - pipelineOps.append(op->serialize()); - } - dataSource["operators"] = pipelineOps; - QJsonArray dataSources; - dataSources.append(dataSource); - state["dataSources"] = dataSources; - - // Now write the update state file to the temporary directory. - QFile stateFile(QDir(workingDir()).filePath(STATE_FILENAME)); - if (!stateFile.open(QIODevice::WriteOnly)) { - displayError("Write Error", "Couldn't open state file for write."); - return Pipeline::emptyFuture(); - } - stateFile.write(QJsonDocument(state).toJson()); - stateFile.close(); - - // Write data to EMD or DataExchange - auto dataFilePath = QDir(workingDir()).filePath(origFileName); - if (origFileName.endsWith("emd")) { - auto imageData = vtkImageData::SafeDownCast(data); - if (!EmdFormat::write(dataFilePath.toLatin1().data(), imageData)) { - displayError("Write Error", - QString("Unable to write data at: %1").arg(dataFilePath)); - return Pipeline::emptyFuture(); - } - } else { - DataExchangeFormat dxfFile; - if (!dxfFile.write(dataFilePath.toLatin1().data(), - pipeline()->dataSource())) { - displayError("Write Error", - QString("Unable to write data at: %1").arg(dataFilePath)); - return Pipeline::emptyFuture(); - } - } - - // Start reading progress updates - auto progressPath = QDir(workingDir()).filePath(PROGRESS_PATH); - -// On Windows and MacOS we have to use files to pass progress updates rather -// than a local socket which we can use on Linux. Looks like docker on MacOS -// may support sharing local sockets as some point, see -// https://github.com/docker/for-mac/issues/483 -#if defined(Q_OS_WIN) || defined(Q_OS_MAC) - m_progressMode = "files"; - m_progressReader.reset(new FilesProgressReader(progressPath, operators)); -#else - m_progressMode = "socket"; - m_progressReader.reset( - new LocalSocketProgressReader(progressPath, operators)); -#endif - - auto future = new ExternalPipelineFuture(operators); - m_progressReader->start(); - connect(m_progressReader.data(), &ProgressReader::operatorStarted, this, - &ExternalPipelineExecutor::operatorStarted); - connect(m_progressReader.data(), &ProgressReader::operatorFinished, this, - &ExternalPipelineExecutor::operatorFinished); - connect(m_progressReader.data(), &ProgressReader::operatorError, this, - &ExternalPipelineExecutor::operatorError); - connect(m_progressReader.data(), &ProgressReader::operatorProgressMaximum, - this, &ExternalPipelineExecutor::operatorProgressMaximum); - connect(m_progressReader.data(), &ProgressReader::operatorProgressStep, this, - &ExternalPipelineExecutor::operatorProgressStep); - connect(m_progressReader.data(), &ProgressReader::operatorProgressMessage, - this, &ExternalPipelineExecutor::operatorProgressMessage); - connect(m_progressReader.data(), &ProgressReader::operatorProgressData, this, - &ExternalPipelineExecutor::operatorProgressData); - connect(m_progressReader.data(), &ProgressReader::pipelineStarted, this, - &ExternalPipelineExecutor::pipelineStarted); - connect(m_progressReader.data(), &ProgressReader::pipelineFinished, this, - [this, future]() { - auto transformedFilePath = - QDir(workingDir()).filePath(TRANSFORM_FILENAME); - vtkSmartPointer transformedData = - vtkImageData::New(); - vtkImageData* transformedImageData = - vtkImageData::SafeDownCast(transformedData.Get()); - // Make sure we don't ask the user about subsampling - QVariantMap options = { { "askForSubsample", false } }; - if (EmdFormat::read(transformedFilePath.toLatin1().data(), - transformedImageData, options)) { - future->setResult(transformedImageData); - } else { - displayError("Read Error", - QString("Unable to load transformed data at: %1") - .arg(transformedFilePath)); - } - emit future->finished(); - transformedImageData->FastDelete(); - }); - connect(future, &Pipeline::Future::finished, this, - &ExternalPipelineExecutor::reset); - - // We need to hook up the transformCanceled signal to each of the operators, - // so we can stop the container if the user cancels any of them. - for (Operator* op : operators) { - connect(op, &Operator::transformCanceled, future, - [this]() { this->cancel(nullptr); }); - } - - return future; -} - -ExternalPipelineExecutor::~ExternalPipelineExecutor() = default; - -ExternalPipelineExecutor::ExternalPipelineExecutor(Pipeline* pipeline) - : PipelineExecutor(pipeline) -{ -} - -void ExternalPipelineExecutor::displayError(const QString& title, - const QString& msg) -{ - qCritical() << msg; - QMessageBox msgBox(tomviz::mainWidget()); - msgBox.setIcon(QMessageBox::Critical); - msgBox.setWindowTitle(title); - msgBox.setText("An error occurred during external pipeline execution"); - msgBox.setDetailedText(msg); - msgBox.exec(); -} - -QString ExternalPipelineExecutor::workingDir() -{ - return m_temporaryDir->path(); -} - -QStringList ExternalPipelineExecutor::executorArgs(int start) -{ - auto baseDir = QDir(executorWorkingDir()); - auto stateFilePath = baseDir.filePath(STATE_FILENAME); - auto outputPath = baseDir.filePath(TRANSFORM_FILENAME); - auto progressPath = baseDir.filePath(PROGRESS_PATH); - - QStringList args; - args << "-s"; - args << stateFilePath; - args << "-i"; - args << QString::number(start); - args << "-o"; - args << outputPath; - args << "-p"; - args << m_progressMode; - args << "-u"; - args << progressPath; - - return args; -} - -void ExternalPipelineExecutor::pipelineStarted() -{ -} - -void ExternalPipelineExecutor::operatorStarted(Operator* op) -{ - op->setState(OperatorState::Running); - emit op->transformingStarted(); - - auto pythonOp = qobject_cast(op); - if (pythonOp != nullptr) { - pythonOp->createChildDataSource(); - } -} - -void ExternalPipelineExecutor::operatorFinished(Operator* op) -{ - QDir temp(m_temporaryDir->path()); - auto operatorIndex = pipeline()->dataSource()->operators().indexOf(op); - QDir operatorPath(temp.filePath(QString::number(operatorIndex))); - // See it we have any child data source updates - if (operatorPath.exists()) { - QMap> childOutput; - - // We are looking for EMD files - foreach (const QFileInfo& fileInfo, - operatorPath.entryInfoList(QDir::Files)) { - - auto name = fileInfo.baseName(); - vtkNew childData; - // Make sure we don't ask the user about subsampling - QVariantMap options = { { "askForSubsample", false } }; - if (EmdFormat::read(fileInfo.filePath().toLatin1().data(), childData, - options)) { - childOutput[name] = childData; - emit pipeline()->finished(); - } else { - displayError( - "Read Error", - QString("Unable to load child data at: %1").arg(fileInfo.filePath())); - break; - } - } - - auto pythonOp = qobject_cast(op); - Q_ASSERT(pythonOp != nullptr); - pythonOp->updateChildDataSource(childOutput); - } - - op->setState(OperatorState::Complete); - emit op->transformingDone(TransformResult::Complete); -} - -void ExternalPipelineExecutor::operatorError(Operator* op, const QString& error) -{ - op->setState(OperatorState::Error); - emit op->transformingDone(TransformResult::Error); - - qCritical() << error; -} - -void ExternalPipelineExecutor::operatorProgressMaximum(Operator* op, int max) -{ - op->setTotalProgressSteps(max); -} - -void ExternalPipelineExecutor::operatorProgressStep(Operator* op, int step) -{ - op->setProgressStep(step); -} - -void ExternalPipelineExecutor::operatorProgressMessage(Operator* op, - const QString& msg) -{ - op->setProgressMessage(msg); -} - -void ExternalPipelineExecutor::operatorProgressData( - Operator* op, vtkSmartPointer data) -{ - auto pythonOperator = qobject_cast(op); - if (pythonOperator != nullptr) { - emit pythonOperator->childDataSourceUpdated(data); - } -} - -void ExternalPipelineExecutor::reset() -{ - // Stop the progress reader - m_progressReader->stop(); - - // Clean up temp directory - m_temporaryDir.reset(nullptr); -} - -QString ExternalPipelineExecutor::originalFileName() -{ - QString ext = ".emd"; - auto* dataSource = pipeline()->dataSource(); - if (dataSource->darkData() && dataSource->whiteData()) { - // Let's write out to data exchange - ext = ".h5"; - } - - return ORIGINAL_FILENAME + ext; -} - -ProgressReader::ProgressReader(const QString& path, - const QList& operators) - : m_path(path), m_operators(operators) -{ - connect(this, &ProgressReader::progressMessage, this, - &ProgressReader::progressReady); -} - -void ProgressReader::progressReady(const QString& progressMessage) -{ - if (progressMessage.isEmpty()) { - return; - } - - auto progressDoc = QJsonDocument::fromJson(progressMessage.toLatin1()); - if (!progressDoc.isObject()) { - qCritical() - << QString("Invalid progress message '%1'").arg(QString(progressMessage)); - return; - } - auto progressObj = progressDoc.object(); - auto type = progressObj["type"].toString(); - - // Operator progress - if (progressObj.contains("operator")) { - auto opIndex = progressObj["operator"].toInt(); - auto op = m_operators[opIndex]; - - if (type == "started") { - emit operatorStarted(op); - } else if (type == "finished") { - emit operatorFinished(op); - } else if (type == "error") { - auto error = progressObj["error"].toString(); - emit operatorError(op, error); - } else if (type == "progress.maximum") { - auto value = progressObj["value"].toInt(); - emit operatorProgressMaximum(op, value); - } else if (type == "progress.step") { - auto value = progressObj["value"].toInt(); - emit operatorProgressStep(op, value); - } else if (type == "progress.message") { - auto value = progressObj["value"].toString(); - emit operatorProgressMessage(op, value); - } else if (type == "progress.data") { - auto value = progressObj["value"].toString(); - emit operatorProgressData(op, readProgressData(value)); - } else { - qCritical() << QString("Unrecognized message type: %1").arg(type); - } - } - // Overall pipeline progress - else { - if (type == "started") { - emit pipelineStarted(); - } else if (type == "finished") { - emit pipelineFinished(); - } else { - qCritical() << QString("Unrecognized message type: %1").arg(type); - } - } -} - -vtkSmartPointer ProgressReader::readProgressData( - const QString& path) -{ - auto data = vtkSmartPointer::New(); - - auto hostPath = QFileInfo(m_path).absoluteDir().filePath(path); - - // Make sure we don't ask the user about subsampling - QVariantMap options = { { "askForSubsample", false } }; - if (!EmdFormat::read(hostPath.toLatin1().data(), data, options)) { - qCritical() << QString("Unable to load progress data at: %1").arg(path); - } - - return data; -} - -FilesProgressReader::FilesProgressReader(const QString& path, - const QList& operators) - : ProgressReader(path, operators), m_pathWatcher(new QFileSystemWatcher()) -{ - QDir dir(m_path); - if (!dir.exists()) { - dir.mkpath("."); - } - - connect(m_pathWatcher.data(), &QFileSystemWatcher::directoryChanged, this, - &FilesProgressReader::checkForProgressFiles); -} - -void FilesProgressReader::start() -{ - m_pathWatcher->addPath(m_path); -} - -void FilesProgressReader::stop() -{ - m_pathWatcher->removePath(m_path); -} - -void FilesProgressReader::checkForProgressFiles() -{ - QDir progressDir(m_path); - foreach (const QString fileName, - progressDir.entryList(QDir::Files, QDir::Name)) { - auto progressFilePath = progressDir.filePath(fileName); - QFile progressFile(progressFilePath); - if (!progressFile.exists()) { - continue; - } - - if (!progressFile.open(QIODevice::ReadOnly | QIODevice::Text)) { - qCritical() << "Unable to read progress file: " << progressFilePath; - continue; - } - - auto msg = progressFile.readLine(); - progressFile.close(); - if (!msg.isEmpty()) { - emit progressMessage(msg); - - progressFile.remove(); - } else { - QTimer::singleShot(0, this, &FilesProgressReader::checkForProgressFiles); - } - } -} - -LocalSocketProgressReader::LocalSocketProgressReader( - const QString& path, const QList& operators) - : ProgressReader(path, operators), m_localServer(new QLocalServer()) -{ - - auto server = m_localServer.data(); - connect(server, &QLocalServer::newConnection, server, [this]() { - auto connection = m_localServer->nextPendingConnection(); - m_progressConnection.reset(connection); - - if (m_progressConnection) { - connect(connection, &QIODevice::readyRead, this, - &LocalSocketProgressReader::readProgress); - connect(connection, static_cast( - &QLocalSocket::errorOccurred), - [this](QLocalSocket::LocalSocketError socketError) { - if (socketError != QLocalSocket::PeerClosedError) { - qCritical() - << QString("Socket connection error: %1").arg(socketError); - } - }); - } - }); -} - -void LocalSocketProgressReader::start() -{ - m_localServer->listen(m_path); -} - -void LocalSocketProgressReader::stop() -{ - m_localServer->close(); -} - -void LocalSocketProgressReader::readProgress() -{ - auto message = m_progressConnection->readLine(); - - if (message.isEmpty()) { - return; - } - - emit progressMessage(message); - - // If we have more data schedule ourselves again. - if (m_progressConnection->bytesAvailable() > 0) { - QTimer::singleShot(0, this, &LocalSocketProgressReader::readProgress); - } -} - -} // namespace tomviz diff --git a/tomviz/PipelineExecutor.h b/tomviz/PipelineExecutor.h deleted file mode 100644 index 95fe83ff8..000000000 --- a/tomviz/PipelineExecutor.h +++ /dev/null @@ -1,163 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineExecutor_h -#define tomvizPipelineExecutor_h - -#include - -#include "Pipeline.h" -#include "PipelineWorker.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include - -namespace tomviz { -class DataSource; -class Operator; -class Pipeline; - -class PipelineExecutor : public QObject -{ - Q_OBJECT - -public: - PipelineExecutor(Pipeline* pipeline); - - /// Execute list of operators on a give data source. start is the index into - /// the operator list indicating where the execution should start. - virtual Pipeline::Future* execute(vtkDataObject* data, - QList operators, int start = 0, - int end = -1) = 0; - virtual void cancel(std::function canceled) = 0; - virtual bool cancel(Operator* op); - virtual bool isRunning() = 0; - -protected: - Pipeline* pipeline(); -}; - -class ProgressReader; - -class ExternalPipelineExecutor : public PipelineExecutor -{ - Q_OBJECT - -public: - ExternalPipelineExecutor(Pipeline* pipeline); - ~ExternalPipelineExecutor(); - Pipeline::Future* execute(vtkDataObject* data, QList operators, - int start = 0, int end = -1) override; - - static const char* ORIGINAL_FILENAME; - static const char* TRANSFORM_FILENAME; - static const char* STATE_FILENAME; - static const char* CONTAINER_MOUNT; - static const char* PROGRESS_PATH; - -protected: - // The working directory that tomviz will write and read data from - virtual QString workingDir(); - // The working directory that will be passed to the executor - virtual QString executorWorkingDir() = 0; - virtual void operatorStarted(Operator* op); - virtual void operatorFinished(Operator* op); - virtual void operatorError(Operator* op, const QString& error); - virtual void operatorProgressMaximum(Operator* op, int max); - virtual void operatorProgressStep(Operator* op, int step); - virtual void operatorProgressMessage(Operator* op, const QString& msg); - virtual void operatorProgressData(Operator* op, - vtkSmartPointer data); - virtual void pipelineStarted(); - virtual void reset(); - - QString originalFileName(); - void displayError(const QString& title, const QString& msg); - QStringList executorArgs(int start); - - QScopedPointer m_temporaryDir; - QScopedPointer m_progressReader; - QString m_progressMode; -}; - -class ProgressReader : public QObject -{ - Q_OBJECT - -public: - ProgressReader(const QString& path, const QList& operators); - - virtual void start() = 0; - virtual void stop() = 0; - vtkSmartPointer readProgressData(const QString& path); - -signals: - void progressMessage(const QString& msg); - void operatorStarted(Operator* op); - void operatorFinished(Operator* op); - void operatorError(Operator* op, const QString& error); - void operatorCanceled(Operator* op); - void operatorProgressMaximum(Operator* op, int max); - void operatorProgressStep(Operator* op, int step); - void operatorProgressMessage(Operator* op, const QString& msg); - void operatorProgressData(Operator* op, vtkSmartPointer data); - void pipelineStarted(); - void pipelineFinished(); - -private slots: - void progressReady(const QString& msg); - -protected: - QString m_path; - QList m_operators; -}; - -class FilesProgressReader : public ProgressReader -{ - Q_OBJECT - -public: - FilesProgressReader(const QString& path, const QList& operators); - - void start(); - void stop(); - -private: - QScopedPointer m_pathWatcher; - - void checkForProgressFiles(); -}; - -class LocalSocketProgressReader : public ProgressReader -{ - Q_OBJECT - -public: - LocalSocketProgressReader(const QString& path, - const QList& operators); - - void start(); - void stop(); - -private: - QScopedPointer m_localServer; - QScopedPointer m_progressConnection; - - void readProgress(); -}; - -} // namespace tomviz - -#endif // tomvizPipelineExecutor_h diff --git a/tomviz/PipelineManager.cxx b/tomviz/PipelineManager.cxx deleted file mode 100644 index 0386a6919..000000000 --- a/tomviz/PipelineManager.cxx +++ /dev/null @@ -1,68 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PipelineManager.h" - -#include "ActiveObjects.h" -#include "Pipeline.h" - -namespace tomviz { - -PipelineManager::PipelineManager(QObject* p) : QObject(p) -{ - PipelineSettings settings; - m_executionMode = settings.executionMode(); - emit executionModeUpdated(m_executionMode); -} - -PipelineManager::~PipelineManager() = default; - -PipelineManager& PipelineManager::instance() -{ - static PipelineManager theInstance; - return theInstance; -} - -void PipelineManager::updateExecutionMode(Pipeline::ExecutionMode mode) -{ - m_executionMode = mode; - emit executionModeUpdated(mode); -} - -void PipelineManager::addPipeline(Pipeline* pipeline) -{ - if (pipeline && !m_pipelines.contains(pipeline)) { - pipeline->setParent(this); - m_pipelines.push_back(pipeline); - - connect(this, &PipelineManager::executionModeUpdated, pipeline, - &Pipeline::setExecutionMode); - } -} - -void PipelineManager::removePipeline(Pipeline* pipeline) -{ - if (m_pipelines.removeOne(pipeline)) { - pipeline->deleteLater(); - } -} - -void PipelineManager::removeAllPipelines() -{ - foreach (Pipeline* pipeline, m_pipelines) { - pipeline->deleteLater(); - } - m_pipelines.clear(); -} - -Pipeline::ExecutionMode PipelineManager::executionMode() -{ - return m_executionMode; -} - -QList>& PipelineManager::pipelines() -{ - return m_pipelines; -} - -} // end of namespace tomviz diff --git a/tomviz/PipelineManager.h b/tomviz/PipelineManager.h deleted file mode 100644 index a628f1fa5..000000000 --- a/tomviz/PipelineManager.h +++ /dev/null @@ -1,47 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineManager_h -#define tomvizPipelineManager_h - -#include - -#include "Pipeline.h" - -class QDir; - -namespace tomviz { - -class Pipeline; - -class PipelineManager : public QObject -{ - Q_OBJECT - -public: - static PipelineManager& instance(); - - /// Update the execution mode the pipelines are using. - void updateExecutionMode(Pipeline::ExecutionMode mode); - Pipeline::ExecutionMode executionMode(); - QList>& pipelines(); - -public slots: - void addPipeline(Pipeline*); - void removePipeline(Pipeline*); - void removeAllPipelines(); - -signals: - void executionModeUpdated(Pipeline::ExecutionMode mode); - -private: - Q_DISABLE_COPY(PipelineManager) - PipelineManager(QObject* parent = nullptr); - ~PipelineManager() override; - - QList> m_pipelines; - Pipeline::ExecutionMode m_executionMode; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/PipelineModel.cxx b/tomviz/PipelineModel.cxx deleted file mode 100644 index 45a9795a2..000000000 --- a/tomviz/PipelineModel.cxx +++ /dev/null @@ -1,1188 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PipelineModel.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "Module.h" -#include "ModuleManager.h" -#include "MoleculeSource.h" -#include "Operator.h" -#include "OperatorResult.h" -#include "Pipeline.h" - -#include -#include -#include - -#include -#include -#include -#include - -namespace tomviz { - -struct PipelineModel::Item -{ - Item(DataSource* source) : tag(DATASOURCE), s(source) {} - Item(Module* module) : tag(MODULE), m(module) {} - Item(Operator* op) : tag(OPERATOR), o(op) {} - Item(OperatorResult* result) : tag(RESULT), r(result) {} - Item(MoleculeSource* source) : tag(MOLECULESOURCE), ms(source) {} - - DataSource* dataSource() { return tag == DATASOURCE ? s : nullptr; } - Module* module() { return tag == MODULE ? m : nullptr; } - Operator* op() { return tag == OPERATOR ? o : nullptr; } - OperatorResult* result() { return tag == RESULT ? r : nullptr; } - MoleculeSource* moleculeSource() - { - return tag == MOLECULESOURCE ? ms : nullptr; - } - - enum - { - DATASOURCE, - MODULE, - OPERATOR, - RESULT, - MOLECULESOURCE - } tag; - union - { - DataSource* s; - Module* m; - Operator* o; - OperatorResult* r; - MoleculeSource* ms; - }; -}; - -class PipelineModel::TreeItem -{ -public: - explicit TreeItem(const PipelineModel::Item& item, - TreeItem* parent = nullptr); - ~TreeItem(); - - TreeItem* parent() { return m_parent; } - void setParent(TreeItem* parent) { m_parent = parent; } - TreeItem* child(int index); - TreeItem* lastChild(); - int childCount() const { return m_children.count(); } - QList& children() { return m_children; } - int childIndex() const; - bool appendChild(const PipelineModel::Item& item); - bool insertChild(int position, const PipelineModel::Item& item); - bool removeChild(int position); - PipelineModel::TreeItem* detachChild(int position); - PipelineModel::TreeItem* detach(); - bool attach(PipelineModel::TreeItem* treeItem); - - bool remove(DataSource* source); - bool remove(MoleculeSource* source); - bool remove(Module* module); - bool remove(Operator* op); - - /// Recursively search entire tree for given object. - TreeItem* find(MoleculeSource* source); - TreeItem* find(Module* module); - TreeItem* find(Operator* op); - TreeItem* find(OperatorResult* result); - - void setItem(const PipelineModel::Item& item) { m_item = item; } - DataSource* dataSource() { return m_item.dataSource(); } - Module* module() { return m_item.module(); } - Operator* op() { return m_item.op(); } - MoleculeSource* moleculeSource() { return m_item.moleculeSource(); } - OperatorResult* result() { return m_item.result(); } - -private: - QList m_children; - PipelineModel::Item m_item; - TreeItem* m_parent = nullptr; -}; - -PipelineModel::TreeItem::TreeItem(const PipelineModel::Item& i, TreeItem* p) - : m_item(i), m_parent(p) -{} - -PipelineModel::TreeItem::~TreeItem() -{ - qDeleteAll(m_children); -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::child(int index) -{ - return m_children.value(index); -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::lastChild() -{ - if (childCount() == 0) { - return nullptr; - } - return child(childCount() - 1); -} - -int PipelineModel::TreeItem::childIndex() const -{ - if (m_parent) { - return m_parent->m_children.indexOf(const_cast(this)); - } - return 0; -} - -bool PipelineModel::TreeItem::insertChild(int pos, - const PipelineModel::Item& item) -{ - if (pos < 0 || pos > m_children.size()) { - return false; - } - TreeItem* treeItem = new TreeItem(item, this); - m_children.insert(pos, treeItem); - return true; -} - -bool PipelineModel::TreeItem::appendChild(const PipelineModel::Item& item) -{ - TreeItem* treeItem = new TreeItem(item, this); - m_children.append(treeItem); - return true; -} - -bool PipelineModel::TreeItem::removeChild(int pos) -{ - if (pos < 0 || pos >= m_children.size()) { - return false; - } - delete m_children.takeAt(pos); - return true; -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::detachChild(int pos) -{ - if (pos < 0 || pos >= m_children.size()) { - return nullptr; - } - auto child = m_children.takeAt(pos); - child->setParent(nullptr); - - return child; -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::detach() -{ - - return parent()->detachChild(childIndex()); -} - -bool PipelineModel::TreeItem::attach(PipelineModel::TreeItem* treeItem) -{ - m_children.append(treeItem); - treeItem->setParent(this); - return true; -} - -bool PipelineModel::TreeItem::remove(DataSource* source) -{ - if (source != dataSource()) { - return false; - } - - // This item is a DataSource item. Remove all children. - foreach (auto childItem, m_children) { - if (childItem->op()) { - auto op = childItem->op(); - // Pause the pipeline - auto pipeline = op->dataSource()->pipeline(); - if (pipeline != nullptr) { - pipeline->pause(); - } - ModuleManager::instance().removeOperator(childItem->op()); - if (pipeline != nullptr) { - // Resume but don't execute as we are removing this data source. - pipeline->resume(); - } - } else if (childItem->module()) { - ModuleManager::instance().removeModule(childItem->module()); - } - } - if (parent()) { - parent()->removeChild(childIndex()); - return true; - } - return false; -} - -bool PipelineModel::TreeItem::remove(MoleculeSource* source) -{ - if (source != moleculeSource()) { - return false; - } - // This item is a DataSource item. Remove all children. - foreach (auto childItem, m_children) { - if (childItem->module()) { - ModuleManager::instance().removeModule(childItem->module()); - } - } - return true; -} - -bool PipelineModel::TreeItem::remove(Module* module) -{ - foreach (auto childItem, m_children) { - if (childItem->module() == module) { - removeChild(childItem->childIndex()); - return true; - } - } - return false; -} - -bool PipelineModel::TreeItem::remove(Operator* o) -{ - foreach (auto childItem, m_children) { - if (childItem->op() == o) { - foreach (auto resultItem, childItem->children()) { - auto dataSource = resultItem->dataSource(); - if (ModuleManager::instance().isChild(dataSource)) { - // if the result is a child datasource, allow it to remove its - // children - resultItem->remove(dataSource); - } else { - // Remove results - childItem->removeChild(resultItem->childIndex()); - } - } - removeChild(childItem->childIndex()); - return true; - } - } - return false; -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::find(Module* module) -{ - if (this->module() == module) { - return this; - } else { - foreach (auto childItem, m_children) { - auto moduleItem = childItem->find(module); - if (moduleItem) { - return moduleItem; - } - } - } - return nullptr; -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::find(Operator* op) -{ - if (this->op() == op) { - return this; - } else { - foreach (auto childItem, m_children) { - auto operatorItem = childItem->find(op); - if (operatorItem) { - return operatorItem; - } - } - } - return nullptr; -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::find(OperatorResult* result) -{ - if (this->result() == result) { - return this; - } else { - foreach (auto childItem, m_children) { - auto resultItem = childItem->find(result); - if (resultItem) { - return resultItem; - } - } - } - return nullptr; -} - -PipelineModel::TreeItem* PipelineModel::TreeItem::find(MoleculeSource* source) -{ - if (this->moleculeSource() == source) { - return this; - } - return nullptr; -} - -PipelineModel::PipelineModel(QObject* p) : QAbstractItemModel(p) -{ - connect(&ModuleManager::instance(), &ModuleManager::dataSourceAdded, this, - &PipelineModel::dataSourceAdded); - connect(&ModuleManager::instance(), &ModuleManager::childDataSourceAdded, - this, &PipelineModel::childDataSourceAdded); - connect(&ModuleManager::instance(), &ModuleManager::moduleAdded, this, - &PipelineModel::moduleAdded); - connect(&ModuleManager::instance(), &ModuleManager::moleculeSourceAdded, this, - &PipelineModel::moleculeSourceAdded); - - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::viewChanged), - this, [this]() { beginResetModel(); endResetModel(); }); - connect(&ModuleManager::instance(), &ModuleManager::dataSourceRemoved, this, - &PipelineModel::dataSourceRemoved); - connect(&ModuleManager::instance(), &ModuleManager::moleculeSourceRemoved, - this, &PipelineModel::moleculeSourceRemoved); - connect(&ModuleManager::instance(), &ModuleManager::moduleRemoved, this, - &PipelineModel::moduleRemoved); - connect(&ModuleManager::instance(), &ModuleManager::childDataSourceRemoved, - this, &PipelineModel::childDataSourceRemoved); - - connect(&ModuleManager::instance(), &ModuleManager::operatorRemoved, this, - &PipelineModel::operatorRemoved); - // Need to register this for cross thread dataChanged signal - qRegisterMetaType>("QVector"); -} - -PipelineModel::~PipelineModel() {} - -namespace { -QIcon iconForDataObject(vtkDataObject* dataObject) -{ - if (vtkTable::SafeDownCast(dataObject)) { - return QIcon(":/pqWidgets/Icons/pqSpreadsheet.svg"); - } else if (vtkUnstructuredGrid::SafeDownCast(dataObject)) { - return QIcon(":/pqWidgets/Icons/pqUnstructuredGrid16.png"); - } else if (vtkStructuredGrid::SafeDownCast(dataObject)) { - return QIcon(":/pqWidgets/Icons/pqStructuredGrid16.png"); - } else if (vtkRectilinearGrid::SafeDownCast(dataObject)) { - return QIcon(":/pqWidgets/Icons/pqRectilinearGrid16.png"); - } - - return QIcon(":/icons/pqInspect.png"); -} - -QIcon iconForOperatorState(tomviz::OperatorState state) -{ - switch (state) { - case OperatorState::Complete: - return QIcon(":/icons/check.png"); - case OperatorState::Edit: - return QIcon(":/icons/edit.png"); - case OperatorState::Queued: - return QIcon(":/icons/question.png"); - case OperatorState::Error: - return QIcon(":/icons/error_notification.png"); - case OperatorState::Canceled: - return QIcon(":/icons/red_cross.png"); - case OperatorState::Running: - // Our subclass of QItemDelegate will take care of this animated icon - break; - } - - return QIcon(); -} - -QString tooltipForOperatorState(tomviz::OperatorState state) -{ - switch (state) { - case OperatorState::Running: - return QString("Running"); - case OperatorState::Complete: - return QString("Complete"); - case OperatorState::Edit: - return QString("Editing"); - case OperatorState::Queued: - return QString("Queued"); - case OperatorState::Error: - return QString("Error"); - case OperatorState::Canceled: - return QString("Canceled"); - } - - return ""; -} -} // namespace - -QVariant PipelineModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.column() > Column::state) - return QVariant(); - - auto treeItem = this->treeItem(index); - auto dataSource = treeItem->dataSource(); - auto moleculeSource = treeItem->moleculeSource(); - auto module = treeItem->module(); - auto op = treeItem->op(); - auto result = treeItem->result(); - - // Data source - if (dataSource) { - if (index.column() == Column::label) { - switch (role) { - case Qt::DecorationRole: - return QIcon(":/icons/pqInspect.png"); - case Qt::DisplayRole: { - QString label = dataSource->label(); - if (dataSource->persistenceState() == - DataSource::PersistenceState::Modified) { - label += QString(" *"); - } - return label; - } - case Qt::ToolTipRole: - return dataSource->fileName(); - case Qt::FontRole: - if (dataSource->persistenceState() == - DataSource::PersistenceState::Modified) { - QFont font; - font.setItalic(true); - return font; - } else { - return QVariant(); - } - default: - return QVariant(); - } - } - } else if (moleculeSource) { - if (index.column() == Column::label) { - switch (role) { - case Qt::DecorationRole: - return QIcon(":/icons/gradient_opacity.png"); - case Qt::DisplayRole: - return moleculeSource->label(); - case Qt::ToolTipRole: - return moleculeSource->label(); - default: - return QVariant(); - } - } else { - return QVariant(); - } - } else if (module) { - if (index.column() == Column::label) { - switch (role) { - case Qt::DecorationRole: - return module->icon(); - case Qt::DisplayRole: - return module->label(); - case Qt::ToolTipRole: - return module->label(); - default: - return QVariant(); - } - } else if (index.column() == Column::state) { - if (role == Qt::DecorationRole) { - if (module->visibility()) { - return QIcon(":/pqWidgets/Icons/pqEyeball.svg"); - } else { - return QIcon(":/pqWidgets/Icons/pqEyeballClosed.svg"); - } - } - } - } else if (op) { - if (index.column() == Column::label) { - switch (role) { - case Qt::DecorationRole: - return op->icon(); - case Qt::DisplayRole: - return op->label(); - case Qt::ToolTipRole: - if (op->isCanceled()) { - return "Operator was canceled"; - } else { - return op->label(); - } - case Qt::FontRole: - if (op->isCanceled()) { - QFont font; - font.setStrikeOut(true); - return font; - } else { - return QVariant(); - } - default: - return QVariant(); - } - } else if (index.column() == Column::state) { - switch (role) { - case Qt::DecorationRole: - return iconForOperatorState(op->state()); - case Qt::ToolTipRole: - return tooltipForOperatorState(op->state()); - default: - return QVariant(); - } - } - } else if (result) { - if (index.column() == Column::label) { - switch (role) { - case Qt::DecorationRole: - return iconForDataObject(result->dataObject()); - case Qt::DisplayRole: - return result->label(); - case Qt::ToolTipRole: - return result->description(); - default: - return QVariant(); - } - } - } - return QVariant(); -} - -bool PipelineModel::setData(const QModelIndex& index, const QVariant& value, - int role) -{ - if (role != Qt::CheckStateRole) { - return false; - } - - auto treeItem = this->treeItem(index); - if (index.column() == Column::state && treeItem->module()) { - treeItem->module()->setVisibility(value == Qt::Checked); - emit dataChanged(index, index); - } - return true; -} - -Qt::ItemFlags PipelineModel::flags(const QModelIndex& index) const -{ - if (!index.isValid()) - return Qt::ItemFlags(); - - auto treeItem = this->treeItem(index); - auto module = treeItem->module(); - auto view = ActiveObjects::instance().activeView(); - - if (module && module->view() != view) { - return Qt::NoItemFlags; - } - return QAbstractItemModel::flags(index); -} - -QVariant PipelineModel::headerData(int, Qt::Orientation, int) const -{ - return QVariant(); -} - -QModelIndex PipelineModel::index(int row, int column, - const QModelIndex& parent) const -{ - if (!parent.isValid() && row < m_treeItems.count()) { - // Data source - return createIndex(row, column, m_treeItems[row]); - } else { - // Module or operator - auto treeItem = this->treeItem(parent); - if (treeItem && row < treeItem->childCount()) { - return createIndex(row, column, treeItem->child(row)); - } - } - - return QModelIndex(); -} - -QModelIndex PipelineModel::parent(const QModelIndex& index) const -{ - if (!index.isValid()) { - return QModelIndex(); - } - auto treeItem = this->treeItem(index); - if (!treeItem || !treeItem->parent()) { - return QModelIndex(); - } - return createIndex(treeItem->parent()->childIndex(), 0, treeItem->parent()); -} - -int PipelineModel::rowCount(const QModelIndex& parent) const -{ - if (!parent.isValid()) { - return m_treeItems.count(); - } else { - auto treeItem = this->treeItem(parent); - return treeItem->childCount(); - } -} - -int PipelineModel::columnCount(const QModelIndex&) const -{ - return 2; -} - -DataSource* PipelineModel::dataSource(const QModelIndex& idx) -{ - if (idx.isValid()) { - auto treeItem = this->treeItem(idx); - return (treeItem ? treeItem->dataSource() : nullptr); - } else { - return nullptr; - } -} - -MoleculeSource* PipelineModel::moleculeSource(const QModelIndex& idx) -{ - if (idx.isValid()) { - auto treeItem = this->treeItem(idx); - return (treeItem ? treeItem->moleculeSource() : nullptr); - } else { - return nullptr; - } -} - -Module* PipelineModel::module(const QModelIndex& idx) -{ - if (idx.isValid()) { - auto treeItem = this->treeItem(idx); - return (treeItem ? treeItem->module() : nullptr); - } else { - return nullptr; - } -} - -Operator* PipelineModel::op(const QModelIndex& idx) -{ - if (idx.isValid()) { - auto treeItem = this->treeItem(idx); - return (treeItem ? treeItem->op() : nullptr); - } else { - return nullptr; - } -} - -OperatorResult* PipelineModel::result(const QModelIndex& idx) -{ - if (idx.isValid()) { - auto treeItem = this->treeItem(idx); - return (treeItem ? treeItem->result() : nullptr); - } else { - return nullptr; - } -} - -QModelIndex PipelineModel::dataSourceIndexHelper( - PipelineModel::TreeItem* treeItem, DataSource* source) -{ - Q_ASSERT(treeItem != nullptr); - if (!source) { - return QModelIndex(); - } else if (treeItem->dataSource() == source) { - - auto row = treeItem->childIndex(); - // If this item has no parent then we are dealing with a root data source, - // childIndex() will return 0. We need to find the item in m_treeItems to - // determine the correct index. - if (treeItem->parent() == nullptr) { - row = m_treeItems.indexOf(treeItem); - Q_ASSERT(row != -1); - } - - return createIndex(row, 0, treeItem); - } else { - // Recurse on children - foreach (auto childItem, treeItem->children()) { - QModelIndex childIndex = dataSourceIndexHelper(childItem, source); - if (childIndex.isValid()) { - return childIndex; - } - } - } - return QModelIndex(); -} - -QModelIndex PipelineModel::dataSourceIndex(DataSource* source) -{ - for (int i = 0; i < m_treeItems.count(); ++i) { - QModelIndex index = dataSourceIndexHelper(m_treeItems[i], source); - if (index.isValid()) { - return index; - } - } - - return QModelIndex(); -} - -QModelIndex PipelineModel::moleculeSourceIndex(MoleculeSource* source) -{ - foreach (auto treeItem, m_treeItems) { - auto moleculeItem = treeItem->find(source); - if (moleculeItem) { - return createIndex(moleculeItem->childIndex(), 0, moleculeItem); - } - } - return QModelIndex(); -} - -QModelIndex PipelineModel::moduleIndex(Module* module) -{ - foreach (auto treeItem, m_treeItems) { - auto moduleItem = treeItem->find(module); - if (moduleItem) { - return createIndex(moduleItem->childIndex(), 0, moduleItem); - } - } - return QModelIndex(); -} - -QModelIndex PipelineModel::operatorIndexHelper( - PipelineModel::TreeItem* treeItem, Operator* op) -{ - Q_ASSERT(treeItem != nullptr); - if (!op) { - return QModelIndex(); - } else if (treeItem->op() == op) { - return createIndex(treeItem->childIndex(), 0, treeItem); - } else { - // Recurse on children - foreach (auto childItem, treeItem->children()) { - QModelIndex childIndex = operatorIndexHelper(childItem, op); - if (childIndex.isValid()) { - return childIndex; - } - } - } - return QModelIndex(); -} - -QModelIndex PipelineModel::operatorIndex(Operator* op) -{ - foreach (auto treeItem, m_treeItems) { - auto operatorItem = treeItem->find(op); - if (operatorItem) { - return createIndex(operatorItem->childIndex(), 0, operatorItem); - } - } - return QModelIndex(); -} - -QModelIndex PipelineModel::resultIndex(OperatorResult* result) -{ - foreach (auto treeItem, m_treeItems) { - auto resultItem = treeItem->find(result); - if (resultItem) { - return createIndex(resultItem->childIndex(), 0, resultItem); - } - } - return QModelIndex(); -} - -void PipelineModel::dataSourceAdded(DataSource* dataSource) -{ - auto treeItem = new PipelineModel::TreeItem(PipelineModel::Item(dataSource)); - beginInsertRows(QModelIndex(), m_treeItems.size(), m_treeItems.size()); - m_treeItems.append(treeItem); - endInsertRows(); - auto pipeline = dataSource->pipeline(); - connect(pipeline, &Pipeline::operatorAdded, this, - &PipelineModel::operatorAdded, Qt::UniqueConnection); - - // Fire signal to indicate that the transformed data source has been modified - // when the pipeline has been executed. - // TODO This should probably be move else where! - connect(pipeline, &Pipeline::finished, [this, pipeline]() { - auto transformed = pipeline->transformedDataSource(); - emit dataSourceModified(transformed); - }); - - // Refresh all operator rows when a breakpoint is reached: the breakpoint - // operator's label column shows the play icon, and operators from the - // breakpoint onwards have their state column updated (Complete → Queued). - connect(pipeline, &Pipeline::breakpointReached, [this](Operator* op) { - auto ds = op->dataSource(); - if (!ds) - return; - auto ops = ds->operators(); - int bpIdx = ops.indexOf(op); - for (int i = bpIdx; i < ops.size(); ++i) { - auto idx = operatorIndex(ops[i]); - auto stateIdx = index(idx.row(), Column::state, idx.parent()); - emit dataChanged(idx, stateIdx); - } - }); - - // When restoring a data source from a state file it will have its operators - // before we can listen to the signal above. Display those operators. - foreach (auto op, dataSource->operators()) { - operatorAdded(op); - } - emit dataSourceItemAdded(dataSource); -} - -void PipelineModel::moleculeSourceAdded(MoleculeSource* moleculeSource) -{ - auto treeItem = - new PipelineModel::TreeItem(PipelineModel::Item(moleculeSource)); - beginInsertRows(QModelIndex(), m_treeItems.size(), m_treeItems.size()); - m_treeItems.append(treeItem); - endInsertRows(); - emit moleculeSourceItemAdded(moleculeSource); -} - -void PipelineModel::moduleAdded(Module* module) -{ - Q_ASSERT(module); - auto dataSource = module->dataSource(); - auto moleculeSource = module->moleculeSource(); - auto operatorResult = module->operatorResult(); - QModelIndex index; - if (moleculeSource) { - index = moleculeSourceIndex(moleculeSource); - } else if (operatorResult) { - index = resultIndex(operatorResult); - } else if (dataSource) { - index = dataSourceIndex(dataSource); - } - if (index.isValid()) { - auto dataSourceItem = treeItem(index); - // Modules straight after the data source so append after any current - // modules. - int insertionRow = dataSourceItem->childCount(); - for (int j = 0; j < dataSourceItem->childCount(); ++j) { - if (!dataSourceItem->child(j)->module()) { - insertionRow = j; - break; - } - } - - beginInsertRows(index, insertionRow, insertionRow); - dataSourceItem->insertChild(insertionRow, PipelineModel::Item(module)); - endInsertRows(); - } - emit moduleItemAdded(module); -} - -void PipelineModel::operatorAdded(Operator* op, - DataSource* transformedDataSource) -{ - // Operators are special, they operate on all data and are shown in the - // visualization modules. So there are some moves necessary to show this. - Q_ASSERT(op); - auto dataSource = op->dataSource(); - Q_ASSERT(dataSource); - connect(op, &Operator::labelModified, this, &PipelineModel::operatorModified); - connect(op, &Operator::transformingDone, this, - &PipelineModel::operatorTransformDone); - connect( - op, - static_cast(&Operator::newChildDataSource), - this, &PipelineModel::childDataSourceAdded); - // Make sure dataChange signal is emitted when operator is complete - connect(op, &Operator::transformingDone, [this, op]() { - auto opIndex = operatorIndex(op); - auto statusIndex = index(opIndex.row(), Column::state, opIndex.parent()); - emit dataChanged(statusIndex, statusIndex); - }); - // Refresh label column when breakpoint state changes (delegate paints it) - connect(op, &Operator::breakpointChanged, [this, op]() { - auto opIndex = operatorIndex(op); - emit dataChanged(opIndex, opIndex); - }); - connect(op, &Operator::dataSourceMoved, this, - &PipelineModel::dataSourceMoved); - - auto index = dataSourceIndex(dataSource); - auto dataSourceItem = treeItem(index); - // Find the correct insertion row based on the operator's position in the - // DataSource's operator list. - int insertionRow = dataSourceItem->childCount(); - auto operators = dataSource->operators(); - int opIndex = operators.indexOf(op); - if (opIndex >= 0 && opIndex < operators.size() - 1) { - // Mid-chain insertion: find the tree item of the next operator and insert - // before it. - auto nextOp = operators[opIndex + 1]; - for (int i = 0; i < dataSourceItem->childCount(); ++i) { - if (dataSourceItem->child(i)->op() == nextOp) { - insertionRow = i; - break; - } - } - } - beginInsertRows(index, insertionRow, insertionRow); - dataSourceItem->insertChild(insertionRow, PipelineModel::Item(op)); - endInsertRows(); - - // Insert operator results in the operator tree item - auto operatorTreeItem = dataSourceItem->find(op); - auto operatorIndex = this->operatorIndex(op); - int numResults = op->numberOfResults(); - if (numResults) { - - beginInsertRows(operatorIndex, 0, numResults - 1); - for (int j = 0; j < numResults; ++j) { - OperatorResult* result = op->resultAt(j); - operatorTreeItem->appendChild(PipelineModel::Item(result)); - } - endInsertRows(); - } - - // If there is a transformed data output to move, move it to be a child - // of this operator - if (transformedDataSource) { - moveDataSourceHelper(transformedDataSource, op); - } - - emit operatorItemAdded(op); -} - -void PipelineModel::operatorRemoved(Operator* op) -{ - removeOp(op); -} - -void PipelineModel::operatorModified() -{ - auto op = qobject_cast(sender()); - Q_ASSERT(op); - - auto index = operatorIndex(op); - dataChanged(index, index); -} - -void PipelineModel::operatorTransformDone() -{ - auto op = qobject_cast(sender()); - Q_ASSERT(op); - - // Find tree item for the operator - TreeItem* operatorItem = nullptr; - foreach (auto child, m_treeItems) { - auto foundChild = child->find(op); - if (foundChild) { - operatorItem = foundChild; - break; - } - } - - if (op->hasChildDataSource() || op->childDataSource() != nullptr) { - auto childDataSource = op->childDataSource(); - if (childDataSource) { - // The Operator's child data set is null initially. We need to set it - // after the Operator has been run. We also assume that the operator item - // has a single child, the child DataSource, and it is the last child of - // the operator tree item. - auto childItem = operatorItem->lastChild(); - if (childItem) { - childItem->setItem(PipelineModel::Item(childDataSource)); - } - } - } -} - -void PipelineModel::dataSourceRemoved(DataSource* source) -{ - auto index = dataSourceIndex(source); - - if (index.isValid()) { - auto item = treeItem(index); - beginRemoveRows(parent(index), index.row(), index.row()); - item->remove(source); - m_treeItems.removeAll(item); - delete item; - endRemoveRows(); - } -} - -void PipelineModel::childDataSourceRemoved(DataSource* source) -{ - auto index = dataSourceIndex(source); - - if (index.isValid()) { - auto operatorIndex = parent(index); - auto operatorTreeItem = treeItem(operatorIndex); - auto op = operatorTreeItem->op(); - - auto item = treeItem(index); - beginRemoveRows(parent(index), index.row(), index.row()); - // Since the item has a parent (it is a child data source), calling remove() - // will delete the item object. No need to delete it again here. - item->remove(source); - m_treeItems.removeAll(item); - endRemoveRows(); - - op->setModified(); - int numResults = op->numberOfResults(); - if (numResults) { - - beginInsertRows(operatorIndex, 0, numResults - 1); - for (int j = 0; j < numResults; ++j) { - OperatorResult* result = op->resultAt(j); - operatorTreeItem->appendChild(PipelineModel::Item(result)); - } - endInsertRows(); - } - } -} - -void PipelineModel::moleculeSourceRemoved(MoleculeSource* moleculeSource) -{ - auto index = moleculeSourceIndex(moleculeSource); - - if (index.isValid()) { - auto item = treeItem(index); - beginRemoveRows(parent(index), index.row(), index.row()); - item->remove(moleculeSource); - m_treeItems.removeAll(item); - delete item; - endRemoveRows(); - } -} - -void PipelineModel::moduleRemoved(Module* module) -{ - auto index = moduleIndex(module); - - if (index.isValid()) { - beginRemoveRows(parent(index), index.row(), index.row()); - auto item = treeItem(index); - item->parent()->remove(module); - endRemoveRows(); - } -} - -bool PipelineModel::removeDataSource(DataSource* source) -{ - if (ModuleManager::instance().isChild(source)) { - childDataSourceRemoved(source); - ModuleManager::instance().removeChildDataSource(source); - } else { - dataSourceRemoved(source); - ModuleManager::instance().removeDataSource(source); - } - return true; -} - -bool PipelineModel::removeMoleculeSource(MoleculeSource* moleculeSource) -{ - moleculeSourceRemoved(moleculeSource); - ModuleManager::instance().removeMoleculeSource(moleculeSource); - return true; -} - -bool PipelineModel::removeModule(Module* module) -{ - moduleRemoved(module); - ModuleManager::instance().removeModule(module); - return true; -} - -bool PipelineModel::removeOp(Operator* o) -{ - auto index = operatorIndex(o); - if (index.isValid()) { - // If this operator has a child data source (the "transformed" output), - // move it to the last remaining operator in the tree model BEFORE calling - // removeOperator(). Previously, this move happened via the signal chain - // (operatorRemoved -> Pipeline handler -> dataSourceMoved -> - // moveDataSourceHelper -> beginMoveRows), but beginMoveRows can crash - // when iterating persistent model indexes during the signal chain. - // By doing the move explicitly here, the subsequent signal-triggered - // moveDataSourceHelper becomes a no-op (oldParent == newParent). - auto childDS = o->childDataSource(); - if (childDS) { - auto operators = o->dataSource()->operators(); - operators.removeAll(o); - if (!operators.isEmpty()) { - moveDataSourceHelper(childDS, operators.last()); - } - } - - // Re-compute the index since moveDataSourceHelper may have modified the - // tree (the operator's child count changed). - index = operatorIndex(o); - if (!index.isValid()) { - return true; - } - - o->dataSource()->removeOperator(o); - beginRemoveRows(parent(index), index.row(), index.row()); - auto item = treeItem(index); - item->parent()->remove(o); - endRemoveRows(); - - return true; - } - - return false; -} - -PipelineModel::TreeItem* PipelineModel::treeItem(const QModelIndex& index) const -{ - if (!index.isValid()) { - return nullptr; - } - - return static_cast(index.internalPointer()); -} - -void PipelineModel::childDataSourceAdded(DataSource* dataSource) -{ - if (Operator* op = qobject_cast(sender())) { - - auto index = dataSourceIndex(op->dataSource()); - auto dataSourceItem = treeItem(index); - auto operatorTreeItem = dataSourceItem->find(op); - auto numResults = op->numberOfResults(); - auto operatorIndex = this->operatorIndex(op); - - // If the last child is already a data source when we just want to set the - // item rather than inserting a new row. - auto last = operatorTreeItem->lastChild(); - if (last && last->dataSource()) { - last->setItem(PipelineModel::Item(dataSource)); - } else { - beginInsertRows(operatorIndex, numResults, numResults); - operatorTreeItem->appendChild(PipelineModel::Item(dataSource)); - endInsertRows(); - } - } - - // When restoring a data source from a state file it will have its operators - // before we can listen to the signal above. Display those operators. - foreach (auto op, dataSource->operators()) { - operatorAdded(op); - } - - emit childDataSourceItemAdded(dataSource); -} - -void PipelineModel::moveDataSourceHelper(DataSource* dataSource, - Operator* newParent) -{ - auto index = dataSourceIndex(dataSource); - if (!index.isValid()) { - return; - } - auto dataSourceItem = treeItem(index); - if (!dataSourceItem || !dataSourceItem->parent()) { - return; - } - auto oldParent = dataSourceItem->parent()->op(); - // Already under the target parent (e.g. removeOp pre-moved it). - if (oldParent == newParent) { - return; - } - auto oldParentIndex = this->operatorIndex(oldParent); - auto operatorIndex = this->operatorIndex(newParent); - auto operatorTreeItem = this->treeItem(operatorIndex); - if (!operatorTreeItem) { - return; - } - - beginMoveRows(oldParentIndex, index.row(), index.row(), operatorIndex, - operatorTreeItem->childCount()); - dataSourceItem = dataSourceItem->detach(); - operatorTreeItem->attach(dataSourceItem); - endMoveRows(); -} - -void PipelineModel::dataSourceMoved(DataSource* dataSource) -{ - if (Operator* newParent = qobject_cast(sender())) { - moveDataSourceHelper(dataSource, newParent); - } -} - -} // namespace tomviz diff --git a/tomviz/PipelineModel.h b/tomviz/PipelineModel.h deleted file mode 100644 index 7a6213029..000000000 --- a/tomviz/PipelineModel.h +++ /dev/null @@ -1,105 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineModel_h -#define tomvizPipelineModel_h - -#include - -namespace { - -enum Column -{ - label, - state -}; -} - -namespace tomviz { - -class DataSource; -class MoleculeSource; -class Module; -class Operator; -class OperatorResult; - -class PipelineModel : public QAbstractItemModel -{ - Q_OBJECT - -public: - explicit PipelineModel(QObject* parent = 0); - ~PipelineModel() override; - - QVariant data(const QModelIndex& index, int role) const override; - bool setData(const QModelIndex& index, const QVariant& value, - int role) override; - Qt::ItemFlags flags(const QModelIndex& index) const override; - QVariant headerData(int section, Qt::Orientation orientation, - int role = Qt::DisplayRole) const override; - QModelIndex index(int row, int column, - const QModelIndex& parent = QModelIndex()) const override; - QModelIndex parent(const QModelIndex& index) const override; - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - int columnCount(const QModelIndex& parent = QModelIndex()) const override; - - DataSource* dataSource(const QModelIndex& index); - MoleculeSource* moleculeSource(const QModelIndex& index); - Module* module(const QModelIndex& index); - Operator* op(const QModelIndex& index); - OperatorResult* result(const QModelIndex& index); - - QModelIndex dataSourceIndex(DataSource* source); - QModelIndex moleculeSourceIndex(MoleculeSource* source); - QModelIndex moduleIndex(Module* module); - QModelIndex operatorIndex(Operator* op); - QModelIndex resultIndex(OperatorResult* result); - - bool removeDataSource(DataSource* dataSource); - bool removeMoleculeSource(MoleculeSource* moleculeSource); - bool removeModule(Module* module); - bool removeOp(Operator* op); - bool removeResult(OperatorResult* result); - -public slots: - void dataSourceAdded(DataSource* dataSource); - void moleculeSourceAdded(MoleculeSource* moleculeSource); - void moduleAdded(Module* module); - void operatorAdded(Operator* op, DataSource* transformedDataSource = nullptr); - void operatorRemoved(Operator* op); - void operatorModified(); - void operatorTransformDone(); - - void dataSourceRemoved(DataSource* dataSource); - void moduleRemoved(Module* module); - void moleculeSourceRemoved(MoleculeSource* moleculeSource); - void childDataSourceAdded(DataSource* dataSource); - void childDataSourceRemoved(DataSource* dataSource); - void dataSourceMoved(DataSource* dataSource); - -signals: - void dataSourceItemAdded(DataSource* dataSource); - void childDataSourceItemAdded(DataSource* dataSource); - void moleculeSourceItemAdded(MoleculeSource* dataSource); - void moduleItemAdded(Module* module); - void operatorItemAdded(Operator* op); - void dataSourceModified(DataSource* dataSource); - -private: - struct Item; - class TreeItem; - - TreeItem* treeItem(const QModelIndex& index) const; - - QList m_treeItems; - - QModelIndex dataSourceIndexHelper(PipelineModel::TreeItem* treeItem, - DataSource* source); - QModelIndex operatorIndexHelper(PipelineModel::TreeItem* treeItem, - Operator* op); - void moveDataSourceHelper(DataSource* dataSource, Operator* newParent); -}; - -} // namespace tomviz - -#endif // tomvizPipelineModel_h diff --git a/tomviz/PipelineModuleMenu.cxx b/tomviz/PipelineModuleMenu.cxx new file mode 100644 index 000000000..fcd1234b2 --- /dev/null +++ b/tomviz/PipelineModuleMenu.cxx @@ -0,0 +1,353 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PipelineModuleMenu.h" + +#include "ActiveObjects.h" +#include "MainWindow.h" + +#include "pipeline/Link.h" +#include "pipeline/Pipeline.h" +#include "pipeline/OutputPort.h" +#include "pipeline/InputPort.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/sinks/VolumeSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/OutlineSink.h" +#include "pipeline/sinks/ThresholdSink.h" +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/RulerSink.h" +#include "pipeline/sinks/ScaleCubeSink.h" +#include "pipeline/sinks/MoleculeSink.h" +#include "pipeline/sinks/PlotSink.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +static vtkSMViewProxy* resolveViewForSink(const QString& sinkType) +{ + bool needsChart = (sinkType == "Plot"); + QString viewTypeName = needsChart ? "XYChartView" : "RenderView"; + + auto* activeView = ActiveObjects::instance().activeView(); + if (activeView) { + bool activeIsChart = vtkSMContextViewProxy::SafeDownCast(activeView); + bool activeIsRender = vtkSMRenderViewProxy::SafeDownCast(activeView); + if ((needsChart && activeIsChart) || (!needsChart && activeIsRender)) { + return activeView; + } + } + + auto* smModel = pqApplicationCore::instance()->getServerManagerModel(); + QList allViews = smModel->findItems(); + + QList matching; + for (auto* v : allViews) { + auto* proxy = v->getViewProxy(); + bool isChart = vtkSMContextViewProxy::SafeDownCast(proxy); + bool isRender = vtkSMRenderViewProxy::SafeDownCast(proxy); + if ((needsChart && isChart) || (!needsChart && isRender)) { + matching.append(v); + } + } + + if (matching.isEmpty()) { + if (!needsChart) { + ActiveObjects::instance().createRenderViewIfNeeded(); + return ActiveObjects::instance().activeView(); + } + + // No chart view exists — split the layout and create one. + int emptyCell = -1; + vtkSMViewLayoutProxy* layout = nullptr; + if (activeView) { + layout = vtkSMViewLayoutProxy::FindLayout(activeView); + if (layout) { + int location = layout->GetViewLocation(activeView); + int leftChild = layout->Split( + location, vtkSMViewLayoutProxy::HORIZONTAL, 0.5); + if (leftChild >= 0) { + emptyCell = leftChild + 1; + } + } + } + + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + auto* server = pqApplicationCore::instance()->getActiveServer(); + auto* newView = builder->createView(viewTypeName, server); + if (!newView) { + return nullptr; + } + auto* proxy = newView->getViewProxy(); + + if (layout && emptyCell >= 0) { + layout->AssignView(emptyCell, proxy); + } + + ActiveObjects::instance().setActiveView(proxy); + return proxy; + } + + if (matching.size() == 1) { + auto* proxy = matching.first()->getViewProxy(); + ActiveObjects::instance().setActiveView(proxy); + return proxy; + } + + QStringList names; + for (auto* v : matching) { + names << v->getSMName(); + } + bool ok = false; + QString chosen = QInputDialog::getItem( + nullptr, "Select View", + QString("Multiple views available. Select one:"), + names, 0, false, &ok); + if (!ok) { + return nullptr; + } + int idx = names.indexOf(chosen); + auto* proxy = matching[idx]->getViewProxy(); + ActiveObjects::instance().setActiveView(proxy); + return proxy; +} + +PipelineModuleMenu::PipelineModuleMenu(QToolBar* toolBar, QMenu* menu, + QObject* parentObject) + : QObject(parentObject), m_menu(menu), m_toolBar(toolBar) +{ + Q_ASSERT(menu); + Q_ASSERT(toolBar); + connect(menu, &QMenu::triggered, this, &PipelineModuleMenu::triggered); + connect(&ActiveObjects::instance(), + &ActiveObjects::activeTipOutputPortChanged, + this, &PipelineModuleMenu::updateEnableState); + connect(&ActiveObjects::instance(), + &ActiveObjects::activePipelineChanged, + this, &PipelineModuleMenu::updateEnableState); + qApp->installEventFilter(this); + updateActions(); +} + +PipelineModuleMenu::~PipelineModuleMenu() = default; + +QList PipelineModuleMenu::sinkTypes() +{ + return { "Volume", "Outline", "Slice", "Contour", "Threshold", "Clip", + "Ruler", "Scale Cube", "Molecule", "Plot" }; +} + +QIcon PipelineModuleMenu::sinkIcon(const QString& type) +{ + // Icon paths must match the legacy Module::icon() implementations + static QMap iconMap = { + { "Volume", ":/icons/pqVolumeData.png" }, + { "Outline", ":/pqWidgets/Icons/pqProbeLocation.svg" }, + { "Slice", ":/icons/orthoslice.svg" }, + { "Contour", ":pqWidgets/Icons/pqIsosurface.svg" }, + { "Threshold", ":/pqWidgets/Icons/pqThreshold.svg" }, + { "Clip", ":/pqWidgets/Icons/pqClip.svg" }, + { "Ruler", ":/pqWidgets/Icons/pqRuler.svg" }, + { "Scale Cube", ":/icons/pqMeasurementCube.png" }, + { "Molecule", ":/pqWidgets/Icons/pqGroup.svg" }, + { "Plot", ":/pqWidgets/Icons/pqLineChart16.png" }, + }; + auto it = iconMap.find(type); + return (it != iconMap.end()) ? QIcon(it.value()) : QIcon(); +} + +pipeline::PortTypes PipelineModuleMenu::sinkAcceptedTypes(const QString& type) +{ + if (type == "Plot") + return pipeline::PortType::Table; + if (type == "Molecule") + return pipeline::PortType::Molecule; + return pipeline::PortType::ImageData; +} + +bool PipelineModuleMenu::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::KeyPress || + event->type() == QEvent::KeyRelease) { + auto* ke = static_cast(event); + if (ke->key() == Qt::Key_Control) { + m_ctrlHeld = (event->type() == QEvent::KeyPress); + updateEnableState(); + } + } + return QObject::eventFilter(obj, event); +} + +void PipelineModuleMenu::updateEnableState() +{ + auto* tipPort = ActiveObjects::instance().activeTipOutputPort(); + + for (auto* action : m_menu->actions()) { + auto type = action->data().toString(); + if (type.isEmpty()) + continue; + + if (m_ctrlHeld || !tipPort) { + // Ctrl held: enable all (user will link manually). + // No tip port: enable all and let triggered() handle the error. + action->setEnabled(m_ctrlHeld); + continue; + } + action->setEnabled( + pipeline::isPortTypeCompatible(tipPort->type(), + sinkAcceptedTypes(type))); + } +} + +pipeline::LegacyModuleSink* PipelineModuleMenu::createSink(const QString& type) +{ + if (type == "Volume") { + return new pipeline::VolumeSink(); + } else if (type == "Outline") { + return new pipeline::OutlineSink(); + } else if (type == "Slice") { + return new pipeline::SliceSink(); + } else if (type == "Contour") { + return new pipeline::ContourSink(); + } else if (type == "Threshold") { + return new pipeline::ThresholdSink(); + } else if (type == "Clip") { + return new pipeline::ClipSink(); + } else if (type == "Ruler") { + return new pipeline::RulerSink(); + } else if (type == "Scale Cube") { + return new pipeline::ScaleCubeSink(); + } else if (type == "Molecule") { + return new pipeline::MoleculeSink(); + } else if (type == "Plot") { + return new pipeline::PlotSink(); + } + return nullptr; +} + +void PipelineModuleMenu::updateActions() +{ + QMenu* menu = m_menu; + QToolBar* toolBar = m_toolBar; + Q_ASSERT(menu); + Q_ASSERT(toolBar); + + menu->clear(); + toolBar->clear(); + + for (const QString& type : sinkTypes()) { + auto actn = menu->addAction(sinkIcon(type), type); + actn->setData(type); + toolBar->addAction(actn); + } + updateEnableState(); +} + +void PipelineModuleMenu::triggered(QAction* maction) +{ + auto type = maction->data().toString(); + if (type.isEmpty()) { + return; + } + + auto* mainWindow = MainWindow::instance(); + auto* pip = mainWindow ? mainWindow->pipeline() : nullptr; + if (!pip) { + qCritical("No active pipeline. Load data first."); + return; + } + + auto* view = resolveViewForSink(type); + if (!view) { + return; + } + + auto* targetPort = ActiveObjects::instance().activeTipOutputPort(); + if (!targetPort) { + qCritical("No output port available. Load data first."); + return; + } + + auto* sink = createSink(type); + if (!sink) { + qCritical("Failed to create sink for type: %s", qPrintable(type)); + return; + } + + sink->setLabel(type); + sink->initialize(view); + pip->addNode(sink); + + // Ctrl held: add the node unconnected (user will link manually) + if (!(QApplication::keyboardModifiers() & Qt::ControlModifier)) { + auto* input = sink->inputPorts()[0]; + if (!pipeline::isPortTypeCompatible(targetPort->type(), + input->acceptedTypes())) { + qCritical("Incompatible port types: sink input does not accept " + "the tip output port type."); + return; + } + + // Resolve which output port to connect the new sink to: + // 1. If the tip port already belongs to a SinkGroupNode (the group + // is explicitly selected), connect directly to that port. + // 2. Else if a compatible SinkGroupNode is already linked to the + // tip port, connect to its matching passthrough output. + // 3. Else create a new SinkGroupNode on the tip port. + pipeline::OutputPort* connectTo = nullptr; + if (qobject_cast(targetPort->node())) { + connectTo = targetPort; + } + if (!connectTo) { + for (auto* link : targetPort->links()) { + auto* sg = qobject_cast( + link->to()->node()); + if (sg) { + // Find the group's output port corresponding to this input. + int idx = sg->inputPorts().indexOf(link->to()); + if (idx >= 0 && idx < sg->outputPorts().size() && + pipeline::isPortTypeCompatible( + sg->outputPorts()[idx]->type(), input->acceptedTypes())) { + connectTo = sg->outputPorts()[idx]; + break; + } + } + } + } + if (!connectTo) { + auto* group = new pipeline::SinkGroupNode(); + pipeline::PortType groupType = + pipeline::isVolumeType(targetPort->type()) + ? pipeline::PortType::ImageData + : targetPort->type(); + group->addPassthrough(targetPort->name(), groupType); + pip->addNode(group); + pip->createLink(targetPort, group->inputPorts()[0]); + connectTo = group->outputPorts()[0]; + } + pip->createLink(connectTo, input); + } + pip->executeWhenIdle(); +} + +} // namespace tomviz diff --git a/tomviz/PipelineModuleMenu.h b/tomviz/PipelineModuleMenu.h new file mode 100644 index 000000000..ef4eb4f8a --- /dev/null +++ b/tomviz/PipelineModuleMenu.h @@ -0,0 +1,58 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineModuleMenu_h +#define tomvizPipelineModuleMenu_h + +#include +#include + +#include "pipeline/PortType.h" + +class QAction; +class QMenu; +class QToolBar; + +namespace tomviz { + +namespace pipeline { +class Pipeline; +class SinkNode; +class LegacyModuleSink; +class OutputPort; +} // namespace pipeline + +/// PipelineModuleMenu manages the Visualization menu and toolbar for the +/// new pipeline infrastructure. It creates sink nodes instead of old Modules. +class PipelineModuleMenu : public QObject +{ + Q_OBJECT + +public: + PipelineModuleMenu(QToolBar* toolBar, QMenu* parentMenu, + QObject* parent = nullptr); + ~PipelineModuleMenu() override; + + static QList sinkTypes(); + static QIcon sinkIcon(const QString& type); + static pipeline::PortTypes sinkAcceptedTypes(const QString& type); + static pipeline::LegacyModuleSink* createSink(const QString& type); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + +private slots: + void updateActions(); + void updateEnableState(); + void triggered(QAction* action); + +private: + Q_DISABLE_COPY(PipelineModuleMenu) + QPointer m_menu; + QPointer m_toolBar; + bool m_ctrlHeld = false; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/PipelineProxy.cxx b/tomviz/PipelineProxy.cxx deleted file mode 100644 index 6db4a57c2..000000000 --- a/tomviz/PipelineProxy.cxx +++ /dev/null @@ -1,784 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PipelineProxy.h" - -#include "core/PythonFactory.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "LoadDataReaction.h" -#include "ModuleFactory.h" -#include "ModuleManager.h" -#include "OperatorFactory.h" -#include "OperatorPython.h" -#include "PipelineManager.h" - -#include -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -namespace { - -Pipeline* findPipeline(QStringList& path, QString id = QString()) -{ - if (path.size() < 2) { - qCritical() << "Path doesn't have enough parts."; - return nullptr; - } - - auto iterator = path.begin(); - auto objType = *(iterator++); - - if (objType != "dataSources") { - qCritical() << "Path doesn't start with 'dataSources'."; - return nullptr; - } - - auto pipelineIndexStr = *(iterator++); - bool success = false; - auto pipelineIndex = pipelineIndexStr.toInt(&success); - if (!success) { - qCritical() << "Unable to convert index to int."; - return nullptr; - } - - auto& pipelineMgr = PipelineManager::instance(); - auto pipelines = pipelineMgr.pipelines(); - - if (pipelineIndex >= pipelines.size()) { - qCritical() << "Pipeline index no longer exists."; - return nullptr; - } - - // Now check the ids - auto dataSource = pipelines[pipelineIndex]->dataSource(); - QString currentId = - QString::asprintf("%p", static_cast(dataSource)); - if (!id.isEmpty() && currentId != id) { - qCritical() << "Pipeline no longer exists."; - return nullptr; - } - - // Remove consumed parts of the path - path.erase(path.begin(), iterator); - - return pipelines[pipelineIndex]; -} - -Operator* findOperator(QStringList& path, QString id = QString()) -{ - auto pipeline = findPipeline(path); - if (pipeline == nullptr) { - qCritical() << "Unable to find pipeline."; - return nullptr; - } - - if (path.size() < 2) { - qCritical() << "Path doesn't have enough parts."; - return nullptr; - } - - auto iterator = path.begin(); - auto objType = *(iterator++); - if (objType != "operators") { - qCritical() << "Path doesn't contain 'operators'."; - return nullptr; - } - - auto opIndexStr = *(iterator++); - bool success = false; - auto opIndex = opIndexStr.toInt(&success); - if (!success) { - qCritical() << "Unable to convert index to int."; - return nullptr; - } - - auto operators = pipeline->dataSource()->operators(); - if (opIndex >= operators.size()) { - qCritical() << "Operator index no longer exists."; - return nullptr; - } - - // Now check the ids - auto op = operators[opIndex]; - QString currentId = QString::asprintf("%p", static_cast(op)); - if (!id.isEmpty() && currentId != id) { - qCritical() << "Operator no longer exists."; - return nullptr; - } - - // Remove consumed parts of the path - path.erase(path.begin(), iterator); - - return op; -} - -Operator* findOperator(const QString& path, QString id = QString()) -{ - auto parts = path.split("/", Qt::SkipEmptyParts); - - return findOperator(parts, id); -} - -DataSource* findDataSource(QStringList& path, QString id = QString()) -{ - auto copy = QStringList(path); - auto pipeline = findPipeline(copy); - if (pipeline == nullptr) { - qCritical() << "Unable to find pipeline."; - return nullptr; - } - - // If the path no longer contains 'dataSources' we are done (its the root data - // source) - if (!copy.contains("dataSources")) { - path.erase(path.begin(), path.begin() + 2); - - // Now check the ids - auto dataSource = pipeline->dataSource(); - QString currentId = - QString::asprintf("%p", static_cast(dataSource)); - if (!id.isEmpty() && currentId != id) { - qCritical() << "Datasource no longer exists."; - return nullptr; - } - - return dataSource; - } - - // Find the operator (the child data source has to be associated with one ...) - auto op = findOperator(path); - if (op == nullptr) { - qCritical() << "Unable to find operator."; - return nullptr; - } - - auto dataSource = op->childDataSource(); - QString currentId = - QString::asprintf("%p", static_cast(dataSource)); - if (!id.isEmpty() && currentId != id) { - qCritical() << "Datasource no longer exists."; - return nullptr; - } - - // Remove the consumed data source - path.erase(path.begin(), path.begin() + 2); - - return dataSource; -} - -DataSource* findDataSource(const QString& path, QString id = QString()) -{ - auto parts = path.split("/", Qt::SkipEmptyParts); - - return findDataSource(parts, id); -} - -Module* findModule(QStringList& path, QString id = QString()) -{ - // First find the data source the module is attached to. - auto dataSource = findDataSource(path); - if (dataSource == nullptr) { - qCritical() << "Unable to find data source."; - return nullptr; - } - - if (path.size() < 2) { - qCritical() << "Path doesn't have enough parts."; - return nullptr; - } - - auto iterator = path.begin(); - auto objType = *(iterator++); - if (objType != "modules") { - qCritical() << "Path doesn't contain 'modules'."; - return nullptr; - } - - auto modIndexStr = *(iterator++); - bool success = false; - auto modIndex = modIndexStr.toInt(&success); - if (!success) { - qCritical() << "Unable to convert index to int."; - return nullptr; - } - - auto& moduleMgr = ModuleManager::instance(); - // TODO: We should use the view information here as well ... - auto modules = moduleMgr.findModulesGeneric( - dataSource, ActiveObjects::instance().activeView()); - - if (modIndex >= modules.size()) { - qCritical() << "Module index no longer exists."; - return nullptr; - } - - auto module = modules[modIndex]; - QString currentId = QString::asprintf("%p", static_cast(module)); - if (!id.isEmpty() && currentId != id) { - qCritical() << "Module no longer exists."; - return nullptr; - } - - return module; -} - -Module* findModule(const QString& path, QString id = QString()) -{ - auto parts = path.split("/", Qt::SkipEmptyParts); - - return findModule(parts, id); -} - -} // namespace - -PipelineProxy::PipelineProxy() -{ - QObject::connect(&ModuleManager::instance(), &ModuleManager::dataSourceAdded, - [this](DataSource* ds) { - QObject::connect(ds, &DataSource::dataChanged, - [this]() { this->syncToPython(); }); - - QObject::connect( - ds, &DataSource::operatorAdded, [this](Operator* op) { - QObject::connect(op, &Operator::transformModified, - [this]() { this->syncToPython(); }); - }); - - QObject::connect(ds, &DataSource::operatorRemoved, - [this]() { this->syncToPython(); }); - }); - - QObject::connect(&ModuleManager::instance(), - &ModuleManager::dataSourceRemoved, - [this]() { this->syncToPython(); }); - - QObject::connect(&ModuleManager::instance(), &ModuleManager::moduleAdded, - [this](Module* module) { - QObject::connect(module, &Module::renderNeeded, - [this]() { this->syncToPython(); }); - }); - - QObject::connect(&ModuleManager::instance(), &ModuleManager::moduleRemoved, - [this]() { this->syncToPython(); }); - - QObject::connect(&ModuleManager::instance(), - &ModuleManager::visibilityChanged, - [this]() { this->syncToPython(); }); - - auto appCore = pqApplicationCore::instance(); - auto model = appCore->getServerManagerModel(); - QObject::connect(model, &pqServerManagerModel::viewAdded, - [this]() { this->syncViewsToPython(); }); - - QObject::connect(model, &pqServerManagerModel::viewRemoved, - [this]() { this->syncViewsToPython(); }); - - QObject::connect(&ActiveObjects::instance(), - QOverload::of(&ActiveObjects::viewChanged), - [this]() { this->syncViewsToPython(); }); -} - -void PipelineProxy::syncToPython() -{ - if (!m_syncToPython) { - return; - } - - Python python; - auto tomvizState = python.import("tomviz.state"); - if (!tomvizState.isValid()) { - qCritical() << "Failed to import tomviz.state"; - } - - auto sync = tomvizState.findFunction("_sync_to_python"); - if (!sync.isValid()) { - qCritical() << "Unable to locate _sync_to_python."; - } - - auto state = serialize(); - - Python::Tuple args(1); - Python::Object pyState(state); - args.set(0, pyState); - Python::Dict kwargs; - sync.call(args, kwargs); -} - -void PipelineProxy::syncViewsToPython() -{ - Python python; - auto tomvizState = python.import("tomviz.state"); - if (!tomvizState.isValid()) { - qCritical() << "Failed to import tomviz.state"; - } - - auto sync = tomvizState.findFunction("_sync_views"); - if (!sync.isValid()) { - qCritical() << "Unable to locate _sync_view."; - } - sync.call(); -} - -std::string PipelineProxy::serialize() -{ - QJsonObject state; - QFileInfo info(QApplication::applicationDirPath()); - auto success = ModuleManager::instance().serialize(state, info.dir(), false); - - if (!success) { - qCritical() << "Serialization failed ..."; - } - - QJsonDocument doc(state); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -void PipelineProxy::load(const std::string& state, - const std::string& stateRelDir) -{ - auto relDir = QDir(QByteArray::fromStdString(stateRelDir)); - auto json = QByteArray::fromStdString(state); - auto doc = QJsonDocument::fromJson(json); - - emit(&ModuleManager::instance())->enablePythonConsole(false); - - QObject::connect( - &ModuleManager::instance(), &ModuleManager::stateDoneLoading, [this]() { - tomviz::Python python; - - auto stateModule = python.import("tomviz.state"); - if (!stateModule.isValid()) { - qCritical() << "Failed to import tomviz.state"; - } - - auto tomviz = stateModule.findFunction("_init"); - if (!tomviz.isValid()) { - qCritical() << "Unable to locate _init."; - } - - Python::Tuple args(0); - Python::Dict kwargs; - tomviz.call(args, kwargs); - - emit(&ModuleManager::instance())->enablePythonConsole(true); - }); - this->disableSyncToPython(); - auto origExecuteOnLoad = ModuleManager::instance().executePipelinesOnLoad(); - ModuleManager::instance().executePipelinesOnLoad(false); - ModuleManager::instance().deserialize(doc.object(), relDir); - ModuleManager::instance().executePipelinesOnLoad(origExecuteOnLoad); - this->enableSyncToPython(); - // loop.exec(); -} - -std::string PipelineProxy::modulesJson() -{ - vtkNew data; - data->SetDimensions(2, 2, 2); - data->AllocateScalars(VTK_INT, 1); - auto dataSource = new DataSource(data); - - QJsonObject modules; - - for (auto t : ModuleFactory::moduleTypes()) { - auto module = ModuleFactory::createModule( - t, dataSource, ActiveObjects::instance().activeView()); - module->setVisibility(false); - QFileInfo info(QApplication::applicationDirPath()); - auto state = module->serialize(); - modules[t] = state; - module->deleteLater(); - } - - QJsonDocument doc(modules); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - // dataSource->deleteLater(); - - return stateByteArray.toStdString(); -} - -std::string PipelineProxy::operatorsJson() -{ - vtkNew data; - data->SetDimensions(2, 2, 2); - data->AllocateScalars(VTK_INT, 1); - auto dataSource = new DataSource(data); - - QJsonObject operators; - auto operatorTypes = OperatorFactory::instance().operatorTypes(); - // Remove Python we will deal with these separately - operatorTypes.removeAll("Python"); - for (auto& type : operatorTypes) { - auto op = OperatorFactory::instance().createOperator(type, dataSource); - auto state = op->serialize(); - operators[type] = state; - op->deleteLater(); - } - - // Now process the python operators - for (auto& info : OperatorFactory::instance().registeredPythonOperators()) { - auto op = new OperatorPython(dataSource); - auto doc = QJsonDocument::fromJson(info.json.toUtf8()); - auto name = doc.object()["name"].toString(); - // If not name is provide camelCase the label - if (name.isEmpty()) { - auto parts = info.label.split(" "); - auto iter = parts.begin(); - name = *(iter++); - - for (; iter != parts.end(); iter++) { - auto part = *iter; - part[0] = part[0].toUpper(); - name += part; - } - } - op->setJSONDescription(info.json); - op->setLabel(info.label); - op->setScript(info.source); - auto state = op->serialize(); - operators[name] = state; - op->deleteLater(); - } - - dataSource->deleteLater(); - - QJsonDocument doc(operators); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -std::string PipelineProxy::serializeOperator(const std::string& path, - const std::string& id) -{ - auto p = QString::fromStdString(path); - auto i = QString::fromStdString(id); - auto op = findOperator(p, i); - if (op == nullptr) { - return ""; - } - - QJsonDocument doc(op->serialize()); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -void PipelineProxy::updateOperator(const std::string& path, - const std::string& state) -{ - auto p = QString::fromStdString(path); - auto json = QByteArray::fromStdString(state); - auto doc = QJsonDocument::fromJson(json); - auto id = doc.object()["id"].toString(); - auto op = findOperator(p, id); - if (op == nullptr) { - return; - } - op->deserialize(doc.object()); -} - -std::string PipelineProxy::serializeModule(const std::string& path, - const std::string& id) -{ - auto p = QString::fromStdString(path); - auto i = QString::fromStdString(id); - auto module = findModule(p, i); - if (module == nullptr) { - return ""; - } - - QJsonDocument doc(module->serialize()); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -void PipelineProxy::updateModule(const std::string& path, - const std::string& state) -{ - auto p = QString::fromStdString(path); - auto json = QByteArray::fromStdString(state); - auto doc = QJsonDocument::fromJson(json); - auto id = doc.object()["id"].toString(); - auto module = findModule(p, id); - if (module == nullptr) { - return; - } - module->deserialize(doc.object()); -} - -std::string PipelineProxy::serializeDataSource(const std::string& path, - const std::string& id) -{ - auto p = QString::fromStdString(path); - auto i = QString::fromStdString(id); - auto ds = findDataSource(p, i); - if (ds == nullptr) { - return ""; - } - - QJsonDocument doc(ds->serialize()); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -void PipelineProxy::updateDataSource(const std::string& path, - const std::string& state) -{ - auto p = QString::fromStdString(path); - auto json = QByteArray::fromStdString(state); - auto doc = QJsonDocument::fromJson(json); - auto id = doc.object()["id"].toString(); - auto ds = findDataSource(p, id); - if (ds == nullptr) { - return; - } - ds->deserialize(doc.object()); -} - -void PipelineProxy::modified(std::vector opPaths, - std::vector modulePaths) -{ - for (auto path : opPaths) { - auto p = QString::fromStdString(path); - auto op = findOperator(p); - emit op->transformModified(); - } - - for (auto path : modulePaths) { - auto p = QString::fromStdString(path); - auto mod = findModule(p); - emit mod->renderNeeded(); - } -} - -std::string PipelineProxy::addModule(const std::string& dataSourcePath, - const std::string& dataSourceId, - const std::string& moduleType) -{ - auto p = QString::fromStdString(dataSourcePath); - auto t = QString::fromStdString(moduleType); - auto i = QString::fromStdString(dataSourceId); - auto dataSource = findDataSource(p, i); - auto module = ModuleManager::instance().createAndAddModule( - t, dataSource, ActiveObjects::instance().activeView()); - - if (module == nullptr) - return ""; - - QJsonDocument doc(module->serialize()); - auto stateByteArray = doc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -std::string PipelineProxy::addOperator(const std::string& dataSourcePath, - const std::string& dataSourceId, - const std::string& opState) -{ - auto p = QString::fromStdString(dataSourcePath); - auto i = QString::fromStdString(dataSourceId); - auto json = QByteArray::fromStdString(opState); - auto doc = QJsonDocument::fromJson(json); - auto opJson = doc.object(); - auto dataSource = findDataSource(p, i); - auto type = opJson["type"].toString(); - - auto op = OperatorFactory::instance().createOperator(type, dataSource); - if (op == nullptr) { - qCritical() << "Failed to create operator."; - return ""; - } - if (op->deserialize(opJson)) { - dataSource->addOperator(op); - } else { - qCritical() << "Failed to deserialize operator."; - } - - QJsonDocument newDoc(op->serialize()); - auto stateByteArray = newDoc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -std::string PipelineProxy::addDataSource(const std::string& dataSourceState) -{ - auto json = QByteArray::fromStdString(dataSourceState); - auto doc = QJsonDocument::fromJson(json); - auto obj = doc.object(); - auto origExecuteOnLoad = ModuleManager::instance().executePipelinesOnLoad(); - ModuleManager::instance().executePipelinesOnLoad(false); - auto ds = ModuleManager::instance().loadDataSource(obj); - ds->pipeline()->pause(); - ModuleManager::instance().executePipelinesOnLoad(origExecuteOnLoad); - - QJsonDocument newDoc(ds->serialize()); - auto stateByteArray = newDoc.toJson(QJsonDocument::Compact); - - return stateByteArray.toStdString(); -} - -void PipelineProxy::removeOperator(const std::string& opPath, - const std::string& dataSourceId, - const std::string& opId) -{ - auto p = QString::fromStdString(opPath); - auto i = QString::fromStdString(dataSourceId); - auto o = QString::fromStdString(opId); - - // Are we removing them all? - if (o.isEmpty()) { - auto ds = findDataSource(p, i); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return; - } - - auto ops = ds->operators(); - while (ops.size() > 0) { - auto lastOperator = ops.takeLast(); - ModuleManager::instance().removeOperator(lastOperator); - } - } else { - auto op = findOperator(p, o); - if (op == nullptr) { - qCritical() << "Failed to find operator."; - return; - } - ModuleManager::instance().removeOperator(op); - } -} - -void PipelineProxy::removeModule(const std::string& modulePath, - const std::string& dataSourceId, - const std::string& moduleId) -{ - auto p = QString::fromStdString(modulePath); - auto i = QString::fromStdString(dataSourceId); - auto m = QString::fromStdString(moduleId); - - // Are we removing them all? - if (m.isEmpty()) { - auto ds = findDataSource(p, i); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return; - } - ModuleManager::instance().removeAllModules(ds); - } else { - auto module = findModule(p, m); - if (module == nullptr) { - qCritical() << "Failed to find module."; - return; - } - ModuleManager::instance().removeModule(module); - } -} - -void PipelineProxy::removeDataSource(const std::string& dataSourcePath, - const std::string& dataSourceId) -{ - auto p = QString::fromStdString(dataSourcePath); - auto i = QString::fromStdString(dataSourceId); - - // Are we removing them all? - if (i.isEmpty()) { - ModuleManager::instance().removeAllDataSources(); - } else { - auto ds = findDataSource(p, i); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return; - } - ModuleManager::instance().removeDataSource(ds); - } -} - -void PipelineProxy::enableSyncToPython() -{ - m_syncToPython = true; -} - -void PipelineProxy::disableSyncToPython() -{ - m_syncToPython = false; -} - -void PipelineProxy::pausePipeline(const std::string& dataSourcePath) -{ - auto p = QString::fromStdString(dataSourcePath); - auto ds = findDataSource(p); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return; - } - - ds->pipeline()->pause(); -} - -void PipelineProxy::resumePipeline(const std::string& dataSourcePath) -{ - auto p = QString::fromStdString(dataSourcePath); - auto ds = findDataSource(p); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return; - } - - ds->pipeline()->resume(); -} - -void PipelineProxy::executePipeline(const std::string& dataSourcePath) -{ - auto p = QString::fromStdString(dataSourcePath); - auto ds = findDataSource(p); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return; - } - - ds->pipeline()->execute()->deleteWhenFinished(); -} - -bool PipelineProxy::pipelinePaused(const std::string& dataSourcePath) -{ - auto p = QString::fromStdString(dataSourcePath); - auto ds = findDataSource(p); - if (ds == nullptr) { - qCritical() << "Failed to find data source."; - return false; - } - - return ds->pipeline()->paused(); -} - -PipelineProxyBase* PipelineProxyFactory::create() -{ - return new PipelineProxy(); -} - -void PipelineProxyFactory::registerWithFactory() -{ - PythonFactory::instance().setPipelineProxyFactory(new PipelineProxyFactory); -} - -} // namespace tomviz diff --git a/tomviz/PipelineProxy.h b/tomviz/PipelineProxy.h deleted file mode 100644 index cd229a769..000000000 --- a/tomviz/PipelineProxy.h +++ /dev/null @@ -1,71 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineProxy_h -#define tomvizPipelineProxy_h - -#include "core/PipelineProxyBase.h" - -namespace tomviz { - -/** Pure virtual base class providing a proxy to the pipeline. */ -class PipelineProxy : public PipelineProxyBase -{ -public: - PipelineProxy(); - std::string serialize() override; - void load(const std::string& state, const std::string& stateRelDir) override; - std::string modulesJson() override; - std::string operatorsJson() override; - std::string serializeOperator(const std::string& path, - const std::string& id) override; - void updateOperator(const std::string& path, - const std::string& state) override; - std::string serializeModule(const std::string& path, - const std::string& id) override; - void updateModule(const std::string& path, const std::string& state) override; - std::string serializeDataSource(const std::string& path, - const std::string& id) override; - void updateDataSource(const std::string& path, - const std::string& state) override; - std::string addModule(const std::string& dataSourcePath, - const std::string& dataSourceId, - const std::string& moduleType) override; - std::string addOperator(const std::string& dataSourcePath, - const std::string& dataSourceId, - const std::string& opState) override; - std::string addDataSource(const std::string& dataSourceState) override; - void removeOperator(const std::string& opPath, - const std::string& dataSourceId, - const std::string& opId = "") override; - void removeModule(const std::string& modulePath, - const std::string& dataSourceId, - const std::string& moduleId = "") override; - void removeDataSource(const std::string& dataSourcePath, - const std::string& dataSourceId = "") override; - void modified(std::vector opPaths, - std::vector modulePaths) override; - void syncToPython() override; - void enableSyncToPython() override; - void disableSyncToPython() override; - void pausePipeline(const std::string& dataSourcePath) override; - void resumePipeline(const std::string& dataSourcePath) override; - void executePipeline(const std::string& dataSourcePath) override; - bool pipelinePaused(const std::string& dataSourcePath) override; - - void syncViewsToPython() override; - -private: - bool m_syncToPython = true; -}; - -class PipelineProxyFactory : public PipelineProxyBaseFactory -{ -public: - PipelineProxyBase* create() override; - static void registerWithFactory(); -}; - -} // namespace tomviz - -#endif \ No newline at end of file diff --git a/tomviz/PipelineSettingsDialog.cxx b/tomviz/PipelineSettingsDialog.cxx deleted file mode 100644 index 1c3273b15..000000000 --- a/tomviz/PipelineSettingsDialog.cxx +++ /dev/null @@ -1,234 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PipelineSettingsDialog.h" -#include "ui_PipelineSettingsDialog.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "PipelineManager.h" -#include "Utilities.h" - -namespace tomviz { - -PipelineSettingsDialog::PipelineSettingsDialog(QWidget* parent) - : QDialog(parent), m_ui(new Ui::PipelineSettingsDialog) -{ - m_ui->setupUi(this); - - QList executor; - - m_executorTypeMetaEnum = QMetaEnum::fromType(); - - m_ui->modeComboBox->addItem( - m_executorTypeMetaEnum.valueToKey(Pipeline::ExecutionMode::Threaded)); - m_ui->modeComboBox->addItem( - m_executorTypeMetaEnum.valueToKey(Pipeline::ExecutionMode::Docker)); - m_ui->modeComboBox->addItem( - m_executorTypeMetaEnum.valueToKey(Pipeline::ExecutionMode::ExternalPython)); - - readSettings(); - - m_ui->dockerGroupBox->setHidden( - m_executorTypeMetaEnum.keyToValue( - m_ui->modeComboBox->currentText().toLatin1().data()) != - Pipeline::ExecutionMode::Docker); - - m_ui->externalGroupBox->setHidden( - m_executorTypeMetaEnum.keyToValue( - m_ui->modeComboBox->currentText().toLatin1().data()) != - Pipeline::ExecutionMode::ExternalPython); - - connect(m_ui->dockerImageLineEdit, &QLineEdit::textChanged, - [this](const QString& text) { - Q_UNUSED(text); - checkEnableOk(); - }); - - connect(m_ui->externalLineEdit, &QLineEdit::textChanged, - [this](const QString& text) { - Q_UNUSED(text); - checkEnableOk(); - this->m_ui->errorLabel->setText(""); - }); - - connect(m_ui->modeComboBox, &QComboBox::currentTextChanged, - [this](const QString& text) { - auto executionMode = - m_executorTypeMetaEnum.keyToValue(text.toLatin1().data()); - m_ui->dockerGroupBox->setHidden(executionMode != - Pipeline::ExecutionMode::Docker); - m_ui->externalGroupBox->setHidden( - executionMode != Pipeline::ExecutionMode::ExternalPython); - }); - - connect(this, &QDialog::accepted, this, [this]() { - - PipelineSettings currentSettings; - auto newMode = m_executorTypeMetaEnum.keyToValue( - m_ui->modeComboBox->currentText().toLatin1().data()); - if (newMode != currentSettings.executionMode()) { - PipelineManager::instance().updateExecutionMode( - static_cast(newMode)); - } - - writeSettings(); - }); - - connect(m_ui->buttonBox, &QDialogButtonBox::helpRequested, - []() { openHelpUrl("pipelines/#configuration"); }); - - connect(m_ui->browseButton, &QPushButton::clicked, [this]() { - auto executable = - QFileDialog::getOpenFileName(this, "Select Python executable"); - m_ui->externalLineEdit->setText(executable); - }); - - checkEnableOk(); -} - -PipelineSettingsDialog::~PipelineSettingsDialog() -{ -} - -void PipelineSettingsDialog::readSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - - if (settings->contains("PipelineSettings.AutoExecuteOnStateLoad")) { - auto execute = - settings->value("PipelineSettings.AutoExecuteOnStateLoad").toBool(); - Qt::CheckState checked = execute ? Qt::Checked : Qt::Unchecked; - m_ui->executePipelinesCheckBox->setCheckState(checked); - m_ui->executePipelinesLabel->show(); - m_ui->executePipelinesCheckBox->show(); - } else { - m_ui->executePipelinesLabel->hide(); - m_ui->executePipelinesCheckBox->hide(); - } - - if (settings->contains("pipeline/geometry")) { - setGeometry(settings->value("pipeline/geometry").toRect()); - } - - PipelineSettings pipelineSettings; - - m_ui->modeComboBox->setCurrentText( - m_executorTypeMetaEnum.valueToKey(pipelineSettings.executionMode())); - - auto dockerImage = pipelineSettings.dockerImage(); - if (!dockerImage.isEmpty()) { - m_ui->dockerImageLineEdit->setText(dockerImage); - } - - m_ui->pullImageCheckBox->setChecked(pipelineSettings.dockerPull()); - m_ui->removeContainersCheckBox->setChecked(pipelineSettings.dockerRemove()); - - auto pythonExecutable = pipelineSettings.externalPythonExecutablePath(); - if (!pythonExecutable.isEmpty()) { - m_ui->externalLineEdit->setText(pythonExecutable); - } -} - -void PipelineSettingsDialog::writeSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - - if (settings->contains("PipelineSettings.AutoExecuteOnStateLoad")) { - switch (m_ui->executePipelinesCheckBox->checkState()) { - case Qt::Checked: { - settings->setValue("PipelineSettings.AutoExecuteOnStateLoad", true); - break; - } - case Qt::Unchecked: { - settings->setValue("PipelineSettings.AutoExecuteOnStateLoad", false); - break; - } - case Qt::PartiallyChecked: - default: { - settings->remove("PipelineSettings.AutoExecuteOnStateLoad"); - break; - } - } - } - - settings->setValue("pipeline/geometry", geometry()); - - PipelineSettings pipelineSettings; - pipelineSettings.setExecutionMode(m_ui->modeComboBox->currentText()); - pipelineSettings.setDockerImage(m_ui->dockerImageLineEdit->text()); - pipelineSettings.setDockerPull(m_ui->pullImageCheckBox->isChecked()); - pipelineSettings.setDockerRemove(m_ui->removeContainersCheckBox->isChecked()); - pipelineSettings.setExternalPythonExecutablePath( - m_ui->externalLineEdit->text()); -} - -void PipelineSettingsDialog::showEvent(QShowEvent* event) -{ - QDialog::showEvent(event); - readSettings(); -} - -void PipelineSettingsDialog::checkEnableOk() -{ - - bool enabled = true; - auto currentMode = m_executorTypeMetaEnum.keyToValue( - m_ui->modeComboBox->currentText().toLatin1().data()); - - if (currentMode == Pipeline::ExecutionMode::Docker) { - enabled = !m_ui->dockerImageLineEdit->text().isEmpty(); - } else if (currentMode == Pipeline::ExecutionMode::ExternalPython) { - enabled = !m_ui->externalLineEdit->text().isEmpty(); - } - - m_ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(enabled); -} - -bool PipelineSettingsDialog::validatePythonEnvironment() -{ - auto pythonExecutable = QFileInfo(m_ui->externalLineEdit->text()); - if (!pythonExecutable.exists()) { - m_ui->errorLabel->setText("The external python executable doesn't exist."); - return false; - } - - auto baseDir = pythonExecutable.dir(); - auto tomvizPipelineExecutable = - QFileInfo(baseDir.filePath("tomviz-pipeline")); - if (!tomvizPipelineExecutable.exists()) { - m_ui->errorLabel->setText( - "Unable to find tomviz-pipeline executable, please ensure " - "tomviz package has been installed in python environment."); - return false; - } - - return true; -} - -void PipelineSettingsDialog::done(int r) -{ - auto currentMode = m_executorTypeMetaEnum.keyToValue( - m_ui->modeComboBox->currentText().toLatin1().data()); - - if (currentMode != Pipeline::ExecutionMode::ExternalPython) { - QDialog::done(r); - return; - } - - // Perform validation for external Python - if (QDialog::Accepted != r || validatePythonEnvironment()) { - QDialog::done(r); - } -} - -} // namespace tomviz diff --git a/tomviz/PipelineSettingsDialog.h b/tomviz/PipelineSettingsDialog.h deleted file mode 100644 index 63d467fe5..000000000 --- a/tomviz/PipelineSettingsDialog.h +++ /dev/null @@ -1,47 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineSettingsDialog_h -#define tomvizPipelineSettingsDialog_h - -#include -#include -#include - -#include "Pipeline.h" - -namespace Ui { -class PipelineSettingsDialog; -} - -namespace tomviz { - -class PipelineSettingsDialog : public QDialog -{ - Q_OBJECT - -public: - PipelineSettingsDialog(QWidget* parent = nullptr); - ~PipelineSettingsDialog() override; - void readSettings(); - -public slots: - void done(int r) override; - -private slots: - void writeSettings(); - -protected: - void showEvent(QShowEvent* event) override; - -private: - Q_DISABLE_COPY(PipelineSettingsDialog) - QScopedPointer m_ui; - QMetaEnum m_executorTypeMetaEnum; - void checkEnableOk(); - bool validatePythonEnvironment(); -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/PipelineSettingsDialog.ui b/tomviz/PipelineSettingsDialog.ui deleted file mode 100644 index 0c0d17494..000000000 --- a/tomviz/PipelineSettingsDialog.ui +++ /dev/null @@ -1,232 +0,0 @@ - - - PipelineSettingsDialog - - - - 0 - 0 - 373 - 191 - - - - Pipeline Settings - - - - - - - - Pipeline Mode - - - - - - - - - - - Execute Pipelines on Load - - - - - - - false - - - - - - - - - true - - - Docker Settings - - - - QFormLayout::AllNonFixedFieldsGrow - - - - - Image - - - - - - - tomviz/pipeline - - - - - - - <html><head/><body><p>Remove the container once the pipeline has finished executing.</p></body></html> - - - Remove Containers - - - - - - - true - - - - - - - <html><head/><body><p>Pull the image before running the pipeline.</p></body></html> - - - Pull Image - - - - - - - true - - - - - - - - - - External Python Settings - - - - - - - - - - - Browse - - - - - - - - - - - Executable - - - - - - - - - - - - color: red - - - - - - Qt::RichText - - - true - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok - - - - - - - - - buttonBox - accepted() - PipelineSettingsDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - PipelineSettingsDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/tomviz/PipelineView.cxx b/tomviz/PipelineView.cxx deleted file mode 100644 index 9281b8bf9..000000000 --- a/tomviz/PipelineView.cxx +++ /dev/null @@ -1,825 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PipelineView.h" - -#include "ActiveObjects.h" -#include "CloneDataReaction.h" -#include "ConformVolumeReaction.h" -#include "DuplicateModuleReaction.h" -#include "EditOperatorDialog.h" -#include "ExportDataReaction.h" -#include "LoadDataReaction.h" -#include "MergeImagesReaction.h" -#include "Module.h" -#include "ModuleManager.h" -#include "Operator.h" -#include "OperatorPython.h" -#include "OperatorResult.h" -#include "Pipeline.h" -#include "PipelineModel.h" -#include "SaveDataReaction.h" -#include "SetDataTypeReaction.h" -#include "SnapshotOperator.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -namespace { - -/// Returns true if the operator's breakpoint has been reached: the operator -/// has a breakpoint, is still Queued, and all preceding operators are Complete. -bool isBreakpointReached(Operator* op) -{ - if (!op || !op->hasBreakpoint() || !op->isQueued()) - return false; - auto ds = op->dataSource(); - if (!ds) - return false; - for (auto o : ds->operators()) { - if (o == op) - break; - if (!o->isCompleted()) - return false; - } - return true; -} - -} // namespace - -class OperatorRunningDelegate : public QItemDelegate -{ - -public: - OperatorRunningDelegate(QWidget* parent = 0); - - void paint(QPainter* painter, const QStyleOptionViewItem& option, - const QModelIndex& index) const; - -public slots: - void start(); - void stop(); - -private: - QTimer* m_timer; - PipelineView* m_view; - mutable qreal m_angle = 0; -}; - -OperatorRunningDelegate::OperatorRunningDelegate(QWidget* parent) - : QItemDelegate(parent) -{ - m_view = qobject_cast(parent); - m_timer = new QTimer(this); - connect(m_timer, &QTimer::timeout, m_view->viewport(), QOverload<>::of(&QWidget::update)); -} - -void OperatorRunningDelegate::paint(QPainter* painter, - const QStyleOptionViewItem& option, - const QModelIndex& index) const -{ - auto pipelineModel = qobject_cast(m_view->model()); - auto op = pipelineModel->op(index); - - if (op && index.column() == Column::label) { - // Reserve space on the left for the breakpoint indicator, then let the - // base class paint the label content in the remaining area. - int bpWidth = PipelineView::breakpointAreaWidth(); - QRect bpRect(option.rect.left(), option.rect.top(), bpWidth, - option.rect.height()); - - // Draw the breakpoint / play / hover indicator - QPixmap pixmap; - qreal opacity = 1.0; - if (isBreakpointReached(op)) { - pixmap = QPixmap(":/icons/play.png"); - } else if (op->hasBreakpoint()) { - pixmap = QPixmap(":/icons/breakpoint.png"); - } else { - // Show semi-transparent breakpoint icon on hover - auto hoverIdx = m_view->hoverIndex(); - if (hoverIdx.isValid() && hoverIdx.row() == index.row() && - hoverIdx.parent() == index.parent()) { - pixmap = QPixmap(":/icons/breakpoint.png"); - opacity = 0.3; - } - } - - if (!pixmap.isNull()) { - painter->save(); - painter->setOpacity(opacity); - int iconSize = qMin(bpRect.width(), bpRect.height()) - 4; - QRect iconRect(bpRect.left() + (bpWidth - iconSize) / 2, - bpRect.top() + (bpRect.height() - iconSize) / 2, - iconSize, iconSize); - painter->drawPixmap(iconRect, pixmap); - painter->restore(); - } - - // Paint the rest of the label content shifted to the right - QStyleOptionViewItem shiftedOption = option; - shiftedOption.rect.setLeft(option.rect.left() + bpWidth); - QItemDelegate::paint(painter, shiftedOption, index); - return; - } - - QItemDelegate::paint(painter, option, index); - - if (op && index.column() == Column::state) { - if (op->state() == OperatorState::Running) { - QPixmap pixmap(":/icons/spinner.png"); - - // Calculate the correct location to draw based on margin. The margin - // calculation is taken from QItemDelegate::doLayout(...), I couldn't - // find an API call that would give this to me directly. - auto leftMargin = - QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - QPoint topLeft = option.rect.topLeft(); - topLeft += QPoint(leftMargin, 0); - - int offset = option.rect.height() / 2; - QRect bounds(-offset, -offset, option.rect.height(), - option.rect.height()); - painter->save(); - painter->translate(topLeft.x() + offset, topLeft.y() + offset); - painter->rotate(m_angle); - m_angle += 10; - painter->drawPixmap(bounds, pixmap); - painter->restore(); - } - } -} - -void OperatorRunningDelegate::start() -{ - m_timer->start(50); -} - -void OperatorRunningDelegate::stop() -{ - m_timer->stop(); -} - -PipelineView::PipelineView(QWidget* p) : QTreeView(p) -{ - connect(this, &QAbstractItemView::clicked, this, &PipelineView::rowActivated); - setIndentation(20); - setRootIsDecorated(false); - setItemsExpandable(false); - setMouseTracking(true); - - QString customStyle = "QTreeView::branch { background-color: white; }"; - setStyleSheet(customStyle); - setAlternatingRowColors(true); - setSelectionBehavior(QAbstractItemView::SelectRows); - setSelectionMode(QAbstractItemView::ExtendedSelection); - OperatorRunningDelegate* delegate = new OperatorRunningDelegate(this); - setItemDelegate(delegate); - - // Connect up operators to start and stop delegate - // New datasource added - connect(&ModuleManager::instance(), &ModuleManager::dataSourceAdded, - [delegate](DataSource* dataSource) { - // New operator added - connect(dataSource, &DataSource::operatorAdded, delegate, - [delegate](Operator* op) { - // Connect transformingStarted to OperatorRunningDelegate - connect(op, &Operator::transformingStarted, delegate, - &OperatorRunningDelegate::start); - // Connect transformingDone - connect(op, &Operator::transformingDone, delegate, - [delegate]() { delegate->stop(); }); - }); - }); - - connect(&ModuleManager::instance(), &ModuleManager::pipelineViewRenderNeeded, - viewport(), QOverload<>::of(&QWidget::update)); - - connect(this, &QAbstractItemView::doubleClicked, this, - &PipelineView::rowDoubleClicked); -} - -void PipelineView::setModel(QAbstractItemModel* model) -{ - QTreeView::setModel(model); - auto pipelineModel = qobject_cast(model); - if (!pipelineModel) { - // Warn about impending segfault - qCritical() << "Unknown model type. PipelineView will not work correctly"; - } - - // Listen for new items being added since the current selection needs - // to be updated. We can't listen on the ModuleManager/DataSource - // since the PipelineModel listens to those signals and setCurrent - // has to happen AFTER the slots on the PipelineModel are called. So - // we listen to the model and respond after it does its update. - connect(pipelineModel, &PipelineModel::dataSourceItemAdded, this, - QOverload::of(&PipelineView::setCurrent)); - connect(pipelineModel, &PipelineModel::childDataSourceItemAdded, this, - QOverload::of(&PipelineView::setCurrent)); - connect(pipelineModel, &PipelineModel::moleculeSourceItemAdded, this, - QOverload::of(&PipelineView::setCurrent)); - connect(pipelineModel, &PipelineModel::moleculeSourceItemAdded, this, - QOverload::of(&PipelineView::setCurrent)); - connect(pipelineModel, &PipelineModel::moduleItemAdded, this, - QOverload::of(&PipelineView::setCurrent)); - connect(pipelineModel, &PipelineModel::operatorItemAdded, this, - QOverload::of(&PipelineView::setCurrent)); - connect(pipelineModel, &PipelineModel::dataSourceModified, this, - QOverload::of(&PipelineView::setCurrent)); - - // This is needed to work around a bug in Qt 5.10, the select resize mode is - // setting reset for some reason. - connect(pipelineModel, &PipelineModel::operatorItemAdded, this, - &PipelineView::initLayout); -} - -void PipelineView::keyPressEvent(QKeyEvent* e) -{ - if (e->key() == Qt::Key_Delete || e->key() == Qt::Key_Backspace) { - if (enableDeleteItems(selectedIndexes())) { - deleteItemsConfirm(selectedIndexes()); - } - } else { - QTreeView::keyPressEvent(e); - } -} - -void PipelineView::contextMenuEvent(QContextMenuEvent* e) -{ - auto idx = indexAt(e->pos()); - if (!idx.isValid()) { - return; - } - - auto pipelineModel = qobject_cast(model()); - auto dataSource = pipelineModel->dataSource(idx); - auto result = pipelineModel->result(idx); - - bool childDataSource = - (dataSource && ModuleManager::instance().isChild(dataSource)); - - QMenu contextMenu; - QAction* cloneAction = nullptr; - QAction* duplicateModuleAction = nullptr; - QAction* markAsVolumeAction = nullptr; - QAction* markAsTiltAction = nullptr; - QAction* markAsFibAction = nullptr; - QAction* saveDataAction = nullptr; - QAction* exportModuleAction = nullptr; - QAction* executeAction = nullptr; - QAction* hideAction = nullptr; - QAction* showAction = nullptr; - QAction* cloneChildAction = nullptr; - QAction* snapshotAction = nullptr; - QAction* showInterfaceAction = nullptr; - QAction* exportTableResultAction = nullptr; - QAction* exportTableCsvAction = nullptr; - QAction* reloadAndResampleAction = nullptr; - bool allowReExecute = false; - CloneDataReaction* cloneReaction; - - if (result && qobject_cast(result->parent())) { - if (vtkTable::SafeDownCast(result->dataObject())) { - exportTableResultAction = contextMenu.addAction("Save as JSON"); - exportTableCsvAction = contextMenu.addAction("Save as CSV"); - } else { - return; - } - } else if (dataSource != nullptr) { - if (!childDataSource) { - // Data source ( non child ) - cloneAction = contextMenu.addAction("Clone"); - cloneReaction = new CloneDataReaction(cloneAction); - if (dataSource->type() == DataSource::Volume) { - markAsTiltAction = contextMenu.addAction("Mark as Tilt Series"); - // markAsFibAction = contextMenu.addAction("Mark as Focused Ion Beam"); - } else if (dataSource->type() == DataSource::TiltSeries) { - markAsVolumeAction = contextMenu.addAction("Mark as Volume"); - // markAsFibAction = contextMenu.addAction("Mark as Focused Ion Beam"); - } else if (dataSource->type() == DataSource::FIB) { - markAsVolumeAction = contextMenu.addAction("Mark as Volume"); - markAsTiltAction = contextMenu.addAction("Mark as Tilt Series"); - } - - if (dataSource->canReloadAndResample()) { - reloadAndResampleAction = contextMenu.addAction("Reload and Resample"); - } - - // Add option to re-execute the pipeline is we have a canceled operator - // in our pipeline. - foreach (Operator* op, dataSource->operators()) { - if (op->isCanceled() || op->isModified()) { - allowReExecute = true; - break; - } - } - } else if (childDataSource) { - // Child data source - cloneChildAction = contextMenu.addAction("Clone"); - } - - saveDataAction = contextMenu.addAction("Save Data"); - new SaveDataReaction(saveDataAction); - - // Add option to merge different datasets - QAction* mergeImageAction = contextMenu.addAction("Merge Images"); - auto micReaction = new MergeImagesReaction(mergeImageAction); - - auto conformVolumeAction = contextMenu.addAction("Conform Volume"); - auto cvReaction = new ConformVolumeReaction(conformVolumeAction); - - // Set the selected data sources in the merge components reaction - QModelIndexList indexList = selectedIndexes(); - QSet selectedDataSources; - for (int i = 0; i < indexList.size(); ++i) { - auto source = pipelineModel->dataSource(indexList[i]); - if (source && !selectedDataSources.contains(source)) { - selectedDataSources.insert(source); - } - } - micReaction->updateDataSources(selectedDataSources); - cvReaction->updateDataSources(selectedDataSources); - } - - // Allow pipeline to be re-executed if we are dealing with a canceled - // operator. - auto op = pipelineModel->op(idx); - allowReExecute = - allowReExecute || (op && (op->isCanceled() || op->isModified())); - - if (allowReExecute) { - executeAction = contextMenu.addAction("Re-execute pipeline"); - } - - // Offer to cache for operators. - if (op) { - snapshotAction = contextMenu.addAction("Snapshot Data"); - } - - // Add a view source entry when it is a Python-based operator. - if (op && qobject_cast(op)) { - showInterfaceAction = contextMenu.addAction("View Source"); - } else if (op && op->hasCustomUI()) { - showInterfaceAction = contextMenu.addAction("Edit"); - } - - // Keep the delete menu entry at the end of the list of options. - - // Don't add a "Delete" menu entry for "Output" data source. - QAction* deleteAction = nullptr; - bool addDelete = true; - if (dataSource != nullptr) { - addDelete = dataSource->forkable(); - } - - if (result) { - addDelete = false; - } - - if (addDelete) { - deleteAction = contextMenu.addAction("Delete"); - if (deleteAction && !enableDeleteItems(selectedIndexes())) { - deleteAction->setEnabled(false); - } - } - - bool allModules = true; - foreach (auto i, selectedIndexes()) { - auto module = pipelineModel->module(i); - if (!module) { - allModules = false; - break; - } - } - - if (allModules) { - hideAction = contextMenu.addAction("Hide"); - showAction = contextMenu.addAction("Show"); - - if (selectedIndexes().size() == 2) { - auto module = pipelineModel->module(selectedIndexes()[0]); - QString exportType = module->exportDataTypeString(); - if (exportType.size() > 0) { - QString menuActionString = QString("Export as %1").arg(exportType); - exportModuleAction = contextMenu.addAction(menuActionString); - new ExportDataReaction(exportModuleAction, module); - } - - duplicateModuleAction = contextMenu.addAction("Duplicate Module"); - new DuplicateModuleReaction(duplicateModuleAction); - } - } - - auto globalPoint = mapToGlobal(e->pos()); - auto selectedItem = contextMenu.exec(globalPoint); - - // Nothing selected - if (!selectedItem) { - return; - } - - // Some action was selected, so process it. - if (selectedItem == deleteAction) { - deleteItemsConfirm(selectedIndexes()); - } else if (executeAction && selectedItem == executeAction) { - if (!dataSource) { - dataSource = op->dataSource(); - } - // Re-execute from the beginning - dataSource->pipeline()->resume(); - dataSource->pipeline() - ->execute(dataSource, dataSource->operators().first()) - ->deleteWhenFinished(); - } else if (markAsVolumeAction != nullptr && - markAsVolumeAction == selectedItem) { - auto mainWindow = qobject_cast(window()); - SetDataTypeReaction::setDataType(mainWindow, dataSource, - DataSource::Volume); - } else if (markAsTiltAction != nullptr && markAsTiltAction == selectedItem) { - auto mainWindow = qobject_cast(window()); - SetDataTypeReaction::setDataType(mainWindow, dataSource, - DataSource::TiltSeries); - } else if (markAsFibAction != nullptr && markAsFibAction == selectedItem) { - auto mainWindow = qobject_cast(window()); - SetDataTypeReaction::setDataType(mainWindow, dataSource, DataSource::FIB); - } else if (hideAction && selectedItem == hideAction) { - setModuleVisibility(selectedIndexes(), false); - } else if (showAction && selectedItem == showAction) { - setModuleVisibility(selectedIndexes(), true); - } else if (cloneChildAction && selectedItem == cloneChildAction) { - cloneReaction->clone(dataSource); - } else if (snapshotAction && selectedItem == snapshotAction) { - op->dataSource()->addOperator(new SnapshotOperator(op->dataSource())); - } else if (showInterfaceAction && selectedItem == showInterfaceAction) { - if (qobject_cast(op)) { - EditOperatorDialog::showDialogForOperator(op, QStringLiteral("viewCode")); - } else { - EditOperatorDialog::showDialogForOperator(op); - } - } else if (selectedItem == exportTableResultAction) { - exportTableAsJson(vtkTable::SafeDownCast(result->dataObject())); - } else if (selectedItem == exportTableCsvAction) { - exportTableAsCsv(vtkTable::SafeDownCast(result->dataObject())); - } else if (selectedItem == reloadAndResampleAction) { - dataSource->reloadAndResample(); - } -} - -void PipelineView::exportTableAsJson(vtkTable* table) -{ - auto json = tableToJson(table); - jsonToFile(json); -} - -void PipelineView::exportTableAsCsv(vtkTable* table) -{ - auto csv = tableToCsv(table); - csvToFile(csv); -} - -void PipelineView::deleteItems(const QModelIndexList& idxs) -{ - auto pipelineModel = qobject_cast(model()); - Q_ASSERT(pipelineModel); - - QList dataSources; - QList moleculeSources; - QList operators; - QList modules; - - foreach (QModelIndex idx, idxs) { - // Make sure we only process one index per row otherwise we try to delete - // things twice - if (idx.column() != 0) { - continue; - } - auto dataSource = pipelineModel->dataSource(idx); - auto moleculeSource = pipelineModel->moleculeSource(idx); - auto module = pipelineModel->module(idx); - auto op = pipelineModel->op(idx); - if (dataSource) { - dataSources.push_back(dataSource); - } else if (moleculeSource) { - moleculeSources.push_back(moleculeSource); - } else if (module) { - modules.push_back(module); - } else if (op) { - operators.push_back(op); - } - } - - foreach (Module* module, modules) { - // If the datasource is being remove don't bother removing the module - if (!dataSources.contains(module->dataSource()) && - !moleculeSources.contains(module->moleculeSource())) { - pipelineModel->removeModule(module); - } - } - - foreach (MoleculeSource* moleculeSource, moleculeSources) { - pipelineModel->removeMoleculeSource(moleculeSource); - } - - QSet paused; - foreach (Operator* op, operators) { - // If the datasource is being remove don't bother removing the operator - if (!dataSources.contains(op->dataSource())) { - op->dataSource()->pipeline()->pause(); - paused.insert(op->dataSource()); - pipelineModel->removeOp(op); - } - } - - foreach (DataSource* dataSource, dataSources) { - pipelineModel->removeDataSource(dataSource); - } - - // Now resume the pipelines - foreach (DataSource* dataSource, paused) { - dataSource->pipeline()->resume(); - dataSource->pipeline()->execute(dataSource)->deleteWhenFinished(); - } - - // Delay rendering until signals have been processed and all modules removed. - QTimer::singleShot(0, []() { ActiveObjects::instance().renderAllViews(); }); -} - -void PipelineView::rowActivated(const QModelIndex& idx) -{ - if (!idx.isValid()) - return; - - auto pipelineModel = qobject_cast(model()); - if (!pipelineModel) - return; - - if (idx.column() == Column::label) { - if (auto op = pipelineModel->op(idx)) { - // Check if the click landed in the breakpoint area (left side of the - // label column). - auto cursorPos = viewport()->mapFromGlobal(QCursor::pos()); - auto cellRect = visualRect(idx); - int clickX = cursorPos.x() - cellRect.left(); - if (clickX >= 0 && clickX < breakpointAreaWidth()) { - auto ds = op->dataSource(); - auto pipeline = ds->pipeline(); - // Don't allow breakpoint changes while the pipeline is running. - if (pipeline && pipeline->isRunning()) { - return; - } - if (isBreakpointReached(op)) { - // Resume execution from this operator - auto operators = ds->operators(); - Operator* nextBp = nullptr; - int bpIdx = operators.indexOf(op); - for (int i = bpIdx + 1; i < operators.size(); ++i) { - if (operators[i]->hasBreakpoint()) { - nextBp = operators[i]; - break; - } - } - pipeline->execute(ds, op, nextBp)->deleteWhenFinished(); - } else { - // Toggle breakpoint - op->setBreakpoint(!op->hasBreakpoint()); - } - return; - } - } - } else if (idx.column() == Column::state) { - if (auto module = pipelineModel->module(idx)) { - module->setVisibility(!module->visibility()); - emit model()->dataChanged(idx, idx); - if (pqView* view = tomviz::convert(module->view())) { - view->render(); - } - } - } -} - -void PipelineView::rowDoubleClicked(const QModelIndex& idx) -{ - auto pipelineModel = qobject_cast(model()); - Q_ASSERT(pipelineModel); - if (auto op = pipelineModel->op(idx)) { - EditOperatorDialog::showDialogForOperator(op); - } else if (auto result = pipelineModel->result(idx)) { - if (vtkTable::SafeDownCast(result->dataObject())) { - auto view = ActiveObjects::instance().activeView(); - - // If the view is not a SpreadSheetView, look for the first one and - // use it if possible. - vtkNew iter; - iter->SetSessionProxyManager(ActiveObjects::instance().proxyManager()); - iter->SetModeToOneGroup(); - for (iter->Begin("views"); !iter->IsAtEnd(); iter->Next()) { - auto viewProxy = vtkSMViewProxy::SafeDownCast(iter->GetProxy()); - if (std::string(viewProxy->GetXMLName()) == "SpreadSheetView") { - view = viewProxy; - break; - } - } - - // If a spreadsheet view wasn't found, controller->ShowInPreferredView() - // will create one. - vtkNew controller; - view = controller->ShowInPreferredView(result->producerProxy(), 0, view); - ActiveObjects::instance().setActiveView(view); - } - } -} - -void PipelineView::currentChanged(const QModelIndex& current, - const QModelIndex&) -{ - if (!current.isValid()) { - return; - } - auto pipelineModel = qobject_cast(model()); - Q_ASSERT(pipelineModel); - - // Clear stale active state before setting new selection. - ActiveObjects::instance().setSelectedDataSource(nullptr); - ActiveObjects::instance().setActiveOperator(nullptr); - if (auto dataSource = pipelineModel->dataSource(current)) { - ActiveObjects::instance().setSelectedDataSource(dataSource); - } else if (auto module = pipelineModel->module(current)) { - ActiveObjects::instance().setActiveModule(module); - } else if (auto op = pipelineModel->op(current)) { - ActiveObjects::instance().setActiveOperator(op); - } else if (auto moleculeSource = pipelineModel->moleculeSource(current)) { - ActiveObjects::instance().setActiveMoleculeSource(moleculeSource); - } - - // Always change the active OperatorResult. It is possible to have both - // a DataSource and an OperatorResult active at the same time, but only - // when the OperatorResult is currently selected. If the OperatorResult is - // not selected, the current active result should be null. - if (auto result = pipelineModel->result(current)) { - ActiveObjects::instance().setActiveOperatorResult(result); - } else { - ActiveObjects::instance().setActiveOperatorResult(nullptr); - } - - // Unset active result -} - -void PipelineView::setCurrent(DataSource* dataSource) -{ - auto pipelineModel = qobject_cast(model()); - auto index = pipelineModel->dataSourceIndex(dataSource); - setCurrentIndex(index); - selectionModel()->select(index, QItemSelectionModel::Select); -} - -void PipelineView::setCurrent(MoleculeSource* dataSource) -{ - auto pipelineModel = qobject_cast(model()); - auto index = pipelineModel->moleculeSourceIndex(dataSource); - setCurrentIndex(index); - selectionModel()->select(index, QItemSelectionModel::Select); -} - -void PipelineView::setCurrent(Module* module) -{ - auto pipelineModel = qobject_cast(model()); - auto index = pipelineModel->moduleIndex(module); - setCurrentIndex(index); - selectionModel()->select(index, QItemSelectionModel::Select); -} - -void PipelineView::setCurrent(Operator* op) -{ - auto pipelineModel = qobject_cast(model()); - auto index = pipelineModel->operatorIndex(op); - setCurrentIndex(index); - selectionModel()->select(index, QItemSelectionModel::Select); -} - -void PipelineView::deleteItemsConfirm(const QModelIndexList& idxs) -{ - if (idxs.isEmpty()) { - return; - } - - QMessageBox::StandardButton response = QMessageBox::question( - this, "Delete pipeline elements?", - "Are you sure you want to delete the selected pipeline elements"); - if (response == QMessageBox::Yes) { - deleteItems(idxs); - } -} - -bool PipelineView::enableDeleteItems(const QModelIndexList& idxs) -{ - if (idxs.isEmpty()) { - return false; - } - - auto pipelineModel = qobject_cast(model()); - for (auto& index : idxs) { - auto dataSource = pipelineModel->dataSource(index); - if (dataSource != nullptr) { - // Disable if pipeline is running - bool disable = - dataSource->pipeline() && dataSource->pipeline()->isRunning(); - - // Disable for "Output" data sources - disable = disable || !dataSource->forkable(); - - return !disable; - } - - auto op = pipelineModel->op(index); - if (op && op->dataSource()->pipeline() && - (op->dataSource()->pipeline()->isRunning() || - op->state() == OperatorState::Edit)) { - return false; - } - } - - return true; -} - -void PipelineView::setModuleVisibility(const QModelIndexList& idxs, - bool visible) -{ - auto pipelineModel = qobject_cast(model()); - - Module* module = nullptr; - foreach (auto idx, idxs) { - module = pipelineModel->module(idx); - module->setVisibility(visible); - } - - if (module) { - if (pqView* view = tomviz::convert(module->view())) { - view->render(); - } - } -} - -void PipelineView::mouseMoveEvent(QMouseEvent* event) -{ - auto idx = indexAt(event->pos()); - if (idx != m_hoverIndex) { - auto oldIndex = m_hoverIndex; - m_hoverIndex = idx; - // Repaint old and new rows so the hover breakpoint indicator updates - if (oldIndex.isValid()) { - auto labelIdx = model()->index(oldIndex.row(), Column::label, - oldIndex.parent()); - update(labelIdx); - } - if (idx.isValid()) { - auto labelIdx = model()->index(idx.row(), Column::label, idx.parent()); - update(labelIdx); - } - } - QTreeView::mouseMoveEvent(event); -} - -void PipelineView::leaveEvent(QEvent* event) -{ - if (m_hoverIndex.isValid()) { - auto oldIndex = m_hoverIndex; - m_hoverIndex = QModelIndex(); - auto labelIdx = model()->index(oldIndex.row(), Column::label, - oldIndex.parent()); - update(labelIdx); - } - QTreeView::leaveEvent(event); -} - -void PipelineView::initLayout() -{ - header()->setStretchLastSection(false); - header()->setVisible(false); - header()->setSectionResizeMode(0, QHeaderView::Stretch); - header()->setSectionResizeMode(1, QHeaderView::Fixed); - header()->resizeSection(1, 30); -} -} // namespace tomviz diff --git a/tomviz/PipelineView.h b/tomviz/PipelineView.h deleted file mode 100644 index 5844adc77..000000000 --- a/tomviz/PipelineView.h +++ /dev/null @@ -1,68 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineView_h -#define tomvizPipelineView_h - -#include - -#include -#include - -#include "EditOperatorDialog.h" - -class vtkTable; - -namespace tomviz { - -class DataSource; -class MoleculeSource; -class Module; -class Operator; - -class PipelineView : public QTreeView -{ - Q_OBJECT - -public: - PipelineView(QWidget* parent = nullptr); - - void setModel(QAbstractItemModel*) override; - void initLayout(); - - /// Returns the model index currently hovered by the mouse, if any. - QModelIndex hoverIndex() const { return m_hoverIndex; } - - /// Width in pixels reserved for the breakpoint indicator area in the label - /// column. - static constexpr int breakpointAreaWidth() { return 20; } - -protected: - void keyPressEvent(QKeyEvent*) override; - void contextMenuEvent(QContextMenuEvent*) override; - void currentChanged(const QModelIndex& current, - const QModelIndex& previous) override; - void deleteItems(const QModelIndexList& idxs); - bool enableDeleteItems(const QModelIndexList& idxs); - void mouseMoveEvent(QMouseEvent* event) override; - void leaveEvent(QEvent* event) override; - -private: - QPersistentModelIndex m_hoverIndex; - -private slots: - void rowActivated(const QModelIndex& idx); - void rowDoubleClicked(const QModelIndex& idx); - - void setCurrent(DataSource* dataSource); - void setCurrent(MoleculeSource* dataSource); - void setCurrent(Module* module); - void setCurrent(Operator* op); - void deleteItemsConfirm(const QModelIndexList& idxs); - void setModuleVisibility(const QModelIndexList& idxs, bool visible); - void exportTableAsJson(vtkTable*); - void exportTableAsCsv(vtkTable*); -}; -} // namespace tomviz - -#endif // tomvizPipelineView_h diff --git a/tomviz/PipelineWorker.cxx b/tomviz/PipelineWorker.cxx deleted file mode 100644 index 2cbe97719..000000000 --- a/tomviz/PipelineWorker.cxx +++ /dev/null @@ -1,320 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PipelineWorker.h" -#include "Operator.h" - -#include -#include -#include -#include -#include - -#include - -namespace tomviz { - -class PipelineWorker::RunnableOperator : public QObject, public QRunnable -{ - Q_OBJECT - -public: - RunnableOperator(Operator* op, vtkDataObject* input, - QObject* parent = nullptr); - - /// Returns the data the operator operates on - vtkDataObject* data() { return m_data; } - Operator* op() { return m_operator; } - void run() override; - void cancel(); - bool isCanceled(); - -signals: - void complete(TransformResult result); - -private: - Operator* m_operator; - vtkDataObject* m_data; - Q_DISABLE_COPY(RunnableOperator) -}; - -class PipelineWorker::Run : public QObject -{ - Q_OBJECT - - enum class State - { - CREATED, - RUNNING, - CANCELED, - COMPLETE - }; - -public: - Run(vtkDataObject* data, QList operators); - - /// Clear all Operators from the queue and attempts to cancel the - /// running Operator. - void cancel(); - /// Returns true if the operator was successfully removed from the queue - /// before it was run, false otherwise. - bool cancel(Operator* op); - /// Returns true if we are currently running the operator pipeline, false - /// otherwise. - bool isRunning(); - - /// If the execution of the pipeline is still in progress then add this - /// operator to it. Return true is - bool addOperator(Operator* op); - - /// Returns the data object being used for this run. - vtkDataObject* data() { return m_data; } - - /// Start the pipeline execution - Future* start(); - - QList operators(); - -public slots: - void operatorComplete(TransformResult result); - - // Start the next operator in the queue - void startNextOperator(); - -signals: - void finished(bool result); - void canceled(); - -private: - RunnableOperator* m_running = nullptr; - vtkSmartPointer m_data; - QQueue m_runnableOperators; - QList m_complete; - QList m_operators; - State m_state = State::CREATED; -}; -} // namespace tomviz - -#include "PipelineWorker.moc" - - -namespace tomviz { -PipelineWorker::RunnableOperator::RunnableOperator(Operator* op, - vtkDataObject* data, - QObject* parent) - : QObject(parent), m_operator(op), m_data(data) -{ - setAutoDelete(false); -} - -void PipelineWorker::RunnableOperator::run() -{ - TransformResult result = m_operator->transform(m_data); - emit complete(result); -} - -void PipelineWorker::RunnableOperator::cancel() -{ - m_operator->cancelTransform(); -} - -bool PipelineWorker::RunnableOperator::isCanceled() -{ - return m_operator->isCanceled(); -} - -PipelineWorker::ConfigureThreadPool::ConfigureThreadPool() -{ - auto threads = QThread::idealThreadCount(); - if (threads < 1) { - threads = 1; - } else { - // Use half the threads we have available. - threads = threads / 2; - } - QThreadPool::globalInstance()->setMaxThreadCount(threads); -} - -PipelineWorker::Run::Run(vtkDataObject* data, QList operators) - : m_data(data) -{ - m_operators = operators; - foreach (auto op, operators) { - m_runnableOperators.enqueue(new RunnableOperator(op, m_data, this)); - } -} - -PipelineWorker::Future* PipelineWorker::Run::start() -{ - auto future = new PipelineWorker::Future(this); - connect(this, &PipelineWorker::Run::finished, future, - &PipelineWorker::Future::finished); - connect(this, &PipelineWorker::Run::canceled, future, - &PipelineWorker::Future::canceled); - - QTimer::singleShot(0, this, &PipelineWorker::Run::startNextOperator); - - m_state = State::RUNNING; - - return future; -} - -void PipelineWorker::Run::startNextOperator() -{ - - if (!m_runnableOperators.isEmpty()) { - m_running = m_runnableOperators.dequeue(); - connect(m_running, &RunnableOperator::complete, this, - &PipelineWorker::Run::operatorComplete); - QThreadPool::globalInstance()->start(m_running); - } -} - -void PipelineWorker::Run::operatorComplete(TransformResult transformResult) -{ - auto runnableOperator = qobject_cast(sender()); - - m_complete.append(runnableOperator); - - bool result = transformResult == TransformResult::Complete; - // Canceled - if (m_state == State::CANCELED || runnableOperator->isCanceled()) { - emit canceled(); - } - // Error - else if (!result) { - emit finished(result); - // The operator's state shows if it failed. This complete means the - // pipeline is no longer running. - m_state = State::COMPLETE; - } - // Run next operator - else if (!m_runnableOperators.isEmpty()) { - startNextOperator(); - } - // We are done - else { - m_state = State::COMPLETE; - emit finished(result); - } - - runnableOperator->deleteLater(); -} - -void PipelineWorker::Run::cancel() -{ - m_state = State::CANCELED; - // Try to cancel the currently running operator - if (m_running != nullptr) { - if (QThreadPool::globalInstance()->tryTake(m_running)) - m_running->deleteLater(); - m_running->cancel(); - m_running = nullptr; - } else { - emit canceled(); - } -} - -bool PipelineWorker::Run::cancel(Operator* op) -{ - - // If the operator is currently running we just have to cancel the execution - // of the whole pipeline. - if (m_running->op() == op) { - cancel(); - return false; - } - - foreach (auto runnable, m_runnableOperators) { - if (runnable->op() == op) { - m_runnableOperators.removeAll(runnable); - return true; - } - } - - return false; -} - -bool PipelineWorker::Run::isRunning() -{ - return m_state == State::RUNNING; -} - -bool PipelineWorker::Run::addOperator(Operator* op) -{ - if (!isRunning()) { - return false; - } - - m_runnableOperators.enqueue(new RunnableOperator(op, m_data, this)); - - return true; -} - -QList PipelineWorker::Run::operators() -{ - return m_operators; -} - -PipelineWorker::Future* PipelineWorker::run(vtkDataObject* data, Operator* op) -{ - QList ops; - ops << op; - - return run(data, ops); -} - -PipelineWorker::Future* PipelineWorker::run(vtkDataObject* data, - QList operators) -{ - // Set all the operators in the queued state - foreach (Operator* op, operators) { - op->resetState(); - } - - Run* run = new Run(data, operators); - - return run->start(); -} - -PipelineWorker::Future::Future(Run* run, QObject* parent) - : QObject(parent), m_run(run) -{} - -PipelineWorker::Future::~Future() -{ - m_run->deleteLater(); -} - -void PipelineWorker::Future::cancel() -{ - m_run->cancel(); -} - -bool PipelineWorker::Future::cancel(Operator* op) -{ - return m_run->cancel(op); -} - -bool PipelineWorker::Future::isRunning() -{ - return m_run->isRunning(); -} - -vtkDataObject* PipelineWorker::Future::result() -{ - return m_run->data(); -} - -bool PipelineWorker::Future::addOperator(Operator* op) -{ - return m_run->addOperator(op); -} - -QList PipelineWorker::Future::operators() -{ - return m_run->operators(); -} - -PipelineWorker::PipelineWorker(QObject* parent) : QObject(parent) {} -} // namespace tomviz diff --git a/tomviz/PipelineWorker.h b/tomviz/PipelineWorker.h deleted file mode 100644 index 4be773c12..000000000 --- a/tomviz/PipelineWorker.h +++ /dev/null @@ -1,82 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineWorker_h -#define tomvizPipelineWorker_h - -#include -#include -#include - -class vtkDataObject; - -namespace tomviz { - -class Operator; - -/// Responsible for running Operator in a separate thread. Backed by the -/// QThreadPool. Operators are run in sequence, one at a time. -class PipelineWorker : public QObject -{ - Q_OBJECT - -public: - class Future; - PipelineWorker(QObject* parent = nullptr); - Future* run(vtkDataObject* data, Operator* op); - Future* run(vtkDataObject* data, QList ops); - -private: - class RunnableOperator; - class Run; - class ConfigureThreadPool - { - public: - ConfigureThreadPool(); - }; - - ConfigureThreadPool configure; -}; - -class PipelineWorker::Future : public QObject -{ - Q_OBJECT - friend class PipelineWorker::Run; - -public: - /// Clear all Operators from the queue and attempts to cancel the - /// running Operator. - void cancel(); - /// Returns true if the operator was successfully removed from the queue - /// before - /// it was run, false otherwise. - bool cancel(Operator* op); - /// Returns true if we are currently running the operator pipeline, false - /// otherwise. - bool isRunning(); - - vtkDataObject* result(); - - /// If the execution of the pipeline is still in progress then add this - /// operator to it. Return true is - bool addOperator(Operator* op); - - QList operators(); - - ~Future(); - -signals: - void canceled(); - void finished(bool result); - void progressRangeChanged(int minimum, int maximum); - void progressTextChanged(const QString& progressText); - void progressValueChanged(int progressValue); - -private: - Future(Run* run, QObject* parent = nullptr); - - Run* m_run; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/PortDataWriter.cxx b/tomviz/PortDataWriter.cxx new file mode 100644 index 000000000..6465a9804 --- /dev/null +++ b/tomviz/PortDataWriter.cxx @@ -0,0 +1,295 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PortDataWriter.h" + +#include "EmdFormat.h" +#include "FileFormatManager.h" +#include "PythonWriter.h" +#include "Utilities.h" + +#include "pipeline/PortData.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace tomviz { + +using pipeline::PortData; +using pipeline::PortType; +using pipeline::VolumeDataPtr; + +namespace { + +bool isEmdExtension(const QString& ext) +{ + return ext == "emd" || ext == "hdf5" || ext == "h5"; +} + +/// Reduce @a image to the named point-data arrays, keeping geometry and +/// field data (tilt angles, scan ids, ...) intact. Returns @a image +/// untouched when the selection already covers everything it carries. +vtkSmartPointer filterArrays(vtkImageData* image, + const QStringList& arrayNames) +{ + auto* pointData = image->GetPointData(); + int total = pointData->GetNumberOfArrays(); + if (arrayNames.isEmpty() || arrayNames.size() >= total) { + return image; + } + + QList selected; + for (const auto& name : arrayNames) { + if (auto* array = pointData->GetArray(name.toUtf8().data())) { + selected.append(array); + } + } + if (selected.isEmpty()) { + return image; + } + + vtkNew filtered; + filtered->CopyStructure(image); + filtered->GetFieldData()->ShallowCopy(image->GetFieldData()); + for (auto* array : selected) { + filtered->GetPointData()->AddArray(array); + } + + // Keep the original active array active when it survived the filter, + // so writers that only look at the scalars pick the expected one. + auto* scalars = pointData->GetScalars(); + const char* activeName = + scalars && selected.contains(scalars) ? scalars->GetName() : nullptr; + filtered->GetPointData()->SetActiveScalars( + activeName ? activeName : selected.first()->GetName()); + + return filtered; +} + +bool writeWithProxyWriter(vtkImageData* image, const QString& path) +{ + auto* pxm = + vtkSMProxyManager::GetProxyManager()->GetActiveSessionProxyManager(); + if (!pxm) { + qCritical() << "No active session proxy manager, cannot write" << path; + return false; + } + + vtkSmartPointer producerProxy; + producerProxy.TakeReference(vtkSMSourceProxy::SafeDownCast( + pxm->NewProxy("sources", "TrivialProducer"))); + + auto* tp = + vtkTrivialProducer::SafeDownCast(producerProxy->GetClientSideObject()); + tp->SetOutput(image); + producerProxy->UpdateVTKObjects(); + producerProxy->UpdatePipeline(); + + auto* writerFactory = + vtkSMProxyManager::GetProxyManager()->GetWriterFactory(); + vtkSmartPointer writerProxy; + writerProxy.TakeReference( + writerFactory->CreateWriter(path.toUtf8().data(), producerProxy, 0)); + + if (!writerProxy) { + qCritical() << "No suitable writer found for:" << path; + return false; + } + + writerProxy->UpdateVTKObjects(); + vtkSMSourceProxy::SafeDownCast(writerProxy)->UpdatePipeline(); + + return true; +} + +bool writeVolume(const VolumeDataPtr& volume, const QStringList& arrayNames, + const QString& path) +{ + if (!volume || !volume->isValid()) { + qCritical() << "Invalid volume data, cannot write" << path; + return false; + } + + auto image = filterArrays(volume->imageData(), arrayNames); + QString ext = QFileInfo(path).suffix().toLower(); + + if (isEmdExtension(ext)) { + return EmdFormat::write(path.toStdString(), image); + } + + if (auto* factory = FileFormatManager::instance().pythonWriterFactory(ext)) { + auto writer = factory->createWriter(); + return writer.write(path, image); + } + + if (ext == "tiff" || ext == "tif") { + auto* scalars = image->GetPointData()->GetScalars(); + if (scalars && scalars->GetDataType() == VTK_DOUBLE) { + vtkNew cast; + cast->SetInputData(image); + cast->SetOutputScalarTypeToFloat(); + cast->Update(); + image = cast->GetOutput(); + } + } + + return writeWithProxyWriter(image, path); +} + +bool writeTextFile(const QByteArray& contents, const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + qCritical() << "Error opening file for writing:" << path; + return false; + } + bool ok = file.write(contents) == contents.size(); + file.close(); + return ok; +} + +bool writeTable(vtkTable* table, const QString& path) +{ + if (!table) { + qCritical() << "Invalid table data, cannot write" << path; + return false; + } + + QString ext = QFileInfo(path).suffix().toLower(); + if (ext == "json") { + return writeTextFile(tableToJson(table).toJson(), path); + } + + return writeTextFile(tableToCsv(table).toUtf8(), path); +} + +} // namespace + +namespace PortDataWriter { + +PortType formatGroup(PortType type) +{ + if (pipeline::isVolumeType(type)) { + return PortType::ImageData; + } + return type; +} + +QList formats(PortType type) +{ + switch (formatGroup(type)) { + case PortType::ImageData: { + // EMD stores the extra arrays under /tomviz_scalars; the VTK + // writers serialize every point-data array. The rest keep only + // the active scalars, so they are flagged single-array and the + // caller splits multi-array volumes across files instead of + // silently dropping data. + QList result{ + { "emd", "EMD", "emd", true }, + { "hdf5", "HDF5", "h5", true }, + { "tiff", "TIFF", "tiff", false }, + { "vti", "VTK ImageData", "vti", true }, + { "mhd", "Meta Image", "mhd", false }, + { "vtk", "Legacy VTK", "vtk", true }, + { "csv", "CSV", "csv", false }, + { "xmf", "XDMF", "xmf", false }, + { "json", "JSON Image", "json", false }, + }; + // Python writers are discovered at runtime (NumPy, MRC, MATLAB, + // ...). They all go through tomviz.internal_utils.get_array, which + // returns the active scalars only. + for (auto* factory : + FileFormatManager::instance().pythonWriterFactories()) { + auto extensions = factory->getExtensions(); + if (extensions.isEmpty()) { + continue; + } + result.append({ extensions.first(), factory->getDescription(), + extensions.first(), false }); + } + return result; + } + case PortType::Table: + return { { "csv", "CSV", "csv", true }, + { "json", "JSON", "json", true } }; + case PortType::Molecule: + return { { "xyz", "XYZ", "xyz", true } }; + default: + return {}; + } +} + +PortFormat formatById(PortType type, const QString& id) +{ + auto available = formats(type); + for (const auto& format : available) { + if (format.id == id) { + return format; + } + } + return available.isEmpty() ? PortFormat() : available.first(); +} + +QStringList arrayNames(const PortData& data) +{ + if (!data.isValid()) { + return {}; + } + + if (pipeline::isVolumeType(data.type())) { + auto volume = data.value(); + if (!volume || !volume->isValid()) { + return {}; + } + auto names = volume->scalarNames(); + return names.isEmpty() ? QStringList{ QString() } : names; + } + + return { QString() }; +} + +bool write(const PortData& data, const QStringList& arrayNames, + const QString& path) +{ + if (!data.isValid()) { + qCritical() << "No data to write to" << path; + return false; + } + + if (pipeline::isVolumeType(data.type())) { + return writeVolume(data.value(), arrayNames, path); + } + + if (data.type() == PortType::Table) { + return writeTable(data.value>(), path); + } + + if (data.type() == PortType::Molecule) { + return moleculeToXyzFile(data.value>(), path); + } + + qCritical() << "No writer for port type" + << pipeline::portTypeToString(data.type()); + return false; +} + +} // namespace PortDataWriter +} // namespace tomviz diff --git a/tomviz/PortDataWriter.h b/tomviz/PortDataWriter.h new file mode 100644 index 000000000..f0f3c44ef --- /dev/null +++ b/tomviz/PortDataWriter.h @@ -0,0 +1,71 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPortDataWriter_h +#define tomvizPortDataWriter_h + +#include "pipeline/PortType.h" + +#include +#include +#include + +namespace tomviz { + +namespace pipeline { +class PortData; +} + +/// A file format a port payload can be written to. +struct PortFormat +{ + /// Stable key used to remember the user's choice in settings. + QString id; + QString description; + /// Filename extension, without the leading dot. + QString extension; + /// Whether one file of this format can hold every scalar array of a + /// volume. False formats keep only the active scalars, so callers + /// must split a multi-array volume across one file per array. + bool multiArray = false; + + QString label() const + { + return QStringLiteral("%1 (*.%2)").arg(description, extension); + } +}; + +/// Writes a single pipeline output-port payload to a file, and answers +/// which formats each port type can be written to. +namespace PortDataWriter { + +/// The type group @a type is offered under: every volume-like type +/// (TiltSeries, Volume, LabelMap, ImageData) collapses to ImageData, so +/// the user picks one format for all of them. +pipeline::PortType formatGroup(pipeline::PortType type); + +/// Formats available for @a type, preferred default first. Empty for +/// types tomviz has no writer for. +QList formats(pipeline::PortType type); + +/// Look up a format of @a type by its id. Returns the group's default +/// format when @a id is unknown, or an empty format when the group has +/// no writers at all. +PortFormat formatById(pipeline::PortType type, const QString& id); + +/// Names of the scalar arrays @a data carries. Volume payloads report +/// their point-data arrays; every other payload reports a single empty +/// name, meaning "the payload as a whole". +QStringList arrayNames(const pipeline::PortData& data); + +/// Write @a data to @a path, choosing the writer from the path's +/// extension. For volume payloads @a arrayNames selects which scalar +/// arrays to include; an empty list means every array. Ignored for +/// payloads that aren't array-bearing. +bool write(const pipeline::PortData& data, const QStringList& arrayNames, + const QString& path); + +} // namespace PortDataWriter +} // namespace tomviz + +#endif diff --git a/tomviz/PresetDialog.cxx b/tomviz/PresetDialog.cxx index 1cd803819..e7e498af0 100644 --- a/tomviz/PresetDialog.cxx +++ b/tomviz/PresetDialog.cxx @@ -40,6 +40,8 @@ PresetDialog::PresetDialog(QWidget* parent) &PresetDialog::warning); connect(m_ui->createSolidColormap, &QPushButton::clicked, this, &PresetDialog::createSolidColormap); + connect(m_ui->createSegmentationColormap, &QPushButton::clicked, this, + &PresetDialog::createSegmentationColormapRequested); connect(this, &PresetDialog::resetToDefaults, m_model, &PresetModel::resetToDefaults); } diff --git a/tomviz/PresetDialog.h b/tomviz/PresetDialog.h index b22a7a5c0..b03adbc01 100644 --- a/tomviz/PresetDialog.h +++ b/tomviz/PresetDialog.h @@ -31,6 +31,7 @@ class PresetDialog : public QDialog signals: void applyPreset(); void resetToDefaults(); + void createSegmentationColormapRequested(); private slots: void warning(); diff --git a/tomviz/PresetDialog.ui b/tomviz/PresetDialog.ui index c844254e7..c8c0059a9 100644 --- a/tomviz/PresetDialog.ui +++ b/tomviz/PresetDialog.ui @@ -46,6 +46,16 @@
+ + + + Create Segmentation Colormap + + + Generate a colormap with distinct colors for each unique value in the data + + + diff --git a/tomviz/ProgressDialogManager.cxx b/tomviz/ProgressDialogManager.cxx index c04c0c97f..32d46f5aa 100644 --- a/tomviz/ProgressDialogManager.cxx +++ b/tomviz/ProgressDialogManager.cxx @@ -3,164 +3,185 @@ #include "ProgressDialogManager.h" -#include "DataSource.h" -#include "ModuleManager.h" -#include "Operator.h" -#include "Pipeline.h" -#include "PipelineManager.h" +#include "pipeline/Node.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineExecutor.h" #include #include #include +#include +#include #include -#include #include #include #include -#include -#include - namespace tomviz { ProgressDialogManager::ProgressDialogManager(QMainWindow* mw) - : Superclass(mw), mainWindow(mw) + : QObject(mw), m_mainWindow(mw) +{} + +ProgressDialogManager::~ProgressDialogManager() = default; + +bool ProgressDialogManager::eventFilter(QObject* obj, QEvent* event) { - ModuleManager& mm = ModuleManager::instance(); - QObject::connect(&mm, &ModuleManager::dataSourceAdded, this, - &ProgressDialogManager::dataSourceAdded); + if (obj == m_progressDialog) { + if (event->type() == QEvent::KeyPress) { + auto* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Escape) { + return true; // swallow + } + } + if (event->type() == QEvent::Close) { + return true; // swallow + } + } + return QObject::eventFilter(obj, event); } -ProgressDialogManager::~ProgressDialogManager() {} +void ProgressDialogManager::setPipeline(pipeline::Pipeline* pipeline) +{ + if (m_pipeline) { + m_pipeline->disconnect(this); + if (auto* exec = m_pipeline->executor()) { + exec->disconnect(this); + } + } + + m_pipeline = pipeline; + if (!m_pipeline) { + return; + } + + connectExecutor(); +} -void ProgressDialogManager::operationStarted() +void ProgressDialogManager::connectExecutor() { - QDialog* progressDialog = new QDialog(this->mainWindow); - progressDialog->setAttribute(Qt::WA_DeleteOnClose); - - Operator* op = qobject_cast(this->sender()); - QObject::connect(op, &Operator::transformingDone, progressDialog, - &QDialog::accept); - - // We have to check after we have connected to the signal as otherwise we - // might miss the state transition as its occurring on another thread. - if (op->isFinished()) { - progressDialog->accept(); - progressDialog->deleteLater(); + auto* exec = m_pipeline ? m_pipeline->executor() : nullptr; + if (!exec) { return; } - QLayout* layout = new QVBoxLayout(); - QWidget* progressWidget = op->getCustomProgressWidget(progressDialog); - if (progressWidget == nullptr) { - QProgressBar* progressBar = new QProgressBar(progressDialog); + // Disconnect any previous executor. + disconnect(exec, nullptr, this, nullptr); + + connect(exec, &pipeline::PipelineExecutor::nodeExecutionStarted, this, + &ProgressDialogManager::onNodeExecutionStarted); + connect(exec, &pipeline::PipelineExecutor::nodeExecutionFinished, this, + &ProgressDialogManager::onNodeExecutionFinished); +} + +void ProgressDialogManager::onNodeExecutionStarted(pipeline::Node* node) +{ + // Close any stale dialog from a previous node. + if (m_progressDialog) { + m_progressDialog->accept(); + } + + auto* dialog = new QDialog(m_mainWindow); + dialog->setAttribute(Qt::WA_DeleteOnClose); + // Title bar with no close/minimize/maximize buttons. Use Qt::Tool rather + // than Qt::Dialog so the window floats above the main window (on macOS a + // parented Qt::Dialog is not guaranteed to stack above its parent and can + // appear behind it) while still leaving the main window interactive, so the + // user can keep working in the render view while an operator runs. + dialog->setWindowFlags(Qt::Tool | Qt::CustomizeWindowHint | + Qt::WindowTitleHint); + dialog->installEventFilter(this); + m_progressDialog = dialog; + + auto* layout = new QVBoxLayout(); + auto* messageLabel = new QLabel(dialog); + messageLabel->setWordWrap(true); + + QWidget* progressWidget = node->getCustomProgressWidget(dialog); + if (!progressWidget) { + auto* progressBar = new QProgressBar(dialog); progressBar->setMinimum(0); - progressBar->setMaximum(op->totalProgressSteps()); + progressBar->setMaximum(node->totalProgressSteps()); progressWidget = progressBar; - QObject::connect(op, &Operator::progressStepChanged, progressBar, - &QProgressBar::setValue); - QObject::connect(op, &Operator::totalProgressStepsChanged, progressBar, - &QProgressBar::setMaximum); - QObject::connect(op, &Operator::progressStepChanged, this, - &ProgressDialogManager::operationProgress); - QObject::connect( - op, &Operator::progressMessageChanged, progressDialog, - [progressDialog, op](const QString& message) { - if (!message.isNull()) { - QString title = QString("%1 Progress").arg(op->label()); - if (!message.isEmpty()) { - title = QString("%1 Progress - %2").arg(op->label()).arg(message); - } - progressDialog->setWindowTitle(title); - } - }); - - connect(op, &Operator::progressMessageChanged, this, - &ProgressDialogManager::showStatusBarMessage); - layout->addWidget(progressBar); + connect(node, &pipeline::Node::progressStepChanged, progressBar, + &QProgressBar::setValue); + connect(node, &pipeline::Node::totalProgressStepsChanged, progressBar, + &QProgressBar::setMaximum); + connect(node, &pipeline::Node::progressMessageChanged, messageLabel, + &QLabel::setText); + connect(node, &pipeline::Node::progressMessageChanged, this, + &ProgressDialogManager::showStatusBarMessage); } + layout->addWidget(progressWidget); - if (op->supportsCompletionMidTransform()) { - // Unless widget has custom progress handling, can't done it - QDialogButtonBox* dialogButtons = + layout->addWidget(messageLabel); + + if (node->supportsCompletionMidExecution()) { + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, - Qt::Horizontal, progressDialog); - - layout->addWidget(dialogButtons); - QObject::connect(progressDialog, &QDialog::rejected, op, - &Operator::cancelTransform); - QObject::connect(dialogButtons, &QDialogButtonBox::rejected, progressDialog, - &QDialog::reject); - - QObject::connect(progressDialog, &QDialog::accepted, op, - &Operator::completionTransform); - QObject::connect(dialogButtons, &QDialogButtonBox::accepted, progressDialog, - &QDialog::accept); - } else if (op->supportsCancelingMidTransform()) { - // Unless the widget has custom progress handling, you can't cancel it. - QDialogButtonBox* dialogButtons = new QDialogButtonBox( - QDialogButtonBox::Cancel, Qt::Horizontal, progressDialog); - layout->addWidget(dialogButtons); - QObject::connect(progressDialog, &QDialog::rejected, op, - &Operator::cancelTransform); - QObject::connect(dialogButtons, &QDialogButtonBox::rejected, progressDialog, - &QDialog::reject); + Qt::Horizontal, dialog); + layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::accepted, node, + &pipeline::Node::completeExecution); + connect(buttons, &QDialogButtonBox::accepted, buttons, + [buttons, messageLabel]() { + buttons->setEnabled(false); + messageLabel->setText( + QCoreApplication::translate("ProgressDialogManager", + "Completion pending...")); + }); + connect(buttons, &QDialogButtonBox::rejected, node, + &pipeline::Node::cancelExecution); + connect(buttons, &QDialogButtonBox::rejected, buttons, + [buttons, messageLabel]() { + buttons->setEnabled(false); + messageLabel->setText( + QCoreApplication::translate("ProgressDialogManager", + "Cancel pending...")); + }); + } else if (node->supportsCancelingMidExecution()) { + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, + Qt::Horizontal, dialog); + layout->addWidget(buttons); + connect(buttons, &QDialogButtonBox::rejected, node, + &pipeline::Node::cancelExecution); + connect(buttons, &QDialogButtonBox::rejected, buttons, + [buttons, messageLabel]() { + buttons->setEnabled(false); + messageLabel->setText( + QCoreApplication::translate("ProgressDialogManager", + "Cancel pending...")); + }); } - progressDialog->setWindowTitle(QString("%1 Progress").arg(op->label())); - progressDialog->setLayout(layout); - progressDialog->adjustSize(); - // Increase size of dialog so we can see title, not sure there is a better - // way. - auto height = progressDialog->height(); - progressDialog->resize(500, height); - progressDialog->show(); - QCoreApplication::processEvents(); -} - -void ProgressDialogManager::operatorAdded(Operator* op) -{ - // Need to ensure that if we are using the docker executor we use a - // DirectQueued - // connection here, otherwise we will deadlock as the sender and receiver will - // will have the same thread affinity. - std::function connectTransformingStarted = [op, this]() { - auto connectionType = Qt::BlockingQueuedConnection; - if (op->dataSource()->pipeline()->executionMode() != - Pipeline::ExecutionMode::Threaded) { - connectionType = Qt::DirectConnection; - } - connect(op, &Operator::transformingStarted, this, - &ProgressDialogManager::operationStarted, connectionType); - }; - connectTransformingStarted(); - - // Recreate the connection with the correct type if the execution mode is - // changed. - connect(&PipelineManager::instance(), &PipelineManager::executionModeUpdated, - op, [this, connectTransformingStarted, op]() { - disconnect(op, &Operator::transformingStarted, this, - &ProgressDialogManager::operationStarted); - connectTransformingStarted(); - }); - - connect( - op, - static_cast(&Operator::newChildDataSource), - this, &ProgressDialogManager::dataSourceAdded); + dialog->setWindowTitle(QString("%1 Progress").arg(node->label())); + dialog->setLayout(layout); + dialog->adjustSize(); + dialog->resize(500, dialog->height()); + dialog->show(); + dialog->raise(); + // NOTE: deliberately no QCoreApplication::processEvents() here. This slot + // runs while handling a nodeExecutionStarted event, and pumping the event + // loop would re-entrantly deliver other already-queued executor signals + // (e.g. executionComplete -> Pipeline::executionFinished), firing finish + // handlers mid-execution while upstream ports are still empty -- a crash. + // The pipeline runs on a worker thread (ThreadedExecutor), so the main loop + // is not blocked and the dialog paints on the next event-loop iteration. } -void ProgressDialogManager::dataSourceAdded(DataSource* ds) +void ProgressDialogManager::onNodeExecutionFinished(pipeline::Node* node, + bool /*success*/) { - QObject::connect(ds, &DataSource::operatorAdded, this, - &ProgressDialogManager::operatorAdded); + Q_UNUSED(node); + if (m_progressDialog) { + m_progressDialog->accept(); + } } -void ProgressDialogManager::operationProgress(int) {} - void ProgressDialogManager::showStatusBarMessage(const QString& message) { - this->mainWindow->statusBar()->showMessage(message, 3000); + m_mainWindow->statusBar()->showMessage(message, 3000); } + } // namespace tomviz diff --git a/tomviz/ProgressDialogManager.h b/tomviz/ProgressDialogManager.h index 4a7533905..8deadf51b 100644 --- a/tomviz/ProgressDialogManager.h +++ b/tomviz/ProgressDialogManager.h @@ -5,32 +5,43 @@ #define tomvizProgressDialogManager_h #include +#include +class QDialog; class QMainWindow; namespace tomviz { -class Operator; -class DataSource; +namespace pipeline { +class Node; +class Pipeline; +} // namespace pipeline class ProgressDialogManager : public QObject { Q_OBJECT - typedef QObject Superclass; - public: ProgressDialogManager(QMainWindow* mw); - virtual ~ProgressDialogManager(); + ~ProgressDialogManager() override; + + /// Start tracking execution on @a pipeline. + void setPipeline(pipeline::Pipeline* pipeline); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; private slots: - void operationStarted(); - void operationProgress(int progress); - void operatorAdded(Operator* op); - void dataSourceAdded(DataSource* ds); + void onNodeExecutionStarted(pipeline::Node* node); + void onNodeExecutionFinished(pipeline::Node* node, bool success); void showStatusBarMessage(const QString& message); private: - QMainWindow* mainWindow; + void connectExecutor(); + + QMainWindow* m_mainWindow = nullptr; + QPointer m_pipeline; + QPointer m_progressDialog; + Q_DISABLE_COPY(ProgressDialogManager) }; } // namespace tomviz diff --git a/tomviz/PtychoDialog.h b/tomviz/PtychoDialog.h deleted file mode 100644 index 585ed2530..000000000 --- a/tomviz/PtychoDialog.h +++ /dev/null @@ -1,37 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPtychoDialog_h -#define tomvizPtychoDialog_h - -#include -#include - -namespace tomviz { - -class PtychoDialog : public QDialog -{ - Q_OBJECT - -public: - explicit PtychoDialog(QWidget* parent); - ~PtychoDialog() override; - - virtual void show(); - - QString ptychoDirectory() const; - QString outputDirectory() const; - bool rotateDatasets() const; - - QList selectedSids() const; - QStringList selectedVersions() const; - QList selectedAngles() const; - -private: - class Internal; - QScopedPointer m_internal; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/PtychoRunner.cxx b/tomviz/PtychoRunner.cxx index f1537451a..7809b1826 100644 --- a/tomviz/PtychoRunner.cxx +++ b/tomviz/PtychoRunner.cxx @@ -4,20 +4,23 @@ #include "PtychoRunner.h" #include "CameraReaction.h" -#include "DataSource.h" #include "LoadDataReaction.h" -#include "ProgressDialog.h" -#include "PtychoDialog.h" +#include "MainWindow.h" #include "PythonUtilities.h" #include "Utilities.h" +#include "pipeline/DeferredLinkInfo.h" +#include "pipeline/Node.h" +#include "pipeline/NodeEditDialog.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/sources/PythonSource.h" + #include -#include -#include -#include +#include +#include #include #include -#include namespace tomviz { @@ -26,28 +29,8 @@ class PtychoRunner::Internal : public QObject public: QPointer parent; QPointer parentWidget; - QPointer ptychoDialog; - QPointer progressDialog; - QFutureWatcher ptychoFutureWatcher; - // Python modules and functions Python::Module ptychoModule; - Python::Function stackPtychoFunc; - - // Ptycho options - QString ptychoDirectory; - QString outputDirectory; - bool rotateDatasets = true; - - // These will be generated by calling functions - QStringList versionList; - QList sidList; - QList angleList; - - QStringList outputFiles; - - // Recon options - QStringList selectedArrays; bool autoLoadFinalData = true; @@ -55,19 +38,6 @@ class PtychoRunner::Internal : public QObject { setParent(p); parentWidget = mainWidget(); - - progressDialog = new ProgressDialog(parentWidget); - progressDialog->setWindowTitle("Tomviz"); - progressDialog->showOutputWidget(true); - progressDialog->resize(progressDialog->width(), 500); - - setupConnections(); - } - - void setupConnections() - { - connect(&ptychoFutureWatcher, &QFutureWatcher::finished, this, - &Internal::stackPtychoFinished); } void importModule() @@ -106,13 +76,6 @@ class PtychoRunner::Internal : public QObject return res.toBool(); } - bool onFailure(const QString& msg) - { - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - return false; - } - QString importError() { importModule(); @@ -135,177 +98,56 @@ class PtychoRunner::Internal : public QObject return res.toString(); } - void importFunctions() - { - importModule(); - - Python python; - - if (!stackPtychoFunc.isValid()) { - stackPtychoFunc = ptychoModule.findFunction("load_stack_ptycho"); - if (!stackPtychoFunc.isValid()) { - qCritical() << "Failed to find function \"load_stack_ptycho\""; - } - } - } - - template - void clearWidget(QPointer d) - { - if (!d) { - return; - } - - d->hide(); - d->deleteLater(); - d.clear(); - } - - void clear() - { - clearWidget(ptychoDialog); - } - void start() { - clear(); - importFunctions(); - showPtychoDialog(); - } - - void showPtychoDialog() - { - clearWidget(ptychoDialog); - - ptychoDialog = new PtychoDialog(parentWidget); - connect(ptychoDialog.data(), &QDialog::accepted, this, - &Internal::ptychoDialogAccepted); - ptychoDialog->show(); - } - - void ptychoDialogAccepted() - { - // Gather the settings and decide what to do - ptychoDirectory = ptychoDialog->ptychoDirectory(); - sidList = ptychoDialog->selectedSids(); - versionList = ptychoDialog->selectedVersions(); - angleList = ptychoDialog->selectedAngles(); - outputDirectory = ptychoDialog->outputDirectory(); - rotateDatasets = ptychoDialog->rotateDatasets(); - - runStackPtycho(); - } - - void runStackPtycho() - { - progressDialog->clearOutputWidget(); - progressDialog->setText("Stacking ptychography datasets..."); - progressDialog->show(); - auto future = QtConcurrent::run(std::bind(&Internal::_runStackPtycho, this)); - ptychoFutureWatcher.setFuture(future); - } - - bool _runStackPtycho() - { - Python python; - - // Reset the output files - outputFiles.clear(); - - if (!stackPtychoFunc.isValid()) { - qCritical() << "Failed to find function \"load_stack_ptycho\""; - return false; - } - - Python::Dict kwargs; - kwargs.set("version_list", versionList); - kwargs.set("sid_list", sidList); - kwargs.set("angle_list", angleList); - kwargs.set("ptycho_dir", ptychoDirectory); - kwargs.set("output_dir", outputDirectory); - kwargs.set("rotate_datasets", rotateDatasets); - auto result = stackPtychoFunc.call(kwargs); - - if (!result.isValid() || !result.isList()) { - qCritical() << "Error calling tomviz.ptycho.load_stack_ptycho"; - return false; - } - - auto resultList = result.toList(); - for (int i = 0; i < resultList.length(); ++i) { - outputFiles.append(resultList[i].toString()); - } - - return true; - } - - void stackPtychoFinished() - { - progressDialog->accept(); - - auto success = ptychoFutureWatcher.result(); - if (!success || !validateResult()) { - QString msg = "Stack ptycho failed"; - onFailure(msg); - // Show the dialog again - showPtychoDialog(); + if (!isInstalled()) { + QMessageBox::critical(parentWidget, "Ptycho is unavailable", + "Failed to import required modules. " + "Error message was:\n\n" + + importError()); return; } - qDebug() << "Results written to:"; - for (const auto& filePath : outputFiles) { - qDebug() << " " << filePath; - } - - if (autoLoadFinalData) { - loadOutputFiles(); - } - } - - bool validateResult() const - { - if (outputFiles.isEmpty()) { - return false; - } - - for (auto& filePath: outputFiles) { - if (!QFile::exists(filePath)) { - return false; - } - } - - return true; - } - - void loadOutputFiles() - { - // Convert sidList to QVector for scan IDs - QVector scanIDs; - scanIDs.reserve(sidList.size()); - for (auto& sid : sidList) { - scanIDs.push_back(static_cast(sid)); - } - - for (auto& filePath: outputFiles) { - auto* dataSource = LoadDataReaction::loadData(filePath); - if (!dataSource || !dataSource->imageData()) { - qCritical() << "Failed to load file:" << filePath; - return; - } - if (!scanIDs.isEmpty()) { - dataSource->setScanIDs(scanIDs); - } + auto* mainWindow = MainWindow::instance(); + auto* pip = mainWindow ? mainWindow->pipeline() : nullptr; + if (!pip) { + return; } - // Automatically update camera to BNL convention - CameraReaction::resetPositiveZ(); - CameraReaction::rotateCamera(-90); - - QString title = "Loading ptycho data complete"; - auto text = - QString("Ptycho data in \"%1\" was written and loaded into Tomviz") - .arg(outputDirectory); - QMessageBox::information(parentWidget, title, text); + auto* source = new pipeline::PythonSource(); + source->setJSONDescription(readInJSONDescription("PtychoSource")); + source->setScript(readInPythonScript("PtychoSource")); + + LoadDataReaction::addSourceToPipeline(source); + + pipeline::DeferredLinkInfo deferred; + auto* dialog = + new pipeline::NodeEditDialog(source, pip, deferred, mainWindow); + dialog->setAttribute(Qt::WA_DeleteOnClose); + + QString title = QJsonDocument::fromJson( + source->jsonDescription().toUtf8()) + .object() + .value(QStringLiteral("label")) + .toString(); + if (title.isEmpty()) { + title = source->label(); + } + dialog->setWindowTitle(QString("Generate - %1").arg(title)); + + QObject::connect( + dialog, &pipeline::NodeEditDialog::insertionCompleted, dialog, + [this](pipeline::Node* node) { + if (auto* src = qobject_cast(node)) { + if (autoLoadFinalData) { + LoadDataReaction::completeSourceSetup(src); + } + } + CameraReaction::resetPositiveZ(); + CameraReaction::rotateCamera(-90); + }); + + dialog->show(); } }; diff --git a/tomviz/PtychoRunner.h b/tomviz/PtychoRunner.h index c3b1da447..5be2c1132 100644 --- a/tomviz/PtychoRunner.h +++ b/tomviz/PtychoRunner.h @@ -4,7 +4,7 @@ #ifndef tomvizPtychoRunner_h #define tomvizPtychoRunner_h -#include +#include #include namespace tomviz { diff --git a/tomviz/PtychoDialog.cxx b/tomviz/PtychoWidget.cxx similarity index 73% rename from tomviz/PtychoDialog.cxx rename to tomviz/PtychoWidget.cxx index ce7630e69..3e1a566f0 100644 --- a/tomviz/PtychoDialog.cxx +++ b/tomviz/PtychoWidget.cxx @@ -1,8 +1,8 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#include "PtychoDialog.h" -#include "ui_PtychoDialog.h" +#include "PtychoWidget.h" +#include "ui_PtychoWidget.h" #include "PythonUtilities.h" #include "Utilities.h" @@ -17,22 +17,28 @@ #include #include #include +#include +#include +#include +#include #include +#include #include #include #include #include #include +#include namespace tomviz { -class PtychoDialog::Internal : public QObject +class PtychoWidget::Internal : public QObject { Q_OBJECT public: - Ui::PtychoDialog ui; - QPointer parent; + Ui::PtychoWidget ui; + QPointer parent; bool ptychoguiIsRunning = false; @@ -54,7 +60,7 @@ class PtychoDialog::Internal : public QObject Python::Module ptychoModule; - Internal(PtychoDialog* p) + Internal(PtychoWidget* p) : parent(p) { ui.setupUi(p); @@ -86,13 +92,8 @@ class PtychoDialog::Internal : public QObject connect(ui.loadSidsFromTxt, &QPushButton::clicked, this, &Internal::onLoadSidsFromTxtClicked); - connect(ui.selectOutputDirectory, &QPushButton::clicked, this, - &Internal::selectOutputDirectory); - - connect(ui.buttonBox, &QDialogButtonBox::accepted, this, - &Internal::accepted); - connect(ui.buttonBox, &QDialogButtonBox::helpRequested, this, - [](){ openHelpUrl("https://tomviz.readthedocs.io/en/latest/workflows_ptycho.html"); }); + connect(ui.selectOutputInfoFile, &QPushButton::clicked, this, + &Internal::selectOutputInfoFile); } void setupTable() @@ -112,34 +113,98 @@ class PtychoDialog::Internal : public QObject auto* header = new QTableWidgetItem(columns[i]); table->setHorizontalHeaderItem(i, header); } + + table->setSelectionMode(QAbstractItemView::ExtendedSelection); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + + table->setContextMenuPolicy(Qt::CustomContextMenu); + connect(table, &QWidget::customContextMenuRequested, this, + &Internal::showTableContextMenu); } - void importModule() + void showTableContextMenu(const QPoint& pos) { - Python python; + auto* table = ui.table; + auto selectedRows = table->selectionModel()->selectedRows(); + if (selectedRows.isEmpty()) { + return; + } - if (ptychoModule.isValid()) { + QSet commonVersions; + bool first = true; + for (auto& index : selectedRows) { + int row = index.row(); + auto sid = filteredSidList[row]; + QSet versions(versionOptions[sid].begin(), + versionOptions[sid].end()); + if (first) { + commonVersions = versions; + first = false; + } else { + commonVersions &= versions; + } + } + + QMenu menu(table); + auto* setVersionAction = menu.addAction("Set Version..."); + if (commonVersions.isEmpty()) { + setVersionAction->setEnabled(false); + setVersionAction->setText("Set Version... (no shared versions)"); + } + auto* chosen = menu.exec(table->viewport()->mapToGlobal(pos)); + if (chosen != setVersionAction) { return; } - ptychoModule = python.import("tomviz.ptycho"); - if (!ptychoModule.isValid()) { - qCritical() << "Failed to import \"tomviz.ptycho\" module"; + QStringList sortedVersions = commonVersions.values(); + sortedVersions.sort(); + + int defaultIndex = 0; + QSet currentVersions; + for (auto& index : selectedRows) { + int row = index.row(); + auto sid = filteredSidList[row]; + auto idx = sidList.indexOf(sid); + currentVersions.insert(versionList[idx]); } + if (currentVersions.size() == 1) { + int idx = sortedVersions.indexOf(*currentVersions.begin()); + if (idx >= 0) { + defaultIndex = idx; + } + } + + bool ok = false; + auto version = QInputDialog::getItem( + parent, "Set Version", "Version:", sortedVersions, defaultIndex, + false, &ok); + if (!ok) { + return; + } + + for (auto& index : selectedRows) { + int row = index.row(); + auto sid = filteredSidList[row]; + auto idx = sidList.indexOf(sid); + versionList[idx] = version; + } + + onSelectedVersionsChanged(); + updateTable(); } - void accepted() + void importModule() { - QString reason; - if (!validate(reason)) { - QString title = "Invalid Settings"; - QMessageBox::critical(parent.data(), title, reason); - parent->show(); + Python python; + + if (ptychoModule.isValid()) { return; } - writeSettings(); - parent->accept(); + ptychoModule = python.import("tomviz.ptycho"); + if (!ptychoModule.isValid()) { + qCritical() << "Failed to import \"tomviz.ptycho\" module"; + } } QList selectedSids() @@ -219,21 +284,6 @@ class PtychoDialog::Internal : public QObject } } - if (!QDir(outputDirectory()).exists()) { - // First ask if the user wants to make it. - QString title = "Directory does not exist"; - auto text = QString("Output directory \"%1\" does not exist. Create it?") - .arg(outputDirectory()); - if (QMessageBox::question(parent, title, text) == QMessageBox::Yes) { - QDir().mkpath(outputDirectory()); - } - } - - if (outputDirectory().isEmpty() || !QDir(outputDirectory()).exists()) { - reason = "Output directory does not exist: " + outputDirectory(); - return false; - } - return true; } @@ -364,7 +414,7 @@ class PtychoDialog::Internal : public QObject return ""; } - QString defaultOutputDirectory() { return QDir::home().filePath("ptycho_output"); } + QString defaultOutputInfoFile() { return ""; } void readSettings() { @@ -383,8 +433,8 @@ class PtychoDialog::Internal : public QObject setCsvFile(settings->value("loadFromCSVFile", "").toString()); setFilterSIDsString(settings->value("filterSIDsString", "").toString()); - setOutputDirectory( - settings->value("outputDirectory", defaultOutputDirectory()).toString()); + setOutputInfoFile( + settings->value("outputInfoFile", defaultOutputInfoFile()).toString()); setRotateDatasets( settings->value("rotateDatasets", true).toBool()); @@ -448,7 +498,7 @@ class PtychoDialog::Internal : public QObject settings->setValue("filterSIDsString", filterSIDsString()); - settings->setValue("outputDirectory", outputDirectory()); + settings->setValue("outputInfoFile", outputInfoFile()); settings->setValue("rotateDatasets", rotateDatasets()); // Save out our lists @@ -835,16 +885,20 @@ class PtychoDialog::Internal : public QObject updateTable(); } - void selectOutputDirectory() + void selectOutputInfoFile() { - QString caption = "Select output directory"; - auto dir = QFileDialog::getExistingDirectory(parent.data(), caption, - outputDirectory()); - if (dir.isEmpty()) { + QString caption = "Select output info file"; + auto startPath = outputInfoFile(); + if (startPath.isEmpty()) { + startPath = ptychoDirectory(); + } + auto file = QFileDialog::getSaveFileName( + parent.data(), caption, startPath, "Text files (*.txt)"); + if (file.isEmpty()) { return; } - setOutputDirectory(dir); + setOutputInfoFile(file); } void clearTable() @@ -878,58 +932,158 @@ class PtychoDialog::Internal : public QObject void setFilterSIDsString(QString s) const { ui.filterSIDsString->setText(s); } - QString outputDirectory() const { return ui.outputDirectory->text(); } + QString outputInfoFile() const { return ui.outputInfoFile->text(); } - void setOutputDirectory(QString s) { ui.outputDirectory->setText(s); } + void setOutputInfoFile(QString s) { ui.outputInfoFile->setText(s); } bool rotateDatasets() const { return ui.rotateDatasets->isChecked(); } void setRotateDatasets(bool b) { ui.rotateDatasets->setChecked(b); } }; -PtychoDialog::PtychoDialog(QWidget* parent) - : QDialog(parent), m_internal(new Internal(this)) +PtychoWidget::PtychoWidget( + const QMap& /*inputs*/, QWidget* p) + : pipeline::CustomPythonNodeWidget(p), m_internal(new Internal(this)) { } -PtychoDialog::~PtychoDialog() = default; +PtychoWidget::~PtychoWidget() = default; -void PtychoDialog::show() +void PtychoWidget::getValues(QMap& map) { - m_internal->readSettings(); - QDialog::show(); -} + auto sids = m_internal->selectedSids(); + auto versions = m_internal->selectedVersions(); + auto angles = m_internal->selectedAngles(); -QString PtychoDialog::ptychoDirectory() const -{ - return m_internal->ptychoDirectory(); -} + QJsonArray sidArray, versionArray, angleArray; + for (auto sid : sids) { + sidArray.append(static_cast(sid)); + } + for (const auto& v : versions) { + versionArray.append(v); + } + for (auto angle : angles) { + angleArray.append(angle); + } -QList PtychoDialog::selectedSids() const -{ - return m_internal->selectedSids(); -} + map.insert("ptycho_dir", m_internal->ptychoDirectory()); + map.insert("output_info_file", m_internal->outputInfoFile()); + map.insert("rotate_datasets", m_internal->rotateDatasets()); + map.insert("sid_list", QString::fromUtf8( + QJsonDocument(sidArray).toJson(QJsonDocument::Compact))); + map.insert("version_list", QString::fromUtf8( + QJsonDocument(versionArray).toJson(QJsonDocument::Compact))); + map.insert("angle_list", QString::fromUtf8( + QJsonDocument(angleArray).toJson(QJsonDocument::Compact))); + + QJsonObject uiState; + uiState["filter_sids_string"] = m_internal->filterSIDsString(); + uiState["csv_file"] = m_internal->csvFile(); + + QJsonArray fullSidList, fullVersionList, fullUseList; + for (auto sid : m_internal->sidList) { + fullSidList.append(static_cast(sid)); + } + for (const auto& v : m_internal->versionList) { + fullVersionList.append(v); + } + for (auto u : m_internal->useList) { + fullUseList.append(u); + } + uiState["full_sid_list"] = fullSidList; + uiState["full_version_list"] = fullVersionList; + uiState["full_use_list"] = fullUseList; -QStringList PtychoDialog::selectedVersions() const -{ - return m_internal->selectedVersions(); + map.insert("ui_state", QString::fromUtf8( + QJsonDocument(uiState).toJson(QJsonDocument::Compact))); } -QList PtychoDialog::selectedAngles() const +void PtychoWidget::setValues(const QMap& map) { - return m_internal->selectedAngles(); -} + auto dir = map.value("ptycho_dir").toString(); + if (dir.isEmpty()) { + m_internal->readSettings(); + return; + } -QString PtychoDialog::outputDirectory() const -{ - return m_internal->outputDirectory(); + { + QSignalBlocker b1(m_internal->ui.ptychoDirectory); + m_internal->setPtychoDirectory(dir); + m_internal->setOutputInfoFile( + map.value("output_info_file").toString()); + m_internal->setRotateDatasets( + map.value("rotate_datasets", true).toBool()); + } + + m_internal->loadPtychoDir(); + + auto uiStateJson = map.value("ui_state").toString(); + if (!uiStateJson.isEmpty()) { + auto uiState = QJsonDocument::fromJson(uiStateJson.toUtf8()).object(); + m_internal->setCsvFile(uiState.value("csv_file").toString()); + m_internal->setFilterSIDsString( + uiState.value("filter_sids_string").toString()); + + auto fullSidArr = uiState.value("full_sid_list").toArray(); + auto fullVersionArr = uiState.value("full_version_list").toArray(); + auto fullUseArr = uiState.value("full_use_list").toArray(); + + QList savedSidList; + for (const auto& v : fullSidArr) { + savedSidList.append(static_cast(v.toInteger())); + } + QStringList savedVersionList; + for (const auto& v : fullVersionArr) { + savedVersionList.append(v.toString()); + } + QList savedUseList; + for (const auto& v : fullUseArr) { + savedUseList.append(v.toBool()); + } + + if (savedSidList == m_internal->sidList) { + m_internal->versionList = savedVersionList; + m_internal->useList = savedUseList; + m_internal->onSelectedVersionsChanged(); + } else if (!m_internal->csvFile().isEmpty()) { + m_internal->setUseAndVersionsFromCSV(); + } + + m_internal->updateFilteredSidList(); + } else { + // No ui_state -- use sid_list/version_list to restore selections + auto sidJson = map.value("sid_list", "[]").toString(); + auto versionJson = map.value("version_list", "[]").toString(); + auto sidArr = QJsonDocument::fromJson(sidJson.toUtf8()).array(); + auto verArr = QJsonDocument::fromJson(versionJson.toUtf8()).array(); + + QSet selectedSids; + QMap selectedVersions; + for (int i = 0; i < sidArr.size(); ++i) { + long sid = static_cast(sidArr[i].toInteger()); + selectedSids.insert(sid); + if (i < verArr.size()) { + selectedVersions[sid] = verArr[i].toString(); + } + } + + for (int i = 0; i < m_internal->sidList.size(); ++i) { + auto sid = m_internal->sidList[i]; + m_internal->useList[i] = selectedSids.contains(sid); + if (selectedVersions.contains(sid)) { + m_internal->versionList[i] = selectedVersions[sid]; + } + } + m_internal->onSelectedVersionsChanged(); + m_internal->updateFilteredSidList(); + } } -bool PtychoDialog::rotateDatasets() const +void PtychoWidget::writeSettings() { - return m_internal->rotateDatasets(); + m_internal->writeSettings(); } } // namespace tomviz -#include "PtychoDialog.moc" +#include "PtychoWidget.moc" diff --git a/tomviz/PtychoWidget.h b/tomviz/PtychoWidget.h new file mode 100644 index 000000000..1f24df7ed --- /dev/null +++ b/tomviz/PtychoWidget.h @@ -0,0 +1,36 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPtychoWidget_h +#define tomvizPtychoWidget_h + +#include "CustomPythonNodeWidget.h" +#include "PortData.h" + +#include +#include +#include + +namespace tomviz { + +class PtychoWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + PtychoWidget(const QMap& inputs, + QWidget* parent = nullptr); + ~PtychoWidget() override; + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + void writeSettings() override; + +private: + class Internal; + QScopedPointer m_internal; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/PtychoDialog.ui b/tomviz/PtychoWidget.ui similarity index 88% rename from tomviz/PtychoDialog.ui rename to tomviz/PtychoWidget.ui index c85354685..ebe1fb4dc 100644 --- a/tomviz/PtychoDialog.ui +++ b/tomviz/PtychoWidget.ui @@ -1,18 +1,7 @@ - PtychoDialog - - - - 0 - 0 - 669 - 732 - - - - Ptycho Dialog - + PtychoWidget + 6 @@ -35,7 +24,10 @@ QAbstractItemView::NoEditTriggers - QAbstractItemView::NoSelection + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows true @@ -101,25 +93,15 @@ - + - <html><head/><body><p>The directory where the output files will be written. If one is not specified, the Ptycho directory will be used.</p></body></html> + <html><head/><body><p>Optional path to write a summary text file with scan IDs, angles, and versions. Leave empty to skip.</p></body></html> Select - - - - Qt::Horizontal - - - QDialogButtonBox::Close|QDialogButtonBox::Help|QDialogButtonBox::Ok - - - @@ -131,15 +113,15 @@ - + - <html><head/><body><p>The directory where the output files will be written. If one is not specified, the Ptycho directory will be used.</p></body></html> + <html><head/><body><p>Optional path to write a summary text file with scan IDs, angles, and versions. Leave empty to skip.</p></body></html> - Output Directory: + Output Info File: - outputDirectory + outputInfoFile @@ -177,9 +159,9 @@ - + - <html><head/><body><p>The directory where the output files will be written. If one is not specified, the Ptycho directory will be used.</p></body></html> + <html><head/><body><p>Optional path to write a summary text file with scan IDs, angles, and versions. Leave empty to skip.</p></body></html> @@ -226,27 +208,10 @@ filterSIDsString loadSidsFromTxt table - outputDirectory - selectOutputDirectory + outputInfoFile + selectOutputInfoFile rotateDatasets - - - buttonBox - rejected() - PtychoDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - + diff --git a/tomviz/PyXRFMakeHDF5Dialog.cxx b/tomviz/PyXRFMakeHDF5Dialog.cxx deleted file mode 100644 index b07e1c904..000000000 --- a/tomviz/PyXRFMakeHDF5Dialog.cxx +++ /dev/null @@ -1,402 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PyXRFMakeHDF5Dialog.h" -#include "ui_PyXRFMakeHDF5Dialog.h" - -#include "Utilities.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace { - -bool executableExists(const QString& command) -{ - if (command.isEmpty()) { - return false; - } - // If the command contains a path separator, treat it as a file path - if (command.contains('/') || command.contains(QDir::separator())) { - QFileInfo info(command); - return info.isFile() && info.isExecutable(); - } - // Otherwise check whether it can be found in $PATH - return !QStandardPaths::findExecutable(command).isEmpty(); -} - -// Returns the best available pyxrf-utils command by checking, in order: -// 1. The previously saved command (if it still exists) -// 2. "run-pyxrf-utils" in $PATH -// 3. "pyxrf-utils" in $PATH -// 4. An absolute fallback path -// 5. Empty string (not found) -QString findPyxrfUtilsCommand(const QString& savedCommand) -{ - if (executableExists(savedCommand)) { - return savedCommand; - } - - const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; - for (const auto& candidate : candidates) { - if (executableExists(candidate)) { - return candidate; - } - } - - const QString absoluteFallback = - "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" - "tomviz-latest-wip/bin/run-pyxrf-utils"; - if (executableExists(absoluteFallback)) { - return absoluteFallback; - } - - return ""; -} - -} // anonymous namespace - -namespace tomviz { - -class PyXRFMakeHDF5Dialog::Internal : public QObject -{ -public: - Ui::PyXRFMakeHDF5Dialog ui; - QPointer parent; - - Internal(PyXRFMakeHDF5Dialog* p) : parent(p) - { - ui.setupUi(p); - setParent(p); - - // Hide the tab bar. We will change pages automatically. - ui.methodWidget->tabBar()->hide(); - - updateEnableStates(); - setupConnections(); - } - - void setupConnections() - { - connect(ui.method, QOverload::of(&QComboBox::currentIndexChanged), - this, &Internal::methodChanged); - connect(ui.remakeCsvFile, &QCheckBox::toggled, this, - &Internal::updateEnableStates); - connect(ui.selectWorkingDirectory, &QPushButton::clicked, this, - &Internal::selectWorkingDirectory); - connect(ui.selectLogFile, &QPushButton::clicked, this, - &Internal::selectLogFile); - - connect(ui.buttonBox, &QDialogButtonBox::accepted, this, - &Internal::accepted); - connect(ui.buttonBox, &QDialogButtonBox::helpRequested, this, - [](){ openHelpUrl("https://tomviz.readthedocs.io/en/latest/workflows_pyxrf.html"); }); - } - - QString command() const - { - return ui.command->text(); - } - - void setCommand(const QString& cmd) - { - ui.command->setText(cmd); - } - - bool useAlreadyExistingData() const - { - return ui.method->currentText() == "Already Existing"; - } - - int scanStart() const { return ui.scanStart->value(); } - - void setScanStart(int x) { ui.scanStart->setValue(x); } - - int scanStop() const { return ui.scanStop->value(); } - - void setScanStop(int x) { ui.scanStop->setValue(x); } - - bool successfulScansOnly() const - { - return ui.successfulScansOnly->isChecked(); - } - - void setSuccessfulScansOnly(bool b) { ui.successfulScansOnly->setChecked(b); } - - bool remakeCsvFile() const { return ui.remakeCsvFile->isChecked(); } - - void setRemakeCsvFile(bool b) { ui.remakeCsvFile->setChecked(b); } - - QString method() const { return ui.method->currentText(); } - - void setMethod(QString s) { ui.method->setCurrentText(s); } - - void methodChanged(int i) - { - // The indices match - ui.methodWidget->setCurrentIndex(i); - updateEnableStates(); - } - - QString workingDirectory() const { return ui.workingDirectory->text(); } - - void setWorkingDirectory(QString s) { ui.workingDirectory->setText(s); } - - QString defaultWorkingDirectory() const - { - return QDir::home().filePath("data"); - } - - void selectWorkingDirectory() - { - QString caption = "Select working directory"; - auto directory = QFileDialog::getExistingDirectory(parent.data(), caption, - workingDirectory()); - if (directory.isEmpty()) { - return; - } - - setWorkingDirectory(directory); - } - - QString logFile() const - { - return ui.logFile->text().trimmed(); - } - - QString logFileOrDefault() const - { - if (!writingNewLogFile()) { - // Return an empty log file - return ""; - } - - auto name = logFile(); - return name.isEmpty() ? defaultLogFile() : name; - } - - void setLogFile(QString s) { ui.logFile->setText(s); } - - QString defaultLogFile() const - { - return QDir(workingDirectory()).filePath("tomo_info.csv"); - } - - void selectLogFile() - { - QString caption = "Select Output CSV File"; - auto fileName = QFileDialog::getSaveFileName(parent.data(), caption, - logFileOrDefault(), - "CSV Files (*.csv)"); - if (fileName.isEmpty()) { - return; - } - - setLogFile(fileName); - } - - bool writingNewLogFile() const - { - return method() == "New" || remakeCsvFile(); - } - - void accepted() - { - QString reason; - if (!validate(reason)) { - QString title = "Invalid Settings"; - QMessageBox::critical(parent.data(), title, reason); - parent->show(); - return; - } - - writeSettings(); - parent->accept(); - } - - bool validate(QString& reason) - { - auto workingDir = workingDirectory(); - if (!QDir(workingDir).exists()) { - // First ask if the user wants to make it. - QString title = "Directory does not exist"; - auto text = QString("Working directory \"%1\" does not exist. Create it?") - .arg(workingDir); - if (QMessageBox::question(parent, title, text) == QMessageBox::Yes) { - QDir().mkpath(workingDir); - } - } - - if (!useAlreadyExistingData() && !QDir(workingDir).isEmpty()) { - QString title = "Directory is not empty"; - auto text = QString("Working directory \"%1\" is not empty. Its " - "contents will be removed. Proceed?") - .arg(workingDir); - - if (QMessageBox::question(parent, title, text) == QMessageBox::No) { - reason = "Working directory is not empty: " + workingDir; - return false; - } - - QDir(workingDir).removeRecursively(); - QDir().mkpath(workingDir); - } - - if (workingDir.isEmpty() || !QDir(workingDir).exists()) { - reason = "Working directory does not exist: " + workingDir; - return false; - } - - auto logFile = logFileOrDefault(); - if (writingNewLogFile() && QFile(logFile).exists()) { - QString title = "CSV Log File Exists"; - auto text = QString("CSV log file \"%1\" already exists. It " - "will be overwritten. Proceed?") - .arg(logFile); - - if (QMessageBox::question(parent, title, text) == QMessageBox::No) { - reason = "Not overwriting log file: " + logFile; - return false; - } - } - - if (scanStart() > scanStop()) { - reason = QString("Scan start, %1, cannot be greater than scan stop, %2") - .arg(scanStart()) - .arg(scanStop()); - return false; - } - - // Check that the executable exists when it will actually be used - if (!useAlreadyExistingData() || remakeCsvFile()) { - auto cmd = command(); - if (!executableExists(cmd)) { - reason = - QString("The pyxrf-utils executable \"%1\" was not found. " - "Please specify a valid path to the executable.") - .arg(cmd.isEmpty() ? QString("(empty)") : cmd); - return false; - } - } - - return true; - } - - void updateEnableStates() - { - auto b = writingNewLogFile(); - ui.scanNumbersGroup->setEnabled(b); - ui.logFileLabel->setEnabled(b); - ui.logFile->setEnabled(b); - ui.selectLogFile->setEnabled(b); - } - - void readSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("pyxrf"); - - // Do this in the general pyxrf settings - auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); - setCommand(findPyxrfUtilsCommand(savedCommand)); - - settings->beginGroup("makeHDF5"); - setMethod(settings->value("method", "New").toString()); - setWorkingDirectory( - settings->value("workingDirectory", defaultWorkingDirectory()) - .toString()); - setScanStart(settings->value("scanStart", 0).toInt()); - setScanStop(settings->value("scanStop", 0).toInt()); - setSuccessfulScansOnly( - settings->value("successfulScansOnly", true).toBool()); - setRemakeCsvFile(settings->value("remakeCsvFile", false).toBool()); - setLogFile(settings->value("logFile", "").toString()); - settings->endGroup(); - - settings->endGroup(); - updateEnableStates(); - } - - void writeSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("pyxrf"); - - // Do this in the general pyxrf settings - settings->setValue("pyxrfUtilsCommand", command()); - - settings->beginGroup("makeHDF5"); - settings->setValue("method", method()); - settings->setValue("workingDirectory", workingDirectory()); - settings->setValue("scanStart", scanStart()); - settings->setValue("scanStop", scanStop()); - settings->setValue("successfulScansOnly", successfulScansOnly()); - settings->setValue("remakeCsvFile", remakeCsvFile()); - settings->setValue("logFile", logFile()); - settings->endGroup(); - - settings->endGroup(); - } -}; - -PyXRFMakeHDF5Dialog::PyXRFMakeHDF5Dialog(QWidget* parent) - : QDialog(parent), m_internal(new Internal(this)) -{ -} - -PyXRFMakeHDF5Dialog::~PyXRFMakeHDF5Dialog() = default; - -void PyXRFMakeHDF5Dialog::show() -{ - m_internal->readSettings(); - QDialog::show(); -} - -QString PyXRFMakeHDF5Dialog::command() const -{ - return m_internal->command(); -} - -bool PyXRFMakeHDF5Dialog::useAlreadyExistingData() const -{ - return m_internal->useAlreadyExistingData(); -} - -QString PyXRFMakeHDF5Dialog::workingDirectory() const -{ - return m_internal->workingDirectory(); -} - -int PyXRFMakeHDF5Dialog::scanStart() const -{ - return m_internal->scanStart(); -} - -int PyXRFMakeHDF5Dialog::scanStop() const -{ - return m_internal->scanStop(); -} - -bool PyXRFMakeHDF5Dialog::successfulScansOnly() const -{ - return m_internal->successfulScansOnly(); -} - -bool PyXRFMakeHDF5Dialog::remakeCsvFile() const -{ - return m_internal->remakeCsvFile(); -} - -QString PyXRFMakeHDF5Dialog::logFile() const -{ - return m_internal->logFileOrDefault(); -} - -} // namespace tomviz diff --git a/tomviz/PyXRFMakeHDF5Dialog.h b/tomviz/PyXRFMakeHDF5Dialog.h deleted file mode 100644 index 6db79a20d..000000000 --- a/tomviz/PyXRFMakeHDF5Dialog.h +++ /dev/null @@ -1,37 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPyXRFMakeHDF5Dialog_h -#define tomvizPyXRFMakeHDF5Dialog_h - -#include -#include - -namespace tomviz { - -class PyXRFMakeHDF5Dialog : public QDialog -{ - Q_OBJECT - -public: - explicit PyXRFMakeHDF5Dialog(QWidget* parent); - ~PyXRFMakeHDF5Dialog() override; - - void show(); - - QString command() const; - bool useAlreadyExistingData() const; - QString workingDirectory() const; - int scanStart() const; - int scanStop() const; - bool successfulScansOnly() const; - bool remakeCsvFile() const; - QString logFile() const; - -private: - class Internal; - QScopedPointer m_internal; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/PyXRFMakeHDF5Dialog.ui b/tomviz/PyXRFMakeHDF5Dialog.ui deleted file mode 100644 index 6c9e38bf4..000000000 --- a/tomviz/PyXRFMakeHDF5Dialog.ui +++ /dev/null @@ -1,263 +0,0 @@ - - - PyXRFMakeHDF5Dialog - - - - 0 - 0 - 595 - 369 - - - - PyXRF Make HDF5 - - - - 6 - - - 6 - - - 6 - - - 6 - - - - - <html><head/><body><p>Command for running pyxrf-utils, which will be used to run the make-hdf5 command.</p></body></html> - - - pyxrf-utils - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - New - - - - - Already Existing - - - - - - - - 0 - - - - New Data - - - - - - Successful scans only? - - - - - - - - Already Existing - - - - - - Remake CSV file? - - - - - - - - - - - <html><head/><body><p>The directory where the data files will be written if the &quot;New&quot; method is selected, or the directory already containing the data files if the &quot;Already Existing&quot; method is selected.</p></body></html> - - - - - - - <html><head/><body><p>The directory where the data files will be written if the &quot;New&quot; method is selected, or the directory already containing the data files if the &quot;Already Existing&quot; method is selected.</p></body></html> - - - Data directory: - - - workingDirectory - - - - - - - Scan Numbers - - - - - - Start: - - - scanStart - - - - - - - 100000000 - - - - - - - Stop: - - - scanStop - - - - - - - 100000000 - - - - - - - - - - <html><head/><body><p>The directory where the data files will be written if the &quot;New&quot; method is selected, or the directory already containing the data files if the &quot;Already Existing&quot; method is selected.</p></body></html> - - - Select - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Close|QDialogButtonBox::Help|QDialogButtonBox::Ok - - - - - - - Method: - - - method - - - - - - - <html><head/><body><p>Command for running pyxrf-utils, which will be used to run the make-hdf5 command.</p></body></html> - - - PyXRF Utils Command: - - - - - - - <html><head/><body><p>Specify the filepath for the new CSV log file. If not specified, `tomo_info.csv` will be written to the data directory.</p></body></html> - - - Output CSV File: - - - - - - - <html><head/><body><p>Specify the filepath for the new CSV log file. If not specified, `tomo_info.csv` will be written to the data directory.</p></body></html> - - - - - - - <html><head/><body><p>Specify the filepath for the new CSV log file. If not specified, `tomo_info.csv` will be written to the data directory.</p></body></html> - - - Select - - - - - - - command - method - methodWidget - successfulScansOnly - remakeCsvFile - scanStart - scanStop - workingDirectory - selectWorkingDirectory - - - - - buttonBox - rejected() - PyXRFMakeHDF5Dialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/tomviz/PyXRFProcessDialog.cxx b/tomviz/PyXRFProcessDialog.cxx deleted file mode 100644 index 73c887fa1..000000000 --- a/tomviz/PyXRFProcessDialog.cxx +++ /dev/null @@ -1,929 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PyXRFProcessDialog.h" -#include "ui_PyXRFProcessDialog.h" - -#include "PythonUtilities.h" -#include "Utilities.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -bool executableExists(const QString& command) -{ - if (command.isEmpty()) { - return false; - } - // If the command contains a path separator, treat it as a file path - if (command.contains('/') || command.contains(QDir::separator())) { - QFileInfo info(command); - return info.isFile() && info.isExecutable(); - } - // Otherwise check whether it can be found in $PATH - return !QStandardPaths::findExecutable(command).isEmpty(); -} - -// Returns the best available pyxrf-utils command by checking, in order: -// 1. The previously saved command (if it still exists) -// 2. "run-pyxrf-utils" in $PATH -// 3. "pyxrf-utils" in $PATH -// 4. An absolute fallback path -// 5. Empty string (not found) -QString findPyxrfUtilsCommand(const QString& savedCommand) -{ - if (executableExists(savedCommand)) { - return savedCommand; - } - - const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; - for (const auto& candidate : candidates) { - if (executableExists(candidate)) { - return candidate; - } - } - - const QString absoluteFallback = - "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" - "tomviz-latest-wip/bin/run-pyxrf-utils"; - if (executableExists(absoluteFallback)) { - return absoluteFallback; - } - - return ""; -} - -} // anonymous namespace - -namespace tomviz { - -class PyXRFProcessDialog::Internal : public QObject -{ -public: - Ui::PyXRFProcessDialog ui; - QPointer parent; - - bool pyxrfIsRunning = false; - QString workingDirectory; - - // We're going to assume these files will be small and just - // load the whole thing into memory... - QList logFileData; - QMap logFileColumnIndices; - QMap tableColumns; - QMap sidToRow; - - QStringList filteredSidList; - - double pixelSizeX = -1; - double pixelSizeY = -1; - - Python::Module pyxrfModule; - - Internal(QString workingDir, QString logFile, PyXRFProcessDialog* p) - : parent(p), workingDirectory(workingDir) - { - ui.setupUi(p); - setParent(p); - - setLogFile(logFile); - - setupTableColumns(); - setupComboBoxes(); - setupConnections(); - } - - void setupConnections() - { - connect(ui.startPyXRFGUI, &QPushButton::clicked, this, - &Internal::startPyXRFGUI); - connect(ui.logFile, &QLineEdit::textChanged, this, &Internal::updateTable); - connect(ui.filterSidsString, &QLineEdit::editingFinished, this, - &Internal::onFilterSidsStringChanged); - connect(ui.loadSidsFromTxt, &QPushButton::clicked, this, - &Internal::onLoadSidsFromTxtClicked); - - connect(ui.selectLogFile, &QPushButton::clicked, this, - &Internal::selectLogFile); - connect(ui.selectParametersFile, &QPushButton::clicked, this, - &Internal::selectParametersFile); - connect(ui.selectOutputDirectory, &QPushButton::clicked, this, - &Internal::selectOutputDirectory); - - connect(ui.buttonBox, &QDialogButtonBox::accepted, this, - &Internal::accepted); - connect(ui.buttonBox, &QDialogButtonBox::helpRequested, this, - [](){ openHelpUrl("https://tomviz.readthedocs.io/en/latest/workflows_pyxrf.html#process-projections"); }); - } - - void setupTableColumns() - { - auto* table = ui.logFileTable; - auto& columns = tableColumns; - - columns.clear(); - columns[0] = "Scan ID"; - columns[1] = "Theta"; - columns[2] = "Status"; - columns[3] = "Use"; - - table->setColumnCount(columns.size()); - for (int i = 0; i < columns.size(); ++i) { - auto* header = new QTableWidgetItem(columns[i]); - table->setHorizontalHeaderItem(i, header); - } - } - - void setupComboBoxes() - { - ui.icName->clear(); - ui.icName->addItems(icNames()); - } - - void importModule() - { - Python python; - - if (pyxrfModule.isValid()) { - return; - } - - pyxrfModule = python.import("tomviz.pyxrf"); - if (!pyxrfModule.isValid()) { - qCritical() << "Failed to import \"tomviz.pyxrf\" module"; - } - } - - void accepted() - { - QString reason; - if (!validate(reason)) { - QString title = "Invalid Settings"; - QMessageBox::critical(parent.data(), title, reason); - parent->show(); - return; - } - - setHiddenRowsToUnused(); - readPixelSizes(); - writeLogFile(); - writeSettings(); - parent->accept(); - } - - bool validate(QString& reason) - { - // Make the parameters file and log file absolute if they are not - if (!QFileInfo(logFile()).isAbsolute()) { - setLogFile(QDir(workingDirectory).filePath(logFile())); - } - - if (!QFileInfo(parametersFile()).isAbsolute()) { - setParametersFile(QDir(workingDirectory).filePath(parametersFile())); - } - - if (logFile().isEmpty() || !QFile::exists(logFile())) { - reason = "Log file does not exist: " + logFile(); - return false; - } - - if (parametersFile().isEmpty() || !QFile::exists(parametersFile())) { - reason = "Parameters file does not exist: " + parametersFile(); - return false; - } - - if (!QDir(outputDirectory()).exists()) { - // First ask if the user wants to make it. - QString title = "Directory does not exist"; - auto text = QString("Output directory \"%1\" does not exist. Create it?") - .arg(outputDirectory()); - if (QMessageBox::question(parent, title, text) == QMessageBox::Yes) { - QDir().mkpath(outputDirectory()); - } - } - - if (outputDirectory().isEmpty() || !QDir(outputDirectory()).exists()) { - reason = "Output directory does not exist: " + outputDirectory(); - return false; - } - - if (filteredSidList.isEmpty()) { - reason = "No SIDs were selected"; - return false; - } - - // Check if there are any duplicate angles selected - QStringList anglesUsed; - QStringList anglesDuplicated; - for (auto sid : filteredSidList) { - auto row = sidToRow[sid]; - auto use = logFileValue(row, "Use"); - if (use != "x" && use != "1") { - // This angle wasn't used. - continue; - } - - auto angle = logFileValue(row, "Theta"); - if (anglesUsed.contains(angle) && !anglesDuplicated.contains(angle)) { - anglesDuplicated.append(angle); - } - - anglesUsed.append(angle); - } - - if (anglesDuplicated.size() != 0) { - QString title = "Duplicate angles detected"; - QString text = "The following duplicate angles were detected. Proceed anyways?"; - text += ("\n\n" + anglesDuplicated.join(", ")); - if (QMessageBox::question(parent, title, text) == QMessageBox::No) { - reason = "Rejected proceeding with duplicate angles."; - return false; - } - } - - // Check that the executable exists before attempting to run it - auto cmd = command(); - if (!executableExists(cmd)) { - reason = - QString("The pyxrf-utils executable \"%1\" was not found. " - "Please specify a valid path to the executable.") - .arg(cmd.isEmpty() ? QString("(empty)") : cmd); - return false; - } - - return true; - } - - void updateTable() - { - auto* table = ui.logFileTable; - table->clear(); - - readLogFile(); - - setupTableColumns(); - table->setRowCount(filteredSidList.size()); - for (int row = 0; row < filteredSidList.size(); ++row) { - auto sid = filteredSidList[row]; - int logFileRow = sidToRow[sid]; - for (auto col : tableColumns.keys()) { - auto columnName = tableColumns[col]; - auto value = logFileValue(logFileRow, columnName); - if (columnName == "Use") { - // Special case - auto* cb = createUseCheckbox(row, value); - table->setCellWidget(row, col, cb); - continue; - } - - auto* item = new QTableWidgetItem(value); - item->setTextAlignment(Qt::AlignCenter); - table->setItem(row, col, item); - } - } - } - - QWidget* createUseCheckbox(int row, QString value) - { - auto cb = new QCheckBox(parent); - cb->setChecked(value == "x" || value == "1"); - connect(cb, &QCheckBox::toggled, this, [this, row](bool b) { - QString val = b ? "1" : "0"; - setLogFileValue(row, "Use", val); - }); - - return createTableWidget(cb); - } - - QWidget* createTableWidget(QWidget* w) - { - // This is required to center the widget - auto* tw = new QWidget(ui.logFileTable); - auto* layout = new QHBoxLayout(tw); - layout->addWidget(w); - layout->setAlignment(Qt::AlignCenter); - layout->setContentsMargins(0, 0, 0, 0); - return tw; - } - - void readLogFile() - { - logFileData.clear(); - logFileColumnIndices.clear(); - sidToRow.clear(); - - QFile file(logFile()); - if (!file.exists()) { - // No problem. Maybe the user is typing. - return; - } - - if (!file.open(QIODevice::ReadOnly)) { - qCritical() - << QString("Failed to open log file \"%1\" with error: ").arg(logFile()) - << file.errorString(); - return; - } - - QTextStream reader(&file); - - // Read the first line and save the column indices - auto firstLineSplit = reader.readLine().split(','); - for (int i = 0; i < firstLineSplit.size(); ++i) { - logFileColumnIndices[firstLineSplit[i]] = i; - } - - while (!reader.atEnd()) { - auto line = reader.readLine(); - logFileData.append(line.split(',')); - } - - for (int row = 0; row < logFileData.size(); ++row) { - auto sid = logFileValue(row, "Scan ID"); - sidToRow[sid] = row; - } - - updateFilteredSidList(); - } - - QMap sidVisibleMap() - { - QMap ret; - for (auto s : allSids()) { - ret[s] = false; - } - - for (auto s : filteredSidList) { - ret[s] = true; - } - - return ret; - } - - void setHiddenRowsToUnused() - { - auto visibleMap = sidVisibleMap(); - for (int row = 0; row < logFileData.size(); ++row) { - auto sid = logFileValue(row, "Scan ID"); - if (!visibleMap[sid]) { - // Make sure "Use" is turned off - setLogFileValue(row, "Use", "0"); - } - } - } - - void readPixelSizes() - { - pixelSizeX = -1; - pixelSizeY = -1; - - // Find the first selected scan index - int firstScanIdx = -1; - for (int i = 0; i < logFileData.size(); ++i) { - auto use = logFileValue(i, "Use"); - if (use == "x" || use == "1") { - firstScanIdx = i; - break; - } - } - - if (firstScanIdx == -1) { - // Don't need to print an error message here, because we have - // bigger problems that will be reported elsewhere. - return; - } - - auto scanId = logFileValue(firstScanIdx, "Scan ID"); - qInfo() << "Reading pixel sizes from the first checked scan: " << scanId; - - static QStringList columnsNeeded = { - "X Start", - "X Stop", - "Num X", - "Y Start", - "Y Stop", - "Num Y" - }; - - // We will store the values in here - QMap values; - for (const auto& colName : columnsNeeded) { - auto value = logFileValue(firstScanIdx, colName); - if (value.isEmpty()) { - qCritical() << "Failed to locate value for column:" << colName; - qCritical() << "Pixel sizes will not be set."; - return; - } - - bool ok; - auto valueD = value.toDouble(&ok); - if (!ok) { - qCritical() << "Failed to convert column value for column" << colName - << "to double. Column value was:" << value; - qCritical() << "Pixel sizes will not be set."; - return; - } - values[colName] = valueD; - } - - // If we made it here, we must have all column values we need. - // Compute and set. - pixelSizeX = (values["X Stop"] - values["X Start"]) / values["Num X"] * 1e3; - pixelSizeY = (values["Y Stop"] - values["Y Start"]) / values["Num Y"] * 1e3; - - qInfo() << "Pixel sizes determined to be: " << pixelSizeX << pixelSizeY; - } - - void writeLogFile() - { - QFile file(logFile()); - if (!file.exists()) { - qCritical() << "Log file does not exist: " << logFile(); - return; - } - - if (!file.open(QIODevice::WriteOnly)) { - qCritical() - << QString("Failed to open log file \"%1\" with error: ").arg(logFile()) - << file.errorString(); - return; - } - - // Write the header row - QStringList headerRow; - for (int i = 0; i < logFileColumnIndices.size(); ++i) { - auto key = logFileColumnIndices.key(i); - if (key.isEmpty()) { - qCritical() << "Could not find key for index: " << i; - return; - } - - headerRow.append(key); - } - - QTextStream writer(&file); - writer << headerRow.join(",") << "\n"; - - for (int i = 0; i < logFileData.size(); ++i) { - writer << logFileData[i].join(","); - if (i < logFileData.size() - 1) { - writer << "\n"; - } - } - } - - QString logFileValue(int row, QString column) - { - if (logFileData.isEmpty()) { - qCritical() << "No log file data"; - return ""; - } - - if (!logFileColumnIndices.contains(column)) { - qCritical() << "Column not found in log file: " << column; - return ""; - } - - if (row >= logFileData.size()) { - qCritical() << QString("Row %1 is out of bounds in log file").arg(row); - return ""; - } - - auto col = logFileColumnIndices[column]; - if (col >= logFileData[row].size()) { - qCritical() - << QString("Column %1 is out of bounds in log file").arg(column); - return ""; - } - - return logFileData[row][col]; - } - - void setLogFileValue(int row, QString column, QString value) - { - if (!logFileColumnIndices.contains(column)) { - qCritical() << "Column not found in log file: " << column; - return; - } - - if (row >= logFileData.size()) { - qCritical() << QString("Row %1 is out of bounds in log file").arg(row); - return; - } - - auto col = logFileColumnIndices[column]; - if (col >= logFileData[row].size()) { - qCritical() - << QString("Column %1 is out of bounds in log file").arg(column); - return; - } - - logFileData[row][col] = value; - } - - QStringList allSids() - { - return sidToRow.keys(); - } - - void onFilterSidsStringChanged() - { - updateTable(); - } - - void onLoadSidsFromTxtClicked() - { - QString caption = "Select txt file"; - QString filter = "*.txt"; - auto startPath = workingDirectory; - auto filePath = - QFileDialog::getOpenFileName(parent.data(), caption, startPath, filter); - - if (filePath.isEmpty()) { - return; - } - - QFile file(filePath); - if (!file.exists()) { - qCritical() << QString("Txt file does not exist: %1").arg(filePath); - return; - } - - if (!file.open(QIODevice::ReadOnly)) { - qCritical() - << QString("Failed to open file \"%1\" with error: ").arg(filePath) - << file.errorString(); - return; - } - - QTextStream reader(&file); - - // Now load the SIDs - QStringList sids; - while (!reader.atEnd()) { - auto line = reader.readLine().trimmed(); - if (line.isEmpty() || line.startsWith('#')) { - // Skip over it - continue; - } - - sids.append(line.split(' ')[0]); - } - - ui.filterSidsString->setText(sids.join(", ")); - - onFilterSidsStringChanged(); - } - - void updateFilteredSidList() - { - auto filterString = filterSidsString().trimmed(); - if (filterString.isEmpty()) { - filteredSidList = allSids(); - return; - } - - // Otherwise, run the Python function to filter out the list - importModule(); - - Python python; - - auto func = pyxrfModule.findFunction("filter_sids"); - if (!func.isValid()) { - qCritical() << "Failed to import tomviz.pyxrf.filter_sids"; - return; - } - - Python::Dict kwargs; - kwargs.set("all_sids", allSids()); - kwargs.set("filter_string", filterString); - auto res = func.call(kwargs); - - if (!res.isValid() || !res.isList()) { - qCritical() << "Error calling tomviz.pyxrf.filter_sids"; - return; - } - - filteredSidList.clear(); - auto resList = res.toList(); - for (int i = 0; i < resList.length(); ++i) { - filteredSidList.append(resList[i].toString()); - } - } - - QString defaultOutputDirectory() { return QDir::home().filePath("recon"); } - - void readSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("pyxrf"); - - auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); - setCommand(findPyxrfUtilsCommand(savedCommand)); - - settings->beginGroup("process"); - // Only load these settings if we are re-using the same previous - // working directory. Otherwise, use all new settings - auto previousWorkingDir = settings->value("previousProcessWorkingDir", ""); - setPyxrfGUICommand( - settings->value("pyxrfGUICommand", "pyxrf").toString()); - if (workingDirectory == previousWorkingDir) { - if (logFile().isEmpty()) { - setLogFile(settings->value("logFile", "").toString()); - } - setFilterSidsString(settings->value("filterSidsString", "").toString()); - setParametersFile(settings->value("parametersFile", "").toString()); - setOutputDirectory( - settings->value("outputDirectory", defaultOutputDirectory()).toString()); - } - setIcName(settings->value("icName", "sclr1_ch4").toString()); - setSkipProcessed(settings->value("skipProcessed", true).toBool()); - setRotateDatasets( - settings->value("rotateDatasets", true).toBool()); - settings->endGroup(); - - settings->endGroup(); - - // Table might have been modified from the settings - updateTable(); - } - - void writeSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("pyxrf"); - - // Do this in the general pyxrf settings - settings->setValue("pyxrfUtilsCommand", command()); - - settings->beginGroup("process"); - settings->setValue("previousProcessWorkingDir", workingDirectory); - settings->setValue("logFile", logFile()); - settings->setValue("filterSidsString", filterSidsString()); - settings->setValue("pyxrfGUICommand", pyxrfGUICommand()); - settings->setValue("parametersFile", parametersFile()); - settings->setValue("outputDirectory", outputDirectory()); - settings->setValue("icName", icName()); - settings->setValue("skipProcessed", skipProcessed()); - settings->setValue("rotateDatasets", rotateDatasets()); - settings->endGroup(); - - settings->endGroup(); - } - - QStringList icNames() - { - QStringList ret; - - importModule(); - - Python python; - - auto icNamesFunc = pyxrfModule.findFunction("ic_names"); - if (!icNamesFunc.isValid()) { - qCritical() << "Failed to import tomviz.pyxrf.ic_names"; - return ret; - } - - Python::Dict kwargs; - kwargs.set("working_directory", workingDirectory); - auto res = icNamesFunc.call(kwargs); - - if (!res.isValid()) { - qCritical() << "Error calling tomviz.pyxrf.ic_names"; - return ret; - } - - for (auto& item : res.toVariant().toList()) { - ret.append(item.toString().c_str()); - } - - return ret; - } - - void startPyXRFGUI() - { - if (pyxrfIsRunning) { - // It's already running. Just return. - return; - } - - QString program = pyxrfGUICommand(); - auto environment = QProcessEnvironment::systemEnvironment(); - if (environment.contains("TOMVIZ_PYXRF_EXECUTABLE")) { - program = environment.value("TOMVIZ_PYXRF_EXECUTABLE"); - } - - QStringList args; - - auto* process = new QProcess(this); - - // Forward stdout/stderr to this process - process->setProcessChannelMode(QProcess::ForwardedChannels); - - process->start(program, args); - - pyxrfIsRunning = true; - - connect(process, - QOverload::of(&QProcess::finished), this, - [this]() { pyxrfIsRunning = false; }); - - connect( - process, &QProcess::errorOccurred, this, - [this, process](QProcess::ProcessError err) { - pyxrfIsRunning = false; - - QString title; - QString msg; - - if (err == QProcess::FailedToStart) { - title = "PyXRF failed to start"; - msg = QString("The program \"%1\" failed to start.\n\n") - .arg(process->program()) + - "Try setting the environment variable " - "\"TOMVIZ_PYXRF_EXECUTABLE\" to the full path, and restart " - "tomviz."; - } else { - QString output = process->readAllStandardOutput(); - QString error = process->readAllStandardError(); - title = "PyXRF exited with an error"; - msg = - QString("stdout: \"%1\"\n\nstderr: \"%2\"").arg(output).arg(error); - } - QMessageBox::critical(parent.data(), title, msg); - }); - } - - void selectLogFile() - { - QString caption = "Select log file"; - QString filter = "*.csv"; - auto startPath = logFile() != "" ? logFile() : workingDirectory; - auto file = - QFileDialog::getOpenFileName(parent.data(), caption, startPath, filter); - if (file.isEmpty()) { - return; - } - - setLogFile(file); - } - - void selectParametersFile() - { - QString caption = "Select parameters file"; - QString filter = "*.json"; - auto startPath = parametersFile() != "" ? parametersFile() : workingDirectory; - auto file = QFileDialog::getOpenFileName(parent.data(), caption, - startPath, filter); - if (file.isEmpty()) { - return; - } - - setParametersFile(file); - } - - void selectOutputDirectory() - { - QString caption = "Select output directory"; - auto startPath = outputDirectory() != "" ? outputDirectory() : workingDirectory; - auto dir = QFileDialog::getExistingDirectory(parent.data(), caption, - startPath); - if (dir.isEmpty()) { - return; - } - - setOutputDirectory(dir); - } - - QString command() const { return ui.command->text(); } - - void setCommand(const QString& s) { ui.command->setText(s); } - - QString pyxrfGUICommand() const { return ui.pyxrfGUICommand->text(); } - - void setPyxrfGUICommand(const QString& s) { ui.pyxrfGUICommand->setText(s); } - - QString parametersFile() const { return ui.parametersFile->text(); } - - void setParametersFile(QString s) { ui.parametersFile->setText(s); } - - QString logFile() const { return ui.logFile->text(); } - - void setLogFile(QString s) { ui.logFile->setText(s); } - - QString filterSidsString() const { return ui.filterSidsString->text(); } - - void setFilterSidsString(QString s) const { ui.filterSidsString->setText(s); } - - QString icName() const { return ui.icName->currentText(); } - - void setIcName(QString s) { ui.icName->setCurrentText(s); } - - QString outputDirectory() const { return ui.outputDirectory->text(); } - - void setOutputDirectory(QString s) { ui.outputDirectory->setText(s); } - - bool skipProcessed() const { return ui.skipProcessed->isChecked(); } - - void setSkipProcessed(bool b) { ui.skipProcessed->setChecked(b); } - - bool rotateDatasets() const { return ui.rotateDatasets->isChecked(); } - - void setRotateDatasets(bool b) { ui.rotateDatasets->setChecked(b); } -}; - -PyXRFProcessDialog::PyXRFProcessDialog(QString workingDirectory, - QString logFile, - QWidget* parent) - : QDialog(parent), m_internal(new Internal(workingDirectory, logFile, this)) -{ -} - -PyXRFProcessDialog::~PyXRFProcessDialog() = default; - -void PyXRFProcessDialog::show() -{ - m_internal->readSettings(); - QDialog::show(); -} - -QString PyXRFProcessDialog::command() const -{ - return m_internal->command(); -} - -QString PyXRFProcessDialog::parametersFile() const -{ - return m_internal->parametersFile(); -} - -QString PyXRFProcessDialog::logFile() const -{ - return m_internal->logFile(); -} - -QString PyXRFProcessDialog::icName() const -{ - return m_internal->icName(); -} - -QString PyXRFProcessDialog::outputDirectory() const -{ - return m_internal->outputDirectory(); -} - -double PyXRFProcessDialog::pixelSizeX() const -{ - return m_internal->pixelSizeX; -} - -double PyXRFProcessDialog::pixelSizeY() const -{ - return m_internal->pixelSizeY; -} - -bool PyXRFProcessDialog::skipProcessed() const -{ - return m_internal->skipProcessed(); -} - -bool PyXRFProcessDialog::rotateDatasets() const -{ - return m_internal->rotateDatasets(); -} - -QVector PyXRFProcessDialog::selectedScanIDs() const -{ - QVector result; - for (const auto& sid : m_internal->filteredSidList) { - auto row = m_internal->sidToRow[sid]; - auto use = m_internal->logFileValue(row, "Use"); - if (use == "x" || use == "1") { - bool ok; - int id = sid.toInt(&ok); - if (!ok) { - id = -1; - } - result.append(id); - } - } - return result; -} - -} // namespace tomviz diff --git a/tomviz/PyXRFProcessDialog.h b/tomviz/PyXRFProcessDialog.h deleted file mode 100644 index b1d713aa4..000000000 --- a/tomviz/PyXRFProcessDialog.h +++ /dev/null @@ -1,41 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPyXRFProcessDialog_h -#define tomvizPyXRFProcessDialog_h - -#include -#include - -namespace tomviz { - -class PyXRFProcessDialog : public QDialog -{ - Q_OBJECT - -public: - explicit PyXRFProcessDialog(QString workingDirectory, QString logFile, - QWidget* parent); - ~PyXRFProcessDialog() override; - - virtual void show(); - - QString command() const; - QString parametersFile() const; - QString logFile() const; - QString icName() const; - QString outputDirectory() const; - double pixelSizeX() const; - double pixelSizeY() const; - bool skipProcessed() const; - bool rotateDatasets() const; - QVector selectedScanIDs() const; - -private: - class Internal; - QScopedPointer m_internal; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/PyXRFProcessDialog.ui b/tomviz/PyXRFProcessDialog.ui deleted file mode 100644 index 537e0970e..000000000 --- a/tomviz/PyXRFProcessDialog.ui +++ /dev/null @@ -1,299 +0,0 @@ - - - PyXRFProcessDialog - - - - 0 - 0 - 595 - 497 - - - - PyXRF Process Projections - - - - 6 - - - 6 - - - 6 - - - 6 - - - - - <html><head/><body><p>The directory where the output will be written.</p></body></html> - - - - - - - <html><head/><body><p>Select the parameters file that was generated using the PyXRF GUI on one of the datasets.</p></body></html> - - - Select - - - - - - - PyXRF GUI Command: - - - - - - - <html><head/><body><p>The directory where the output will be written.</p></body></html> - - - Output Directory: - - - outputDirectory - - - - - - - pyxrf-utils - - - - - - - - - <html><head/><body><p>The csv file. Once loaded, the table below will be populated.</p></body></html> - - - Log File: - - - logFile - - - - - - - <html><head/><body><p>The csv file. Once loaded, the table below will be populated.</p></body></html> - - - - - - - <html><head/><body><p>The csv file. Once loaded, the table below will be populated.</p></body></html> - - - Select - - - - - - - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::NoSelection - - - true - - - - - - - <html><head/><body><p>Name of the scalar for normalization of fluorescence data.</p></body></html> - - - Normalization Channel: - - - icName - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Close|QDialogButtonBox::Help|QDialogButtonBox::Ok - - - - - - - <html><head/><body><p>The directory where the output will be written.</p></body></html> - - - Select - - - - - - - pyxrf - - - - - - - <html><head/><body><p>Select the parameters file that was generated using the PyXRF GUI on one of the datasets.</p></body></html> - - - Parameters File: - - - parametersFile - - - - - - - <html><head/><body><p>Select the parameters file that was generated using the PyXRF GUI on one of the datasets.</p></body></html> - - - - - - - <html><head/><body><p>Name of the scalar for normalization of fluorescence data.</p></body></html> - - - - - - - <html><head/><body><p>The orientation of the tilt series from PyXRF does match what Tomviz expects internally. Rotating the data ensures that the later Tomviz operators will be able to run properly.</p></body></html> - - - Rotate datasets to Tomviz convention? - - - true - - - - - - - PyXRF Utils Command: - - - - - - - <html><head/><body><p>Use the PyXRF GUI to generate a parameters file with one of the datasets. Then, select the parameters file below.</p></body></html> - - - Start PyXRF GUI - - - - - - - <html><head/><body><p>xrf-tomo will store the output from processed scans within the input HDF5 files. If this is checked, any HDF5 files that have already been processed will be skipped over, and their old output re-used.</p><p><br/></p><p>This should be unchecked if you change the parameters file or the normalization channel - otherwise, previously processed files will not be processed with the updated values.</p></body></html> - - - Skip already processed scans? - - - true - - - - - - - - - <html><head/><body><p>Filter out SIDs using numpy-like slice syntax. For example: <br/></p><p>157394:157413:3<br/></p><p>This would only allow every third number between 157394 (inclusive) and 157413 (exclusive) to be shown.<br/></p><p>The filtered out SIDs are hidden and won't be used in the next step.<br/></p><p>This also supports separate sets of comma-delimited slices, for example:<br/></p><p>157394:157413:3, 157420:157500:2</p></body></html> - - - Filter SIDs: - - - - - - - <html><head/><body><p>Filter out SIDs using numpy-like slice syntax. For example: <br/></p><p>157394:157413:3<br/></p><p>This would only allow every third number between 157394 (inclusive) and 157413 (exclusive) to be shown.<br/></p><p>The filtered out SIDs are hidden and won't be used in the next step.<br/></p><p>This also supports separate sets of comma-delimited slices, for example:<br/></p><p>157394:157413:3, 157420:157500:2</p></body></html> - - - - - - - <html><head/><body><p>Load a list of SIDs from a txt file. The txt file should be formatted so that on each row, the first number that occurs is the SID, like so:<br/></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383563 -90.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383565 -88.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383567 -86.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383569 -84.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383571 -82.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383573 -80.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383575 -78.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383577 -76.000</span></p><p><span style=" font-family:'Courier New','Courier','monospace','arial','sans-serif'; font-size:14px; color:#000000; background-color:#ffffff;">383579 -74.000</span><br/></p><p>The second column of angles is optional (the angles, if present, will be ignored). It is only necessary that the first column be the SID numbers.</p></body></html> - - - Load from txt - - - - - - - - - command - logFile - selectLogFile - filterSidsString - loadSidsFromTxt - logFileTable - pyxrfGUICommand - startPyXRFGUI - parametersFile - selectParametersFile - outputDirectory - selectOutputDirectory - icName - skipProcessed - rotateDatasets - - - - - buttonBox - rejected() - PyXRFProcessDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/tomviz/PyXRFRunner.cxx b/tomviz/PyXRFRunner.cxx index 3a4877cf1..9a3509935 100644 --- a/tomviz/PyXRFRunner.cxx +++ b/tomviz/PyXRFRunner.cxx @@ -4,92 +4,33 @@ #include "PyXRFRunner.h" #include "CameraReaction.h" -#include "DataSource.h" -#include "EmdFormat.h" #include "LoadDataReaction.h" -#include "ProgressDialog.h" -#include "PyXRFMakeHDF5Dialog.h" -#include "PyXRFProcessDialog.h" +#include "MainWindow.h" #include "PythonUtilities.h" -#include "SelectItemsDialog.h" #include "Utilities.h" +#include "pipeline/DeferredLinkInfo.h" +#include "pipeline/Node.h" +#include "pipeline/NodeEditDialog.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/sources/PythonSource.h" + #include -#include -#include +#include +#include #include #include -#include -#include - -#include -#include -#include -#include namespace tomviz { -QString processErrorToString(QProcess::ProcessError error) -{ - if (error == QProcess::FailedToStart) { - return "FailedToStart"; - } else if (error == QProcess::Crashed) { - return "Crashed"; - } else if (error == QProcess::Timedout) { - return "Timedout"; - } else if (error == QProcess::WriteError) { - return "WriteError"; - } else if (error == QProcess::ReadError) { - return "ReadError"; - } else if (error == QProcess::UnknownError) { - return "UnknownError"; - } - - return "UnhandledError"; -} - class PyXRFRunner::Internal : public QObject { public: QPointer parent; QPointer parentWidget; - QPointer makeHDF5Dialog; - QPointer processDialog; - QPointer progressDialog; - QProcess makeHDF5Process; - QProcess remakeCsvFileProcess; - QProcess processProjectionsProcess; - - // Python modules and functions - Python::Module pyxrfModule; - - // Options we will use as arguments to various pieces - - // General options - QString workingDirectory; - QString pyxrfUtilsCommand; - - // Make HDF5 options - int scanStart = 0; - int scanStop = 0; - bool successfulScansOnly = true; - bool remakeCsvFile = false; - QString logFile; - - // Process projection options - QString parametersFile; - QString icName; - QString outputDirectory; - bool skipProcessed = true; - double pixelSizeX = -1; - double pixelSizeY = -1; - bool rotateDatasets = true; - // Recon options - QStringList selectedElements; - - // Scan IDs from the process dialog - QVector scanIDs; + Python::Module pyxrfModule; bool autoLoadFinalData = true; @@ -97,43 +38,6 @@ class PyXRFRunner::Internal : public QObject { setParent(p); parentWidget = mainWidget(); - - progressDialog = new ProgressDialog(parentWidget); - progressDialog->setWindowTitle("Tomviz"); - progressDialog->showOutputWidget(true); - progressDialog->resize(progressDialog->width(), 500); - - setupConnections(); - } - - void setupConnections() - { - connect(&makeHDF5Process, &QProcess::finished, this, - &Internal::makeHDF5Finished); - connect(&makeHDF5Process, &QProcess::errorOccurred, this, - &Internal::makeHDF5ErrorOccurred); - connect(&makeHDF5Process, &QProcess::readyReadStandardOutput, this, - &Internal::_printProcStdout); - connect(&makeHDF5Process, &QProcess::readyReadStandardError, this, - &Internal::_printProcStderr); - - connect(&remakeCsvFileProcess, &QProcess::finished, this, - &Internal::remakeCsvFileFinished); - connect(&remakeCsvFileProcess, &QProcess::errorOccurred, this, - &Internal::remakeCsvFileErrorOccurred); - connect(&remakeCsvFileProcess, &QProcess::readyReadStandardOutput, this, - &Internal::_printProcStdout); - connect(&remakeCsvFileProcess, &QProcess::readyReadStandardError, this, - &Internal::_printProcStderr); - - connect(&processProjectionsProcess, &QProcess::finished, this, - &Internal::processProjectionsFinished); - connect(&processProjectionsProcess, &QProcess::errorOccurred, this, - &Internal::processProjectionsErrorOccurred); - connect(&processProjectionsProcess, &QProcess::readyReadStandardOutput, this, - &Internal::_printProcStdout); - connect(&processProjectionsProcess, &QProcess::readyReadStandardError, this, - &Internal::_printProcStderr); } void importModule() @@ -194,526 +98,56 @@ class PyXRFRunner::Internal : public QObject return res.toString(); } - void importFunctions() - { - importModule(); - } - - template - void clearWidget(QPointer d) - { - if (!d) { - return; - } - - d->hide(); - d->deleteLater(); - d.clear(); - } - - void clear() - { - clearWidget(makeHDF5Dialog); - clearWidget(processDialog); - } - void start() { - clear(); - importModule(); - showMakeHDF5Dialog(); - } - - void showMakeHDF5Dialog() - { - clearWidget(makeHDF5Dialog); - - makeHDF5Dialog = new PyXRFMakeHDF5Dialog(parentWidget); - connect(makeHDF5Dialog.data(), &QDialog::accepted, this, - &Internal::makeHDF5DialogAccepted); - makeHDF5Dialog->show(); - } - - void makeHDF5DialogAccepted() - { - // Gather the settings and decide what to do - pyxrfUtilsCommand = makeHDF5Dialog->command(); - workingDirectory = makeHDF5Dialog->workingDirectory(); - scanStart = makeHDF5Dialog->scanStart(); - scanStop = makeHDF5Dialog->scanStop(); - successfulScansOnly = makeHDF5Dialog->successfulScansOnly(); - remakeCsvFile = makeHDF5Dialog->remakeCsvFile(); - logFile = makeHDF5Dialog->logFile(); - - auto useAlreadyExistingData = makeHDF5Dialog->useAlreadyExistingData(); - if (useAlreadyExistingData) { - if (remakeCsvFile) { - runRemakeCsvFile(); - } else { - // Proceed to the next step - showProcessProjectionsDialog(); - } - } else { - runMakeHDF5(); - } - } - - void runMakeHDF5() - { - progressDialog->clearOutputWidget(); - progressDialog->setText("Generating HDF5 Files..."); - progressDialog->show(); - - QString program = pyxrfUtilsCommand; - QStringList args; - - args << "make-hdf5" << workingDirectory - << "-s" << QString::number(scanStart) - << "-e" << QString::number(scanStop) - << "-l" << logFile; - - if (successfulScansOnly) { - args.append("-b"); - } - - qInfo() << "Running:" << program + " " + args.join(" "); - makeHDF5Process.start(program, args); - } - - void makeHDF5Finished() - { - progressDialog->accept(); - - auto success = makeHDF5Process.exitStatus() == QProcess::NormalExit && - makeHDF5Process.exitCode() == 0; - if (!success) { - QString msg = "Make HDF5 failed"; - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - // Show the dialog again - showMakeHDF5Dialog(); - return; - } - - showProcessProjectionsDialog(); - } - - void makeHDF5ErrorOccurred(QProcess::ProcessError error) - { - progressDialog->accept(); - - auto errorMessage = makeHDF5Process.errorString(); - auto errorType = processErrorToString(error); - - QString msg = "Error running makeHDF5 (" + errorType + "): " + errorMessage; - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - - // Show the dialog again - showMakeHDF5Dialog(); - } - - void runRemakeCsvFile() - { - progressDialog->clearOutputWidget(); - progressDialog->setText("Remaking CSV file..."); - progressDialog->show(); - - QString program = pyxrfUtilsCommand; - QStringList args; - - QString rangeString = QString::number(scanStart) + - ":" + QString::number(scanStop + 1); - args << "make-csv" << "-i" - << "-w" << workingDirectory - << "-s" << rangeString - << logFile; - - qInfo() << "Running:" << program + " " + args.join(" "); - remakeCsvFileProcess.start(program, args); - } - - void remakeCsvFileFinished() - { - progressDialog->accept(); - - auto success = remakeCsvFileProcess.exitStatus() == QProcess::NormalExit && - remakeCsvFileProcess.exitCode() == 0; - if (!success) { - QString msg = "Remake CSV file failed"; - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - // Show the dialog again - showMakeHDF5Dialog(); - return; - } - - showProcessProjectionsDialog(); - } - - void remakeCsvFileErrorOccurred(QProcess::ProcessError error) - { - progressDialog->accept(); - - auto errorMessage = remakeCsvFileProcess.errorString(); - auto errorType = processErrorToString(error); - - QString msg = "Error running remake CSV (" + errorType + "): " + - errorMessage; - - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - - // Show the dialog again - showMakeHDF5Dialog(); - } - - void showProcessProjectionsDialog() - { - if (!validateWorkingDirectory()) { - // Go back to the make HDF5 dialog - showMakeHDF5Dialog(); - return; - } - - clearWidget(processDialog); - - processDialog = new PyXRFProcessDialog(workingDirectory, logFile, - parentWidget); - connect(processDialog.data(), &QDialog::accepted, this, - &Internal::processDialogAccepted); - // If the user rejects the process dialog, go back to - // the make HDF5 dialog. - connect(processDialog.data(), &QDialog::rejected, this, - &Internal::showMakeHDF5Dialog); - processDialog->show(); - } - - bool validateWorkingDirectory() - { - // Make sure there is at least one .h5 file inside - auto dataFiles = QDir(workingDirectory).entryList(QStringList("*.h5")); - if (dataFiles.isEmpty()) { - QString title = "Invalid Settings"; - auto msg = - QString("Working directory \"%1\" must contain at least one .h5 file") - .arg(workingDirectory); - QMessageBox::critical(parentWidget, title, msg); - } - return !dataFiles.isEmpty(); - } - - void processDialogAccepted() - { - // Pull out the options that were chosen - pyxrfUtilsCommand = processDialog->command(); - parametersFile = processDialog->parametersFile(); - logFile = processDialog->logFile(); - icName = processDialog->icName(); - outputDirectory = processDialog->outputDirectory(); - pixelSizeX = processDialog->pixelSizeX(); - pixelSizeY = processDialog->pixelSizeY(); - skipProcessed = processDialog->skipProcessed(); - rotateDatasets = processDialog->rotateDatasets(); - - // Store the selected scan IDs - scanIDs = processDialog->selectedScanIDs(); - - // Make sure the output directory exists - QDir().mkpath(outputDirectory); - - // Run process projections - runProcessProjections(); - } - - void runProcessProjections() - { - progressDialog->clearOutputWidget(); - progressDialog->setText("Processing projections..."); - progressDialog->show(); - - QString program = pyxrfUtilsCommand; - QStringList args; - - args << "process-projections" << workingDirectory - << "-p" << parametersFile - << "-l" << logFile - << "-i" << icName - << "-o" << outputDirectory; - - if (skipProcessed) { - args << "-s"; - } - - qInfo() << "Running:" << program + " " + args.join(" "); - processProjectionsProcess.start(program, args); - } - - void _printProcStdout() - { - auto* proc = qobject_cast(sender()); - if (!proc) { - return; - } - - auto output = proc->readAllStandardOutput(); - if (output.size() == 0) { - return; - } - - // Remove the ending newline because qInfo() will add one - if (output.endsWith("\r\n")) { - output.chop(2); - } else if (output.endsWith('\n')) { - output.chop(1); - } - qInfo() << output.constData(); - } - - void _printProcStderr() - { - auto* proc = qobject_cast(sender()); - if (!proc) { - return; - } - - auto output = proc->readAllStandardError(); - if (output.size() == 0) { - return; - } - - // Remove the ending newline because qWarning() will add one - if (output.endsWith("\r\n")) { - output.chop(2); - } else if (output.endsWith('\n')) { - output.chop(1); - } - qWarning() << output.constData(); - } - - void processProjectionsFinished() - { - progressDialog->accept(); - - auto success = processProjectionsProcess.exitStatus() == QProcess::NormalExit && - processProjectionsProcess.exitCode() == 0; - if (!success) { - QString msg = QString("Process projections failed (exit code %1)") - .arg(processProjectionsProcess.exitCode()); - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - // Show the dialog again - showProcessProjectionsDialog(); + if (!isInstalled()) { + QMessageBox::critical(parentWidget, "PyXRF is unavailable", + "Failed to import required modules. " + "Error message was:\n\n" + + importError()); return; } - if (!validateOutputDirectory()) { - // Show the dialog again - showProcessProjectionsDialog(); + auto* mainWindow = MainWindow::instance(); + auto* pip = mainWindow ? mainWindow->pipeline() : nullptr; + if (!pip) { return; } - selectElements(); - } - - void processProjectionsErrorOccurred(QProcess::ProcessError error) - { - progressDialog->accept(); - - auto errorMessage = processProjectionsProcess.errorString(); - auto errorType = processErrorToString(error); - - QString msg = "Error running process projections (" + errorType + "): " + - errorMessage; - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - - // Show the dialog again - showProcessProjectionsDialog(); - } - - bool validateOutputDirectory() - { - if (!QDir(outputDirectory).exists("tomo.h5")) { - auto msg = - QString("Output \"tomo.h5\" file not found in output directory \"%1\"") - .arg(outputDirectory); - QMessageBox::critical(parentWidget, "Tomviz", msg); - return false; - } - - return true; - } - - QString outputFile() { return QDir(outputDirectory).filePath("tomo.h5"); } - - QStringList outputVolumes() - { - QStringList ret; - - Python python; + auto* source = new pipeline::PythonSource(); + source->setJSONDescription(readInJSONDescription("PyXRFSource")); + source->setScript(readInPythonScript("PyXRFSource")); - auto listElementsFunc = pyxrfModule.findFunction("list_elements"); - if (!listElementsFunc.isValid()) { - qCritical() << "Failed to import \"tomviz.pyxrf.list_elements\""; - return ret; - } + LoadDataReaction::addSourceToPipeline(source); - Python::Dict kwargs; - kwargs.set("filename", outputFile()); - auto res = listElementsFunc.call(kwargs); + pipeline::DeferredLinkInfo deferred; + auto* dialog = + new pipeline::NodeEditDialog(source, pip, deferred, mainWindow); + dialog->setAttribute(Qt::WA_DeleteOnClose); - if (!res.isValid()) { - qCritical("Error calling tomviz.pyxrf.list_elements"); - return ret; + QString title = QJsonDocument::fromJson( + source->jsonDescription().toUtf8()) + .object() + .value(QStringLiteral("label")) + .toString(); + if (title.isEmpty()) { + title = source->label(); } + dialog->setWindowTitle(QString("Generate - %1").arg(title)); - for (auto& item : res.toVariant().toList()) { - ret.append(item.toString().c_str()); - } + QObject::connect( + dialog, &pipeline::NodeEditDialog::insertionCompleted, dialog, + [this](pipeline::Node* node) { + if (auto* src = qobject_cast(node)) { + if (autoLoadFinalData) { + LoadDataReaction::completeSourceSetup(src); + } + } + CameraReaction::resetPositiveZ(); + CameraReaction::rotateCamera(-90); + }); - return ret; - } - - void selectElements() - { - auto options = outputVolumes(); - - // By default, select all items that start with a capital letter and - // contain a hyphen. - QList defaultSelections; - for (auto& item : options) { - bool select = item.contains("_") && item[0].isUpper(); - defaultSelections.append(select); - } - - SelectItemsDialog dialog(options, parentWidget); - dialog.setWindowTitle("Select elements to extract"); - dialog.setSelections(defaultSelections); - - selectedElements.clear(); - while (true) { - if (!dialog.exec()) { - return; - } - - if (!dialog.selectedItems().isEmpty()) { - break; - } - - QString msg = "At least one element must be selected"; - qCritical() << msg; - QMessageBox::critical(parentWidget, "Tomviz", msg); - } - - selectedElements = dialog.selectedItems(); - extractSelectedElements(); - } - - void extractSelectedElements() - { - Python python; - - auto extractElementsFunc = pyxrfModule.findFunction("extract_elements"); - if (!extractElementsFunc.isValid()) { - qCritical() << "Failed to import \"tomviz.pyxrf.extract_elements\""; - return; - } - - std::vector variantList; - for (auto& element : selectedElements) { - variantList.push_back(element.toStdString()); - } - - QString outputPath = QDir(outputDirectory).filePath("extracted_elements"); - Python::Dict kwargs; - kwargs.set("filename", outputFile()); - kwargs.set("elements", variantList); - kwargs.set("output_path", outputPath); - kwargs.set("rotate_datasets", rotateDatasets); - kwargs.set("pixel_size_x", pixelSizeX); - kwargs.set("pixel_size_y", pixelSizeY); - auto res = extractElementsFunc.call(kwargs); - - if (!res.isValid()) { - qCritical("Error calling tomviz.pyxrf.extract_elements"); - return; - } - - QStringList ret; - for (auto& item : res.toVariant().toList()) { - ret.append(item.toString().c_str()); - } - - if (ret.size() == 0) { - qCritical("No elements were extracted"); - return; - } - - if (autoLoadFinalData) { - loadElementsIntoArray(ret); - QString title = "Element extraction complete"; - auto text = - QString("Elements were extracted to \"%1\" and loaded into Tomviz") - .arg(outputPath); - QMessageBox::information(parentWidget, title, text); - } - } - - void loadElementsIntoArray(const QStringList& fileList) { - // Load the first file into a DataSource - auto* dataSource = LoadDataReaction::loadData(fileList[0]); - if (!dataSource || !dataSource->imageData()) { - qCritical() << "Failed to load file:" << fileList[0]; - return; - } - - auto* rootImageData = dataSource->imageData(); - auto* rootPointData = rootImageData->GetPointData(); - auto newRootName = QFileInfo(fileList[0]).baseName(); - rootPointData->GetScalars()->SetName(newRootName.toStdString().c_str()); - - // The other files should have identical metadata. We'll just load - // the image data for those, and add them to the point data. - EmdFormat format; - for (int i = 1; i < fileList.size(); ++i) { - vtkNew imageData; - format.read(fileList[i].toStdString(), imageData); - if (!imageData || !imageData->GetPointData()->GetScalars()) { - qCritical() << "Failed to read image data for file:" << fileList[i]; - continue; - } - - auto* scalars = imageData->GetPointData()->GetScalars(); - auto newName = QFileInfo(fileList[i]).baseName(); - scalars->SetName(newName.toStdString().c_str()); - - // Add the array to the root image data - rootPointData->AddArray(scalars); - } - - // Sort the list, and make the first one alphabetically be selected - auto sortedList = fileList; - sortedList.sort(); - auto firstName = QFileInfo(sortedList[0]).baseName(); - - dataSource->setActiveScalars(firstName.toStdString().c_str()); - dataSource->setLabel("Extracted Elements"); - - if (!scanIDs.isEmpty()) { - dataSource->setScanIDs(scanIDs); - } - - dataSource->dataModified(); - - // Write this to an EMD format - QString saveFile = QFileInfo(sortedList[0]).dir().absoluteFilePath("extracted_elements.emd"); - EmdFormat::write(saveFile.toStdString(), dataSource); - dataSource->setFileName(saveFile); - - // Automatically update camera to BNL convention - CameraReaction::resetPositiveZ(); - CameraReaction::rotateCamera(-90); + dialog->show(); } }; @@ -722,23 +156,7 @@ PyXRFRunner::PyXRFRunner(QObject* parent) { } -PyXRFRunner::~PyXRFRunner() -{ - // Terminate any running processes to avoid SEGFAULT from QProcess - // being destroyed while still running. - QProcess* processes[] = { &m_internal->makeHDF5Process, - &m_internal->remakeCsvFileProcess, - &m_internal->processProjectionsProcess }; - for (auto* p : processes) { - if (p->state() != QProcess::NotRunning) { - p->terminate(); - if (!p->waitForFinished(5000)) { - p->kill(); - p->waitForFinished(3000); - } - } - } -} +PyXRFRunner::~PyXRFRunner() = default; bool PyXRFRunner::isInstalled() { diff --git a/tomviz/PyXRFRunner.h b/tomviz/PyXRFRunner.h index 0d900ac56..fd87eabf1 100644 --- a/tomviz/PyXRFRunner.h +++ b/tomviz/PyXRFRunner.h @@ -4,7 +4,7 @@ #ifndef tomvizPyXRFRunner_h #define tomvizPyXRFRunner_h -#include +#include #include namespace tomviz { diff --git a/tomviz/PyXRFWidget.cxx b/tomviz/PyXRFWidget.cxx new file mode 100644 index 000000000..d98dfea86 --- /dev/null +++ b/tomviz/PyXRFWidget.cxx @@ -0,0 +1,838 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PyXRFWidget.h" +#include "ui_PyXRFWidget.h" + +#include "PythonUtilities.h" +#include "Utilities.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool executableExists(const QString& command) +{ + if (command.isEmpty()) { + return false; + } + if (command.contains('/') || command.contains(QDir::separator())) { + QFileInfo info(command); + return info.isFile() && info.isExecutable(); + } + return !QStandardPaths::findExecutable(command).isEmpty(); +} + +QString findPyxrfUtilsCommand(const QString& savedCommand) +{ + if (executableExists(savedCommand)) { + return savedCommand; + } + + const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; + for (const auto& candidate : candidates) { + if (executableExists(candidate)) { + return candidate; + } + } + + const QString absoluteFallback = + "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" + "tomviz-latest-wip/bin/run-pyxrf-utils"; + if (executableExists(absoluteFallback)) { + return absoluteFallback; + } + + return ""; +} + +} // anonymous namespace + +namespace tomviz { + +class PyXRFWidget::Internal : public QObject +{ +public: + Ui::PyXRFWidget ui; + QPointer parent; + + bool pyxrfIsRunning = false; + + struct ScanEntry + { + int scanId; + double theta; + QString status; + bool use; + }; + QList scanEntries; + + Python::Module pyxrfModule; + + Internal(PyXRFWidget* p) : parent(p) + { + ui.setupUi(p); + setParent(p); + + setupTableColumns(); + setupConnections(); + } + + void setupConnections() + { + connect(ui.selectWorkingDirectory, &QPushButton::clicked, this, + &Internal::selectWorkingDirectory); + connect(ui.selectParametersFile, &QPushButton::clicked, this, + &Internal::selectParametersFile); + connect(ui.selectCsvOutput, &QPushButton::clicked, this, + &Internal::selectCsvOutput); + + connect(ui.skipDownloads, &QCheckBox::toggled, this, + [this](bool checked) { + ui.redownloadSuccessful->setEnabled(!checked); + ui.downloadData->setEnabled(!checked); + }); + + connect(ui.downloadData, &QPushButton::clicked, this, + &Internal::onDownloadData); + + connect(ui.loadSidsFromTxtOrCSV, &QPushButton::clicked, this, + &Internal::onLoadSidsFromTxt); + connect(ui.applyFilter, &QPushButton::clicked, this, + &Internal::applyFilter); + + connect(ui.startPyXRFGUI, &QPushButton::clicked, this, + &Internal::startPyXRFGUI); + + connect(ui.workingDirectory, &QLineEdit::editingFinished, this, + &Internal::onDirectoryOrRangeChanged); + connect(ui.scanRange, &QLineEdit::editingFinished, this, + &Internal::onDirectoryOrRangeChanged); + } + + void setupTableColumns() + { + auto* table = ui.scanTable; + table->setColumnCount(4); + table->setHorizontalHeaderItem(0, new QTableWidgetItem("Scan ID")); + table->setHorizontalHeaderItem(1, new QTableWidgetItem("Theta")); + table->setHorizontalHeaderItem(2, new QTableWidgetItem("Status")); + table->setHorizontalHeaderItem(3, new QTableWidgetItem("Use")); + } + + void setupComboBoxes() + { + ui.icName->clear(); + auto names = icNames(); + if (names.isEmpty()) { + names.append("sclr1_ch4"); + } + ui.icName->addItems(names); + int idx = ui.icName->findText("sclr1_ch4"); + if (idx >= 0) { + ui.icName->setCurrentIndex(idx); + } + } + + void importModule() + { + Python python; + if (pyxrfModule.isValid()) { + return; + } + pyxrfModule = python.import("tomviz.pyxrf"); + if (!pyxrfModule.isValid()) { + qCritical() << "Failed to import \"tomviz.pyxrf\" module"; + } + } + + // --- Accessors --- + + QString command() const { return ui.command->text(); } + void setCommand(const QString& s) { ui.command->setText(s); } + + QString workingDirectory() const { return ui.workingDirectory->text(); } + void setWorkingDirectory(const QString& s) + { + ui.workingDirectory->setText(s); + } + + QString scanRange() const { return ui.scanRange->text().trimmed(); } + void setScanRange(const QString& s) { ui.scanRange->setText(s); } + + bool skipDownloads() const { return ui.skipDownloads->isChecked(); } + void setSkipDownloads(bool b) { ui.skipDownloads->setChecked(b); } + + bool redownloadSuccessful() const { return ui.redownloadSuccessful->isChecked(); } + void setRedownloadSuccessful(bool b) { ui.redownloadSuccessful->setChecked(b); } + + QString parametersFile() const { return ui.parametersFile->text(); } + void setParametersFile(const QString& s) { ui.parametersFile->setText(s); } + + QString icName() const { return ui.icName->currentText(); } + void setIcName(const QString& s) { ui.icName->setCurrentText(s); } + + bool skipProcessed() const { return ui.skipProcessed->isChecked(); } + void setSkipProcessed(bool b) { ui.skipProcessed->setChecked(b); } + + bool rotateDatasets() const { return ui.rotateDatasets->isChecked(); } + void setRotateDatasets(bool b) { ui.rotateDatasets->setChecked(b); } + + QString csvOutput() const { return ui.csvOutput->text().trimmed(); } + void setCsvOutput(const QString& s) { ui.csvOutput->setText(s); } + + QString pyxrfGUICommand() const { return ui.pyxrfGUICommand->text(); } + void setPyxrfGUICommand(const QString& s) + { + ui.pyxrfGUICommand->setText(s); + } + + QString skipScanIds() const + { + QJsonArray arr; + for (const auto& entry : scanEntries) { + if (!entry.use) { + arr.append(entry.scanId); + } + } + return QString::fromUtf8(QJsonDocument(arr).toJson(QJsonDocument::Compact)); + } + + // --- Helpers --- + + QString defaultWorkingDirectory() const + { + return QDir::home().filePath("data"); + } + + // --- UI Slots --- + + void selectWorkingDirectory() + { + auto dir = QFileDialog::getExistingDirectory( + parent.data(), "Select data directory", workingDirectory()); + if (!dir.isEmpty()) { + setWorkingDirectory(dir); + onDirectoryOrRangeChanged(); + } + } + + void onDirectoryOrRangeChanged() + { + populateScanTable(); + setupComboBoxes(); + autoApplyFilter(); + } + + void selectParametersFile() + { + auto startPath = + parametersFile().isEmpty() ? workingDirectory() : parametersFile(); + auto file = QFileDialog::getOpenFileName( + parent.data(), "Select parameters file", startPath, "*.json"); + if (!file.isEmpty()) { + setParametersFile(file); + } + } + + void selectCsvOutput() + { + auto startPath = + csvOutput().isEmpty() ? workingDirectory() : csvOutput(); + auto file = QFileDialog::getSaveFileName( + parent.data(), "Select output CSV file", startPath, + "CSV Files (*.csv)"); + if (!file.isEmpty()) { + setCsvOutput(file); + } + } + + // --- Download & Table --- + + void onDownloadData() + { + auto range = scanRange(); + if (range.isEmpty()) { + QMessageBox::warning(parent.data(), "Missing Scan Range", + "Please enter a scan range before downloading."); + return; + } + + auto cmd = command(); + if (!executableExists(cmd)) { + QMessageBox::critical( + parent.data(), "Command Not Found", + QString("The pyxrf-utils executable \"%1\" was not found.") + .arg(cmd.isEmpty() ? QString("(empty)") : cmd)); + return; + } + + QStringList args = { "make-hdf5", workingDirectory(), "--range", range }; + if (redownloadSuccessful()) { + args.append("--force"); + } + + auto* process = new QProcess(this); + process->setProcessChannelMode(QProcess::ForwardedChannels); + ui.downloadData->setEnabled(false); + ui.downloadData->setText("Downloading..."); + + connect(process, + QOverload::of(&QProcess::finished), this, + [this, process](int exitCode, QProcess::ExitStatus) { + ui.downloadData->setEnabled(true); + ui.downloadData->setText("Download Data"); + process->deleteLater(); + if (exitCode == 0) { + populateScanTable(); + setupComboBoxes(); + } else { + QMessageBox::warning(parent.data(), "Download Failed", + "pyxrf-utils make-hdf5 failed. " + "Check the terminal output for details."); + } + }); + + connect(process, &QProcess::errorOccurred, this, + [this, process](QProcess::ProcessError) { + ui.downloadData->setEnabled(true); + ui.downloadData->setText("Download Data"); + QMessageBox::critical( + parent.data(), "Download Failed", + QString("Failed to start \"%1\"").arg(process->program())); + process->deleteLater(); + }); + + process->start(cmd, args); + } + + void populateScanTable() + { + scanEntries.clear(); + + auto wd = workingDirectory(); + if (wd.isEmpty() || !QDir(wd).exists()) { + rebuildTableUI(); + return; + } + + importModule(); + + Python python; + auto func = pyxrfModule.findFunction("read_scan_metadata"); + if (!func.isValid()) { + qCritical() << "Failed to find tomviz.pyxrf.read_scan_metadata"; + rebuildTableUI(); + return; + } + + Python::Dict kwargs; + kwargs.set("working_directory", wd); + kwargs.set("scan_range", scanRange()); + auto res = func.call(kwargs); + if (!res.isValid() || !res.isList()) { + rebuildTableUI(); + return; + } + + auto resList = res.toList(); + for (int i = 0; i < resList.length(); ++i) { + auto item = resList[i]; + if (!item.isDict()) { + continue; + } + + auto dict = item.toDict(); + int scanId = static_cast(dict["scan_id"].toLong()); + double theta = dict["theta"].toDouble(); + QString status = dict["status"].toString(); + ScanEntry entry; + entry.scanId = scanId; + entry.theta = theta; + entry.status = status; + entry.use = (status != "fail" && status != "missing"); + scanEntries.append(entry); + } + + rebuildTableUI(); + } + + void rebuildTableUI() + { + auto* table = ui.scanTable; + table->clearContents(); + table->setRowCount(scanEntries.size()); + + for (int i = 0; i < scanEntries.size(); ++i) { + const auto& entry = scanEntries[i]; + bool failed = (entry.status == "fail"); + + auto* idItem = new QTableWidgetItem(QString::number(entry.scanId)); + idItem->setTextAlignment(Qt::AlignCenter); + table->setItem(i, 0, idItem); + + auto* thetaItem = new QTableWidgetItem( + (failed || entry.status == "missing") + ? QString("-") + : QString::number(entry.theta, 'f', 3)); + thetaItem->setTextAlignment(Qt::AlignCenter); + table->setItem(i, 1, thetaItem); + + auto* statusItem = new QTableWidgetItem(entry.status); + statusItem->setTextAlignment(Qt::AlignCenter); + table->setItem(i, 2, statusItem); + + auto* cb = new QCheckBox(parent); + cb->setChecked(entry.use); + cb->setEnabled(!failed); + connect(cb, &QCheckBox::toggled, this, [this, i](bool b) { + if (i < scanEntries.size()) { + scanEntries[i].use = b; + } + }); + + auto* tw = new QWidget(table); + auto* layout = new QHBoxLayout(tw); + layout->addWidget(cb); + layout->setAlignment(Qt::AlignCenter); + layout->setContentsMargins(0, 0, 0, 0); + table->setCellWidget(i, 3, tw); + } + } + + // --- Filter SIDs --- + + void onLoadSidsFromTxt() + { + auto filePath = QFileDialog::getOpenFileName( + parent.data(), "Select txt or csv file", workingDirectory(), + "Text/CSV Files (*.txt *.csv)"); + if (filePath.isEmpty()) { + return; + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << "Failed to open file:" << filePath; + return; + } + + QTextStream reader(&file); + + if (filePath.endsWith(".csv", Qt::CaseInsensitive)) { + loadSidsFromCsv(reader); + } else { + loadSidsFromTxt(reader); + } + } + + void loadSidsFromTxt(QTextStream& reader) + { + QStringList sids; + while (!reader.atEnd()) { + auto line = reader.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + sids.append(line.split(' ')[0]); + } + + ui.filterSidsString->setText(sids.join(", ")); + } + + void loadSidsFromCsv(QTextStream& reader) + { + // Read header to find column indices + auto header = reader.readLine().trimmed(); + auto columns = header.split(','); + for (auto& col : columns) { + col = col.trimmed(); + } + + int sidCol = columns.indexOf("Scan ID"); + if (sidCol < 0) { + sidCol = columns.indexOf("Scan_ID"); + } + if (sidCol < 0) { + qCritical() << "CSV file has no \"Scan ID\" column"; + return; + } + + int useCol = columns.indexOf("Use"); + if (useCol < 0) { + useCol = columns.indexOf("use"); + } + + QStringList sids; + QList sidInts; + QList useFlags; + while (!reader.atEnd()) { + auto line = reader.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#')) { + continue; + } + auto fields = line.split(','); + if (sidCol >= fields.size()) { + continue; + } + auto sidStr = fields[sidCol].trimmed(); + sids.append(sidStr); + sidInts.append(sidStr.toInt()); + + if (useCol >= 0 && useCol < fields.size()) { + auto val = fields[useCol].trimmed(); + useFlags.append(val == "1" || val.toLower() == "x"); + } + } + + ui.filterSidsString->setText(sids.join(", ")); + + // If the CSV has a "Use" column, apply it to the scan table + if (!useFlags.isEmpty()) { + for (int i = 0; i < sidInts.size() && i < useFlags.size(); ++i) { + for (auto& entry : scanEntries) { + if (entry.scanId == sidInts[i]) { + entry.use = useFlags[i]; + break; + } + } + } + rebuildTableUI(); + } + } + + void applyFilter() + { + auto filterString = ui.filterSidsString->text().trimmed(); + if (filterString.isEmpty() || scanEntries.isEmpty()) { + return; + } + + importModule(); + + Python python; + auto func = pyxrfModule.findFunction("filter_sids"); + if (!func.isValid()) { + qCritical() << "Failed to find tomviz.pyxrf.filter_sids"; + return; + } + + QStringList allSids; + for (const auto& entry : scanEntries) { + if (entry.status != "fail") { + allSids.append(QString::number(entry.scanId)); + } + } + + Python::Dict kwargs; + kwargs.set("all_sids", allSids); + kwargs.set("filter_string", filterString); + auto res = func.call(kwargs); + + if (!res.isValid() || !res.isList()) { + qCritical() << "Error calling tomviz.pyxrf.filter_sids"; + return; + } + + QSet matchedIds; + auto resList = res.toList(); + for (int i = 0; i < resList.length(); ++i) { + matchedIds.insert(resList[i].toString().toInt()); + } + + for (int i = 0; i < scanEntries.size(); ++i) { + auto& entry = scanEntries[i]; + if (entry.status == "fail" || entry.status == "missing") { + continue; + } + entry.use = matchedIds.contains(entry.scanId); + } + + rebuildTableUI(); + } + + void autoApplyFilter() + { + if (!ui.filterSidsString->text().trimmed().isEmpty() && + !scanEntries.isEmpty()) { + applyFilter(); + } + } + + // --- IC Names --- + + QStringList icNames() + { + QStringList ret; + importModule(); + + Python python; + auto func = pyxrfModule.findFunction("ic_names"); + if (!func.isValid()) { + return ret; + } + + Python::Dict kwargs; + kwargs.set("working_directory", workingDirectory()); + kwargs.set("scan_range", scanRange()); + auto res = func.call(kwargs); + + if (!res.isValid()) { + return ret; + } + + for (auto& item : res.toVariant().toList()) { + ret.append(item.toString().c_str()); + } + return ret; + } + + // --- PyXRF GUI --- + + void startPyXRFGUI() + { + if (pyxrfIsRunning) { + return; + } + + QString program = pyxrfGUICommand(); + auto environment = QProcessEnvironment::systemEnvironment(); + if (environment.contains("TOMVIZ_PYXRF_EXECUTABLE")) { + program = environment.value("TOMVIZ_PYXRF_EXECUTABLE"); + } + + auto* process = new QProcess(this); + process->setProcessChannelMode(QProcess::ForwardedChannels); + process->start(program, QStringList()); + + pyxrfIsRunning = true; + + connect(process, + QOverload::of(&QProcess::finished), this, + [this]() { pyxrfIsRunning = false; }); + + connect(process, &QProcess::errorOccurred, this, + [this, process](QProcess::ProcessError err) { + pyxrfIsRunning = false; + QString title; + QString msg; + if (err == QProcess::FailedToStart) { + title = "PyXRF failed to start"; + msg = QString("The program \"%1\" failed to start.\n\n") + .arg(process->program()) + + "Set the \"PyXRF GUI Command\" field to the full path " + "of the PyXRF executable and try again."; + } else { + title = "PyXRF exited with an error"; + msg = process->readAllStandardError(); + } + QMessageBox::critical(parent.data(), title, msg); + }); + } + + bool validate(QString& reason) + { + auto workingDir = workingDirectory(); + + if (workingDir.isEmpty() || !QDir(workingDir).exists()) { + reason = "Data directory does not exist: " + workingDir; + return false; + } + + if (scanRange().isEmpty()) { + reason = "Scan range is required."; + return false; + } + + // Make paths absolute + if (!QFileInfo(parametersFile()).isAbsolute() && + !parametersFile().isEmpty()) { + setParametersFile(QDir(workingDir).filePath(parametersFile())); + } + + if (parametersFile().isEmpty() || !QFile::exists(parametersFile())) { + reason = "Parameters file does not exist: " + parametersFile(); + return false; + } + + auto cmd = command(); + if (!executableExists(cmd)) { + reason = QString("The pyxrf-utils executable \"%1\" was not found.") + .arg(cmd.isEmpty() ? QString("(empty)") : cmd); + return false; + } + + return true; + } + + // --- Settings --- + + void readSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("pyxrf"); + + auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); + setCommand(findPyxrfUtilsCommand(savedCommand)); + + setWorkingDirectory( + settings->value("workingDirectory", defaultWorkingDirectory()) + .toString()); + setScanRange(settings->value("scanRange", "").toString()); + setSkipDownloads(settings->value("skipDownloads", false).toBool()); + setRedownloadSuccessful( + settings->value("redownloadSuccessful", false).toBool()); + ui.filterSidsString->setText( + settings->value("filterSidsString", "").toString()); + + settings->beginGroup("process"); + setPyxrfGUICommand( + settings->value("pyxrfGUICommand", "pyxrf").toString()); + setParametersFile(settings->value("parametersFile", "").toString()); + setCsvOutput(settings->value("csvOutput", "").toString()); + setupComboBoxes(); + setIcName(settings->value("icName", "sclr1_ch4").toString()); + setSkipProcessed(settings->value("skipProcessed", true).toBool()); + setRotateDatasets(settings->value("rotateDatasets", true).toBool()); + settings->endGroup(); + + settings->endGroup(); + + populateScanTable(); + autoApplyFilter(); + } + + void writeSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("pyxrf"); + + settings->setValue("pyxrfUtilsCommand", command()); + settings->setValue("workingDirectory", workingDirectory()); + settings->setValue("scanRange", scanRange()); + settings->setValue("skipDownloads", skipDownloads()); + settings->setValue("redownloadSuccessful", redownloadSuccessful()); + settings->setValue("filterSidsString", + ui.filterSidsString->text().trimmed()); + + settings->beginGroup("process"); + settings->setValue("pyxrfGUICommand", pyxrfGUICommand()); + settings->setValue("parametersFile", parametersFile()); + settings->setValue("csvOutput", csvOutput()); + settings->setValue("icName", icName()); + settings->setValue("skipProcessed", skipProcessed()); + settings->setValue("rotateDatasets", rotateDatasets()); + settings->endGroup(); + + settings->endGroup(); + } + +}; + +PyXRFWidget::PyXRFWidget( + const QMap& /*inputs*/, QWidget* p) + : pipeline::CustomPythonNodeWidget(p), m_internal(new Internal(this)) +{ +} + +PyXRFWidget::~PyXRFWidget() = default; + +void PyXRFWidget::getValues(QMap& map) +{ + map.insert("pyxrf_utils_command", m_internal->command()); + map.insert("working_directory", m_internal->workingDirectory()); + map.insert("scan_range", m_internal->scanRange()); + map.insert("skip_scan_ids", m_internal->skipScanIds()); + map.insert("skip_downloads", m_internal->skipDownloads()); + map.insert("redownload_successful", m_internal->redownloadSuccessful()); + map.insert("parameters_file", m_internal->parametersFile()); + map.insert("ic_name", m_internal->icName()); + map.insert("skip_processed", m_internal->skipProcessed()); + map.insert("rotate_datasets", m_internal->rotateDatasets()); + map.insert("csv_output", m_internal->csvOutput()); + + QJsonObject uiState; + uiState["filter_sids_string"] = + m_internal->ui.filterSidsString->text().trimmed(); + map.insert("ui_state", QString::fromUtf8( + QJsonDocument(uiState).toJson(QJsonDocument::Compact))); +} + +void PyXRFWidget::setValues(const QMap& map) +{ + auto wd = map.value("working_directory").toString(); + if (wd.isEmpty()) { + m_internal->readSettings(); + return; + } + + { + QSignalBlocker b1(m_internal->ui.workingDirectory); + QSignalBlocker b2(m_internal->ui.scanRange); + + m_internal->setCommand( + map.value("pyxrf_utils_command", "pyxrf-utils").toString()); + m_internal->setWorkingDirectory(wd); + m_internal->setScanRange(map.value("scan_range").toString()); + m_internal->setSkipDownloads(map.value("skip_downloads").toBool()); + m_internal->setRedownloadSuccessful( + map.value("redownload_successful").toBool()); + m_internal->setParametersFile(map.value("parameters_file").toString()); + m_internal->setSkipProcessed( + map.value("skip_processed", true).toBool()); + m_internal->setRotateDatasets( + map.value("rotate_datasets", true).toBool()); + m_internal->setCsvOutput(map.value("csv_output").toString()); + } + + m_internal->populateScanTable(); + + auto skipJson = map.value("skip_scan_ids", "[]").toString(); + auto skipArr = QJsonDocument::fromJson(skipJson.toUtf8()).array(); + QSet skipIds; + for (const auto& v : skipArr) { + skipIds.insert(v.toInt()); + } + for (auto& entry : m_internal->scanEntries) { + if (skipIds.contains(entry.scanId)) { + entry.use = false; + } + } + m_internal->rebuildTableUI(); + + m_internal->setupComboBoxes(); + m_internal->setIcName(map.value("ic_name", "sclr1_ch4").toString()); + + auto uiStateJson = map.value("ui_state").toString(); + if (!uiStateJson.isEmpty()) { + auto uiState = QJsonDocument::fromJson(uiStateJson.toUtf8()).object(); + m_internal->ui.filterSidsString->setText( + uiState.value("filter_sids_string").toString()); + } +} + +void PyXRFWidget::writeSettings() +{ + m_internal->writeSettings(); +} + +} // namespace tomviz diff --git a/tomviz/PyXRFWidget.h b/tomviz/PyXRFWidget.h new file mode 100644 index 000000000..b73a35995 --- /dev/null +++ b/tomviz/PyXRFWidget.h @@ -0,0 +1,36 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPyXRFWidget_h +#define tomvizPyXRFWidget_h + +#include "CustomPythonNodeWidget.h" +#include "PortData.h" + +#include +#include +#include + +namespace tomviz { + +class PyXRFWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + PyXRFWidget(const QMap& inputs, + QWidget* parent = nullptr); + ~PyXRFWidget() override; + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + void writeSettings() override; + +private: + class Internal; + QScopedPointer m_internal; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/PyXRFWidget.ui b/tomviz/PyXRFWidget.ui new file mode 100644 index 000000000..cb8c0a100 --- /dev/null +++ b/tomviz/PyXRFWidget.ui @@ -0,0 +1,259 @@ + + + PyXRFWidget + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + + + Data + + + + + + PyXRF Utils Command: + + + + + + + pyxrf-utils + + + + + + + Data Directory: + + + workingDirectory + + + + + + + + + + Select + + + + + + + Scan Range: + + + scanRange + + + + + + + start:stop or start:stop:stride + + + + + + + Skip downloads + + + + + + + Re-download successful scans? + + + + + + + Download Data + + + + + + + + + Filter SIDs: + + + + + + + e.g. 100:200, 205, 210:220 + + + + + + + Apply + + + + + + + Load from txt/csv + + + + + + + + + QAbstractItemView::NoEditTriggers + + + QAbstractItemView::NoSelection + + + true + + + + + + + + + + + + Process Projections + + + + + + Parameters File: + + + parametersFile + + + + + + + + + + Select + + + + + + + Normalization Channel: + + + icName + + + + + + + + + + Output CSV File: + + + csvOutput + + + + + + + (optional) + + + + + + + Select + + + + + + + Skip already processed scans? + + + true + + + + + + + Rotate datasets to Tomviz convention? + + + true + + + + + + + PyXRF GUI Command: + + + + + + + pyxrf + + + + + + + Start PyXRF GUI + + + + + + + + + + + diff --git a/tomviz/PythonGeneratedDatasetReaction.cxx b/tomviz/PythonGeneratedDatasetReaction.cxx deleted file mode 100644 index 8f17cf096..000000000 --- a/tomviz/PythonGeneratedDatasetReaction.cxx +++ /dev/null @@ -1,544 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PythonGeneratedDatasetReaction.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "LoadDataReaction.h" -#include "ModuleManager.h" -#include "PythonUtilities.h" -#include "Utilities.h" -#include "core/Variant.h" - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace { - -class PythonGeneratedDataSource : public QObject -{ - Q_OBJECT - -public: - PythonGeneratedDataSource(const QString& l, QObject* p = nullptr) - : QObject(p), m_label(l) - {} - - void setScript(const QString& script) - { - tomviz::Python::initialize(); - - { - tomviz::Python python; - m_operatorModule = python.import("tomviz.internal_utils"); - if (!m_operatorModule.isValid()) { - qCritical() << "Failed to import tomviz.utils module."; - } - - // Don't let these be the same, even for similar scripts. Seems to - // cause python crashes. - QString moduleName = - QString("tomviz_%1%2").arg(m_label).arg(s_numberOfScripts++); - - tomviz::Python::Module module = - python.import(script, m_label, moduleName); - if (!module.isValid()) { - qCritical() << "Failed to create module."; - return; - } - - m_generateFunction = module.findFunction("generate_dataset"); - if (!m_generateFunction.isValid()) { - qCritical() << "Script does not have a 'generate_dataset' function."; - return; - } - - m_makeDatasetFunction = m_operatorModule.findFunction("make_dataset"); - if (!m_makeDatasetFunction.isValid()) { - qCritical() << "Could not find make_dataset function in tomviz.utils"; - return; - } - } - - m_pythonScript = script; - } - - tomviz::DataSource* createDataSource(const int shape[3]) - { - vtkNew image; - - { - tomviz::Python python; - - tomviz::Python::Object result; - tomviz::Python::Tuple args(5); - - args.set(0, shape[0]); - args.set(1, shape[1]); - args.set(2, shape[2]); - tomviz::Python::Object imageData = - tomviz::Python::VTK::GetObjectFromPointer(image); - args.set(3, imageData); - args.set(4, m_generateFunction); - - tomviz::Python::Dict kwargs; - foreach (QString key, m_arguments.keys()) { - tomviz::Variant value = tomviz::toVariant(m_arguments[key]); - kwargs.set(key, value); - } - - result = m_makeDatasetFunction.call(args, kwargs); - if (!result.isValid()) { - qCritical() << "Failed to execute script."; - return nullptr; - } - } - - QVariantMap vmap; - foreach (QString key, m_arguments.keys()) { - vmap[key] = m_arguments[key]; - } - - QJsonObject argsJson = QJsonObject::fromVariantMap(vmap); - - QJsonArray size; - size.append(shape[0]); - size.append(shape[1]); - size.append(shape[2]); - - QJsonObject description; - description["script"] = m_pythonScript; - description["label"] = m_label; - description["args"] = argsJson; - description["shape"] = size; - - tomviz::DataSource* dataSource = new tomviz::DataSource( - m_label, tomviz::DataSource::Volume, nullptr, - tomviz::DataSource::PersistenceState::Transient, description); - - dataSource->setData(image); - - return dataSource; - } - - void setArguments(QMap args) { m_arguments = args; } - -private: - tomviz::Python::Module m_operatorModule; - tomviz::Python::Function m_generateFunction; - tomviz::Python::Function m_makeDatasetFunction; - QString m_label; - QString m_pythonScript; - QMap m_arguments; - static int s_numberOfScripts; -}; - -int PythonGeneratedDataSource::s_numberOfScripts = 0; - -class ShapeWidget : public QWidget -{ - Q_OBJECT - -public: - ShapeWidget(QWidget* p = nullptr) - : QWidget(p), m_xSpinBox(new QSpinBox(this)), - m_ySpinBox(new QSpinBox(this)), m_zSpinBox(new QSpinBox(this)) - { - QHBoxLayout* boundsLayout = new QHBoxLayout; - - QLabel* xLabel = new QLabel("X:", this); - QLabel* yLabel = new QLabel("Y:", this); - QLabel* zLabel = new QLabel("Z:", this); - - m_xSpinBox->setMaximum(std::numeric_limits::max()); - m_xSpinBox->setMinimum(1); - m_xSpinBox->setValue(100); - m_ySpinBox->setMaximum(std::numeric_limits::max()); - m_ySpinBox->setMinimum(1); - m_ySpinBox->setValue(100); - m_zSpinBox->setMaximum(std::numeric_limits::max()); - m_zSpinBox->setMinimum(1); - m_zSpinBox->setValue(100); - - boundsLayout->addWidget(xLabel); - boundsLayout->addWidget(m_xSpinBox); - boundsLayout->addWidget(yLabel); - boundsLayout->addWidget(m_ySpinBox); - boundsLayout->addWidget(zLabel); - boundsLayout->addWidget(m_zSpinBox); - - setLayout(boundsLayout); - } - - void getShape(int shape[3]) - { - shape[0] = m_xSpinBox->value(); - shape[1] = m_ySpinBox->value(); - shape[2] = m_zSpinBox->value(); - } - - void setSpinBoxValue(int newX, int newY, int newZ) - { - m_xSpinBox->setValue(newX); - m_ySpinBox->setValue(newY); - m_zSpinBox->setValue(newZ); - } - - void setSpinBoxMaximum(int xmax, int ymax, int zmax) - { - m_xSpinBox->setMaximum(xmax); - m_ySpinBox->setMaximum(ymax); - m_zSpinBox->setMaximum(zmax); - } - -private: - Q_DISABLE_COPY(ShapeWidget) - - QSpinBox* m_xSpinBox; - QSpinBox* m_ySpinBox; - QSpinBox* m_zSpinBox; -}; -} // namespace - -#include "PythonGeneratedDatasetReaction.moc" - -namespace tomviz { - -PythonGeneratedDatasetReaction::PythonGeneratedDatasetReaction( - QAction* parentObject, const QString& l, const QString& s) - : pqReaction(parentObject) -{ - m_scriptLabel = l; - m_scriptSource = s; -} - -void PythonGeneratedDatasetReaction::addDataset() -{ - PythonGeneratedDataSource generator(m_scriptLabel); - if (m_scriptLabel == "Constant Dataset") { - QDialog dialog; - dialog.setWindowTitle("Generate Constant Dataset"); - ShapeWidget* shapeWidget = new ShapeWidget(&dialog); - - QLabel* label = new QLabel("Value: ", &dialog); - QDoubleSpinBox* constant = new QDoubleSpinBox(&dialog); - - QHBoxLayout* parametersLayout = new QHBoxLayout; - parametersLayout->addWidget(label); - parametersLayout->addWidget(constant); - - QVBoxLayout* layout = new QVBoxLayout; - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Cancel | QDialogButtonBox::Ok, Qt::Horizontal, &dialog); - QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, - &QDialog::accept); - QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, - &QDialog::reject); - - layout->addWidget(shapeWidget); - layout->addItem(parametersLayout); - layout->addWidget(buttons); - - dialog.setLayout(layout); - if (dialog.exec() != QDialog::Accepted) { - return; - } - - generator.setScript(m_scriptSource); - QMap args; - args["CONSTANT"] = constant->value(); - generator.setArguments(args); - - int shape[3]; - shapeWidget->getShape(shape); - LoadDataReaction::dataSourceAdded(generator.createDataSource(shape), true, - false); - } else if (m_scriptLabel == "Random Particles") { - QDialog dialog; - dialog.setWindowTitle("Generate Random Particles"); - QVBoxLayout* layout = new QVBoxLayout; // overall layout - // Guide - QLabel* guide = new QLabel; - guide->setText("Generate many random 3D \"particles\" using the Fourier " - "Noise method. You can increase the \"Internal Complexity\" " - "of particles and their average \"Particle Size\". You can " - "also specify the sparsity (percentage of non-zero voxels) " - "of the generated dataset. Note: 512x512x512 may take a " - "couple minutes to run."); - guide->setWordWrap(true); - layout->addWidget(guide); - - // Shape Layout - ShapeWidget* shapeLayout = new ShapeWidget(&dialog); - shapeLayout->setSpinBoxValue(128, 128, 128); - shapeLayout->setSpinBoxMaximum(512, 512, 512); - // Parameter Layout - QGridLayout* parametersLayout = new QGridLayout; - - QLabel* label = new QLabel("Internal Complexity ([1-100]): ", &dialog); - parametersLayout->addWidget(label, 0, 0, 1, 2); - - QDoubleSpinBox* innerStructureParameter = new QDoubleSpinBox(&dialog); - innerStructureParameter->setRange(1, 100); - innerStructureParameter->setValue(30); - innerStructureParameter->setSingleStep(5); - parametersLayout->addWidget(innerStructureParameter, 0, 2, 1, 1); - - label = new QLabel("Particle Size ([1-100]): ", &dialog); - parametersLayout->addWidget(label, 1, 0, 1, 2); - - QDoubleSpinBox* shapeParameter = new QDoubleSpinBox(&dialog); - shapeParameter->setRange(1, 100); - shapeParameter->setValue(60); - shapeParameter->setSingleStep(5); - parametersLayout->addWidget(shapeParameter, 1, 2, 1, 1); - - label = new QLabel("Sparsity (percentage of non-zero voxels): ", &dialog); - parametersLayout->addWidget(label, 2, 0, 2, 1); - - QDoubleSpinBox* sparsityParameter = new QDoubleSpinBox(&dialog); - sparsityParameter->setRange(0, 1); - sparsityParameter->setValue(0.2); - sparsityParameter->setSingleStep(0.05); - parametersLayout->addWidget(sparsityParameter, 2, 2, 1, 1); - - // Buttons - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Cancel | QDialogButtonBox::Ok, Qt::Horizontal, &dialog); - QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, - &QDialog::accept); - QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, - &QDialog::reject); - - layout->addWidget(shapeLayout); - layout->addItem(parametersLayout); - layout->addWidget(buttons); - dialog.setLayout(layout); - dialog.layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - - // substitute values - if (dialog.exec() == QDialog::Accepted) { - generator.setScript(m_scriptSource); - QMap args; - args["p_in"] = innerStructureParameter->value(); - args["p_s"] = shapeParameter->value(); - args["sparsity"] = sparsityParameter->value(); - generator.setArguments(args); - - int shape[3]; - shapeLayout->getShape(shape); - LoadDataReaction::dataSourceAdded(generator.createDataSource(shape), true, - false); - } - } else if (m_scriptLabel == "Electron Beam Shape") { - QDialog dialog; - dialog.setWindowTitle("Generate Electron Beam Shape"); - QVBoxLayout* layout = new QVBoxLayout; // overall layout - // Guide - QLabel* guide = new QLabel; - guide->setText("Generate a convergent electron beam in 3D. This represents " - "the 3D probe used for atomic resolution imaging in a " - "scanning transmission electron microscope."); - guide->setWordWrap(true); - layout->addWidget(guide); - - // Parameter Layout - QGridLayout* parametersLayout = new QGridLayout; - - QLabel* label = new QLabel("Beam energy (keV): ", &dialog); - parametersLayout->addWidget(label, 0, 0, 1, 2); - QDoubleSpinBox* voltage = new QDoubleSpinBox(&dialog); - voltage->setRange(1, 1000000); - voltage->setValue(300); - voltage->setSingleStep(50); - parametersLayout->addWidget(voltage, 0, 2, 1, 1); - - label = new QLabel("Semi-convergence angle (mrad): ", &dialog); - parametersLayout->addWidget(label, 1, 0, 1, 2); - QDoubleSpinBox* alpha_max = new QDoubleSpinBox(&dialog); - alpha_max->setRange(0, 500); - alpha_max->setValue(30); - alpha_max->setSingleStep(0.5); - parametersLayout->addWidget(alpha_max, 1, 2, 1, 1); - - label = new QLabel("Transerve plane (x-y): ", &dialog); - parametersLayout->addWidget(label, 2, 0, 1, 1); - label = new QLabel("Number of pixels: ", &dialog); - parametersLayout->addWidget(label, 2, 1, 1, 1); - QSpinBox* Nxy = new QSpinBox(&dialog); - Nxy->setRange(64, 2048); - Nxy->setValue(256); - Nxy->setSingleStep(1); - parametersLayout->addWidget(Nxy, 2, 2, 1, 1); - - label = new QLabel("x-y pixel size (angstrom): ", &dialog); - parametersLayout->addWidget(label, 3, 1, 1, 1); - QDoubleSpinBox* dxy = new QDoubleSpinBox(&dialog); - dxy->setDecimals(4); - dxy->setMinimum(0.0001); - dxy->setValue(0.1); - dxy->setSingleStep(0.1); - parametersLayout->addWidget(dxy, 3, 2, 1, 1); - - label = new QLabel("Propagation direction (z): ", &dialog); - parametersLayout->addWidget(label, 4, 0, 1, 1); - label = new QLabel("Number of pixels: ", &dialog); - parametersLayout->addWidget(label, 4, 1, 1, 1); - QSpinBox* Nz = new QSpinBox(&dialog); - Nz->setRange(1, 2048); - Nz->setValue(512); - Nz->setSingleStep(1); - parametersLayout->addWidget(Nz, 4, 2, 1, 1); - - label = new QLabel("Minimum defocus (nm): ", &dialog); - parametersLayout->addWidget(label, 5, 1, 1, 1); - QDoubleSpinBox* df_min = new QDoubleSpinBox(&dialog); - df_min->setRange(-1000000, 1000000); - df_min->setValue(-50.0); - df_min->setSingleStep(5.0); - parametersLayout->addWidget(df_min, 5, 2, 1, 1); - - label = new QLabel("Maximum defocus (nm): ", &dialog); - parametersLayout->addWidget(label, 6, 1, 1, 1); - QDoubleSpinBox* df_max = new QDoubleSpinBox(&dialog); - df_max->setRange(-1000000, 1000000); - df_max->setValue(100.0); - df_max->setSingleStep(5.0); - parametersLayout->addWidget(df_max, 6, 2, 1, 1); - - label = new QLabel("Third-order spherical aberration (mm): ", &dialog); - parametersLayout->addWidget(label, 0, 3, 1, 2); - QDoubleSpinBox* c3 = new QDoubleSpinBox(&dialog); - c3->setRange(-1000000, 1000000); - c3->setValue(0.2); - c3->setSingleStep(0.1); - parametersLayout->addWidget(c3, 0, 5, 1, 1); - - label = new QLabel("Twofold astigmatism: ", &dialog); - parametersLayout->addWidget(label, 1, 3, 1, 1); - label = new QLabel("Value (nm): ", &dialog); - parametersLayout->addWidget(label, 1, 4, 1, 1); - QDoubleSpinBox* f_a2 = new QDoubleSpinBox(&dialog); - f_a2->setRange(-1000000, 1000000); - f_a2->setValue(0.0); - f_a2->setSingleStep(1000); - parametersLayout->addWidget(f_a2, 1, 5, 1, 1); - label = new QLabel("Orientation (rad): ", &dialog); - parametersLayout->addWidget(label, 2, 4, 1, 1); - QDoubleSpinBox* phi_a2 = new QDoubleSpinBox(&dialog); - phi_a2->setValue(0.0); - phi_a2->setSingleStep(0.1); - parametersLayout->addWidget(phi_a2, 2, 5, 1, 1); - - label = new QLabel("Threefold astigmatism: ", &dialog); - parametersLayout->addWidget(label, 3, 3, 1, 1); - label = new QLabel("Value (nm): ", &dialog); - parametersLayout->addWidget(label, 3, 4, 1, 1); - QDoubleSpinBox* f_a3 = new QDoubleSpinBox(&dialog); - f_a3->setRange(-1000000, 1000000); - f_a3->setValue(0.0); - f_a3->setSingleStep(1000); - parametersLayout->addWidget(f_a3, 3, 5, 1, 1); - label = new QLabel("Orientation (rad): ", &dialog); - parametersLayout->addWidget(label, 4, 4, 1, 1); - QDoubleSpinBox* phi_a3 = new QDoubleSpinBox(&dialog); - phi_a3->setValue(0.0); - phi_a3->setSingleStep(0.1); - parametersLayout->addWidget(phi_a3, 4, 5, 1, 1); - - label = new QLabel("Coma: ", &dialog); - parametersLayout->addWidget(label, 5, 3, 1, 1); - label = new QLabel("Value (nm): ", &dialog); - parametersLayout->addWidget(label, 5, 4, 1, 1); - QDoubleSpinBox* f_c3 = new QDoubleSpinBox(&dialog); - f_c3->setRange(-1000000, 1000000); - f_c3->setValue(1500.0); - f_c3->setSingleStep(1000); - parametersLayout->addWidget(f_c3, 5, 5, 1, 1); - label = new QLabel("Orientation (rad): ", &dialog); - parametersLayout->addWidget(label, 6, 4, 1, 1); - QDoubleSpinBox* phi_c3 = new QDoubleSpinBox(&dialog); - phi_c3->setValue(0.0); - phi_c3->setSingleStep(0.1); - parametersLayout->addWidget(phi_c3, 6, 5, 1, 1); - - // Buttons - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Cancel | QDialogButtonBox::Ok, Qt::Horizontal, &dialog); - QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, - &QDialog::accept); - QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, - &QDialog::reject); - - layout->addItem(parametersLayout); - layout->addWidget(buttons); - dialog.setLayout(layout); - dialog.layout()->setSizeConstraint( - QLayout::SetFixedSize); // Make the UI non-resizeable - - // substitute values - if (dialog.exec() == QDialog::Accepted) { - generator.setScript(m_scriptSource); - QMap args; - args["voltage"] = voltage->value(); - args["alpha_max"] = alpha_max->value(); - args["Nxy"] = Nxy->value(); - args["Nz"] = Nz->value(); - args["dxy"] = dxy->value(); - args["df_min"] = df_min->value(); - args["df_max"] = df_max->value(); - args["c3"] = c3->value(); - args["f_a2"] = f_a2->value(); - args["phi_a2"] = phi_a2->value(); - args["f_a3"] = f_a3->value(); - args["phi_a3"] = phi_a3->value(); - args["f_c3"] = f_c3->value(); - args["phi_c3"] = phi_c3->value(); - generator.setArguments(args); - - const int shape[3] = { Nxy->value(), Nxy->value(), Nz->value() }; - LoadDataReaction::dataSourceAdded(generator.createDataSource(shape), true, - false); - } - } // end of else if -} - -DataSource* PythonGeneratedDatasetReaction::createDataSource( - const QJsonObject& sourceInformation) -{ - PythonGeneratedDataSource generator(sourceInformation["label"].toString()); - generator.setScript(sourceInformation["script"].toString()); - QVariantMap args = sourceInformation["args"].toObject().toVariantMap(); - generator.setArguments(args); - int shape[3]; - QJsonArray shapeJson = sourceInformation["shape"].toArray(); - shape[0] = shapeJson[0].toInt(); - shape[1] = shapeJson[1].toInt(); - shape[2] = shapeJson[2].toInt(); - return generator.createDataSource(shape); -} -} // namespace tomviz diff --git a/tomviz/PythonGeneratedDatasetReaction.h b/tomviz/PythonGeneratedDatasetReaction.h deleted file mode 100644 index 016c0aa7c..000000000 --- a/tomviz/PythonGeneratedDatasetReaction.h +++ /dev/null @@ -1,35 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPythonGeneratedDatasetReaction_h -#define tomvizPythonGeneratedDatasetReaction_h - -#include - -namespace tomviz { -class DataSource; - -class PythonGeneratedDatasetReaction : public pqReaction -{ - Q_OBJECT - -public: - PythonGeneratedDatasetReaction(QAction* parent, const QString& label, - const QString& source); - - void addDataset(); - - static DataSource* createDataSource(const QJsonObject& sourceInformation); - -protected: - void onTriggered() override { this->addDataset(); } - -private: - Q_DISABLE_COPY(PythonGeneratedDatasetReaction) - - QString m_scriptLabel; - QString m_scriptSource; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/PythonReader.cxx b/tomviz/PythonReader.cxx index ae8d17888..2664f2cc6 100644 --- a/tomviz/PythonReader.cxx +++ b/tomviz/PythonReader.cxx @@ -3,8 +3,6 @@ #include "PythonReader.h" -#include "DataSource.h" - #include #include diff --git a/tomviz/PythonUtilities.cxx b/tomviz/PythonUtilities.cxx index 2e828951e..496af6b8e 100644 --- a/tomviz/PythonUtilities.cxx +++ b/tomviz/PythonUtilities.cxx @@ -8,11 +8,7 @@ #include "vtkPython.h" // must be first #pragma pop_macro("slots") -#include "core/DataSourceBase.h" - -#include "DataSource.h" #include "Logger.h" -#include "OperatorFactory.h" #include #include @@ -25,6 +21,9 @@ #include #pragma pop_macro("slots") +#include +#include + namespace py = pybind11; namespace tomviz { @@ -91,13 +90,6 @@ Python::Object::Object(const Variant& value) m_smartPyObject = new vtkSmartPyObject(toPyObject(value)); } -Python::Object::Object(const DataSourceBase& source) -{ - // The vtkSmartPyObject will take ownership of the PyObject* - py::object obj = py::cast(source, py::return_value_policy::reference); - m_smartPyObject = new vtkSmartPyObject(obj.release().ptr()); -} - Python::Object::Object(PyObject* obj) { m_smartPyObject = new vtkSmartPyObject(obj); @@ -176,6 +168,9 @@ Python::List Python::Object::toList() QString Python::Object::toString() const { + if (!isValid()) { + return QString(); + } // Function documentation says the caller of either of the functions below // is not responsible for deallocating the buffer. #if PY_MAJOR_VERSION >= 3 @@ -189,11 +184,17 @@ QString Python::Object::toString() const long Python::Object::toLong() const { + if (!isValid()) { + return 0; + } return PyLong_AsLong(m_smartPyObject->GetPointer()); } double Python::Object::toDouble() const { + if (!isValid()) { + return 0.0; + } return PyFloat_AsDouble(m_smartPyObject->GetPointer()); } @@ -598,44 +599,23 @@ void Python::prependPythonPath(std::string dir) vtkPythonInterpreter::PrependPythonPath(dir.c_str()); } -Python::Object Python::createDataset(vtkObjectBase* data, - const DataSource& source) -{ - Python python; - auto module = python.import("tomviz.internal_dataset"); - if (!module.isValid()) { - Logger::critical("Failed to import tomviz.internal_dataset module."); - } - - auto createDatasetFunc = module.findFunction("create_dataset"); - if (!createDatasetFunc.isValid()) { - Logger::critical("Unable to locate create_dataset."); - } - - auto dataObj = Python::VTK::GetObjectFromPointer(data); - auto dataSourceObj = Python::Object(*source.pythonProxy()); - - Python::Tuple args(2); - args.set(0, dataObj); - args.set(1, dataSourceObj); - - return createDatasetFunc.call(args); -} - std::vector findCustomOperators(const QString& path) { + std::vector operators; + Python python; auto internalModule = python.import("tomviz._internal"); if (!internalModule.isValid()) { Logger::critical("Failed to import tomviz._internal module."); + return operators; } auto findCustomOperators = internalModule.findFunction("find_operators"); if (!findCustomOperators.isValid()) { Logger::critical("Unable to locate find_operators."); + return operators; } - std::vector operators; Python::Object pyPath(path); Python::Tuple args(1); args.set(0, pyPath); @@ -643,6 +623,7 @@ std::vector findCustomOperators(const QString& path) auto pyOperators = findCustomOperators.call(args); if (!pyOperators.isValid()) { Logger::critical("Failed to execute findCustomOperators."); + return operators; } Python::List ops(pyOperators); @@ -654,6 +635,15 @@ std::vector findCustomOperators(const QString& path) op.pythonPath = opDict["pythonPath"].toString(); op.valid = opDict["valid"].toBool(); + QString type = opDict["type"].toString(); + if (type == "source") { + op.type = OperatorDescription::Type::Source; + } else if (type == "transform") { + op.type = OperatorDescription::Type::Transform; + } else { + op.type = OperatorDescription::Type::LegacyTransform; + } + // Do we have a JSON file? Python::Object jsonPath = opDict["jsonPath"]; if (jsonPath.isValid()) { diff --git a/tomviz/PythonUtilities.h b/tomviz/PythonUtilities.h index 22350d78f..af0018fd7 100644 --- a/tomviz/PythonUtilities.h +++ b/tomviz/PythonUtilities.h @@ -29,9 +29,6 @@ class capsule; namespace tomviz { -class DataSource; -class DataSourceBase; - class Python { @@ -60,7 +57,6 @@ class Python Object(const QList& intList); Object(const QList& floatList); Object(const Variant& value); - Object(const DataSourceBase& source); Object(PyObject* obj); Object& operator=(const Object& other); @@ -192,9 +188,6 @@ class Python /// vtkPythonPythonInterpreter::PrependPythonPath(...) to do the work. static void prependPythonPath(std::string dir); - /// Create an internal Dataset object for operators to use - static Object createDataset(vtkObjectBase* data, const DataSource& source); - private: vtkPythonScopeGilEnsurer* m_ensurer = nullptr; }; @@ -211,11 +204,19 @@ class TemporarilyReleaseGil struct OperatorDescription { + enum class Type + { + Source, + Transform, + LegacyTransform, + }; + QString label; QString pythonPath; QString jsonPath; QString loadError; bool valid = true; + Type type = Type::LegacyTransform; }; std::vector findCustomOperators(const QString& path); diff --git a/tomviz/PythonWriter.cxx b/tomviz/PythonWriter.cxx index 97cf46c46..7ed6c1f21 100644 --- a/tomviz/PythonWriter.cxx +++ b/tomviz/PythonWriter.cxx @@ -3,8 +3,6 @@ #include "PythonWriter.h" -#include "DataSource.h" - #include #include diff --git a/tomviz/Reaction.cxx b/tomviz/Reaction.cxx index 21c2bab03..54d78bb5c 100644 --- a/tomviz/Reaction.cxx +++ b/tomviz/Reaction.cxx @@ -4,31 +4,61 @@ #include "Reaction.h" #include "ActiveObjects.h" -#include "DataSource.h" -#include "PipelineManager.h" +#include "pipeline/OutputPort.h" + +#include +#include +#include namespace tomviz { Reaction::Reaction(QAction* parentObject) : pqReaction(parentObject) { connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), + &ActiveObjects::activePipelineChanged, this, &Reaction::updateEnableState); - connect(&PipelineManager::instance(), &PipelineManager::executionModeUpdated, + connect(&ActiveObjects::instance(), + &ActiveObjects::activeTipOutputPortChanged, this, &Reaction::updateEnableState); + qApp->installEventFilter(this); updateEnableState(); } +bool Reaction::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::KeyPress || + event->type() == QEvent::KeyRelease) { + auto* ke = static_cast(event); + if (ke->key() == Qt::Key_Control) { + m_ctrlHeld = (event->type() == QEvent::KeyPress); + updateEnableState(); + } + } + return pqReaction::eventFilter(obj, event); +} + void Reaction::updateEnableState() { - auto compatibleExecutionMode = PipelineManager::instance().executionMode() == - Pipeline::ExecutionMode::Threaded; + if (m_ctrlHeld) { + parentAction()->setEnabled(true); + return; + } - parentAction()->setEnabled(ActiveObjects::instance().activeDataSource() != - nullptr && - compatibleExecutionMode); + auto& ao = ActiveObjects::instance(); + auto* tipPort = ao.activeTipOutputPort(); + if (!tipPort) { + parentAction()->setEnabled(false); + return; + } + parentAction()->setEnabled( + pipeline::isPortTypeCompatible(tipPort->type(), m_acceptedInputTypes)); +} + +void Reaction::setAcceptedInputTypes(pipeline::PortTypes types) +{ + m_acceptedInputTypes = types; + updateEnableState(); } } // namespace tomviz diff --git a/tomviz/Reaction.h b/tomviz/Reaction.h index b8359c2a4..8827b305c 100644 --- a/tomviz/Reaction.h +++ b/tomviz/Reaction.h @@ -4,6 +4,8 @@ #ifndef tomvizReaction_h #define tomvizReaction_h +#include "pipeline/PortType.h" + #include namespace tomviz { @@ -15,8 +17,18 @@ class Reaction : public pqReaction public: Reaction(QAction* parent); + bool eventFilter(QObject* obj, QEvent* event) override; + protected: void updateEnableState() override; + + /// Set the port types this reaction's transform accepts. Defaults to + /// ImageData (the supertype, compatible with Volume and TiltSeries). + void setAcceptedInputTypes(pipeline::PortTypes types); + +private: + pipeline::PortTypes m_acceptedInputTypes = pipeline::PortType::ImageData; + bool m_ctrlHeld = false; }; } // namespace tomviz #endif diff --git a/tomviz/RecentFilesMenu.cxx b/tomviz/RecentFilesMenu.cxx index 983c8ae17..f3a9292cc 100644 --- a/tomviz/RecentFilesMenu.cxx +++ b/tomviz/RecentFilesMenu.cxx @@ -4,11 +4,14 @@ #include "RecentFilesMenu.h" #include "ActiveObjects.h" -#include "DataSource.h" #include "LoadDataReaction.h" #include "SaveLoadStateReaction.h" +#include "SaveLoadTemplateReaction.h" #include "Utilities.h" +#include "pipeline/Node.h" +#include "pipeline/SourceNode.h" + #include #include @@ -61,27 +64,23 @@ void saveSettings(QJsonObject json) if (json.contains("molecules") && json["molecules"].isArray()) { molecules = json["molecules"].toArray(); } - if (readers.size() > MAX_ITEMS) { - // We need to prune the list to 10. - while (readers.size() > MAX_ITEMS) { - readers.removeLast(); - } + QJsonArray templates; + if (json.contains("templates") && json["templates"].isArray()) { + templates = json["templates"].toArray(); } - if (states.size() > MAX_ITEMS) { - // We need to prune the list to 10. - while (states.size() > MAX_ITEMS) { - states.removeLast(); + auto prune = [](QJsonArray& arr) { + while (arr.size() > MAX_ITEMS) { + arr.removeLast(); } - } - if (molecules.size() > MAX_ITEMS) { - // We need to prune the list to 10. - while (molecules.size() > MAX_ITEMS) { - molecules.removeLast(); - } - } + }; + prune(readers); + prune(states); + prune(molecules); + prune(templates); json["readers"] = readers; json["states"] = states; json["molecules"] = molecules; + json["templates"] = templates; QJsonDocument doc(json); auto settings = pqApplicationCore::instance()->settings(); @@ -99,14 +98,18 @@ RecentFilesMenu::RecentFilesMenu(QMenu& menu, QObject* p) : QObject(p) RecentFilesMenu::~RecentFilesMenu() = default; -void RecentFilesMenu::pushDataReader(DataSource* dataSource) +void RecentFilesMenu::pushDataReader(pipeline::SourceNode* source) { // Add non-proxy based readers separately. auto settings = loadSettings(); auto readerList = settings["readers"].toArray(); - QJsonObject readerJson = - QJsonObject::fromVariantMap(dataSource->readerProperties()); - auto fileNames = dataSource->fileNames(); + + // Get reader properties and file names from node properties + QVariantMap readerProps = + source->property("readerProperties").toMap(); + QJsonObject readerJson = QJsonObject::fromVariantMap(readerProps); + QStringList fileNames = source->property("fileNames").toStringList(); + if (fileNames.size() < 1) { return; } @@ -125,25 +128,6 @@ void RecentFilesMenu::pushDataReader(DataSource* dataSource) saveSettings(settings); } -void RecentFilesMenu::pushMoleculeReader(MoleculeSource* moleculeSource) -{ - auto settings = loadSettings(); - auto readerList = settings["molecules"].toArray(); - QJsonObject readerJson; - - readerJson["fileName"] = moleculeSource->fileName(); - - // Remove the file if it is already in the list - for (int i = readerList.size() - 1; i >= 0; --i) { - if (readerList[i].toObject()["fileName"] == readerJson["fileName"]) { - readerList.removeAt(i); - } - } - readerList.push_front(readerJson); - settings["molecules"] = readerList; - saveSettings(settings); -} - void RecentFilesMenu::pushStateFile(const QString& fileName) { auto settings = loadSettings(); @@ -161,6 +145,22 @@ void RecentFilesMenu::pushStateFile(const QString& fileName) saveSettings(settings); } +void RecentFilesMenu::pushTemplateFile(const QString& fileName) +{ + auto settings = loadSettings(); + auto templateList = settings["templates"].toArray(); + QJsonObject templateJson; + templateJson["fileName"] = fileName; + for (int i = templateList.size() - 1; i >= 0; --i) { + if (templateList[i].toObject()["fileName"] == templateJson["fileName"]) { + templateList.removeAt(i); + } + } + templateList.push_front(templateJson); + settings["templates"] = templateList; + saveSettings(settings); +} + void RecentFilesMenu::aboutToShowMenu() { auto menu = qobject_cast(sender()); @@ -184,13 +184,14 @@ void RecentFilesMenu::aboutToShowMenu() int index = 0; - foreach (QJsonValue file, json["readers"].toArray()) { + const auto readers = json["readers"].toArray(); + for (const QJsonValue& file : readers) { if (file.isObject()) { auto object = file.toObject(); auto fileNamesArray = object["fileNames"].toArray(); QStringList fileNames; - foreach (file, fileNamesArray) { - fileNames << file.toString(""); + for (const QJsonValue& fileName : fileNamesArray) { + fileNames << fileName.toString(""); } QString label = fileNames[0]; QString toolTip; @@ -211,36 +212,34 @@ void RecentFilesMenu::aboutToShowMenu() } } - // We have something, let's populate the recent files and/or state files. - if (json["molecules"].toArray().size() > 0) { - menu->addAction("Molecule files")->setEnabled(false); + if (json["states"].toArray().size() > 0) { + menu->addAction("State files")->setEnabled(false); } - index = 0; - - foreach (QJsonValue file, json["molecules"].toArray()) { + const auto stateFiles = json["states"].toArray(); + for (const QJsonValue& file : stateFiles) { if (file.isObject()) { auto object = file.toObject(); - QString label = object["fileName"].toString(""); - auto actn = menu->addAction(QIcon(":/icons/gradient_opacity.png"), label); - actn->setData(index); - connect(actn, &QAction::triggered, - [this, actn, label]() { moleculeSourceTriggered(actn, label); }); - ++index; + auto actn = menu->addAction(QIcon(":/icons/tomviz.png"), + object["fileName"].toString("")); + actn->setData(object["fileName"].toString("")); + connect(actn, &QAction::triggered, this, &RecentFilesMenu::stateTriggered); } } - if (json["states"].toArray().size() > 0) { - menu->addAction("State files")->setEnabled(false); + if (json["templates"].toArray().size() > 0) { + menu->addAction("Template files")->setEnabled(false); } - foreach (QJsonValue file, json["states"].toArray()) { + const auto templateFiles = json["templates"].toArray(); + for (const QJsonValue& file : templateFiles) { if (file.isObject()) { auto object = file.toObject(); auto actn = menu->addAction(QIcon(":/icons/tomviz.png"), object["fileName"].toString("")); actn->setData(object["fileName"].toString("")); - connect(actn, &QAction::triggered, this, &RecentFilesMenu::stateTriggered); + connect(actn, &QAction::triggered, this, + &RecentFilesMenu::templateTriggered); } } } @@ -271,29 +270,6 @@ void RecentFilesMenu::dataSourceTriggered(QAction* actn, QStringList fileNames) LoadDataReaction::loadData(fileNames); } -void RecentFilesMenu::moleculeSourceTriggered(QAction* actn, QString fileName) -{ - // Check the files actually exists, remove the recent entry if not. - bool missing = false; - if (!QFileInfo::exists(fileName)) { - QMessageBox::warning(tomviz::mainWidget(), "Error", - QString("The file '%1' does not exist").arg(fileName)); - missing = true; - } - - if (missing) { - int index = actn->data().toInt(); - auto json = loadSettings(); - auto readers = json["molecules"].toArray(); - readers.removeAt(index); - json["molecules"] = readers; - saveSettings(json); - return; - } - - LoadDataReaction::loadMolecule(fileName); -} - void RecentFilesMenu::stateTriggered() { auto actn = qobject_cast(sender()); @@ -327,4 +303,36 @@ void RecentFilesMenu::stateTriggered() saveSettings(json); } } + +void RecentFilesMenu::templateTriggered() +{ + auto actn = qobject_cast(sender()); + Q_ASSERT(actn); + + QString fileName = actn->data().toString(); + + if (QFileInfo::exists(fileName)) { + if (SaveLoadTemplateReaction::loadTemplate(fileName)) { + // Move the entry back to the top of the list. + pushTemplateFile(fileName); + return; + } + } else { + QMessageBox::warning( + tomviz::mainWidget(), "Error", + QString("The file '%1' does not exist").arg(fileName)); + } + + auto json = loadSettings(); + if (json["templates"].isArray()) { + auto templates = json["templates"].toArray(); + for (int i = 0; i < templates.size(); ++i) { + if (templates[i].toObject()["fileName"].toString() == fileName) { + templates.removeAt(i); + } + } + json["templates"] = templates; + saveSettings(json); + } +} } // namespace tomviz diff --git a/tomviz/RecentFilesMenu.h b/tomviz/RecentFilesMenu.h index 8d9a1663c..f8fddb626 100644 --- a/tomviz/RecentFilesMenu.h +++ b/tomviz/RecentFilesMenu.h @@ -11,8 +11,9 @@ class QMenu; namespace tomviz { -class DataSource; -class MoleculeSource; +namespace pipeline { +class SourceNode; +} /// Adds recent file and recent state file support to Tomviz. class RecentFilesMenu : public QObject @@ -24,15 +25,15 @@ class RecentFilesMenu : public QObject ~RecentFilesMenu() override; /// Pushes a reader on the recent files stack. - static void pushDataReader(DataSource* dataSource); - static void pushMoleculeReader(MoleculeSource* moleculeSource); + static void pushDataReader(pipeline::SourceNode* source); static void pushStateFile(const QString& filename); + static void pushTemplateFile(const QString& filename); private slots: void aboutToShowMenu(); void dataSourceTriggered(QAction* actn, QStringList fileNames); - void moleculeSourceTriggered(QAction* actn, QString fileName); void stateTriggered(); + void templateTriggered(); private: Q_DISABLE_COPY(RecentFilesMenu) diff --git a/tomviz/ReconstructionReaction.cxx b/tomviz/ReconstructionReaction.cxx index ac98a3b44..8ca1eb7f8 100644 --- a/tomviz/ReconstructionReaction.cxx +++ b/tomviz/ReconstructionReaction.cxx @@ -3,34 +3,20 @@ #include "ReconstructionReaction.h" -#include "ActiveObjects.h" -#include "DataSource.h" -#include "Pipeline.h" - -#include -#include - -#include "ReconstructionOperator.h" - -#include -#include +#include "TransformUtils.h" +#include "pipeline/transforms/ReconstructionTransform.h" namespace tomviz { ReconstructionReaction::ReconstructionReaction(QAction* parentObject) : Reaction(parentObject) { + setAcceptedInputTypes(pipeline::PortType::TiltSeries); } -void ReconstructionReaction::recon(DataSource* input) +void ReconstructionReaction::recon(DataSource*) { - input = input ? input : ActiveObjects::instance().activeParentDataSource(); - if (!input) { - qDebug() << "Exiting early - no data :-("; - return; - } - - Operator* op = new ReconstructionOperator(input); - input->addOperator(op); + auto* transform = new pipeline::ReconstructionTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/ReconstructionWidget.cxx b/tomviz/ReconstructionWidget.cxx index 31f7cb807..3bb219ba3 100644 --- a/tomviz/ReconstructionWidget.cxx +++ b/tomviz/ReconstructionWidget.cxx @@ -5,13 +5,8 @@ #include "ui_ReconstructionWidget.h" -#include "DataSource.h" -#include "LoadDataReaction.h" -#include "TomographyReconstruction.h" -#include "TomographyTiltSeries.h" #include "Utilities.h" -#include #include #include #include @@ -25,14 +20,11 @@ #include #include #include -#include +#include #include #include -#include #include -#include -#include namespace tomviz { @@ -40,6 +32,7 @@ class ReconstructionWidget::RWInternal { public: Ui::ReconstructionWidget Ui; + vtkNew producer; vtkNew dataSliceMapper; vtkNew reconstructionSliceMapper; vtkNew sinogramMapper; @@ -54,71 +47,67 @@ class ReconstructionWidget::RWInternal vtkNew currentSliceLine; vtkNew currentSliceActor; - QPointer dataSource; - bool canceled; - bool started; + vtkImageData* inputData = nullptr; + int totalSlicesToProcess = 0; QElapsedTimer timer; - int totalSlicesToProcess; void setupCurrentSliceLine(int sliceNum) { - auto t = this->dataSource->producer(); - if (!t) { + if (!inputData) { return; } - auto imageData = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (imageData) { - int extent[6]; - imageData->GetExtent(extent); - double spacing[3]; - imageData->GetSpacing(spacing); - double bounds[6]; - imageData->GetBounds(bounds); - double point1[3], point2[3]; - point1[0] = bounds[0] + sliceNum * spacing[0]; - point2[0] = bounds[0] + sliceNum * spacing[0]; - point1[1] = bounds[2] - (bounds[3] - bounds[2]); - point2[1] = bounds[3] + (bounds[3] - bounds[2]); - point1[2] = bounds[5] + 1; - point2[2] = bounds[5] + 1; - this->currentSliceLine->SetPoint1(point1); - this->currentSliceLine->SetPoint2(point2); - this->currentSliceLine->Update(); - this->currentSliceActor->GetMapper()->Update(); - } + int extent[6]; + inputData->GetExtent(extent); + double spacing[3]; + inputData->GetSpacing(spacing); + double bounds[6]; + inputData->GetBounds(bounds); + double point1[3], point2[3]; + point1[0] = bounds[0] + sliceNum * spacing[0]; + point2[0] = bounds[0] + sliceNum * spacing[0]; + point1[1] = bounds[2] - (bounds[3] - bounds[2]); + point2[1] = bounds[3] + (bounds[3] - bounds[2]); + point1[2] = bounds[5] + 1; + point2[2] = bounds[5] + 1; + this->currentSliceLine->SetPoint1(point1); + this->currentSliceLine->SetPoint2(point2); + this->currentSliceLine->Update(); + this->currentSliceActor->GetMapper()->Update(); } }; -ReconstructionWidget::ReconstructionWidget(DataSource* source, QWidget* p) +ReconstructionWidget::ReconstructionWidget(vtkImageData* inputData, + vtkSMProxy* colorMap, + QWidget* p) : QWidget(p), Internals(new RWInternal) { this->Internals->Ui.setupUi(this); - this->Internals->dataSource = source; - this->Internals->canceled = false; - this->Internals->started = false; + this->Internals->inputData = inputData; - auto t = source->producer(); + if (!inputData) { + return; + } - this->Internals->dataSliceMapper->SetInputConnection(t->GetOutputPort()); - this->Internals->sinogramMapper->SetInputConnection(t->GetOutputPort()); + // Wrap the input in a trivial producer for the mappers. + this->Internals->producer->SetOutput(inputData); + + this->Internals->dataSliceMapper->SetInputConnection( + this->Internals->producer->GetOutputPort()); + this->Internals->sinogramMapper->SetInputConnection( + this->Internals->producer->GetOutputPort()); this->Internals->sinogramMapper->SetOrientationToX(); this->Internals->sinogramMapper->SetSliceNumber( this->Internals->sinogramMapper->GetSliceNumberMinValue()); this->Internals->sinogramMapper->Update(); - vtkImageData* imageData = - vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!imageData) { - // We can't really handle this well since we depend on there being data - return; - } int extent[6]; - imageData->GetExtent(extent); + inputData->GetExtent(extent); this->Internals->totalSlicesToProcess = extent[1] - extent[0] + 1; - this->Internals->dataSliceMapper->SetSliceNumber(extent[0] + - (extent[1] - extent[0]) / 2); + this->Internals->dataSliceMapper->SetSliceNumber( + extent[0] + (extent[1] - extent[0]) / 2); this->Internals->dataSliceMapper->Update(); + int extent2[6] = { 0, 0, extent[2], extent[3], extent[2], extent[3] }; this->Internals->reconstruction->SetExtent(extent2); this->Internals->reconstruction->AllocateScalars(VTK_FLOAT, 1); @@ -135,12 +124,13 @@ ReconstructionWidget::ReconstructionWidget(DataSource* source, QWidget* p) this->Internals->reconstructionSliceMapper); this->Internals->sinogram->SetMapper(this->Internals->sinogramMapper); - vtkScalarsToColors* lut = - vtkScalarsToColors::SafeDownCast(source->colorMap()->GetClientSideObject()); - - this->Internals->dataSlice->GetProperty()->SetLookupTable(lut); - this->Internals->reconstructionSlice->GetProperty()->SetLookupTable(lut); - this->Internals->sinogram->GetProperty()->SetLookupTable(lut); + if (colorMap) { + vtkScalarsToColors* lut = + vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); + this->Internals->dataSlice->GetProperty()->SetLookupTable(lut); + this->Internals->reconstructionSlice->GetProperty()->SetLookupTable(lut); + this->Internals->sinogram->GetProperty()->SetLookupTable(lut); + } this->Internals->dataSliceRenderer->AddViewProp( this->Internals->dataSlice); @@ -164,10 +154,8 @@ ReconstructionWidget::ReconstructionWidget(DataSource* source, QWidget* p) this->Internals->Ui.currentSliceView->renderWindow()->AddRenderer( this->Internals->dataSliceRenderer); - this->Internals->Ui.currentReconstructionView->renderWindow()->AddRenderer( this->Internals->reconstructionSliceRenderer); - this->Internals->Ui.sinogramView->renderWindow()->AddRenderer( this->Internals->sinogramRenderer); @@ -190,6 +178,8 @@ ReconstructionWidget::ReconstructionWidget(DataSource* source, QWidget* p) this->Internals->sinogramMapper); tomviz::setupRenderer(this->Internals->reconstructionSliceRenderer, this->Internals->reconstructionSliceMapper); + + this->Internals->timer.start(); } ReconstructionWidget::~ReconstructionWidget() @@ -197,19 +187,8 @@ ReconstructionWidget::~ReconstructionWidget() delete this->Internals; } -void ReconstructionWidget::startReconstruction() -{ - Ui::ReconstructionWidget& ui = this->Internals->Ui; - ui.statusLabel->setText( - QString("Slice # 0 out of %1\nTime remaining: unknown") - .arg(this->Internals->totalSlicesToProcess)); - this->Internals->timer.start(); -} - void ReconstructionWidget::updateProgress(int progress) { - // with the new setup this may happen. The initial estimates may be off, but - // this will keep garbage from populating the time remaining field. if (!this->Internals->timer.isValid()) { this->Internals->timer.start(); } diff --git a/tomviz/ReconstructionWidget.h b/tomviz/ReconstructionWidget.h index 33548310e..90fe1b762 100644 --- a/tomviz/ReconstructionWidget.h +++ b/tomviz/ReconstructionWidget.h @@ -6,26 +6,24 @@ #include +class vtkImageData; +class vtkSMProxy; + namespace tomviz { -class DataSource; class ReconstructionWidget : public QWidget { Q_OBJECT public: - ReconstructionWidget(DataSource* source, QWidget* parent = nullptr); + ReconstructionWidget(vtkImageData* inputData, vtkSMProxy* colorMap, + QWidget* parent = nullptr); ~ReconstructionWidget() override; public slots: - void startReconstruction(); void updateProgress(int progress); void updateIntermediateResults(std::vector reconSlice); -signals: - void reconstructionFinished(); - void reconstructionCancelled(); - private: Q_DISABLE_COPY(ReconstructionWidget) diff --git a/tomviz/ResetReaction.cxx b/tomviz/ResetReaction.cxx index 6ced4bdac..6cb22447e 100644 --- a/tomviz/ResetReaction.cxx +++ b/tomviz/ResetReaction.cxx @@ -3,7 +3,9 @@ #include "ResetReaction.h" -#include "ModuleManager.h" +#include "ActiveObjects.h" +#include "HistogramManager.h" +#include "pipeline/Pipeline.h" #include "Utilities.h" #include @@ -15,14 +17,15 @@ ResetReaction::ResetReaction(QAction* parentObject) : Superclass(parentObject) void ResetReaction::updateEnableState() { - bool enabled = !ModuleManager::instance().hasRunningOperators(); + auto* pipeline = ActiveObjects::instance().pipeline(); + bool enabled = !pipeline || !pipeline->isExecuting(); parentAction()->setEnabled(enabled); } void ResetReaction::reset() { - if (ModuleManager::instance().hasDataSources() || - ModuleManager::instance().hasMoleculeSources()) { + auto* pipeline = ActiveObjects::instance().pipeline(); + if (pipeline && !pipeline->nodes().isEmpty()) { if (QMessageBox::Yes != QMessageBox::warning( tomviz::mainWidget(), "Reset", @@ -31,6 +34,9 @@ void ResetReaction::reset() return; } } - ModuleManager::instance().reset(); + if (pipeline) { + pipeline->clear(); + } + HistogramManager::instance().clearCaches(); } } // namespace tomviz diff --git a/tomviz/RotateAlignWidget.cxx b/tomviz/RotateAlignWidget.cxx index 905c6f9ad..05e1ee5f9 100644 --- a/tomviz/RotateAlignWidget.cxx +++ b/tomviz/RotateAlignWidget.cxx @@ -4,14 +4,12 @@ #include "RotateAlignWidget.h" #include "ActiveObjects.h" -#include "AddPythonTransformReaction.h" #include "ColorMap.h" -#include "DataSource.h" -#include "LoadDataReaction.h" #include "PresetDialog.h" #include "TomographyReconstruction.h" #include "TomographyTiltSeries.h" #include "Utilities.h" +#include "pipeline/data/VolumeData.h" #include @@ -122,6 +120,9 @@ class RotateAlignWidget::RAWInternal void setupColorMaps() { vtkSMSessionProxyManager* pxm = ActiveObjects::instance().proxyManager(); + if (!pxm) { + return; + } vtkNew tfmgr; for (int i = 0; i < 3; ++i) { @@ -241,6 +242,9 @@ class RotateAlignWidget::RAWInternal vtkDataArray* tiltAnglesArray = imageData->GetFieldData()->GetArray("tilt_angles"); + if (!tiltAnglesArray) { + return; + } double* tiltAngles = static_cast(tiltAnglesArray->GetVoidPointer(0)); @@ -279,11 +283,13 @@ class RotateAlignWidget::RAWInternal } } - vtkSMTransferFunctionProxy::RescaleTransferFunction( - this->ReconColorMap[i], range); - this->reconSlice[i]->GetProperty()->SetLookupTable( - vtkScalarsToColors::SafeDownCast( - this->ReconColorMap[i]->GetClientSideObject())); + if (this->ReconColorMap[i]) { + vtkSMTransferFunctionProxy::RescaleTransferFunction( + this->ReconColorMap[i], range); + this->reconSlice[i]->GetProperty()->SetLookupTable( + vtkScalarsToColors::SafeDownCast( + this->ReconColorMap[i]->GetClientSideObject())); + } tomviz::QVTKGLWidget* sliceView[] = { this->Ui.sliceView_1, this->Ui.sliceView_2, @@ -411,51 +417,56 @@ class RotateAlignWidget::RAWInternal } }; -RotateAlignWidget::RotateAlignWidget(Operator* op, - vtkSmartPointer image, - QWidget* p) - : CustomPythonOperatorWidget(p), Internals(new RAWInternal) +RotateAlignWidget::RotateAlignWidget( + const QMap& inputs, QWidget* p) + : pipeline::CustomPythonNodeWidget(p), Internals(new RAWInternal) { + vtkSmartPointer image; + vtkSMProxy* sourceColorMap = nullptr; + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + image = vol->imageData(); + vol->initColorMap(); + sourceColorMap = vol->colorMap(); + } + } this->Internals->m_image = image; - this->Internals->Ui.setupUi(this); + initUI(sourceColorMap); +} - this->Internals->readSettings(); +void RotateAlignWidget::initUI(vtkSMProxy* sourceColorMap) +{ + auto* d = this->Internals.data(); + d->Ui.setupUi(this); + d->readSettings(); + d->setupColorMaps(); - this->Internals->setupColorMaps(); QIcon setColorMapIcon(":/pqWidgets/Icons/pqFavorites.svg"); - this->Internals->Ui.colorMapButton_1->setIcon(setColorMapIcon); - this->Internals->Ui.colorMapButton_2->setIcon(setColorMapIcon); - this->Internals->Ui.colorMapButton_3->setIcon(setColorMapIcon); - this->connect(this->Internals->Ui.colorMapButton_1, &QToolButton::clicked, this, - &RotateAlignWidget::showChangeColorMapDialog0); - this->connect(this->Internals->Ui.colorMapButton_2, &QToolButton::clicked, this, - &RotateAlignWidget::showChangeColorMapDialog1); - this->connect(this->Internals->Ui.colorMapButton_3, &QToolButton::clicked, this, - &RotateAlignWidget::showChangeColorMapDialog2); - - this->Internals->mainSlice->SetMapper(this->Internals->mainSliceMapper); - this->Internals->reconSlice[0]->SetMapper( - this->Internals->reconSliceMapper[0]); - this->Internals->reconSlice[1]->SetMapper( - this->Internals->reconSliceMapper[1]); - this->Internals->reconSlice[2]->SetMapper( - this->Internals->reconSliceMapper[2]); - this->Internals->mainRenderer->AddViewProp(this->Internals->mainSlice); - this->Internals->reconRenderer[0]->AddViewProp( - this->Internals->reconSlice[0]); - this->Internals->reconRenderer[1]->AddViewProp( - this->Internals->reconSlice[1]); - this->Internals->reconRenderer[2]->AddViewProp( - this->Internals->reconSlice[2]); - - this->Internals->Ui.sliceView->renderWindow()->AddRenderer( - this->Internals->mainRenderer); - this->Internals->Ui.sliceView_1->renderWindow()->AddRenderer( - this->Internals->reconRenderer[0]); - this->Internals->Ui.sliceView_2->renderWindow()->AddRenderer( - this->Internals->reconRenderer[1]); - this->Internals->Ui.sliceView_3->renderWindow()->AddRenderer( - this->Internals->reconRenderer[2]); + d->Ui.colorMapButton_1->setIcon(setColorMapIcon); + d->Ui.colorMapButton_2->setIcon(setColorMapIcon); + d->Ui.colorMapButton_3->setIcon(setColorMapIcon); + connect(d->Ui.colorMapButton_1, &QToolButton::clicked, this, + &RotateAlignWidget::showChangeColorMapDialog0); + connect(d->Ui.colorMapButton_2, &QToolButton::clicked, this, + &RotateAlignWidget::showChangeColorMapDialog1); + connect(d->Ui.colorMapButton_3, &QToolButton::clicked, this, + &RotateAlignWidget::showChangeColorMapDialog2); + + d->mainSlice->SetMapper(d->mainSliceMapper); + d->reconSlice[0]->SetMapper(d->reconSliceMapper[0]); + d->reconSlice[1]->SetMapper(d->reconSliceMapper[1]); + d->reconSlice[2]->SetMapper(d->reconSliceMapper[2]); + d->mainRenderer->AddViewProp(d->mainSlice); + d->reconRenderer[0]->AddViewProp(d->reconSlice[0]); + d->reconRenderer[1]->AddViewProp(d->reconSlice[1]); + d->reconRenderer[2]->AddViewProp(d->reconSlice[2]); + + d->Ui.sliceView->renderWindow()->AddRenderer(d->mainRenderer); + d->Ui.sliceView_1->renderWindow()->AddRenderer(d->reconRenderer[0]); + d->Ui.sliceView_2->renderWindow()->AddRenderer(d->reconRenderer[1]); + d->Ui.sliceView_3->renderWindow()->AddRenderer(d->reconRenderer[2]); vtkNew interatorStyleMain; vtkNew interatorStyle1; @@ -466,141 +477,120 @@ RotateAlignWidget::RotateAlignWidget(Operator* op, interatorStyle2->SetRenderOnMouseMove(true); interatorStyle3->SetRenderOnMouseMove(true); - this->Internals->Ui.sliceView->interactor()->SetInteractorStyle( - interatorStyleMain); - this->Internals->Ui.sliceView_1->interactor()->SetInteractorStyle( - interatorStyle1); - this->Internals->Ui.sliceView_2->interactor()->SetInteractorStyle( - interatorStyle2); - this->Internals->Ui.sliceView_3->interactor()->SetInteractorStyle( - interatorStyle3); + d->Ui.sliceView->interactor()->SetInteractorStyle(interatorStyleMain); + d->Ui.sliceView_1->interactor()->SetInteractorStyle(interatorStyle1); + d->Ui.sliceView_2->interactor()->SetInteractorStyle(interatorStyle2); + d->Ui.sliceView_3->interactor()->SetInteractorStyle(interatorStyle3); - this->Internals->rotationAxis->SetPoint1(0, 0, 0); - this->Internals->rotationAxis->SetPoint1(1, 1, 1); - this->Internals->rotationAxis->Update(); + d->rotationAxis->SetPoint1(0, 0, 0); + d->rotationAxis->SetPoint1(1, 1, 1); + d->rotationAxis->Update(); vtkNew mapper; - mapper->SetInputConnection(this->Internals->rotationAxis->GetOutputPort()); - - this->Internals->axisActor->SetMapper(mapper); - this->Internals->axisActor->GetProperty()->SetColor(1, 1, 0); // yellow - this->Internals->axisActor->GetProperty()->SetLineWidth(2.5); - this->Internals->mainRenderer->AddActor(this->Internals->axisActor); + mapper->SetInputConnection(d->rotationAxis->GetOutputPort()); + d->axisActor->SetMapper(mapper); + d->axisActor->GetProperty()->SetColor(1, 1, 0); // yellow + d->axisActor->GetProperty()->SetLineWidth(2.5); + d->mainRenderer->AddActor(d->axisActor); for (int i = 0; i < 3; ++i) { - this->Internals->reconSliceLine[i]->Update(); + d->reconSliceLine[i]->Update(); vtkNew sMapper; - sMapper->SetInputConnection( - this->Internals->reconSliceLine[i]->GetOutputPort()); - this->Internals->reconSliceLineActor[i]->SetMapper(sMapper); - this->Internals->reconSliceLineActor[i]->GetProperty()->SetColor(1, 0, 0); - this->Internals->reconSliceLineActor[i]->GetProperty()->SetLineWidth(2.0); - this->Internals->reconSliceLineActor[i] - ->GetProperty() - ->SetLineStipplePattern(0xFF00); - this->Internals->mainRenderer->AddActor( - this->Internals->reconSliceLineActor[i]); + sMapper->SetInputConnection(d->reconSliceLine[i]->GetOutputPort()); + d->reconSliceLineActor[i]->SetMapper(sMapper); + d->reconSliceLineActor[i]->GetProperty()->SetColor(1, 0, 0); + d->reconSliceLineActor[i]->GetProperty()->SetLineWidth(2.0); + d->reconSliceLineActor[i]->GetProperty()->SetLineStipplePattern(0xFF00); + d->mainRenderer->AddActor(d->reconSliceLineActor[i]); } - QObject::connect(this->Internals->Ui.sumProjections, &QCheckBox::toggled, - this, &RotateAlignWidget::onSumProjectionsToggled); - QObject::connect(this->Internals->Ui.projection, - QOverload::of(&QDoubleSpinBox::valueChanged), this, - &RotateAlignWidget::onProjectionNumberChanged); - this->Internals->Ui.projection->installEventFilter(this); - - QObject::connect(this->Internals->Ui.spinBox_1, - QOverload::of(&QDoubleSpinBox::valueChanged), this, - [this](int val) { this->onReconSliceChanged(0, val); }); - this->Internals->Ui.spinBox_1->installEventFilter(this); - - QObject::connect(this->Internals->Ui.spinBox_2, - QOverload::of(&QDoubleSpinBox::valueChanged), this, - [this](int val) { this->onReconSliceChanged(1, val); }); - this->Internals->Ui.spinBox_2->installEventFilter(this); - - QObject::connect(this->Internals->Ui.spinBox_3, - QOverload::of(&QDoubleSpinBox::valueChanged), this, - [this](int val) { this->onReconSliceChanged(2, val); }); - this->Internals->Ui.spinBox_3->installEventFilter(this); - - QObject::connect(this->Internals->Ui.rotationAxis, - QOverload::of(&QDoubleSpinBox::valueChanged), this, - &RotateAlignWidget::onRotationShiftChanged); - this->Internals->Ui.rotationAxis->installEventFilter(this); - - QObject::connect(this->Internals->Ui.rotationAngle, - QOverload::of(&QDoubleSpinBox::valueChanged), this, - &RotateAlignWidget::onRotationAngleChanged); - this->Internals->Ui.rotationAngle->installEventFilter(this); - - QObject::connect(this->Internals->Ui.orientation, - QOverload::of(&QComboBox::currentIndexChanged), this, - [this](int val) { this->onOrientationChanged(val); }); - this->Internals->Ui.orientation->installEventFilter(this); - - // this->connect(this->Internals->Ui.pushButton, SIGNAL(pressed()), - // SLOT(onFinalReconButtonPressed())); - - this->Internals->mainSliceMapper->SetInputData(this->Internals->m_image); - this->Internals->mainSliceMapper->Update(); + connect(d->Ui.sumProjections, &QCheckBox::toggled, this, + &RotateAlignWidget::onSumProjectionsToggled); + connect(d->Ui.projection, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &RotateAlignWidget::onProjectionNumberChanged); + d->Ui.projection->installEventFilter(this); + + connect(d->Ui.spinBox_1, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](int val) { this->onReconSliceChanged(0, val); }); + d->Ui.spinBox_1->installEventFilter(this); + + connect(d->Ui.spinBox_2, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](int val) { this->onReconSliceChanged(1, val); }); + d->Ui.spinBox_2->installEventFilter(this); + + connect(d->Ui.spinBox_3, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](int val) { this->onReconSliceChanged(2, val); }); + d->Ui.spinBox_3->installEventFilter(this); + + connect(d->Ui.rotationAxis, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &RotateAlignWidget::onRotationShiftChanged); + d->Ui.rotationAxis->installEventFilter(this); + + connect(d->Ui.rotationAngle, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &RotateAlignWidget::onRotationAngleChanged); + d->Ui.rotationAngle->installEventFilter(this); + + connect(d->Ui.orientation, + QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int val) { this->onOrientationChanged(val); }); + d->Ui.orientation->installEventFilter(this); + + if (!d->m_image) { + return; + } - // Use a child data source if one is available so the color map will match - DataSource* ds; - if (op->childDataSource()) - ds = op->childDataSource(); - else if (op->dataSource()) - ds = op->dataSource(); - else - ds = ActiveObjects::instance().activeDataSource(); - - vtkScalarsToColors* lut = - vtkScalarsToColors::SafeDownCast(ds->colorMap()->GetClientSideObject()); - if (lut) { - this->Internals->mainSlice->GetProperty()->SetLookupTable(lut); - for (int i = 0; i < 3; ++i) { - this->Internals->ReconColorMap[i]->Copy(ds->colorMap()); - this->Internals->ReconColorMap[i]->UpdateVTKObjects(); + d->mainSliceMapper->SetInputData(d->m_image); + d->mainSliceMapper->Update(); + + // Apply source color map if provided + if (sourceColorMap) { + vtkScalarsToColors* lut = vtkScalarsToColors::SafeDownCast( + sourceColorMap->GetClientSideObject()); + if (lut) { + d->mainSlice->GetProperty()->SetLookupTable(lut); + for (int i = 0; i < 3; ++i) { + d->ReconColorMap[i]->Copy(sourceColorMap); + d->ReconColorMap[i]->UpdateVTKObjects(); + } } } - vtkImageData* imageData = this->Internals->m_image; + + vtkImageData* imageData = d->m_image; int dims[3]; imageData->GetDimensions(dims); - this->Internals->m_slice0 = vtkMath::Round(0.25 * dims[0]); - this->Internals->m_slice1 = vtkMath::Round(0.50 * dims[0]); - this->Internals->m_slice2 = vtkMath::Round(0.75 * dims[0]); + d->m_slice0 = vtkMath::Round(0.25 * dims[0]); + d->m_slice1 = vtkMath::Round(0.50 * dims[0]); + d->m_slice2 = vtkMath::Round(0.75 * dims[0]); int projectionNum = dims[2] / 2; - this->Internals->m_projectionNum = projectionNum; - this->Internals->mainSliceMapper->SetSliceNumber(projectionNum); - this->Internals->mainSliceMapper->Update(); + d->m_projectionNum = projectionNum; + d->mainSliceMapper->SetSliceNumber(projectionNum); + d->mainSliceMapper->Update(); - this->Internals->m_shiftRotation = 0; - this->Internals->m_tiltRotation = 0; + d->m_shiftRotation = 0; + d->m_tiltRotation = 0; - // Make sure the line matches the orientation - this->Internals->moveRotationAxisLine(); + d->moveRotationAxisLine(); updateControls(); - // We have to do this here since we need the output to exist so the camera - // can be initialized below - this->Internals->updateReconSlice(0); - this->Internals->updateReconSlice(1); - this->Internals->updateReconSlice(2); + d->updateReconSlice(0); + d->updateReconSlice(1); + d->updateReconSlice(2); - this->Internals->setupCameras(); - this->Internals->setupRotationAxisLine(); + d->setupCameras(); + d->setupRotationAxisLine(); updateWidgets(); } -CustomPythonOperatorWidget* RotateAlignWidget::New( - QWidget* p, Operator* op, vtkSmartPointer data) -{ - return new RotateAlignWidget(op, data, p); -} RotateAlignWidget::~RotateAlignWidget() {} @@ -778,7 +768,7 @@ std::array make_array(std::initializer_list list) { auto array = std::array(); int i = 0; - foreach (T t, list) { + for (const T& t : list) { array[i++] = t; } return array; diff --git a/tomviz/RotateAlignWidget.h b/tomviz/RotateAlignWidget.h index d61e975ac..8632fcede 100644 --- a/tomviz/RotateAlignWidget.h +++ b/tomviz/RotateAlignWidget.h @@ -4,29 +4,29 @@ #ifndef tomvizRotateAlignWidget_h #define tomvizRotateAlignWidget_h -#include "CustomPythonOperatorWidget.h" +#include "CustomPythonNodeWidget.h" +#include "PortData.h" #include "vtkSmartPointer.h" +#include #include +#include class vtkImageData; +class vtkSMProxy; namespace tomviz { -class Operator; -class RotateAlignWidget : public CustomPythonOperatorWidget +class RotateAlignWidget : public pipeline::CustomPythonNodeWidget { Q_OBJECT public: - RotateAlignWidget(Operator* op, vtkSmartPointer image, - QWidget* parent = NULL); + RotateAlignWidget(const QMap& inputs, + QWidget* parent = nullptr); ~RotateAlignWidget(); - static CustomPythonOperatorWidget* New(QWidget* p, Operator* op, - vtkSmartPointer data); - void getValues(QMap& map) override; void setValues(const QMap& map) override; @@ -58,6 +58,7 @@ protected slots: void changeColorMap2() { this->changeColorMap(2); } private: + void initUI(vtkSMProxy* sourceColorMap); void onReconSliceChanged(int idx, int val); void showChangeColorMapDialog(int reconSlice); void changeColorMap(int reconSlice); diff --git a/tomviz/SAM2SeedWidget.cxx b/tomviz/SAM2SeedWidget.cxx new file mode 100644 index 000000000..a1f9f01bf --- /dev/null +++ b/tomviz/SAM2SeedWidget.cxx @@ -0,0 +1,519 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "SAM2SeedWidget.h" + +#include "Utilities.h" +#include "pipeline/NodePropertiesWidget.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +// 2D slice display that reports left clicks in image-pixel +// coordinates. The mouse wheel zooms about the cursor and a +// right-button drag pans; both only alter imageRect(), which all +// click/marker mapping goes through. The marker is the current seed +// point. Plain QWidget on purpose - no Q_OBJECT, the click is +// delivered through a callback. +class SliceClickView : public QWidget +{ +public: + using ClickCallback = std::function; + + SliceClickView(QWidget* parent = nullptr) : QWidget(parent) + { + setMinimumHeight(220); + setCursor(Qt::CrossCursor); + } + + void setImage(const QImage& image) + { + // A new slice orientation gets a fresh view; same-size updates + // (slice scrubbing) keep the current zoom and pan. + if (image.size() != m_image.size()) { + m_zoom = 1.0; + m_pan = QPoint(); + } + m_image = image; + update(); + } + + // Marker in image-pixel coordinates; (-1, -1) hides it. + void setMarker(int px, int py) + { + m_marker = QPoint(px, py); + update(); + } + + void setClickCallback(ClickCallback cb) { m_callback = std::move(cb); } + + QSize sizeHint() const override { return QSize(400, 400); } + +protected: + void paintEvent(QPaintEvent*) override + { + QPainter painter(this); + painter.fillRect(rect(), Qt::black); + if (m_image.isNull()) { + return; + } + QRect target = imageRect(); + painter.drawImage(target, m_image); + + if (m_marker.x() >= 0 && m_marker.y() >= 0 && + m_marker.x() < m_image.width() && m_marker.y() < m_image.height()) { + double sx = target.width() / double(m_image.width()); + double sy = target.height() / double(m_image.height()); + QPointF c(target.x() + (m_marker.x() + 0.5) * sx, + target.y() + (m_marker.y() + 0.5) * sy); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(QColor(255, 60, 60), 1.5)); + painter.drawLine(QPointF(c.x() - 12, c.y()), QPointF(c.x() - 4, c.y())); + painter.drawLine(QPointF(c.x() + 4, c.y()), QPointF(c.x() + 12, c.y())); + painter.drawLine(QPointF(c.x(), c.y() - 12), QPointF(c.x(), c.y() - 4)); + painter.drawLine(QPointF(c.x(), c.y() + 4), QPointF(c.x(), c.y() + 12)); + painter.drawEllipse(c, 3.0, 3.0); + } + } + + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() == Qt::LeftButton) { + deliverClick(event->pos()); + } else if (event->button() == Qt::RightButton) { + m_panAnchor = event->pos(); + } + } + + void mouseMoveEvent(QMouseEvent* event) override + { + if (event->buttons() & Qt::LeftButton) { + deliverClick(event->pos()); + } else if (event->buttons() & Qt::RightButton) { + m_pan += event->pos() - m_panAnchor; + m_panAnchor = event->pos(); + clampPan(); + update(); + } + } + + void wheelEvent(QWheelEvent* event) override + { + QRect before = imageRect(); + if (before.width() <= 0 || before.height() <= 0) { + return; + } + // Zoom about the cursor: remember which image fraction is under it + // and shift the pan so that point stays put at the new zoom. + QPointF pos = event->position(); + double rx = (pos.x() - before.x()) / before.width(); + double ry = (pos.y() - before.y()) / before.height(); + m_zoom = + std::clamp(m_zoom * std::pow(1.0015, event->angleDelta().y()), 1.0, 32.0); + QRect after = imageRect(); + m_pan += QPoint(int(std::lround(pos.x() - (after.x() + rx * after.width()))), + int(std::lround(pos.y() - (after.y() + ry * after.height())))); + clampPan(); + update(); + } + + void resizeEvent(QResizeEvent* event) override + { + QWidget::resizeEvent(event); + clampPan(); + } + +private: + // The image's on-screen rect: letterboxed fit, scaled by the zoom, + // shifted by the pan. + QRect imageRect() const + { + if (m_image.isNull()) { + return QRect(); + } + QSize scaled = + (QSizeF(m_image.size()).scaled(QSizeF(size()), Qt::KeepAspectRatio) * + m_zoom).toSize(); + return QRect(QPoint((width() - scaled.width()) / 2 + m_pan.x(), + (height() - scaled.height()) / 2 + m_pan.y()), + scaled); + } + + // Keep the image edge-to-edge at most: no panning while it fits the + // viewport, and no revealing black past an edge once zoomed in. + void clampPan() + { + QRect target = imageRect(); + int mx = std::max(0, (target.width() - width()) / 2); + int my = std::max(0, (target.height() - height()) / 2); + m_pan = QPoint(std::clamp(m_pan.x(), -mx, mx), + std::clamp(m_pan.y(), -my, my)); + } + + void deliverClick(const QPoint& pos) + { + QRect target = imageRect(); + if (!m_callback || target.width() <= 0 || target.height() <= 0) { + return; + } + int px = (pos.x() - target.x()) * m_image.width() / target.width(); + int py = (pos.y() - target.y()) * m_image.height() / target.height(); + m_callback(std::clamp(px, 0, m_image.width() - 1), + std::clamp(py, 0, m_image.height() - 1)); + } + + QImage m_image; + QPoint m_marker = QPoint(-1, -1); + double m_zoom = 1.0; + QPoint m_pan; + QPoint m_panAnchor; + ClickCallback m_callback; +}; + +} // anonymous namespace + +namespace tomviz { + +class SAM2SeedWidget::Internal +{ +public: + pipeline::VolumeDataPtr volume; + vtkSmartPointer image; + int dims[3] = { 0, 0, 0 }; + + // The volume's shared color transfer function (null outside a full + // ParaView session); the slice is rendered through it so the dialog + // matches the main window's colormap, and a ModifiedEvent observer + // keeps it live while the user edits the colormap there. + vtkColorTransferFunction* ctf = nullptr; + unsigned long ctfObserverId = 0; + QTimer* refreshTimer = nullptr; + + SliceClickView* view = nullptr; + QSlider* slider = nullptr; + QLabel* sliderLabel = nullptr; + QVBoxLayout* layout = nullptr; + + pipeline::NodePropertiesWidget* form = nullptr; + QSpinBox* seedX = nullptr; + QSpinBox* seedY = nullptr; + QSpinBox* seedZ = nullptr; + QComboBox* zAxisCombo = nullptr; + + bool syncing = false; + + // The two volume axes lying in the displayed slice plane, in + // ascending order. This matches the operator: it moves the chosen + // z_axis to the end, so seed_x indexes the lower remaining axis and + // seed_y the higher one. + void inPlaneAxes(int zAxis, int& a, int& b) const + { + a = (zAxis == 0) ? 1 : 0; + b = (zAxis == 2) ? 1 : 2; + } +}; + +SAM2SeedWidget::SAM2SeedWidget( + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent), m_internal(new Internal) +{ + // Registered with needsData=true, so the host gates creation on + // input availability. + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + m_internal->volume = vol; + m_internal->image = vol->imageData(); + } + } + if (m_internal->image) { + m_internal->image->GetDimensions(m_internal->dims); + } + + // Render slices through the volume's own color map so the dialog + // matches the main window, and follow live edits made there. Use the + // color map only if the volume already has one: creating one here + // would leave the input volume carrying an unrescaled default + // transfer function it never had before (see the same caution in + // CentralWidget::setActiveVolumeData). Without one, refreshSlice() + // falls back to grayscale. + if (m_internal->volume && m_internal->volume->hasColorMap()) { + m_internal->ctf = m_internal->volume->colorTransferFunction(); + } + if (m_internal->ctf) { + m_internal->refreshTimer = new QTimer(this); + m_internal->refreshTimer->setSingleShot(true); + m_internal->refreshTimer->setInterval(0); + connect(m_internal->refreshTimer, &QTimer::timeout, this, + &SAM2SeedWidget::refreshSlice); + m_internal->ctfObserverId = m_internal->ctf->AddObserver( + vtkCommand::ModifiedEvent, this, &SAM2SeedWidget::onColorMapModified); + } + + m_internal->layout = new QVBoxLayout(this); + + auto* hint = new QLabel( + tr("Click the slice to set the seed point. Scroll to zoom, " + "right-drag to pan, and use the slider to pick the seed slice."), + this); + hint->setWordWrap(true); + m_internal->layout->addWidget(hint); + + m_internal->view = new SliceClickView(this); + m_internal->view->setObjectName("sam2SeedSliceView"); + m_internal->layout->addWidget(m_internal->view, 1); + m_internal->view->setClickCallback([this](int px, int py) { + if (m_internal->seedX && m_internal->seedY) { + m_internal->seedX->setValue(px); + m_internal->seedY->setValue(py); + } + }); + + auto* sliderRow = new QHBoxLayout; + sliderRow->addWidget(new QLabel(tr("Slice"), this)); + m_internal->slider = new QSlider(Qt::Horizontal, this); + m_internal->slider->setObjectName("sam2SeedSliceSlider"); + sliderRow->addWidget(m_internal->slider, 1); + m_internal->sliderLabel = new QLabel(this); + sliderRow->addWidget(m_internal->sliderLabel); + m_internal->layout->addLayout(sliderRow); + + connect(m_internal->slider, &QSlider::valueChanged, this, [this](int value) { + if (m_internal->syncing) { + return; + } + if (m_internal->seedZ) { + m_internal->syncing = true; + m_internal->seedZ->setValue(value); + m_internal->syncing = false; + } + refreshSlice(); + }); + + // The parameter form is built in setValues(), which the host calls + // with the node's current values right after construction. +} + +SAM2SeedWidget::~SAM2SeedWidget() +{ + // The CTF is owned by the volume, which m_internal->volume keeps + // alive until after this runs. + if (m_internal->ctf && m_internal->ctfObserverId) { + m_internal->ctf->RemoveObserver(m_internal->ctfObserverId); + } +} + +// Coalesce the bursts of ModifiedEvents fired while the user drags +// colormap controls into one repaint per event-loop pass. +void SAM2SeedWidget::onColorMapModified() +{ + m_internal->refreshTimer->start(); +} + +void SAM2SeedWidget::getValues(QMap& map) +{ + if (m_internal->form) { + map.insert(m_internal->form->values()); + } +} + +void SAM2SeedWidget::setValues(const QMap& map) +{ + delete m_internal->form; + m_internal->form = new pipeline::NodePropertiesWidget( + readInJSONDescription(QStringLiteral("SAM2Segment3D")), map, {}, this); + m_internal->layout->addWidget(m_internal->form); + wireForm(); +} + +void SAM2SeedWidget::wireForm() +{ + auto* form = m_internal->form; + + // The builder's description label uses an Ignored size policy, which + // collapses to zero height here because the form is not the layout's + // stretch widget (the slice view is). Let it request its wrapped + // height instead; Ignored stays on the horizontal axis so a long + // description cannot widen the dialog. + for (auto* label : form->findChildren()) { + if (label->wordWrap() && + label->sizePolicy().verticalPolicy() == QSizePolicy::Ignored) { + label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Minimum); + } + } + + // The vector "seed" parameter builds one spinbox per component, + // named seed#000 (x), seed#001 (y), seed#002 (z, the slice). + m_internal->seedX = form->findChild("seed#000"); + m_internal->seedY = form->findChild("seed#001"); + m_internal->seedZ = form->findChild("seed#002"); + m_internal->zAxisCombo = form->findChild("z_axis"); + + auto resync = [this]() { + if (!m_internal->syncing) { + syncSliderAndSlice(); + } + }; + if (m_internal->seedX) { + connect(m_internal->seedX, QOverload::of(&QSpinBox::valueChanged), + this, &SAM2SeedWidget::refreshMarker); + } + if (m_internal->seedY) { + connect(m_internal->seedY, QOverload::of(&QSpinBox::valueChanged), + this, &SAM2SeedWidget::refreshMarker); + } + if (m_internal->seedZ) { + connect(m_internal->seedZ, QOverload::of(&QSpinBox::valueChanged), + this, resync); + } + if (m_internal->zAxisCombo) { + connect(m_internal->zAxisCombo, + QOverload::of(&QComboBox::currentIndexChanged), this, resync); + } + syncSliderAndSlice(); +} + +// Bring the slider's range and position in line with the current +// Z Axis and Seed Z form values, then re-render. +void SAM2SeedWidget::syncSliderAndSlice() +{ + int n = m_internal->dims[sliceAxis()]; + m_internal->syncing = true; + m_internal->slider->setRange(0, qMax(0, n - 1)); + m_internal->slider->setValue(effectiveSliceIndex()); + m_internal->syncing = false; + refreshSlice(); +} + +int SAM2SeedWidget::sliceAxis() const +{ + if (!m_internal->zAxisCombo) { + return 0; + } + return std::clamp(m_internal->zAxisCombo->currentData().toInt(), 0, 2); +} + +int SAM2SeedWidget::effectiveSliceIndex() const +{ + int n = m_internal->dims[sliceAxis()]; + if (n <= 0) { + return 0; + } + int k = m_internal->seedZ ? m_internal->seedZ->value() : -1; + // Negative means "middle slice", matching the operator. + return k < 0 ? n / 2 : std::clamp(k, 0, n - 1); +} + +void SAM2SeedWidget::refreshSlice() +{ + auto* image = m_internal->image.GetPointer(); + int* dims = m_internal->dims; + if (!image || dims[0] <= 0 || dims[1] <= 0 || dims[2] <= 0) { + return; + } + + int zAxis = sliceAxis(); + int a, b; + m_internal->inPlaneAxes(zAxis, a, b); + int k = effectiveSliceIndex(); + int w = dims[a]; + int h = dims[b]; + + std::vector values(size_t(w) * h); + int coords[3]; + coords[zAxis] = k; + for (int py = 0; py < h; ++py) { + coords[b] = py; + for (int px = 0; px < w; ++px) { + coords[a] = px; + values[size_t(py) * w + px] = image->GetScalarComponentAsDouble( + coords[0], coords[1], coords[2], 0); + } + } + + QImage img; + if (m_internal->ctf) { + // Map values through the volume's color transfer function so the + // slice matches the main window's colormap, including any + // brightness/contrast edits to its control points. + img = QImage(w, h, QImage::Format_RGB888); + for (int py = 0; py < h; ++py) { + uchar* line = img.scanLine(py); + for (int px = 0; px < w; ++px) { + double rgb[3]; + m_internal->ctf->GetColor(values[size_t(py) * w + px], rgb); + for (int c = 0; c < 3; ++c) { + line[3 * px + c] = uchar(std::clamp(rgb[c], 0.0, 1.0) * 255.0); + } + } + } + } else { + // No colormap (no ParaView session): robust 1%/99% grayscale + // stretch so dim features stay visible next to bright cores. + std::vector sorted(values); + auto percentile = [&sorted](double frac) { + auto nth = sorted.begin() + size_t(frac * (sorted.size() - 1)); + std::nth_element(sorted.begin(), nth, sorted.end()); + return *nth; + }; + double vlo = percentile(0.01); + double vhi = percentile(0.99); + if (vhi <= vlo) { + vlo = percentile(0.0); + vhi = percentile(1.0); + } + double scale = vhi > vlo ? 255.0 / (vhi - vlo) : 0.0; + + img = QImage(w, h, QImage::Format_Grayscale8); + for (int py = 0; py < h; ++py) { + uchar* line = img.scanLine(py); + for (int px = 0; px < w; ++px) { + double v = (values[size_t(py) * w + px] - vlo) * scale; + line[px] = uchar(std::clamp(v, 0.0, 255.0)); + } + } + } + + m_internal->view->setImage(img); + m_internal->sliderLabel->setText( + QString("%1 / %2").arg(k).arg(dims[zAxis] - 1)); + refreshMarker(); +} + +void SAM2SeedWidget::refreshMarker() +{ + int* dims = m_internal->dims; + int a, b; + m_internal->inPlaneAxes(sliceAxis(), a, b); + + // Negative seed means "center", matching the operator. + int px = m_internal->seedX ? m_internal->seedX->value() : -1; + int py = m_internal->seedY ? m_internal->seedY->value() : -1; + px = px < 0 ? dims[a] / 2 : std::clamp(px, 0, qMax(0, dims[a] - 1)); + py = py < 0 ? dims[b] / 2 : std::clamp(py, 0, qMax(0, dims[b] - 1)); + m_internal->view->setMarker(px, py); +} + +} // namespace tomviz diff --git a/tomviz/SAM2SeedWidget.h b/tomviz/SAM2SeedWidget.h new file mode 100644 index 000000000..f741ef4f2 --- /dev/null +++ b/tomviz/SAM2SeedWidget.h @@ -0,0 +1,48 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizSAM2SeedWidget_h +#define tomvizSAM2SeedWidget_h + +#include "pipeline/CustomPythonNodeWidget.h" +#include "pipeline/PortData.h" + +#include +#include + +namespace tomviz { + +/// Custom parameters widget for the SAM 2 segmentation operator. Shows +/// the standard JSON-driven parameter form plus an input-volume slice +/// viewer: clicking the slice sets Seed X/Y, a slider picks the seed +/// slice (Seed Z), and the Z Axis choice controls the slicing +/// direction. The seed round-trips through the same seed_x/y/z +/// parameters, so serialization and external execution are unchanged. +class SAM2SeedWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + SAM2SeedWidget(const QMap& inputs, + QWidget* parent = nullptr); + ~SAM2SeedWidget() override; + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + +private: + void wireForm(); + void syncSliderAndSlice(); + void refreshSlice(); + void refreshMarker(); + void onColorMapModified(); + int sliceAxis() const; + int effectiveSliceIndex() const; + + class Internal; + QScopedPointer m_internal; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/SaveDataDialog.cxx b/tomviz/SaveDataDialog.cxx new file mode 100644 index 000000000..38b69233e --- /dev/null +++ b/tomviz/SaveDataDialog.cxx @@ -0,0 +1,619 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "SaveDataDialog.h" + +#include "pipeline/Node.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SinkNode.h" + +#include "ui_SaveDataDialog.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace tomviz { + +using pipeline::Node; +using pipeline::OutputPort; +using pipeline::Pipeline; +using pipeline::PortType; + +namespace { + +const char* kSettingsGroup = "SaveDataDialog"; + +/// Sinks never produce data of their own, and a sink group's +/// passthrough ports only mirror their upstream port. +bool isSinkLike(Node* node) +{ + return qobject_cast(node) != nullptr || + qobject_cast(node) != nullptr; +} + +/// A leaf is a node whose output only feeds visualizations (or nothing +/// at all) — the tip of a processing branch. +bool isLeafNode(Node* node) +{ + for (auto* downstream : node->downstreamNodes()) { + if (!isSinkLike(downstream)) { + return false; + } + } + return true; +} + +/// Reduce @a name to characters that are safe in a filename on every +/// platform tomviz runs on. Returns an empty string if nothing survives. +QString sanitize(const QString& name) +{ + static const QRegularExpression unsafe(QStringLiteral("[^A-Za-z0-9._-]+")); + QString result = name; + result.replace(unsafe, QStringLiteral("_")); + while (result.startsWith('_') || result.startsWith('.')) { + result.remove(0, 1); + } + while (result.endsWith('_') || result.endsWith('.')) { + result.chop(1); + } + return result; +} + +QString nodeBaseName(Node* node) +{ + QString name = sanitize(node->label()); + return name.isEmpty() ? QStringLiteral("node") : name; +} + +/// Filename stem for each node, with a 1-based numeric suffix added to +/// every node sharing a label so the stems stay distinguishable. +QHash uniqueNodeNames(const QList& ports) +{ + QList ordered; + QHash counts; + for (auto* port : ports) { + auto* node = port->node(); + if (!node || ordered.contains(node)) { + continue; + } + ordered.append(node); + counts[nodeBaseName(node)]++; + } + + QHash result; + QHash seen; + for (auto* node : ordered) { + QString base = nodeBaseName(node); + if (counts.value(base) > 1) { + base = QStringLiteral("%1_%2").arg(base).arg(++seen[base]); + } + result.insert(node, base); + } + return result; +} + +/// Take @a stem, or the first `stem_N` variant nothing has claimed yet. +/// Sanitizing can collapse distinct names onto the same stem, so this +/// runs even after node labels have been disambiguated. +QString claimName(const QString& stem, QSet& used) +{ + QString claimed = stem; + for (int suffix = 2; used.contains(claimed.toLower()); ++suffix) { + claimed = QStringLiteral("%1_%2").arg(stem).arg(suffix); + } + used.insert(claimed.toLower()); + return claimed; +} + +/// The part of @a stem past the shared @a base, for the braced display +/// name. Reads from the claimed stem rather than the array name so a +/// collision suffix shows up in the row the way it will on disk. +QString tailOf(const QString& stem, const QString& base) +{ + QString tail = stem.mid(base.size()); + if (tail.startsWith('_')) { + tail.remove(0, 1); + } + return tail; +} + +} // namespace + +SaveDataDialog::SaveDataDialog(Pipeline* pipeline, QWidget* parent) + : QDialog(parent), m_ui(new Ui::SaveDataDialog), m_pipeline(pipeline) +{ + init(); +} + +SaveDataDialog::SaveDataDialog(Node* node, QWidget* parent) + : QDialog(parent), m_ui(new Ui::SaveDataDialog), m_node(node) +{ + init(); + restrictScope(); +} + +SaveDataDialog::SaveDataDialog(OutputPort* port, QWidget* parent) + : QDialog(parent), m_ui(new Ui::SaveDataDialog), m_port(port) +{ + init(); + restrictScope(); +} + +void SaveDataDialog::restrictScope() +{ + // "Leaf nodes only" is meaningless once we're down to one node or one + // port — pin the scope, but leave the group visible so the rule the + // dialog is following stays on screen. + m_ui->allPersistedRadio->setChecked(true); + m_ui->scopeGroup->setEnabled(false); + + rebuildPlan(); +} + +void SaveDataDialog::init() +{ + m_ui->setupUi(this); + + const QList> combos = { + { m_ui->imageDataCombo, PortType::ImageData }, + { m_ui->tableCombo, PortType::Table }, + { m_ui->moleculeCombo, PortType::Molecule } + }; + for (const auto& [combo, type] : combos) { + for (const auto& format : PortDataWriter::formats(type)) { + combo->addItem(format.label(), format.id); + } + connect(combo, &QComboBox::currentIndexChanged, this, + &SaveDataDialog::rebuildPlan); + } + + m_ui->fileTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + + connect(m_ui->browseButton, &QPushButton::clicked, this, + &SaveDataDialog::browseForDirectory); + connect(m_ui->directoryEdit, &QLineEdit::textChanged, this, + &SaveDataDialog::rebuildPlan); + connect(m_ui->leafOnlyRadio, &QRadioButton::toggled, this, + &SaveDataDialog::rebuildPlan); + connect(m_ui->selectAllButton, &QPushButton::clicked, this, + [this]() { setAllChecked(true); }); + connect(m_ui->deselectAllButton, &QPushButton::clicked, this, + [this]() { setAllChecked(false); }); + connect(m_ui->fileTree, &QTreeWidget::itemChanged, this, [this]() { + rememberCheckState(); + updateSummary(); + }); + connect(this, &QDialog::accepted, this, &SaveDataDialog::saveSettings); + + restoreSettings(); + rebuildPlan(); +} + +SaveDataDialog::~SaveDataDialog() = default; + +QList SaveDataDialog::candidatePorts(OutputPort* port, Scope scope) +{ + if (!port || !port->hasData()) { + return {}; + } + if (scope == Scope::AllPersisted && !port->isPersistent()) { + return {}; + } + if (PortDataWriter::formats(port->type()).isEmpty()) { + return {}; + } + return { port }; +} + +QList SaveDataDialog::candidatePorts(Node* node, Scope scope) +{ + QList result; + if (!node || isSinkLike(node)) { + return result; + } + if (scope == Scope::LeafNodes && !isLeafNode(node)) { + return result; + } + + for (auto* port : node->outputPorts()) { + result.append(candidatePorts(port, scope)); + } + + return result; +} + +QList SaveDataDialog::candidatePorts(Pipeline* pipeline, + Scope scope) +{ + QList result; + if (!pipeline) { + return result; + } + + for (auto* node : pipeline->nodes()) { + result.append(candidatePorts(node, scope)); + } + + return result; +} + +QList SaveDataDialog::planPorts( + const QList& ports, + const QHash& arrayNames, const QString& directory, + const QHash& formats) +{ + auto nodeNames = uniqueNodeNames(ports); + QDir destination(directory); + + QList plans; + QSet usedNames; + for (auto* port : ports) { + auto format = formats.value(PortDataWriter::formatGroup(port->type())); + if (format.extension.isEmpty()) { + continue; + } + + QStringList base{ nodeNames.value(port->node()), sanitize(port->name()) }; + base.removeAll(QString()); + + PortPlan plan; + plan.port = port; + + auto arrays = arrayNames.value(port); + if (format.multiArray) { + QString stem = claimName(base.join('_'), usedNames); + plan.displayName = QStringLiteral("%1.%2").arg(stem, format.extension); + plan.entries.append( + { port, arrays, destination.filePath(plan.displayName) }); + } else { + // One file per array: the stems differ only in their tail, so the + // row can show them braced rather than one row per file. + QStringList tails; + for (const auto& arrayName : arrays) { + QStringList parts = base; + parts.append(sanitize(arrayName)); + parts.removeAll(QString()); + + QString stem = claimName(parts.join('_'), usedNames); + QString fileName = + QStringLiteral("%1.%2").arg(stem, format.extension); + tails.append(tailOf(stem, base.join('_'))); + plan.entries.append( + { port, { arrayName }, destination.filePath(fileName) }); + } + + plan.displayName = + plan.entries.size() == 1 + ? QFileInfo(plan.entries.first().path).fileName() + : QStringLiteral("%1_{%2}.%3") + .arg(base.join('_'), tails.join('|'), format.extension); + } + + if (!plan.entries.isEmpty()) { + plans.append(plan); + } + } + + return plans; +} + +SaveDataDialog::Scope SaveDataDialog::currentScope() const +{ + if (m_port || m_node) { + return Scope::AllPersisted; + } + return m_ui->leafOnlyRadio->isChecked() ? Scope::LeafNodes + : Scope::AllPersisted; +} + +QList SaveDataDialog::currentCandidates() const +{ + auto scope = currentScope(); + if (m_port) { + return candidatePorts(m_port, scope); + } + if (m_node) { + return candidatePorts(m_node, scope); + } + return candidatePorts(m_pipeline, scope); +} + +PortFormat SaveDataDialog::formatFor(PortType type) const +{ + QComboBox* combo = nullptr; + switch (PortDataWriter::formatGroup(type)) { + case PortType::ImageData: + combo = m_ui->imageDataCombo; + break; + case PortType::Table: + combo = m_ui->tableCombo; + break; + case PortType::Molecule: + combo = m_ui->moleculeCombo; + break; + default: + return PortFormat(); + } + + return PortDataWriter::formatById(type, combo->currentData().toString()); +} + +void SaveDataDialog::browseForDirectory() +{ + // Mirrors the "directory" parameter widget in ParameterInterfaceBuilder + // so the two browse affordances behave identically. + QString browseDir; + if (!m_ui->directoryEdit->text().isEmpty()) { + QDir dir = QFileInfo(m_ui->directoryEdit->text()).dir(); + if (dir.exists()) { + browseDir = dir.absolutePath(); + } + } + + QString path = + QFileDialog::getExistingDirectory(window(), "Select Directory", browseDir); + if (!path.isNull()) { + m_ui->directoryEdit->setText(path); + } +} + +void SaveDataDialog::rebuildPlan() +{ + auto ports = currentCandidates(); + + // Reading array names requires the payload, which for an OnDisk port + // means a load from the cache file. Cache the result per port so + // flipping formats doesn't pay that cost again. + QApplication::setOverrideCursor(Qt::WaitCursor); + for (auto* port : ports) { + if (m_arrayNames.contains(port)) { + continue; + } + auto handle = port->materialize(); + m_arrayNames.insert(port, handle ? PortDataWriter::arrayNames(*handle) + : QStringList()); + } + QApplication::restoreOverrideCursor(); + + QHash formats; + for (auto group : + { PortType::ImageData, PortType::Table, PortType::Molecule }) { + formats.insert(group, formatFor(group)); + } + + m_plans = + planPorts(ports, m_arrayNames, m_ui->directoryEdit->text(), formats); + + QSignalBlocker blocker(m_ui->fileTree); + m_ui->fileTree->clear(); + for (const auto& plan : m_plans) { + auto* item = new QTreeWidgetItem(m_ui->fileTree); + item->setText(0, plan.port->node()->label()); + item->setText(1, plan.port->name()); + item->setText(2, pipeline::portTypeToString(plan.port->type())); + item->setText(3, plan.displayName); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(0, m_unchecked.contains(plan.port) ? Qt::Unchecked + : Qt::Checked); + } + blocker.unblock(); + + // Grey out format rows for types that aren't in scope. + QSet groups; + for (auto* port : ports) { + groups.insert(PortDataWriter::formatGroup(port->type())); + } + const std::initializer_list> rows = { + { m_ui->imageDataLabel, m_ui->imageDataCombo, PortType::ImageData }, + { m_ui->tableLabel, m_ui->tableCombo, PortType::Table }, + { m_ui->moleculeLabel, m_ui->moleculeCombo, PortType::Molecule } + }; + for (const auto& [label, combo, type] : rows) { + label->setEnabled(groups.contains(type)); + combo->setEnabled(groups.contains(type)); + } + + updateSummary(); +} + +void SaveDataDialog::rememberCheckState() +{ + for (int i = 0; i < m_ui->fileTree->topLevelItemCount(); ++i) { + auto* port = m_plans[i].port; + if (m_ui->fileTree->topLevelItem(i)->checkState(0) == Qt::Checked) { + m_unchecked.remove(port); + } else { + m_unchecked.insert(port); + } + } +} + +void SaveDataDialog::setAllChecked(bool checked) +{ + QSignalBlocker blocker(m_ui->fileTree); + for (int i = 0; i < m_ui->fileTree->topLevelItemCount(); ++i) { + m_ui->fileTree->topLevelItem(i)->setCheckState( + 0, checked ? Qt::Checked : Qt::Unchecked); + } + blocker.unblock(); + + rememberCheckState(); + updateSummary(); +} + +void SaveDataDialog::updateSummary() +{ + int total = 0; + for (const auto& plan : m_plans) { + total += plan.entries.size(); + } + + int count = selectedEntries().size(); + if (m_plans.isEmpty()) { + m_ui->summaryLabel->setText("No data available to save."); + } else { + m_ui->summaryLabel->setText( + QStringLiteral("%1 of %2 files selected.").arg(count).arg(total)); + } + + bool hasDirectory = !m_ui->directoryEdit->text().trimmed().isEmpty(); + m_ui->buttonBox->button(QDialogButtonBox::Save) + ->setEnabled(count > 0 && hasDirectory); +} + +QList SaveDataDialog::selectedEntries() const +{ + QList selected; + for (int i = 0; i < m_ui->fileTree->topLevelItemCount(); ++i) { + if (m_ui->fileTree->topLevelItem(i)->checkState(0) == Qt::Checked) { + selected.append(m_plans[i].entries); + } + } + return selected; +} + +int SaveDataDialog::writeEntries(const QList& entries, QWidget* parent) +{ + if (entries.isEmpty()) { + return 0; + } + + QStringList existing; + for (const auto& entry : entries) { + if (QFileInfo::exists(entry.path)) { + existing << QFileInfo(entry.path).fileName(); + } + } + if (!existing.isEmpty()) { + auto answer = QMessageBox::question( + parent, "Overwrite Files?", + QStringLiteral("%1 of the %2 files already exist and will be " + "overwritten:\n\n%3\n\nContinue?") + .arg(existing.size()) + .arg(entries.size()) + .arg(existing.mid(0, 10).join('\n') + + (existing.size() > 10 ? "\n..." : ""))); + if (answer != QMessageBox::Yes) { + return 0; + } + } + + // Every entry shares one destination, so the first is representative. + QDir().mkpath(QFileInfo(entries.first().path).absolutePath()); + + QProgressDialog progress("Saving data...", "Cancel", 0, entries.size(), + parent); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(0); + + QStringList failures; + int written = 0; + for (int i = 0; i < entries.size(); ++i) { + const auto& entry = entries[i]; + QString name = QFileInfo(entry.path).fileName(); + + progress.setValue(i); + progress.setLabelText(QStringLiteral("Saving %1...").arg(name)); + QApplication::processEvents(); + if (progress.wasCanceled()) { + break; + } + + auto handle = entry.port->materialize(); + if (!handle || + !PortDataWriter::write(*handle, entry.arrayNames, entry.path)) { + failures << name; + continue; + } + ++written; + } + progress.setValue(entries.size()); + + if (!failures.isEmpty()) { + QMessageBox::warning( + parent, "Save Data", + QStringLiteral("Failed to write %1 of %2 files:\n\n%3") + .arg(failures.size()) + .arg(entries.size()) + .arg(failures.mid(0, 10).join('\n') + + (failures.size() > 10 ? "\n..." : ""))); + } + + return written; +} + +void SaveDataDialog::restoreSettings() +{ + auto* core = pqApplicationCore::instance(); + if (!core) { + return; + } + + auto* settings = core->settings(); + settings->beginGroup(kSettingsGroup); + + // The destination is deliberately not restored — writing a pile of + // files is destructive enough that the user should name the directory + // every time rather than inherit one from a previous session. + + const QList> combos = { + { m_ui->imageDataCombo, "imageDataFormat" }, + { m_ui->tableCombo, "tableFormat" }, + { m_ui->moleculeCombo, "moleculeFormat" } + }; + for (const auto& [combo, key] : combos) { + int index = combo->findData(settings->value(key).toString()); + if (index >= 0) { + combo->setCurrentIndex(index); + } + } + + bool allPersisted = settings->value("allPersisted", true).toBool(); + m_ui->allPersistedRadio->setChecked(allPersisted); + m_ui->leafOnlyRadio->setChecked(!allPersisted); + + settings->endGroup(); +} + +void SaveDataDialog::saveSettings() const +{ + auto* core = pqApplicationCore::instance(); + if (!core) { + return; + } + + auto* settings = core->settings(); + settings->beginGroup(kSettingsGroup); + settings->setValue("imageDataFormat", m_ui->imageDataCombo->currentData()); + settings->setValue("tableFormat", m_ui->tableCombo->currentData()); + settings->setValue("moleculeFormat", m_ui->moleculeCombo->currentData()); + if (!m_node && !m_port) { + // A restricted save has no scope choice to remember; persisting its + // pinned value would silently retarget the next unrestricted one. + settings->setValue("allPersisted", m_ui->allPersistedRadio->isChecked()); + } + settings->endGroup(); +} + +} // namespace tomviz diff --git a/tomviz/SaveDataDialog.h b/tomviz/SaveDataDialog.h new file mode 100644 index 000000000..311d70dda --- /dev/null +++ b/tomviz/SaveDataDialog.h @@ -0,0 +1,167 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizSaveDataDialog_h +#define tomvizSaveDataDialog_h + +#include "PortDataWriter.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace Ui { +class SaveDataDialog; +} + +namespace tomviz { + +namespace pipeline { +class Node; +class OutputPort; +class Pipeline; +} // namespace pipeline + +/// Batch export of pipeline output-port payloads into a directory, one +/// file per scalar array. The user picks the destination, one file +/// format per port-type group, and which ports are in scope, then +/// reviews the resolved filenames before anything is written. +class SaveDataDialog : public QDialog +{ + Q_OBJECT + +public: + /// Which output ports the dialog offers. + enum class Scope + { + /// Ports of nodes whose only downstream nodes are sinks. + LeafNodes, + /// Every persistent port currently holding data, in memory or on disk. + AllPersisted + }; + + /// One file to be written. + struct Entry + { + pipeline::OutputPort* port = nullptr; + /// Scalar arrays to write into this file. A payload that isn't + /// array-bearing carries a single empty name. + QStringList arrayNames; + /// Absolute path, already disambiguated against the other entries. + QString path; + }; + + /// Everything one port contributes, shown as a single row. + struct PortPlan + { + pipeline::OutputPort* port = nullptr; + QList entries; + /// The filename, or — when the port produces several files — the + /// shared name with the varying part braced: + /// `node_port_{arrayA|arrayB}.ext`. + QString displayName; + }; + + /// Export from anywhere in @a pipeline; the user picks the scope. + explicit SaveDataDialog(pipeline::Pipeline* pipeline, + QWidget* parent = nullptr); + + /// Export just @a node's own ports. A single node has no useful + /// notion of leaves, so the scope is pinned to AllPersisted and the + /// choice is disabled. + explicit SaveDataDialog(pipeline::Node* node, QWidget* parent = nullptr); + + /// Export a single output port, with the scope pinned as above. + explicit SaveDataDialog(pipeline::OutputPort* port, + QWidget* parent = nullptr); + + ~SaveDataDialog() override; + + /// The entries the user left checked, in display order. + QList selectedEntries() const; + + /// Write @a entries, prompting once about any files that already + /// exist and reporting failures at the end. Returns the number of + /// files successfully written. + static int writeEntries(const QList& entries, QWidget* parent); + + /// Output ports in @a scope that tomviz has a writer for. Shared with + /// SaveDataReaction, which uses it to decide whether the action should + /// be enabled at all. + static QList candidatePorts( + pipeline::Pipeline* pipeline, Scope scope); + + /// The same filtering restricted to one node's own output ports. + static QList candidatePorts(pipeline::Node* node, + Scope scope); + + /// The same filtering narrowed to a single port. Returns an empty + /// list when the port doesn't qualify — nothing to write, no writer + /// for its type, or transient under an AllPersisted scope. + static QList candidatePorts( + pipeline::OutputPort* port, Scope scope); + + /// Resolve the files each port in @a ports produces under @a directory. + /// A format that can hold every array of a volume gets one file named + /// `{node}_{port}.{extension}`; one that can't gets one file per array, + /// named `{node}_{port}_{array}.{extension}`. Node labels aren't unique, + /// so labels shared by more than one node gain a 1-based numeric suffix; + /// every component is sanitized, and names that still collide afterwards + /// get a numeric suffix of their own. + /// + /// @a arrayNames supplies each port's scalar array names (see + /// PortDataWriter::arrayNames) — passed in rather than read from the + /// ports so callers can cache it across replans. @a formats maps a + /// port-type group (see PortDataWriter::formatGroup) to the format its + /// ports should be written in; ports whose group is absent are skipped. + static QList planPorts( + const QList& ports, + const QHash& arrayNames, + const QString& directory, + const QHash& formats); + +private: + Q_DISABLE_COPY(SaveDataDialog) + + /// Shared body of the constructors. + void init(); + /// Pin the scope for the node- and port-restricted modes. + void restrictScope(); + /// The ports the current restriction and scope admit. + QList currentCandidates() const; + void browseForDirectory(); + /// Recompute the file list from the current scope, formats and + /// destination, and repopulate the tree. + void rebuildPlan(); + void updateSummary(); + void setAllChecked(bool checked); + void rememberCheckState(); + void restoreSettings(); + void saveSettings() const; + Scope currentScope() const; + PortFormat formatFor(pipeline::PortType type) const; + + QScopedPointer m_ui; + /// Exactly one of these is set, narrowest first: a single port, a + /// single node, or the whole graph. + pipeline::OutputPort* m_port = nullptr; + pipeline::Node* m_node = nullptr; + pipeline::Pipeline* m_pipeline = nullptr; + /// One per tree row, in display order. + QList m_plans; + /// Scalar-array names per port. Filling this materializes the payload, + /// so it is cached across rebuilds to avoid re-reading on-disk data + /// every time a format or scope changes. + QHash m_arrayNames; + /// Ports the user has explicitly unchecked, so a rebuild doesn't + /// silently re-select them. + QSet m_unchecked; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/SaveDataDialog.ui b/tomviz/SaveDataDialog.ui new file mode 100644 index 000000000..2969ea298 --- /dev/null +++ b/tomviz/SaveDataDialog.ui @@ -0,0 +1,228 @@ + + + SaveDataDialog + + + + 0 + 0 + 720 + 560 + + + + Save Data + + + + + + + + Destination: + + + + + + + Choose a directory to write the files into + + + + + + + Browse + + + + + + + + + + + File formats + + + + + + Image data: + + + + + + + + + + Table: + + + + + + + + + + Molecule: + + + + + + + + + + + + + Ports to save + + + + + + Leaf nodes only + + + Nodes whose output does not feed another operator. Nodes that only feed visualizations still count as leaves. + + + + + + + All persisted ports + + + Every pinned output port currently holding data, in memory or on disk. + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + + false + + + true + + + true + + + + Node + + + + + Port + + + + + Type + + + + + File + + + + + + + + + + Select All + + + + + + + Deselect All + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + + + + + + + buttonBox + accepted() + SaveDataDialog + accept() + + + buttonBox + rejected() + SaveDataDialog + reject() + + + diff --git a/tomviz/SaveDataReaction.cxx b/tomviz/SaveDataReaction.cxx index fe95ddf03..8f1552efe 100644 --- a/tomviz/SaveDataReaction.cxx +++ b/tomviz/SaveDataReaction.cxx @@ -3,227 +3,70 @@ #include "SaveDataReaction.h" -#include "ConvertToFloatOperator.h" -#include "DataExchangeFormat.h" -#include "EmdFormat.h" -#include "Utilities.h" - #include "ActiveObjects.h" -#include "DataSource.h" -#include "FileFormatManager.h" -#include "ModuleManager.h" -#include "PythonWriter.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include +#include "SaveDataDialog.h" +#include "Utilities.h" -#include +#include "pipeline/Pipeline.h" -#include -#include -#include -#include -#include +#include namespace tomviz { SaveDataReaction::SaveDataReaction(QAction* parentObject) : pqReaction(parentObject) { - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &SaveDataReaction::updateEnableState); + auto& ao = ActiveObjects::instance(); + connect(&ao, &ActiveObjects::activePipelineChanged, this, + &SaveDataReaction::connectToPipeline); + // MainWindow creates the pipeline before it creates its reactions, so + // activePipelineChanged has usually already fired by the time we get + // here — pick up whatever is already there. + connectToPipeline(ao.pipeline()); +} + +void SaveDataReaction::connectToPipeline(pipeline::Pipeline* p) +{ + // Ports gain data when a run finishes and lose it when their node goes + // away; between them those cover the transitions worth greying the menu + // item for. A port that gains data by some other route — a state file + // attaching payloads before the first run — leaves the item stale until + // the next run, which the dialog's empty state handles gracefully. + if (p) { + connect(p, &pipeline::Pipeline::executionFinished, this, + &SaveDataReaction::updateEnableState, Qt::UniqueConnection); + connect(p, &pipeline::Pipeline::nodeRemoved, this, + &SaveDataReaction::updateEnableState, Qt::UniqueConnection); + } updateEnableState(); } void SaveDataReaction::updateEnableState() { - parentAction()->setEnabled(ActiveObjects::instance().activeDataSource() != - nullptr); + auto* pipeline = ActiveObjects::instance().pipeline(); + // Either scope having something to offer is reason enough to let the + // user open the dialog. + bool anything = + !SaveDataDialog::candidatePorts(pipeline, + SaveDataDialog::Scope::AllPersisted) + .isEmpty() || + !SaveDataDialog::candidatePorts(pipeline, SaveDataDialog::Scope::LeafNodes) + .isEmpty(); + parentAction()->setEnabled(anything); } void SaveDataReaction::onTriggered() { - QStringList filters; - filters << "TIFF format (*.tiff)" - << "EMD format (*.emd *.hdf5)" - << "HDF5 format (*.h5)" - << "CSV File (*.csv)" - << "Exodus II File (*.e *.ex2 *.ex2v2 *.exo *.exoII *.exoii *.g)" - << "Legacy VTK Files (*.vtk)" - << "Meta Image Files (*.mhd)" - << "ParaView Data Files (*.pvd)" - << "VTK ImageData Files (*.vti)" - << "XDMF Data File (*.xmf)" - << "JSON Image Files (*.json)"; - - foreach (auto writer, FileFormatManager::instance().pythonWriterFactories()) { - filters << writer->getFileDialogFilter(); + auto* pipeline = ActiveObjects::instance().pipeline(); + if (!pipeline) { + return; } - QFileDialog dialog(nullptr); - dialog.setFileMode(QFileDialog::AnyFile); - dialog.setNameFilters(filters); - dialog.setObjectName("FileOpenDialog-tomviz"); // avoid name collision? - dialog.setAcceptMode(QFileDialog::AcceptSave); - + auto* parent = tomviz::mainWidget(); + SaveDataDialog dialog(pipeline, parent); if (dialog.exec() == QDialog::Accepted) { - QStringList filenames = dialog.selectedFiles(); - QString format = dialog.selectedNameFilter(); - QString filename = filenames[0]; - int startPos = format.indexOf("(") + 1; - int n = format.indexOf(")") - startPos; - QString extensionString = format.mid(startPos, n); - QStringList extensions = extensionString.split(QRegularExpression(" ?\\*"), - Qt::SkipEmptyParts); - bool hasExtension = false; - for (QString& str : extensions) { - if (filename.endsWith(str)) { - hasExtension = true; - } - } - if (!hasExtension) { - filename = QString("%1%2").arg(filename, extensions[0]); - } - saveData(filename); + SaveDataDialog::writeEntries(dialog.selectedEntries(), parent); } } -bool SaveDataReaction::saveData(const QString& filename) -{ - auto server = pqActiveObjects::instance().activeServer(); - auto source = ActiveObjects::instance().activeDataSource(); - auto result = ActiveObjects::instance().activeOperatorResult(); - - auto updateSource = [](QString fileName, DataSource* ds) { - ds->setPersistenceState(DataSource::PersistenceState::Saved); - ds->setFileName(fileName); - }; - - if (!server) { - qCritical("No active server located."); - return false; - } - - if (!source && !result) { - qCritical("No active source located."); - return false; - } - - QFileInfo info(filename); - if (info.suffix() == "emd") { - if (!EmdFormat::write(filename.toLatin1().data(), source)) { - qCritical() << "Failed to write out data."; - return false; - } else { - updateSource(filename, source); - return true; - } - } else if (info.suffix() == "h5") { - DataExchangeFormat writer; - if (!writer.write(filename.toLatin1().data(), source)) { - qCritical() << "Failed to write out data."; - return false; - } else { - updateSource(filename, source); - return true; - } - } else if (FileFormatManager::instance().pythonWriterFactory( - info.suffix().toLower()) != nullptr) { - auto factory = FileFormatManager::instance().pythonWriterFactory( - info.suffix().toLower()); - Q_ASSERT(factory != nullptr); - auto writer = factory->createWriter(); - auto t = source->producer(); - auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (!writer.write(filename, data)) { - qCritical() << "Failed to write out data."; - return false; - } else { - updateSource(filename, source); - return true; - } - } - - vtkSMSourceProxy* producer = nullptr; - if (source) { - producer = source->proxy(); - } - // If an operator result is active, save it. Otherwise, save the source. - if (result) { - producer = result->producerProxy(); - } - - auto writerFactory = vtkSMProxyManager::GetProxyManager()->GetWriterFactory(); - vtkSmartPointer proxy; - proxy.TakeReference( - writerFactory->CreateWriter(filename.toLatin1().data(), producer)); - auto writer = vtkSMSourceProxy::SafeDownCast(proxy); - if (!writer) { - qCritical() << "Failed to create writer for: " << filename; - return false; - } - - // Convert to float if the type is found to be a double. - if (strcmp(writer->GetClientSideObject()->GetClassName(), "vtkTIFFWriter") == - 0) { - auto t = vtkTrivialProducer::SafeDownCast(producer->GetClientSideObject()); - auto imageData = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - if (imageData->GetPointData()->GetScalars()->GetDataType() == VTK_DOUBLE) { - vtkNew fImage; - fImage->DeepCopy(imageData); - ConvertToFloatOperator convertFloat; - convertFloat.applyTransform(fImage); - - vtkNew tiff; - tiff->SetInputData(fImage); - tiff->SetFileName(filename.toLatin1().data()); - tiff->Write(); - - updateSource(filename, source); - return true; - } - } - - pqProxyWidgetDialog dialog(writer, tomviz::mainWidget()); - dialog.setObjectName("WriterSettingsDialog"); - dialog.setEnableSearchBar(true); - dialog.setWindowTitle( - QString("Configure Writer (%1)").arg(writer->GetXMLLabel())); - - // Check to see if this writer has any properties that can be configured by - // the user. If it does, display the dialog. - if (dialog.hasVisibleWidgets()) { - dialog.exec(); - if (dialog.result() == QDialog::Rejected) { - // The user pressed Cancel so don't write - return false; - } - } - writer->UpdateVTKObjects(); - writer->UpdatePipeline(); - - updateSource(filename, source); - - return true; -} - } // end of namespace tomviz diff --git a/tomviz/SaveDataReaction.h b/tomviz/SaveDataReaction.h index 42ecc8eb4..3a89a820a 100644 --- a/tomviz/SaveDataReaction.h +++ b/tomviz/SaveDataReaction.h @@ -4,14 +4,21 @@ #ifndef tomvizSaveDataReaction_h #define tomvizSaveDataReaction_h +#include "SaveDataDialog.h" + #include +#include + namespace tomviz { -class DataSource; -class PythonWriterFactory; + +namespace pipeline { +class Pipeline; +} /// SaveDataReaction handles the "Save Data" action in tomviz. On trigger, -/// this will save the data file. +/// it opens SaveDataDialog and writes the output-port payloads the user +/// selected into the destination directory. class SaveDataReaction : public pqReaction { Q_OBJECT @@ -19,11 +26,8 @@ class SaveDataReaction : public pqReaction public: SaveDataReaction(QAction* parentAction); - /// Save the file - bool saveData(const QString& filename); - protected: - /// Called when the data changes to enable/disable the menu item + /// Called when the pipeline changes to enable/disable the menu item void updateEnableState() override; /// Called when the action is triggered. @@ -31,6 +35,9 @@ class SaveDataReaction : public pqReaction private: Q_DISABLE_COPY(SaveDataReaction) + + /// Subscribe to the pipeline events that change what there is to save. + void connectToPipeline(pipeline::Pipeline* pipeline); }; } // namespace tomviz #endif diff --git a/tomviz/SaveLoadStateReaction.cxx b/tomviz/SaveLoadStateReaction.cxx index bb73df99b..f42c84d4a 100644 --- a/tomviz/SaveLoadStateReaction.cxx +++ b/tomviz/SaveLoadStateReaction.cxx @@ -3,10 +3,19 @@ #include "SaveLoadStateReaction.h" -#include "ModuleManager.h" +#include "ActiveObjects.h" +#include "MainWindow.h" +#include "pipeline/LegacyStateLoader.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineStateIO.h" +#include "pipeline/InputPort.h" +#include "pipeline/sinks/LegacyModuleSink.h" #include "RecentFilesMenu.h" #include "Tvh5Format.h" #include "Utilities.h" +#include "ViewsLayoutsSerializer.h" + +#include #include #include @@ -45,11 +54,9 @@ void SaveLoadStateReaction::onTriggered() bool SaveLoadStateReaction::saveState() { QString tvh5Filter = "Tomviz full state files (*.tvh5)"; - QString tvsmFilter = "Tomvis state files (*.tvsm)"; + QString tvsmFilter = "Tomviz state files (*.tvsm)"; QStringList filters; - filters << tvh5Filter - << tvsmFilter - << "All files (*)"; + filters << tvh5Filter << tvsmFilter << "All files (*)"; QFileDialog fileDialog(tomviz::mainWidget(), tr("Save State File"), QString(), filters.join(";;")); @@ -68,7 +75,7 @@ bool SaveLoadStateReaction::saveState() if (success) { // Only set the most recent state file if the user picked a file // to save via a file dialog, and the save was successful. - ModuleManager::instance().setMostRecentStateFile(filename); + MainWindow::instance()->setMostRecentStateFile(filename); } return success; } @@ -93,7 +100,8 @@ bool SaveLoadStateReaction::loadState() bool SaveLoadStateReaction::loadState(const QString& filename) { - if (ModuleManager::instance().hasDataSources()) { + auto* pipeline = ActiveObjects::instance().pipeline(); + if (pipeline && !pipeline->nodes().isEmpty()) { if (QMessageBox::Yes != QMessageBox::warning(tomviz::mainWidget(), "Load State Warning", "Current data and operators will be cleared when " @@ -106,9 +114,10 @@ bool SaveLoadStateReaction::loadState(const QString& filename) bool success = false; if (filename.endsWith(".tvh5")) { - success = loadTvh5(filename); + success = loadTvh5(filename, /*executePipelines=*/true); } else if (filename.endsWith(".tvsm")) { - success = loadTvsm(filename); + bool executePipelines = automaticallyExecutePipelines(); + success = loadTvsm(filename, executePipelines); } else { qCritical() << "Unknown state format for file: " << filename; return false; @@ -118,18 +127,132 @@ bool SaveLoadStateReaction::loadState(const QString& filename) RecentFilesMenu::pushStateFile(filename); // Set the most recent state file if we successfully loaded a // state, whether it was done programmatically or via file dialog - ModuleManager::instance().setMostRecentStateFile(filename); + MainWindow::instance()->setMostRecentStateFile(filename); } return success; } -bool SaveLoadStateReaction::loadTvh5(const QString& filename) +namespace { + +/// Kick consume() on every LegacyModuleSink whose inputs are ready, +/// so pre-loaded upstream data gets wired into the VTK filter and +/// rendered. Does NOT call Pipeline::execute() — transforms and +/// sources are untouched. Used in the "don't auto execute" path so +/// the user sees whatever data was already restored (e.g. from .tvh5 +/// payload groups) without any pipeline actually running. +void consumeReadySinks(pipeline::Pipeline* pipeline) +{ + for (auto* node : pipeline->nodes()) { + auto* sink = dynamic_cast(node); + if (!sink) { + continue; + } + bool ready = !sink->inputPorts().isEmpty(); + for (auto* in : sink->inputPorts()) { + if (!in->link() || !in->hasData()) { + ready = false; + break; + } + } + if (ready) { + sink->execute(); + } + } +} + +/// Shared end-of-load orchestration for the new-format loaders. +/// Auto-execute path: unpause and schedule pipeline->execute(), which +/// walks the DAG (Current nodes skipped, Stale ones re-run). +/// Declined path: direct-consume any sink whose inputs are ready (so +/// pre-loaded data is displayed), then pause and fire +/// executionFinished so the deferred sink-deserialize lambdas apply +/// their saved visibility / colormaps against the now-Current sinks. +void finalizeNewFormatLoad(pipeline::Pipeline* pipeline, + bool executePipelines) +{ + if (executePipelines) { + pipeline->setPaused(false); + QTimer::singleShot(0, pipeline, + [pipeline]() { pipeline->execute(); }); + return; + } + consumeReadySinks(pipeline); + pipeline->setPaused(true); + QMetaObject::invokeMethod(pipeline, &pipeline::Pipeline::executionFinished, + Qt::QueuedConnection); +} + +/// Active-view fallback: bind any LegacyModuleSink left without a +/// view to the application's active render view. +void bindFallbackView(pipeline::Pipeline* pipeline) { - return Tvh5Format::read(filename.toStdString()); + ActiveObjects::instance().createRenderViewIfNeeded(); + auto* activeView = ActiveObjects::instance().activeView(); + if (activeView && + QString(activeView->GetXMLName()) != QLatin1String("RenderView")) { + ActiveObjects::instance().setActiveViewToFirstRenderView(); + activeView = ActiveObjects::instance().activeView(); + } + if (!activeView) { + return; + } + for (auto* node : pipeline->nodes()) { + if (auto* sink = dynamic_cast(node)) { + if (!sink->view()) { + sink->initialize(activeView); + } + } + } +} + +} // namespace + +bool SaveLoadStateReaction::loadTvh5(const QString& filename, + bool executePipelines) +{ + // Peek at /tomviz_state to dispatch by format. Legacy .tvh5 files + // have no schemaVersion (or < 2) and go through LegacyStateLoader. + // New-format .tvh5 files carry their pipeline graph in the same + // JSON and their voxels under /data//. + QJsonObject state = Tvh5Format::readState(filename.toStdString()); + int schemaVersion = state.value("schemaVersion").toInt(1); + if (state.isEmpty() || schemaVersion < 2) { + return pipeline::LegacyStateLoader::loadFromH5(filename, executePipelines); + } + + auto* pipeline = ActiveObjects::instance().pipeline(); + if (!pipeline) { + qWarning("No active pipeline for new-format .tvh5 load."); + return false; + } + + pipeline->clear(); + + QMap viewIdMap; + pipeline::LegacyStateLoader::restoreViewsLayoutsAndPalette( + state, pipeline, &viewIdMap); + + // Source voxels live in HDF5 groups inside this file. A pre-execute + // hook populates source output ports directly so eager-execute in + // PipelineStateIO::load becomes a no-op for those sources. + std::string fileNameStd = filename.toStdString(); + auto hook = [fileNameStd](pipeline::Pipeline* p, + const QJsonObject& pipelineJson) { + Tvh5Format::populatePayloadData(p, pipelineJson, fileNameStd); + }; + + if (!pipeline::PipelineStateIO::load(pipeline, state, viewIdMap, hook)) { + return false; + } + + bindFallbackView(pipeline); + finalizeNewFormatLoad(pipeline, executePipelines); + return true; } -bool SaveLoadStateReaction::loadTvsm(const QString& filename) +bool SaveLoadStateReaction::loadTvsm(const QString& filename, + bool executePipelines) { QFile openFile(filename); if (!openFile.open(QIODevice::ReadOnly)) { @@ -153,28 +276,35 @@ bool SaveLoadStateReaction::loadTvsm(const QString& filename) } } - auto executeOnLoad = automaticallyExecutePipelines(); - ModuleManager::instance().executePipelinesOnLoad(executeOnLoad); - if (doc.isObject()) { - // This needs to run here, but needs to run after the dialog is connected - // and execed. Otherwise we miss signals fired from within deserialize. - // So put it on a timer. - QTimer::singleShot(0, [doc, filename]() { - ModuleManager::instance().deserialize(doc.object(), - QFileInfo(filename).dir()); - }); - QDialog dialog(tomviz::mainWidget(), Qt::WindowStaysOnTopHint); - QHBoxLayout* layout = new QHBoxLayout(); - QLabel* label = new QLabel("Please wait... loading state file"); - layout->addWidget(label); - dialog.setLayout(layout); - connect(&ModuleManager::instance(), &ModuleManager::stateDoneLoading, - &dialog, &QDialog::accept); - dialog.exec(); - if (ModuleManager::instance().lastLoadStateSucceeded()) { + auto object = doc.object(); + int schemaVersion = object.value("schemaVersion").toInt(1); + if (schemaVersion >= 2) { + auto* pipeline = ActiveObjects::instance().pipeline(); + if (!pipeline) { + qWarning("No active pipeline for new-format state load."); + return false; + } + // Drop whatever was in the pipeline (matching the confirmation + // dialog we already showed the user in loadState()). + pipeline->clear(); + + // Order matters: restore views first so sinks can bind to them + // during PipelineStateIO::load. The view-state application is + // scheduled on Pipeline::executionFinished so the camera lands + // after any resetCameraIfFirstSink pass runs. + QMap viewIdMap; + pipeline::LegacyStateLoader::restoreViewsLayoutsAndPalette( + object, pipeline, &viewIdMap); + if (!pipeline::PipelineStateIO::load(pipeline, object, viewIdMap)) { + return false; + } + bindFallbackView(pipeline); + finalizeNewFormatLoad(pipeline, executePipelines); return true; - } // else continue to error message + } + return pipeline::LegacyStateLoader::load( + object, QFileInfo(filename).dir(), executePipelines); } if (!legacyStateFile) { @@ -193,31 +323,46 @@ bool SaveLoadStateReaction::saveState(const QString& fileName, bool interactive) } else if (fileName.endsWith(".tvh5")) { return saveTvh5(fileName); } - qCritical() << "Unknown format for saveState(): " << fileName; return false; } -bool SaveLoadStateReaction::saveTvsm(const QString& fileName, bool interactive) +bool SaveLoadStateReaction::saveTvh5(const QString& fileName) +{ + auto* pip = ActiveObjects::instance().pipeline(); + if (!pip) { + qWarning("No active pipeline to save."); + return false; + } + QJsonObject extraState; + ViewsLayoutsSerializer::saveActive(extraState); + return Tvh5Format::write(fileName.toStdString(), pip, extraState); +} + +bool SaveLoadStateReaction::saveTvsm(const QString& fileName, bool /*interactive*/) { - QFileInfo info(fileName); QFile saveFile(fileName); if (!saveFile.open(QIODevice::WriteOnly)) { qWarning("Couldn't open save file."); return false; } + auto* pip = ActiveObjects::instance().pipeline(); + if (!pip) { + qWarning("No active pipeline to save."); + return false; + } + QJsonObject state; - auto success = - ModuleManager::instance().serialize(state, info.dir(), interactive); - QJsonDocument doc(state); - auto writeSuccess = saveFile.write(doc.toJson()); - return success && writeSuccess != -1; -} + if (!pipeline::PipelineStateIO::save(pip, state)) { + return false; + } + // PipelineStateIO leaves views/layouts/palette to the caller; + // append them via the shared helper. + ViewsLayoutsSerializer::saveActive(state); -bool SaveLoadStateReaction::saveTvh5(const QString& fileName) -{ - return Tvh5Format::write(fileName.toStdString()); + QJsonDocument doc(state); + return saveFile.write(doc.toJson()) != -1; } QString SaveLoadStateReaction::extractLegacyStateFileVersion( diff --git a/tomviz/SaveLoadStateReaction.h b/tomviz/SaveLoadStateReaction.h index bed8bea38..e4826d37d 100644 --- a/tomviz/SaveLoadStateReaction.h +++ b/tomviz/SaveLoadStateReaction.h @@ -36,8 +36,8 @@ class SaveLoadStateReaction : public pqReaction static bool saveTvsm(const QString& filename, bool interactive = true); static bool saveTvh5(const QString& filename); - static bool loadTvsm(const QString& filename); - static bool loadTvh5(const QString& filename); + static bool loadTvsm(const QString& filename, bool executePipelines); + static bool loadTvh5(const QString& filename, bool executePipelines); }; } // namespace tomviz diff --git a/tomviz/SaveLoadTemplateReaction.cxx b/tomviz/SaveLoadTemplateReaction.cxx index 7d05172a5..a2f5188aa 100644 --- a/tomviz/SaveLoadTemplateReaction.cxx +++ b/tomviz/SaveLoadTemplateReaction.cxx @@ -4,18 +4,29 @@ #include "SaveLoadTemplateReaction.h" #include "ActiveObjects.h" -#include "ModuleManager.h" -#include "Pipeline.h" #include "RecentFilesMenu.h" +#include "Tvh5Format.h" +#include "pipeline/LegacyStateLoader.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineStateIO.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SinkNode.h" +#include "pipeline/SourceNode.h" #include "Utilities.h" #include #include +#include +#include +#include +#include +#include #include #include #include #include #include +#include #include namespace tomviz { @@ -41,17 +52,29 @@ bool SaveLoadTemplateReaction::saveTemplate() QString fileName = text.replace(" ", "_"); if (ok && !text.isEmpty()) { QString path; - // Save the template to the tomviz/templates directory if it exists - if (!tomviz::userDataPath().isEmpty()) { - path = tomviz::userDataPath() + "/templates"; - QDir dir(path); - if (!dir.exists() && !dir.mkdir(path)) { - QMessageBox::warning( - tomviz::mainWidget(), "Could not create tomviz directory", - QString("Could not create tomviz directory '%1'.").arg(path)); - return false; + // If TOMVIZ_PIPELINE_TEMPLATES_PATH is set, save to the first path it + // lists; otherwise fall back to /templates. + QByteArray envOverride = qgetenv("TOMVIZ_PIPELINE_TEMPLATES_PATH"); + if (!envOverride.isEmpty()) { + QStringList envPaths = QString::fromLocal8Bit(envOverride) + .split(QDir::listSeparator(), Qt::SkipEmptyParts); + if (!envPaths.isEmpty()) { + path = envPaths.first(); } } + if (path.isEmpty() && !tomviz::userDataPath().isEmpty()) { + path = tomviz::userDataPath() + "/templates"; + } + if (path.isEmpty()) { + return false; + } + QDir dir(path); + if (!dir.exists() && !dir.mkpath(path)) { + QMessageBox::warning( + tomviz::mainWidget(), "Could not create tomviz directory", + QString("Could not create tomviz directory '%1'.").arg(path)); + return false; + } QString destination = QString("%1%2%3.tvsm").arg(path).arg(QDir::separator()).arg(fileName); return SaveLoadTemplateReaction::saveTemplate(destination); @@ -59,90 +82,265 @@ bool SaveLoadTemplateReaction::saveTemplate() return false; } -bool SaveLoadTemplateReaction::loadTemplate(const QString& fileName) +namespace { + +bool loadTemplateFromJson(const QJsonObject& state, pipeline::Pipeline* pip) { - if (tomviz::userDataPath().isEmpty()) { + if (state.value("schemaVersion").toInt(1) < 2) { + // Pre-schemaVersion files: templates stored their chain as a + // top-level "operators" array, full state files nest the same + // shape under "dataSources[].operators". Try the template form + // first, then fall back to walking dataSources so a full v1 state + // can also be ingested as a transform-only fragment. + auto topLevel = state.value("operators").toArray(); + if (!topLevel.isEmpty()) { + pipeline::LegacyStateLoader::loadTemplateOperators(topLevel, pip); + return true; + } + for (const auto& dsVal : state.value("dataSources").toArray()) { + auto ds = dsVal.toObject(); + pipeline::LegacyStateLoader::loadTemplateOperators( + ds.value("operators").toArray(), pip); + } + return true; + } + + auto pipelineJson = state.value("pipeline").toObject(); + if (pipelineJson.isEmpty()) { + qWarning("Template file missing 'pipeline' section."); return false; } - QString location = tomviz::userDataPath() + "/templates"; - QString path = QString("%1%2%3.tvsm").arg(location).arg(QDir::separator()).arg(fileName); - if (!QFile(path).exists()) { - location = QApplication::applicationDirPath() + "/../share/tomviz/templates"; - path = QString("%1%2%3.tvsm").arg(location).arg(QDir::separator()).arg(fileName); + // Sanitize: keep only transform nodes. A correctly-saved template + // already excludes sources/sinks/sink groups, but defend against + // hand-edited or older files. Type names are dotted; the registry + // uses the prefixes source./transform./sink. and the bare "sinkGroup". + auto isExcludedType = [](const QString& type) { + return type.startsWith("source.") || type.startsWith("sink.") || + type == QLatin1String("sinkGroup"); + }; + + QSet droppedIds; + QJsonArray keptNodes; + for (const auto& nv : pipelineJson.value("nodes").toArray()) { + auto entry = nv.toObject(); + int id = entry.value("id").toInt(-1); + if (id < 0 || isExcludedType(entry.value("type").toString())) { + if (id >= 0) { + droppedIds.insert(id); + } + continue; + } + keptNodes.append(entry); } - QFile openFile(path); - if (!openFile.open(QIODevice::ReadOnly)) { - qWarning("Could not open template."); + QJsonArray keptLinks; + for (const auto& lv : pipelineJson.value("links").toArray()) { + auto entry = lv.toObject(); + int fromId = entry.value("from").toObject().value("node").toInt(-1); + int toId = entry.value("to").toObject().value("node").toInt(-1); + if (droppedIds.contains(fromId) || droppedIds.contains(toId)) { + continue; + } + keptLinks.append(entry); + } + + // Remap template ids to fresh ones from the active pipeline's + // allocator so they can't collide with nodes already in the session. + QHash idMap; + QJsonArray remappedNodes; + for (const auto& nv : keptNodes) { + auto entry = nv.toObject(); + int oldId = entry.value("id").toInt(-1); + int newId = pip->nextNodeId(); + pip->setNextNodeId(newId + 1); + idMap.insert(oldId, newId); + entry["id"] = newId; + remappedNodes.append(entry); + } + + QJsonArray remappedLinks; + for (const auto& lv : keptLinks) { + auto entry = lv.toObject(); + auto from = entry.value("from").toObject(); + auto to = entry.value("to").toObject(); + from["node"] = idMap.value(from.value("node").toInt(-1), -1); + to["node"] = idMap.value(to.value("node").toInt(-1), -1); + entry["from"] = from; + entry["to"] = to; + remappedLinks.append(entry); + } + + // Hand a minimal state object to PipelineStateIO::load. Drop + // nextNodeId so it doesn't clobber the allocator we just advanced, + // and ignore any views/layouts/palette that may be present. + QJsonObject loadPipeline; + loadPipeline["nodes"] = remappedNodes; + loadPipeline["links"] = remappedLinks; + + QJsonObject loadState; + loadState["schemaVersion"] = 2; + loadState["pipeline"] = loadPipeline; + + if (!pipeline::PipelineStateIO::load(pip, loadState)) { return false; } - QJsonParseError error; - auto contents = openFile.readAll(); - auto doc = QJsonDocument::fromJson(contents, &error); + // Parameters came from the template, so the nodes shouldn't be "New" + // — that's the state the link-completion auto-edit-dialog uses to + // decide a freshly-dropped node still needs to be configured. + // Anything that survived as New here is just missing data. + for (int newId : idMap.values()) { + if (auto* n = pip->nodeById(newId)) { + if (n->state() == pipeline::NodeState::New) { + n->setStateNoCascade(pipeline::NodeState::Stale); + } + } + } + return true; +} - // Get the parent data source, as well as the active (i.e. data and output) - auto activeParent = ActiveObjects::instance().activeParentDataSource(); - auto activeData = ActiveObjects::instance().activeDataSource(); +} // namespace - if (!activeParent) { - qWarning("No active data source to apply template to."); +bool SaveLoadTemplateReaction::loadTemplate(const QString& fileName) +{ + auto* pip = ActiveObjects::instance().pipeline(); + if (!pip) { + qWarning("No active pipeline to load template into."); return false; } - // Read in the template file and apply it to the current data source - activeParent->deserialize(doc.object()); - // Load the default modules on the output if there are none - bool noModules = ModuleManager::instance().findModulesGeneric(activeData, nullptr).isEmpty(); - if (noModules && activeData && activeData != activeParent) { - activeParent->pipeline()->addDefaultModules(activeData); + QJsonObject state; + if (fileName.endsWith(".tvh5", Qt::CaseInsensitive)) { + state = Tvh5Format::readState(fileName.toStdString()); + if (state.isEmpty()) { + qWarning() << "Could not read /tomviz_state from:" << fileName; + return false; + } + } else { + QFile openFile(fileName); + if (!openFile.open(QIODevice::ReadOnly)) { + qWarning() << "Couldn't open template file:" << fileName; + return false; + } + QJsonParseError error; + auto doc = QJsonDocument::fromJson(openFile.readAll(), &error); + if (!doc.isObject()) { + qWarning() << "Invalid template file:" << fileName << error.errorString(); + return false; + } + state = doc.object(); + } + + return loadTemplateFromJson(state, pip); +} + +bool SaveLoadTemplateReaction::loadTemplateWithDialog() +{ + QStringList filters; + filters << "Tomviz state files (*.tvsm *.tvh5)" + << "All files (*)"; + QFileDialog dialog(tomviz::mainWidget(), tr("Load Template"), QString(), + filters.join(";;")); + dialog.setObjectName("LoadTemplateDialog"); + dialog.setFileMode(QFileDialog::ExistingFile); + if (dialog.exec() != QDialog::Accepted) { + return false; + } + QString fileName = dialog.selectedFiles().value(0); + if (fileName.isEmpty()) { + return false; + } + if (!loadTemplate(fileName)) { + return false; } - + RecentFilesMenu::pushTemplateFile(fileName); return true; } +bool SaveLoadTemplateReaction::saveTemplateAs() +{ + QString tvsmFilter = "Tomviz state files (*.tvsm)"; + QStringList filters; + filters << tvsmFilter << "All files (*)"; + QFileDialog dialog(tomviz::mainWidget(), tr("Save Template As"), QString(), + filters.join(";;")); + dialog.setObjectName("SaveTemplateDialog"); + dialog.setAcceptMode(QFileDialog::AcceptSave); + dialog.setFileMode(QFileDialog::AnyFile); + if (dialog.exec() != QDialog::Accepted) { + return false; + } + QString fileName = dialog.selectedFiles().value(0); + if (fileName.isEmpty()) { + return false; + } + if (dialog.selectedNameFilter() == tvsmFilter && + !fileName.endsWith(".tvsm", Qt::CaseInsensitive)) { + fileName += ".tvsm"; + } + return saveTemplate(fileName); +} + bool SaveLoadTemplateReaction::saveTemplate(const QString& fileName) { - QFileInfo info(fileName); - QFile saveFile(fileName); - if (!saveFile.open(QIODevice::WriteOnly)) { - qWarning("Couldn't open save file."); + auto* pip = ActiveObjects::instance().pipeline(); + if (!pip) { + qWarning("No active pipeline to save as template."); return false; } - DataSource* activeParent = ActiveObjects::instance().activeParentDataSource(); - QJsonObject state = activeParent->serialize(); - QJsonObject json; - // Save any modules loaded on the parent data source - if (state.contains("modules")) { - json["modules"] = state.value("modules"); - } - - if (state.contains("operators")) { - QJsonArray ops; - foreach (QJsonValue val, state["operators"].toArray()) { - QJsonObject temp; - foreach (QString key, val.toObject().keys()) { - // Save the operators - if (key != QString("dataSources")) { - temp.insert(key, val.toObject().value(key)); - } else { - // If there are modules loaded on the child data source, save those as well - QJsonValue dataSources = val.toObject().value("dataSources"); - QJsonObject modules = {{"modules", dataSources.toArray()[0].toObject().value("modules")}}; - QJsonArray arr = { modules }; - temp.insert("dataSources", arr); - } - } - ops.push_back(temp); + QJsonObject state; + if (!pipeline::PipelineStateIO::save(pip, state)) { + return false; + } + + // A template is a transform-only graph fragment. Drop sources, sinks, + // and sink groups (plus any link touching them). What's left is the + // transform graph and its connectivity — to be applied to whatever + // data the user loads later. + QSet excludedIds; + for (auto* node : pip->nodes()) { + if (dynamic_cast(node) || + dynamic_cast(node) || + dynamic_cast(node)) { + excludedIds.insert(pip->nodeId(node)); + } + } + + QJsonObject pipelineJson = state.value("pipeline").toObject(); + + QJsonArray filteredNodes; + for (const auto& nv : pipelineJson.value("nodes").toArray()) { + auto entry = nv.toObject(); + if (excludedIds.contains(entry.value("id").toInt(-1))) { + continue; } - json["operators"] = ops; + filteredNodes.append(entry); } + pipelineJson["nodes"] = filteredNodes; - QJsonDocument doc(json); - auto writeSuccess = saveFile.write(doc.toJson()); - return !json.isEmpty() && writeSuccess != -1; + QJsonArray filteredLinks; + for (const auto& lv : pipelineJson.value("links").toArray()) { + auto entry = lv.toObject(); + int fromId = entry.value("from").toObject().value("node").toInt(-1); + int toId = entry.value("to").toObject().value("node").toInt(-1); + if (excludedIds.contains(fromId) || excludedIds.contains(toId)) { + continue; + } + filteredLinks.append(entry); + } + pipelineJson["links"] = filteredLinks; + + state["pipeline"] = pipelineJson; + + QFile saveFile(fileName); + if (!saveFile.open(QIODevice::WriteOnly)) { + qWarning() << "Could not open template file for writing:" << fileName; + return false; + } + QJsonDocument doc(state); + return saveFile.write(doc.toJson()) != -1; } } // namespace tomviz diff --git a/tomviz/SaveLoadTemplateReaction.h b/tomviz/SaveLoadTemplateReaction.h index 35ee8cb43..1e5e1268c 100644 --- a/tomviz/SaveLoadTemplateReaction.h +++ b/tomviz/SaveLoadTemplateReaction.h @@ -19,7 +19,14 @@ class SaveLoadTemplateReaction : public pqReaction static bool saveTemplate(); static bool saveTemplate(const QString& filename); + /// Pop a Save dialog so the user can choose any path for the .tvsm. + /// Used by the File > Save Template As… action. + static bool saveTemplateAs(); static bool loadTemplate(const QString& filename); + /// Pop a file dialog accepting .tvsm or .tvh5 (any schema). On success + /// the file is pushed onto the recent template files list. Used by + /// the File > Load Template… action. + static bool loadTemplateWithDialog(); protected: void onTriggered() override; diff --git a/tomviz/SaveScreenshotReaction.cxx b/tomviz/SaveScreenshotReaction.cxx index 718d99379..4e932b87a 100644 --- a/tomviz/SaveScreenshotReaction.cxx +++ b/tomviz/SaveScreenshotReaction.cxx @@ -5,6 +5,7 @@ #include "MainWindow.h" #include "SaveScreenshotDialog.h" +#include "Utilities.h" #include #include @@ -79,7 +80,7 @@ void SaveScreenshotReaction::saveScreenshot(MainWindow* mw) filters << "PPM image (*.ppm)"; filters << "JPG image (*.jpg)"; - QFileDialog file_dialog(nullptr, "Save Screenshot:"); + QFileDialog file_dialog(tomviz::mainWidget(), "Save Screenshot:"); file_dialog.setFileMode(QFileDialog::AnyFile); file_dialog.setNameFilters(filters); file_dialog.setObjectName("FileSaveScreenshotDialog"); diff --git a/tomviz/SaveWebReaction.cxx b/tomviz/SaveWebReaction.cxx index 0ab7b39b9..61ee5b040 100644 --- a/tomviz/SaveWebReaction.cxx +++ b/tomviz/SaveWebReaction.cxx @@ -4,8 +4,6 @@ #include "SaveWebReaction.h" #include "ActiveObjects.h" -#include "DataSource.h" -#include "ModuleManager.h" #include "pqActiveObjects.h" #include "pqCoreUtilities.h" @@ -36,17 +34,18 @@ namespace tomviz { SaveWebReaction::SaveWebReaction(QAction* parentObject, MainWindow* mainWindow) : pqReaction(parentObject), m_mainWindow(mainWindow) { - connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &SaveWebReaction::updateEnableState); + // TODO: migrate to new pipeline + // Old code connected to ActiveObjects::dataSourceChanged + connect(&ActiveObjects::instance(), &ActiveObjects::activeNodeChanged, this, + &SaveWebReaction::updateEnableState); updateEnableState(); } void SaveWebReaction::updateEnableState() { - parentAction()->setEnabled(ActiveObjects::instance().activeDataSource() != - nullptr); + // TODO: migrate to new pipeline + // Old code checked activeDataSource() != nullptr + parentAction()->setEnabled(false); } void SaveWebReaction::onTriggered() @@ -65,7 +64,7 @@ void SaveWebReaction::onTriggered() QStringList filters; filters << "HTML (*.html)"; - QFileDialog fileDialog(nullptr, "Save Web Export:"); + QFileDialog fileDialog(tomviz::mainWidget(), "Save Web Export:"); fileDialog.setFileMode(QFileDialog::AnyFile); fileDialog.setNameFilters(filters); fileDialog.setAcceptMode(QFileDialog::AcceptSave); diff --git a/tomviz/SelectCylinderWidget.cxx b/tomviz/SelectCylinderWidget.cxx new file mode 100644 index 000000000..b31c139ec --- /dev/null +++ b/tomviz/SelectCylinderWidget.cxx @@ -0,0 +1,505 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "SelectCylinderWidget.h" + +#include "ActiveObjects.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +// Subclass that forces left-click = translate center, right-click = adjust radius. +class CylinderWidgetCustom : public vtkImplicitCylinderWidget +{ +public: + static CylinderWidgetCustom* New() + { + auto* self = new CylinderWidgetCustom; + self->InitializeObjectBase(); + return self; + } + vtkTypeMacro(CylinderWidgetCustom, vtkImplicitCylinderWidget) + + void setupInteraction() + { + this->EventTranslator->ClearEvents(); + this->EventTranslator->SetTranslation(vtkCommand::LeftButtonPressEvent, + vtkWidgetEvent::Translate); + this->EventTranslator->SetTranslation(vtkCommand::LeftButtonReleaseEvent, + vtkWidgetEvent::EndTranslate); + this->EventTranslator->SetTranslation(vtkCommand::RightButtonPressEvent, + vtkWidgetEvent::Scale); + this->EventTranslator->SetTranslation(vtkCommand::RightButtonReleaseEvent, + vtkWidgetEvent::EndScale); + this->EventTranslator->SetTranslation(vtkCommand::MouseMoveEvent, + vtkWidgetEvent::Move); + + this->CallbackMapper->SetCallbackMethod( + vtkCommand::LeftButtonPressEvent, vtkWidgetEvent::Translate, this, + CylinderWidgetCustom::ForceTranslateAction); + this->CallbackMapper->SetCallbackMethod( + vtkCommand::LeftButtonReleaseEvent, vtkWidgetEvent::EndTranslate, this, + vtkImplicitCylinderWidget::EndSelectAction); + this->CallbackMapper->SetCallbackMethod( + vtkCommand::RightButtonPressEvent, vtkWidgetEvent::Scale, this, + CylinderWidgetCustom::ForceRadiusAction); + this->CallbackMapper->SetCallbackMethod( + vtkCommand::RightButtonReleaseEvent, vtkWidgetEvent::EndScale, this, + vtkImplicitCylinderWidget::EndSelectAction); + this->CallbackMapper->SetCallbackMethod( + vtkCommand::MouseMoveEvent, vtkWidgetEvent::Move, this, + vtkImplicitCylinderWidget::MoveAction); + } + + static void ForceTranslateAction(vtkAbstractWidget* w) + { + auto* self = reinterpret_cast(w); + int X = self->Interactor->GetEventPosition()[0]; + int Y = self->Interactor->GetEventPosition()[1]; + + auto* rep = + reinterpret_cast(self->WidgetRep); + rep->SetInteractionState(vtkImplicitCylinderRepresentation::Moving); + int state = rep->ComputeInteractionState(X, Y); + + if (state == vtkImplicitCylinderRepresentation::Outside) { + return; + } + + // If the axis arrow was picked, allow rotation; otherwise force translate + if (state != vtkImplicitCylinderRepresentation::RotatingAxis) { + rep->SetInteractionState(vtkImplicitCylinderRepresentation::MovingCenter); + } + + self->GrabFocus(self->EventCallbackCommand); + double eventPos[2] = { static_cast(X), static_cast(Y) }; + self->WidgetState = vtkImplicitCylinderWidget::Active; + self->WidgetRep->StartWidgetInteraction(eventPos); + + self->EventCallbackCommand->SetAbortFlag(1); + self->StartInteraction(); + self->InvokeEvent(vtkCommand::StartInteractionEvent, nullptr); + self->Render(); + } + + static void ForceRadiusAction(vtkAbstractWidget* w) + { + auto* self = reinterpret_cast(w); + int X = self->Interactor->GetEventPosition()[0]; + int Y = self->Interactor->GetEventPosition()[1]; + + auto* rep = + reinterpret_cast(self->WidgetRep); + rep->SetInteractionState(vtkImplicitCylinderRepresentation::Moving); + rep->ComputeInteractionState(X, Y); + + if (rep->GetInteractionState() == + vtkImplicitCylinderRepresentation::Outside) { + return; + } + + // Force radius adjustment regardless of what was picked + rep->SetInteractionState( + vtkImplicitCylinderRepresentation::AdjustingRadius); + + self->GrabFocus(self->EventCallbackCommand); + double eventPos[2] = { static_cast(X), static_cast(Y) }; + self->WidgetState = vtkImplicitCylinderWidget::Active; + self->WidgetRep->StartWidgetInteraction(eventPos); + + self->EventCallbackCommand->SetAbortFlag(1); + self->StartInteraction(); + self->InvokeEvent(vtkCommand::StartInteractionEvent, nullptr); + self->Render(); + } +}; + +} // anonymous namespace + +namespace tomviz { + +class SelectCylinderWidget::Internal +{ +public: + vtkNew cylinderWidget; + vtkNew eventLink; + vtkSmartPointer interactor; + + QDoubleSpinBox* centerX = nullptr; + QDoubleSpinBox* centerY = nullptr; + QDoubleSpinBox* centerZ = nullptr; + QDoubleSpinBox* axisX = nullptr; + QDoubleSpinBox* axisY = nullptr; + QDoubleSpinBox* axisZ = nullptr; + QDoubleSpinBox* radius = nullptr; + QDoubleSpinBox* fillValue = nullptr; + + int extent[6] = { 0, 0, 0, 0, 0, 0 }; + double origin[3] = { 0, 0, 0 }; + double spacing[3] = { 1, 1, 1 }; + double dataBounds[6] = { 0, 0, 0, 0, 0, 0 }; + + bool blockSignals = false; +}; + +SelectCylinderWidget::SelectCylinderWidget( + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent), m_internal(new Internal) +{ + // Registered with needsData=true, so the host gates creation on + // input availability — image is expected to be valid here. + vtkSmartPointer image; + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + image = vol->imageData(); + } + } + image->GetExtent(m_internal->extent); + image->GetOrigin(m_internal->origin); + image->GetSpacing(m_internal->spacing); + + int* ext = m_internal->extent; + double* orig = m_internal->origin; + double* sp = m_internal->spacing; + + int nx = ext[1] - ext[0] + 1; + int ny = ext[3] - ext[2] + 1; + int nz = ext[5] - ext[4] + 1; + + double defaultCenterX = (nx - 1) / 2.0; + double defaultCenterY = (ny - 1) / 2.0; + double defaultCenterZ = (nz - 1) / 2.0; + double defaultRadius = qMin(nx, ny) / 2.0; + + double* bounds = m_internal->dataBounds; + for (int i = 0; i < 3; ++i) { + bounds[2 * i] = orig[i] + sp[i] * ext[2 * i]; + bounds[2 * i + 1] = orig[i] + sp[i] * ext[2 * i + 1]; + } + + vtkNew rep; + rep->SetPlaceFactor(1.0); + rep->PlaceWidget(bounds); + rep->SetAxis(0, 0, 1); + double center[3] = { orig[0] + sp[0] * defaultCenterX, + orig[1] + sp[1] * defaultCenterY, + orig[2] + sp[2] * defaultCenterZ }; + rep->SetCenter(center); + rep->SetRadius(defaultRadius * sp[0]); + rep->SetDrawCylinder(true); + rep->SetScaleEnabled(false); + rep->SetTubing(true); + rep->SetResolution(64); + rep->GetCylinderProperty()->SetOpacity(0.3); + + // Hide the rectangular outline and constrain to data bounds + rep->GetOutlineProperty()->SetOpacity(0.0); + rep->GetSelectedOutlineProperty()->SetOpacity(0.0); + rep->SetOutlineTranslation(false); + rep->SetOutsideBounds(false); + rep->SetConstrainToWidgetBounds(true); + + vtkRenderWindowInteractor* iren = + ActiveObjects::instance().activeView()->GetRenderWindow()->GetInteractor(); + m_internal->interactor = iren; + + m_internal->cylinderWidget->SetRepresentation(rep.GetPointer()); + m_internal->cylinderWidget->SetInteractor(iren); + m_internal->cylinderWidget->SetPriority(1); + m_internal->cylinderWidget->setupInteraction(); + m_internal->cylinderWidget->EnabledOn(); + + m_internal->eventLink->Connect( + m_internal->cylinderWidget.GetPointer(), vtkCommand::InteractionEvent, this, + SLOT(onInteractionEnd())); + + iren->GetRenderWindow()->Render(); + + // Build the Qt UI + auto* layout = new QVBoxLayout; + + auto* instructions = new QLabel( + "Left-click the cylinder and drag to translate. " + "Left-click the arrow and drag to adjust the orientation. " + "Right-click the cylinder and drag to adjust the radius."); + instructions->setWordWrap(true); + layout->addWidget(instructions); + + auto* grid = new QGridLayout; + + int row = 0; + + grid->addWidget(new QLabel("Center X:"), row, 0); + m_internal->centerX = new QDoubleSpinBox; + m_internal->centerX->setRange(-(double)nx, 2.0 * nx); + m_internal->centerX->setDecimals(1); + m_internal->centerX->setValue(defaultCenterX); + grid->addWidget(m_internal->centerX, row++, 1); + + grid->addWidget(new QLabel("Center Y:"), row, 0); + m_internal->centerY = new QDoubleSpinBox; + m_internal->centerY->setRange(-(double)ny, 2.0 * ny); + m_internal->centerY->setDecimals(1); + m_internal->centerY->setValue(defaultCenterY); + grid->addWidget(m_internal->centerY, row++, 1); + + grid->addWidget(new QLabel("Center Z:"), row, 0); + m_internal->centerZ = new QDoubleSpinBox; + m_internal->centerZ->setRange(-(double)nz, 2.0 * nz); + m_internal->centerZ->setDecimals(1); + m_internal->centerZ->setValue(defaultCenterZ); + grid->addWidget(m_internal->centerZ, row++, 1); + + grid->addWidget(new QLabel("Axis X:"), row, 0); + m_internal->axisX = new QDoubleSpinBox; + m_internal->axisX->setRange(-1.0, 1.0); + m_internal->axisX->setDecimals(4); + m_internal->axisX->setSingleStep(0.1); + m_internal->axisX->setValue(0.0); + grid->addWidget(m_internal->axisX, row++, 1); + + grid->addWidget(new QLabel("Axis Y:"), row, 0); + m_internal->axisY = new QDoubleSpinBox; + m_internal->axisY->setRange(-1.0, 1.0); + m_internal->axisY->setDecimals(4); + m_internal->axisY->setSingleStep(0.1); + m_internal->axisY->setValue(0.0); + grid->addWidget(m_internal->axisY, row++, 1); + + grid->addWidget(new QLabel("Axis Z:"), row, 0); + m_internal->axisZ = new QDoubleSpinBox; + m_internal->axisZ->setRange(-1.0, 1.0); + m_internal->axisZ->setDecimals(4); + m_internal->axisZ->setSingleStep(0.1); + m_internal->axisZ->setValue(1.0); + grid->addWidget(m_internal->axisZ, row++, 1); + + grid->addWidget(new QLabel("Radius:"), row, 0); + m_internal->radius = new QDoubleSpinBox; + m_internal->radius->setRange(0.1, qMax(nx, qMax(ny, nz))); + m_internal->radius->setDecimals(1); + m_internal->radius->setValue(defaultRadius); + grid->addWidget(m_internal->radius, row++, 1); + + grid->addWidget(new QLabel("Fill Value:"), row, 0); + m_internal->fillValue = new QDoubleSpinBox; + m_internal->fillValue->setRange(-1e12, 1e12); + m_internal->fillValue->setDecimals(4); + m_internal->fillValue->setValue(0.0); + grid->addWidget(m_internal->fillValue, row++, 1); + + layout->addLayout(grid); + layout->addStretch(); + setLayout(layout); + + connect(m_internal->centerX, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); + connect(m_internal->centerY, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); + connect(m_internal->centerZ, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); + connect(m_internal->axisX, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); + connect(m_internal->axisY, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); + connect(m_internal->axisZ, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); + connect(m_internal->radius, &QDoubleSpinBox::editingFinished, this, + &SelectCylinderWidget::onSpinBoxChanged); +} + +SelectCylinderWidget::~SelectCylinderWidget() +{ + disableWidget(); +} + +void SelectCylinderWidget::writeSettings() +{ + disableWidget(); +} + +void SelectCylinderWidget::disableWidget() +{ + m_internal->eventLink->Disconnect(); + m_internal->cylinderWidget->EnabledOff(); + m_internal->cylinderWidget->SetInteractor(nullptr); + if (m_internal->interactor) { + m_internal->interactor->GetRenderWindow()->Render(); + } +} + +void SelectCylinderWidget::getValues(QMap& map) +{ + map["center_x"] = m_internal->centerX->value(); + map["center_y"] = m_internal->centerY->value(); + map["center_z"] = m_internal->centerZ->value(); + map["axis_x"] = m_internal->axisX->value(); + map["axis_y"] = m_internal->axisY->value(); + map["axis_z"] = m_internal->axisZ->value(); + map["radius"] = m_internal->radius->value(); + map["fill_value"] = m_internal->fillValue->value(); +} + +void SelectCylinderWidget::setValues(const QMap& map) +{ + if (map.contains("center_x")) { + double cx = map["center_x"].toDouble(); + if (cx >= 0) { + m_internal->centerX->setValue(cx); + } + } + if (map.contains("center_y")) { + double cy = map["center_y"].toDouble(); + if (cy >= 0) { + m_internal->centerY->setValue(cy); + } + } + if (map.contains("center_z")) { + double cz = map["center_z"].toDouble(); + if (cz >= 0) { + m_internal->centerZ->setValue(cz); + } + } + if (map.contains("axis_x")) { + m_internal->axisX->setValue(map["axis_x"].toDouble()); + } + if (map.contains("axis_y")) { + m_internal->axisY->setValue(map["axis_y"].toDouble()); + } + if (map.contains("axis_z")) { + m_internal->axisZ->setValue(map["axis_z"].toDouble()); + } + if (map.contains("radius")) { + double r = map["radius"].toDouble(); + if (r > 0) { + m_internal->radius->setValue(r); + } + } + if (map.contains("fill_value")) { + m_internal->fillValue->setValue(map["fill_value"].toDouble()); + } + + onSpinBoxChanged(); +} + +void SelectCylinderWidget::onInteractionEnd() +{ + if (m_internal->blockSignals) { + return; + } + + auto* rep = m_internal->cylinderWidget->GetCylinderRepresentation(); + + // Re-clamp the bounding box to data bounds so the cylinder is always + // visually clipped against the dataset extent. + double center[3], axis[3]; + rep->GetCenter(center); + rep->GetAxis(axis); + double radius = rep->GetRadius(); + + rep->PlaceWidget(m_internal->dataBounds); + rep->SetCenter(center); + rep->SetAxis(axis); + rep->SetRadius(radius); + rep->BuildRepresentation(); + + double* orig = m_internal->origin; + double* sp = m_internal->spacing; + + // Convert from physical space to voxel coordinates + double cx = (center[0] - orig[0]) / sp[0]; + double cy = (center[1] - orig[1]) / sp[1]; + double cz = (center[2] - orig[2]) / sp[2]; + + // Convert axis from physical space to voxel space direction + double axVoxel[3] = { axis[0] * sp[0], axis[1] * sp[1], axis[2] * sp[2] }; + double axLen = vtkMath::Norm(axVoxel); + if (axLen > 1e-12) { + axVoxel[0] /= axLen; + axVoxel[1] /= axLen; + axVoxel[2] /= axLen; + } + + double avgSpacing = (sp[0] + sp[1]) / 2.0; + double r = radius / avgSpacing; + + m_internal->blockSignals = true; + m_internal->centerX->setValue(cx); + m_internal->centerY->setValue(cy); + m_internal->centerZ->setValue(cz); + m_internal->axisX->setValue(axVoxel[0]); + m_internal->axisY->setValue(axVoxel[1]); + m_internal->axisZ->setValue(axVoxel[2]); + m_internal->radius->setValue(r); + m_internal->blockSignals = false; +} + +void SelectCylinderWidget::onSpinBoxChanged() +{ + if (m_internal->blockSignals) { + return; + } + + double* orig = m_internal->origin; + double* sp = m_internal->spacing; + + double cx = m_internal->centerX->value(); + double cy = m_internal->centerY->value(); + double cz = m_internal->centerZ->value(); + double ax = m_internal->axisX->value(); + double ay = m_internal->axisY->value(); + double az = m_internal->axisZ->value(); + double r = m_internal->radius->value(); + + auto* rep = m_internal->cylinderWidget->GetCylinderRepresentation(); + + double center[3] = { orig[0] + sp[0] * cx, + orig[1] + sp[1] * cy, + orig[2] + sp[2] * cz }; + + // Convert axis direction from voxel to physical space + double axPhys[3] = { ax / sp[0], ay / sp[1], az / sp[2] }; + double axLen = vtkMath::Norm(axPhys); + if (axLen > 1e-12) { + axPhys[0] /= axLen; + axPhys[1] /= axLen; + axPhys[2] /= axLen; + } + + double avgSpacing = (sp[0] + sp[1]) / 2.0; + + m_internal->blockSignals = true; + rep->PlaceWidget(m_internal->dataBounds); + rep->SetCenter(center); + rep->SetAxis(axPhys); + rep->SetRadius(r * avgSpacing); + rep->BuildRepresentation(); + m_internal->interactor->GetRenderWindow()->Render(); + m_internal->blockSignals = false; +} + +} // namespace tomviz diff --git a/tomviz/SelectCylinderWidget.h b/tomviz/SelectCylinderWidget.h new file mode 100644 index 000000000..4992e736f --- /dev/null +++ b/tomviz/SelectCylinderWidget.h @@ -0,0 +1,49 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizSelectCylinderWidget_h +#define tomvizSelectCylinderWidget_h + +#include "CustomPythonNodeWidget.h" +#include "PortData.h" + +#include + +#include +#include +#include + +class vtkImageData; +class vtkSMProxy; + +namespace tomviz { + +class SelectCylinderWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + SelectCylinderWidget(const QMap& inputs, + QWidget* parent = nullptr); + ~SelectCylinderWidget(); + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + void writeSettings() override; + +private slots: + void onInteractionEnd(); + void onSpinBoxChanged(); + +private: + void disableWidget(); + + Q_DISABLE_COPY(SelectCylinderWidget) + + class Internal; + QScopedPointer m_internal; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/SetDataTypeReaction.cxx b/tomviz/SetDataTypeReaction.cxx index 0c01c830a..c038a04db 100644 --- a/tomviz/SetDataTypeReaction.cxx +++ b/tomviz/SetDataTypeReaction.cxx @@ -4,78 +4,62 @@ #include "SetDataTypeReaction.h" #include "ActiveObjects.h" -#include "OperatorFactory.h" -#include "Pipeline.h" -#include "SetTiltAnglesReaction.h" +#include "TransformUtils.h" + +#include "pipeline/OutputPort.h" +#include "pipeline/PortType.h" +#include "pipeline/transforms/ConvertToVolumeTransform.h" +#include "pipeline/transforms/SetTiltAnglesTransform.h" #include namespace tomviz { SetDataTypeReaction::SetDataTypeReaction(QAction* action, QMainWindow* mw, - DataSource::DataSourceType t) + pipeline::PortType t) : pqReaction(action), m_mainWindow(mw), m_type(t) { + connect(&ActiveObjects::instance(), &ActiveObjects::activeNodeChanged, this, + &SetDataTypeReaction::updateEnableState); connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), + &ActiveObjects::activePipelineChanged, this, &SetDataTypeReaction::updateEnableState); setWidgetText(t); updateEnableState(); } -void SetDataTypeReaction::setDataType(QMainWindow* mw, DataSource* dsource, - DataSource::DataSourceType t) +void SetDataTypeReaction::setDataType(QMainWindow* mw, DataSource*, + pipeline::PortType t) { - if (dsource == nullptr) { - dsource = ActiveObjects::instance().activeDataSource(); - } - // if it is still null, give up - if (dsource == nullptr) { - return; - } - if (t == DataSource::TiltSeries) { - SetTiltAnglesReaction::showSetTiltAnglesUI(mw, dsource); + Q_UNUSED(mw); + if (t == pipeline::PortType::TiltSeries) { + auto* transform = new pipeline::SetTiltAnglesTransform(); + insertTransformIntoPipeline(transform); } else { - // If it was a TiltSeries convert to volume - // if (dsource->type() == DataSource::TiltSeries) { - Operator* op = OperatorFactory::instance().createConvertToVolumeOperator(t); - dsource->addOperator(op); - // dsource->setType(t); - // } + auto* transform = new pipeline::ConvertToVolumeTransform(); + transform->setOutputType(pipeline::PortType::Volume); + transform->setOutputLabel("Mark as Volume"); + insertTransformIntoPipeline(transform); } } void SetDataTypeReaction::onTriggered() { - DataSource* dsource = ActiveObjects::instance().activeParentDataSource(); - setDataType(m_mainWindow, dsource, m_type); - // setWidgetText(dsource); + setDataType(m_mainWindow, nullptr, m_type); } void SetDataTypeReaction::updateEnableState() { - // auto dsource = ActiveObjects::instance().activeDataSource(); - auto pipeline = ActiveObjects::instance().activePipeline(); - bool enable = pipeline != nullptr; - if (enable) { - auto dsource = pipeline->transformedDataSource(); - enable = dsource != nullptr; - if (enable) { - enable = dsource->type() != m_type; - } - } - parentAction()->setEnabled(enable); + auto& ao = ActiveObjects::instance(); + parentAction()->setEnabled(ao.activeTipOutputPort() != nullptr); } -void SetDataTypeReaction::setWidgetText(DataSource::DataSourceType t) +void SetDataTypeReaction::setWidgetText(pipeline::PortType t) { - if (t == DataSource::Volume) { + if (t == pipeline::PortType::Volume) { parentAction()->setText("Mark Data As Volume"); - } else if (t == DataSource::TiltSeries) { + } else if (t == pipeline::PortType::TiltSeries) { parentAction()->setText("Mark Data As Tilt Series"); - } else if (t == DataSource::FIB) { - parentAction()->setText("Mark Data As Focused Ion Beam (FIB)"); } else { assert("Unknown data source type" && false); } diff --git a/tomviz/SetDataTypeReaction.h b/tomviz/SetDataTypeReaction.h index c99d08ac0..08e5ce836 100644 --- a/tomviz/SetDataTypeReaction.h +++ b/tomviz/SetDataTypeReaction.h @@ -6,7 +6,7 @@ #include -#include "DataSource.h" +#include "pipeline/PortType.h" class QMainWindow; @@ -20,10 +20,10 @@ class SetDataTypeReaction : public pqReaction public: SetDataTypeReaction(QAction* action, QMainWindow* mw, - DataSource::DataSourceType t = DataSource::Volume); + pipeline::PortType t = pipeline::PortType::Volume); static void setDataType(QMainWindow* mw, DataSource* source = nullptr, - DataSource::DataSourceType t = DataSource::Volume); + pipeline::PortType t = pipeline::PortType::Volume); protected: /// Called when the action is triggered. @@ -32,9 +32,9 @@ class SetDataTypeReaction : public pqReaction private: QMainWindow* m_mainWindow; - DataSource::DataSourceType m_type; + pipeline::PortType m_type; - void setWidgetText(DataSource::DataSourceType t); + void setWidgetText(pipeline::PortType t); Q_DISABLE_COPY(SetDataTypeReaction) }; diff --git a/tomviz/SetTiltAnglesReaction.cxx b/tomviz/SetTiltAnglesReaction.cxx index 06aea7677..953f913a8 100644 --- a/tomviz/SetTiltAnglesReaction.cxx +++ b/tomviz/SetTiltAnglesReaction.cxx @@ -4,9 +4,10 @@ #include "SetTiltAnglesReaction.h" #include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "SetTiltAnglesOperator.h" +#include "TransformUtils.h" + +#include "pipeline/OutputPort.h" +#include "pipeline/transforms/SetTiltAnglesTransform.h" #include @@ -16,44 +17,24 @@ SetTiltAnglesReaction::SetTiltAnglesReaction(QAction* p, QMainWindow* mw) : pqReaction(p), m_mainWindow(mw) { connect(&ActiveObjects::instance(), - static_cast( - &ActiveObjects::dataSourceChanged), - this, &SetTiltAnglesReaction::updateEnableState); + &ActiveObjects::activePipelineChanged, + this, [this]() { updateEnableState(); }); + connect(&ActiveObjects::instance(), + &ActiveObjects::activeTipOutputPortChanged, + this, [this]() { updateEnableState(); }); updateEnableState(); } void SetTiltAnglesReaction::updateEnableState() { - bool enable = ActiveObjects::instance().activeDataSource() != nullptr; - if (enable) { - enable = ActiveObjects::instance().activeDataSource()->type() == - DataSource::TiltSeries; - } - parentAction()->setEnabled(enable); + auto& ao = ActiveObjects::instance(); + auto* tipPort = ao.activeTipOutputPort(); + parentAction()->setEnabled(tipPort != nullptr); } -void SetTiltAnglesReaction::showSetTiltAnglesUI(QMainWindow* window, - DataSource* source) +void SetTiltAnglesReaction::showSetTiltAnglesUI(QMainWindow*, DataSource*) { - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - return; - } - auto operators = source->operators(); - SetTiltAnglesOperator* op = nullptr; - bool needToAddOp = false; - if (operators.size() > 0) { - op = qobject_cast(operators[operators.size() - 1]); - } - if (!op) { - op = new SetTiltAnglesOperator; - needToAddOp = true; - } - EditOperatorDialog* dialog = - new EditOperatorDialog(op, source, needToAddOp, window); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setWindowTitle("Set Tilt Angles"); - dialog->show(); - connect(op, &QObject::destroyed, dialog, &QDialog::reject); + auto* transform = new pipeline::SetTiltAnglesTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/ShiftRotationCenterWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx index d05304c24..4c4369224 100644 --- a/tomviz/ShiftRotationCenterWidget.cxx +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -6,15 +6,26 @@ #include "ActiveObjects.h" #include "ColorMap.h" -#include "DataSource.h" -#include "InternalPythonHelper.h" #include "PresetDialog.h" +#include "PythonUtilities.h" #include "Utilities.h" +#include "pipeline/data/VolumeData.h" + +// Qt defines 'slots' as a macro which conflicts with Python's object.h. +// We must undef it before including any pybind11/Python headers. +#pragma push_macro("slots") +#undef slots +#include +#include + +#include "pybind11/PybindVTKTypeCaster.h" +#pragma pop_macro("slots") #include #include #include +#include #include #include @@ -58,6 +69,10 @@ #include +PYBIND11_VTK_TYPECASTER(vtkImageData) + +namespace py = pybind11; + namespace tomviz { class InternalProgressDialog : public QProgressDialog @@ -129,7 +144,6 @@ class ShiftRotationCenterWidget::Internal : public QObject public: Ui::ShiftRotationCenterWidget ui; - QPointer op; vtkSmartPointer image; vtkSmartPointer rotationImages; vtkSmartPointer colorMap; @@ -160,19 +174,25 @@ class ShiftRotationCenterWidget::Internal : public QObject QList qnValues; QString script; - InternalPythonHelper pythonHelper; QPointer parent; - QPointer dataSource; + vtkSMProxy* m_sourceColorMap = nullptr; int sliceNumber = 0; QScopedPointer progressDialog; QFutureWatcher futureWatcher; bool testRotationsSuccess = false; QString testRotationsErrorMessage; - Internal(Operator* o, vtkSmartPointer img, + Internal(vtkSmartPointer img, vtkSMProxy* sourceColorMap, ShiftRotationCenterWidget* p) - : op(o), image(img) + : image(img) { + init(p, sourceColorMap); + } + + void init(ShiftRotationCenterWidget* p, vtkSMProxy* sourceColorMap) + { + m_sourceColorMap = sourceColorMap; + // Must call setupUi() before using p in any way ui.setupUi(p); setParent(p); @@ -196,28 +216,23 @@ class ShiftRotationCenterWidget::Internal : public QObject ui.sliceView->interactor()->SetInteractorStyle(interactorStyle); setRotationData(vtkImageData::New()); - // Use a child data source if one is available so the color map will match - if (op->childDataSource()) { - dataSource = op->childDataSource(); - } else if (op->dataSource()) { - dataSource = op->dataSource(); - } else { - dataSource = ActiveObjects::instance().activeDataSource(); - } - // Set up the projection view showing one projection image (Z-axis slice). // This matches the orientation used by the main slice view in // RotateAlignWidget: XY plane, camera looking from +Z. - projMapper->SetInputData(image); - projMapper->SetSliceNumber(image->GetDimensions()[2] / 2); - projMapper->Update(); + if (image) { + projMapper->SetInputData(image); + projMapper->SetSliceNumber(image->GetDimensions()[2] / 2); + projMapper->Update(); + } projSlice->SetMapper(projMapper); - // Use the data source's color map for the projection view - auto* dsLut = vtkScalarsToColors::SafeDownCast( - dataSource->colorMap()->GetClientSideObject()); - if (dsLut) { - projSlice->GetProperty()->SetLookupTable(dsLut); + // Use the source color map for the projection view + if (m_sourceColorMap) { + auto* dsLut = vtkScalarsToColors::SafeDownCast( + m_sourceColorMap->GetClientSideObject()); + if (dsLut) { + projSlice->GetProperty()->SetLookupTable(dsLut); + } } projRenderer->AddViewProp(projSlice); @@ -258,16 +273,18 @@ class ShiftRotationCenterWidget::Internal : public QObject chartQn->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center (px)"); chartQn->GetAxis(vtkAxis::LEFT)->SetTitle(""); - tomviz::setupRenderer(projRenderer, projMapper, nullptr); - projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); + if (image) { + tomviz::setupRenderer(projRenderer, projMapper, nullptr); + projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); - // Mirror the image left-to-right by placing the camera on the -Z side. - auto* cam = projRenderer->GetActiveCamera(); - double* pos = cam->GetPosition(); - double* fp = cam->GetFocalPoint(); - cam->SetPosition(pos[0], pos[1], fp[2] - (pos[2] - fp[2])); + // Mirror the image left-to-right by placing the camera on the -Z side. + auto* cam = projRenderer->GetActiveCamera(); + double* pos = cam->GetPosition(); + double* fp = cam->GetFocalPoint(); + cam->SetPosition(pos[0], pos[1], fp[2] - (pos[2] - fp[2])); - projRenderer->ResetCameraClippingRange(); + projRenderer->ResetCameraClippingRange(); + } updateCenterLine(); updateSliceLine(); @@ -284,14 +301,18 @@ class ShiftRotationCenterWidget::Internal : public QObject .data(), pxm); - // Default to the same colormap as the data source (projection view / + // Default to the same colormap as the source (projection view / // main render window). Fall back to grayscale if unavailable. - auto* dsLutVtk = vtkScalarsToColors::SafeDownCast( - dataSource->colorMap()->GetClientSideObject()); - auto* colorMapVtk = - vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); - if (dsLutVtk && colorMapVtk) { - colorMapVtk->DeepCopy(dsLutVtk); + if (m_sourceColorMap) { + auto* dsLutVtk = vtkScalarsToColors::SafeDownCast( + m_sourceColorMap->GetClientSideObject()); + auto* colorMapVtk = + vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); + if (dsLutVtk && colorMapVtk) { + colorMapVtk->DeepCopy(dsLutVtk); + } else { + setColorMapToGrayscale(); + } } else { setColorMapToGrayscale(); } @@ -303,31 +324,33 @@ class ShiftRotationCenterWidget::Internal : public QObject // This isn't always working in Qt designer, so set it here as well ui.colorPresetButton->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); - auto* dims = image->GetDimensions(); + if (image) { + auto* dims = image->GetDimensions(); - // All center-related values are offsets from the image midpoint in pixels. - // 0 means the rotation center is exactly at the midpoint. - setRotationCenter(0); + // All center-related values are offsets from the image midpoint in pixels. + // 0 means the rotation center is exactly at the midpoint. + setRotationCenter(0); - // Default start/stop to +/- 50 pixels - ui.start->setValue(-50); - ui.stop->setValue(50); + // Default start/stop to +/- 50 pixels + ui.start->setValue(-50); + ui.stop->setValue(50); - // Display image dimensions - ui.imageDimensionsLabel->setText( - QString("Image: %1 x %2 x %3 (shift axis: Y = %2 px)") - .arg(dims[0]).arg(dims[1]).arg(dims[2])); + // Display image dimensions + ui.imageDimensionsLabel->setText( + QString("Image: %1 x %2 x %3 (shift axis: Y = %2 px)") + .arg(dims[0]).arg(dims[1]).arg(dims[2])); - // Default projection number to the middle projection - ui.projectionNo->setMaximum(dims[2] - 1); - ui.projectionNo->setValue(dims[2] / 2); + // Default projection number to the middle projection + ui.projectionNo->setMaximum(dims[2] - 1); + ui.projectionNo->setValue(dims[2] / 2); - // Default slice to the middle slice (bounded by image height) - ui.slice->setMaximum(dims[0] - 1); - ui.slice->setValue(dims[0] / 2); + // Default slice to the middle slice (bounded by image height) + ui.slice->setMaximum(dims[0] - 1); + ui.slice->setValue(dims[0] / 2); - // Load saved settings for steps, algorithm, numIterations only - readSettings(); + // Load saved settings for steps, algorithm, numIterations only + readSettings(); + } // Hide iterations by default (only shown for iterative algorithms) updateAlgorithmUI(); @@ -539,6 +562,11 @@ class ShiftRotationCenterWidget::Internal : public QObject ui.numIterations->setValue(settings->value("numIterations", 15).toInt()); ui.circMaskRatio->setValue(settings->value("circMaskRatio", 0.8).toDouble()); + if (!image) { + settings->endGroup(); + return; + } + // Restore start/stop only if the saved values fit within the current image auto halfDim = image->GetDimensions()[1] / 2.0; if (settings->contains("start")) { @@ -578,6 +606,11 @@ class ShiftRotationCenterWidget::Internal : public QObject void startGeneratingTestImages() { + if (!image) { + QMessageBox::warning(parent, "Tomviz", + "No input data available."); + return; + } progressDialog->show(); auto future = QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); @@ -614,91 +647,109 @@ class ShiftRotationCenterWidget::Internal : public QObject testRotationsSuccess = false; rotations.clear(); - { + try { Python python; - auto module = pythonHelper.loadModule(script); - if (!module.isValid()) { - testRotationsErrorMessage = "Failed to load script"; - return; - } - auto func = module.findFunction("test_rotations"); - if (!func.isValid()) { + // Wrap the vtkImageData in a Dataset (uses LegacyDataset since + // the test script may rely on the v1 create_child_dataset API). + py::module_ datasetMod = py::module_::import("tomviz.internal_dataset"); + py::object datasetCls = datasetMod.attr("LegacyDataset"); + py::object dataset = datasetCls( + py::cast(image.Get(), py::return_value_policy::reference)); + + // Load the Python script as a module + py::module_ types = py::module_::import("types"); + py::object moduleType = types.attr("ModuleType"); + py::object scriptModule = + moduleType(py::str("test_rotations_module")); + py::exec(py::str(script.toStdString()), + scriptModule.attr("__dict__")); + + // Find and call test_rotations + if (!py::hasattr(scriptModule, "test_rotations")) { testRotationsErrorMessage = "Failed to find function \"test_rotations\""; return; } - - Python::Object data = Python::createDataset(image, *dataSource); - - Python::Dict kwargs; - kwargs.set("dataset", data); - kwargs.set("start", ui.start->value()); - kwargs.set("stop", ui.stop->value()); - kwargs.set("steps", ui.steps->value()); - kwargs.set("sli", ui.slice->value()); - kwargs.set("algorithm", algorithm()); - kwargs.set("num_iter", ui.numIterations->value()); - kwargs.set("circ_mask_ratio", ui.circMaskRatio->value()); - - auto ret = func.call(kwargs); - auto result = ret.toDict(); - if (!result.isValid()) { + py::object testFunc = scriptModule.attr("test_rotations"); + + py::dict kwargs; + kwargs["dataset"] = dataset; + kwargs["start"] = ui.start->value(); + kwargs["stop"] = ui.stop->value(); + kwargs["steps"] = ui.steps->value(); + kwargs["sli"] = ui.slice->value(); + kwargs["algorithm"] = algorithm().toStdString(); + kwargs["num_iter"] = ui.numIterations->value(); + kwargs["circ_mask_ratio"] = ui.circMaskRatio->value(); + + py::object ret = testFunc(**kwargs); + + // Extract results + if (!py::isinstance(ret)) { testRotationsErrorMessage = "Failed to execute test_rotations()"; return; } + py::dict result = ret.cast(); - auto pyImages = result["images"]; - auto* object = Python::VTK::convertToDataObject(pyImages); - if (!object) { + // Extract images + if (!result.contains("images")) { testRotationsErrorMessage = "No image data was returned from test_rotations()"; return; } - - auto* imageData = vtkImageData::SafeDownCast(object); + py::object imagesObj = result["images"].cast(); + if (py::hasattr(imagesObj, "_data_object")) { + imagesObj = imagesObj.attr("_data_object"); + } + auto* imageData = dynamic_cast( + vtkPythonUtil::GetPointerFromObject(imagesObj.ptr(), + "vtkImageData")); if (!imageData) { + PyErr_Clear(); testRotationsErrorMessage = "No image data was returned from test_rotations()"; return; } - auto centers = result["centers"]; - auto pyRotations = centers.toList(); - if (!pyRotations.isValid() || pyRotations.length() <= 0) { + // Extract centers + if (!result.contains("centers")) { testRotationsErrorMessage = "No rotations returned from test_rotations()"; return; } - - for (int i = 0; i < pyRotations.length(); ++i) { - rotations.append(pyRotations[i].toDouble()); + py::list pyCenters = result["centers"].cast(); + for (size_t i = 0; i < pyCenters.size(); ++i) { + rotations.append(pyCenters[i].cast()); } + // Extract quality metrics qiaValues.clear(); qnValues.clear(); - - auto pyQia = result["qia"]; - auto qiaList = pyQia.toList(); - if (qiaList.isValid()) { - for (int i = 0; i < qiaList.length(); ++i) { - qiaValues.append(qiaList[i].toDouble()); + if (result.contains("qia")) { + py::list pyQia = result["qia"].cast(); + for (size_t i = 0; i < pyQia.size(); ++i) { + qiaValues.append(pyQia[i].cast()); } } - - auto pyQn = result["qn"]; - auto qnList = pyQn.toList(); - if (qnList.isValid()) { - for (int i = 0; i < qnList.length(); ++i) { - qnValues.append(qnList[i].toDouble()); + if (result.contains("qn")) { + py::list pyQn = result["qn"].cast(); + for (size_t i = 0; i < pyQn.size(); ++i) { + qnValues.append(pyQn[i].cast()); } } setRotationData(imageData); + + } catch (const py::error_already_set& e) { + testRotationsErrorMessage = + QString("Python error: %1").arg(e.what()); + return; + } catch (const std::exception& e) { + testRotationsErrorMessage = QString("Error: %1").arg(e.what()); + return; } - // If we made it this far, it was a success - // Save these settings in case the user wants to use them again... writeSettings(); testRotationsSuccess = true; } @@ -1051,10 +1102,21 @@ class ShiftRotationCenterWidget::Internal : public QObject #include "ShiftRotationCenterWidget.moc" ShiftRotationCenterWidget::ShiftRotationCenterWidget( - Operator* op, vtkSmartPointer image, QWidget* p) - : CustomPythonOperatorWidget(p) + const QMap& inputs, QWidget* p) + : pipeline::CustomPythonNodeWidget(p) { - m_internal.reset(new Internal(op, image, this)); + vtkSmartPointer image; + vtkSMProxy* sourceColorMap = nullptr; + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + image = vol->imageData(); + vol->initColorMap(); + sourceColorMap = vol->colorMap(); + } + } + m_internal.reset(new Internal(image, sourceColorMap, this)); } ShiftRotationCenterWidget::~ShiftRotationCenterWidget() = default; @@ -1095,13 +1157,11 @@ void ShiftRotationCenterWidget::setValues(const QVariantMap& map) void ShiftRotationCenterWidget::setScript(const QString& script) { - Superclass::setScript(script); m_internal->script = script; } void ShiftRotationCenterWidget::writeSettings() { - Superclass::writeSettings(); m_internal->writeSettings(); } diff --git a/tomviz/ShiftRotationCenterWidget.h b/tomviz/ShiftRotationCenterWidget.h index 2e6496dc4..fc5945a80 100644 --- a/tomviz/ShiftRotationCenterWidget.h +++ b/tomviz/ShiftRotationCenterWidget.h @@ -4,30 +4,30 @@ #ifndef tomvizShiftRotationCenterWidget_h #define tomvizShiftRotationCenterWidget_h -#include "CustomPythonOperatorWidget.h" +#include "CustomPythonNodeWidget.h" +#include "PortData.h" #include +#include #include +#include class vtkImageData; +class vtkSMProxy; namespace tomviz { -class Operator; -class ShiftRotationCenterWidget : public CustomPythonOperatorWidget +class ShiftRotationCenterWidget : public pipeline::CustomPythonNodeWidget { Q_OBJECT - typedef CustomPythonOperatorWidget Superclass; public: - ShiftRotationCenterWidget(Operator* op, vtkSmartPointer image, - QWidget* parent = NULL); + ShiftRotationCenterWidget( + const QMap& inputs, + QWidget* parent = nullptr); ~ShiftRotationCenterWidget(); - static CustomPythonOperatorWidget* New(QWidget* p, Operator* op, - vtkSmartPointer data); - void getValues(QMap& map) override; void setValues(const QMap& map) override; @@ -42,11 +42,5 @@ class ShiftRotationCenterWidget : public CustomPythonOperatorWidget QScopedPointer m_internal; }; -inline CustomPythonOperatorWidget* ShiftRotationCenterWidget::New( - QWidget* p, Operator* op, vtkSmartPointer data) -{ - return new ShiftRotationCenterWidget(op, data, p); -} - } // namespace tomviz #endif diff --git a/tomviz/SliceViewDialog.cxx b/tomviz/SliceViewDialog.cxx deleted file mode 100644 index 8cef5b511..000000000 --- a/tomviz/SliceViewDialog.cxx +++ /dev/null @@ -1,138 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "SliceViewDialog.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "DataSource.h" -#include "QVTKGLWidget.h" -#include "Utilities.h" - -namespace tomviz { - -SliceViewDialog::SliceViewDialog(QWidget* parent) : QDialog(parent) -{ - // Pick a reasonable size. It is very tiny otherwise. - resize(500, 500); - auto* vLayout = new QVBoxLayout(this); - - // Set the margins to all be 0 - vLayout->setContentsMargins(0, 0, 0, 0); - - m_glWidget = new QVTKGLWidget(this); - vLayout->addWidget(m_glWidget); - - m_slice->SetMapper(m_mapper); - m_renderer->AddViewProp(m_slice); - - m_glWidget->renderWindow()->AddRenderer(m_renderer); - - vtkNew interatorStyle; - interatorStyle->SetRenderOnMouseMove(true); - m_glWidget->interactor()->SetInteractorStyle(interatorStyle); - - // Add in the radio buttons - m_darkButton = new QRadioButton(this); - m_whiteButton = new QRadioButton(this); - - auto* buttonLayout = new QHBoxLayout; - vLayout->addLayout(buttonLayout); - - buttonLayout->addWidget(m_darkButton); - buttonLayout->addWidget(m_whiteButton); - buttonLayout->addStretch(1); - - m_darkButton->setText("Dark"); - m_whiteButton->setText("White"); - - setupConnections(); -} - -void SliceViewDialog::setupConnections() -{ - connect(m_darkButton, &QRadioButton::clicked, this, - &SliceViewDialog::switchToDark); - connect(m_whiteButton, &QRadioButton::clicked, this, - &SliceViewDialog::switchToWhite); -} - -SliceViewDialog::~SliceViewDialog() = default; - -void SliceViewDialog::setActiveImageData(vtkImageData* image) -{ - m_mapper->SetInputData(image); - setSliceNumber(0); - updateLUTRange(); - - // Set up the renderer to show the slice in parallel projection - // It also zooms the renderer so the entire slice is visible - tomviz::setupRenderer(m_renderer, m_mapper); - m_glWidget->renderWindow()->Render(); -} - -void SliceViewDialog::setSliceNumber(int slice) -{ - m_mapper->SetSliceNumber(slice); - m_mapper->Update(); -} - -void SliceViewDialog::setLookupTable(vtkScalarsToColors* lut) -{ - m_lut = lut; - m_slice->GetProperty()->SetLookupTable(lut); -} - -void SliceViewDialog::updateLUTRange() -{ - auto* image = m_mapper->GetInput(); - auto* ctf = vtkColorTransferFunction::SafeDownCast(m_lut.Get()); - - if (!image || !ctf) { - return; - } - - // Make a deep copy to put on the image property, so we can modify it - auto* lut = ctf->NewInstance(); - lut->DeepCopy(ctf); - m_slice->GetProperty()->SetLookupTable(lut); - // Decrement the reference count - lut->Delete(); - - // Rescale the points - double* range = image->GetScalarRange(); - rescaleLut(lut, range[0], range[1]); -} - -void SliceViewDialog::switchToDark() -{ - m_darkButton->setChecked(true); - m_whiteButton->setChecked(false); - - setActiveImageData(m_darkImage); -} - -void SliceViewDialog::switchToWhite() -{ - m_whiteButton->setChecked(true); - m_darkButton->setChecked(false); - - setActiveImageData(m_whiteImage); -} - -} // namespace tomviz diff --git a/tomviz/SliceViewDialog.h b/tomviz/SliceViewDialog.h deleted file mode 100644 index 01653e016..000000000 --- a/tomviz/SliceViewDialog.h +++ /dev/null @@ -1,63 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizSliceViewDialog_h -#define tomvizSliceViewDialog_h - -#include - -#include - -#include -#include -#include - -class QRadioButton; - -class vtkImageData; -class vtkImageSlice; -class vtkImageSliceMapper; -class vtkRenderer; -class vtkScalarsToColors; - -namespace tomviz { - -class QVTKGLWidget; - -class SliceViewDialog : public QDialog -{ - Q_OBJECT -public: - SliceViewDialog(QWidget* parent = nullptr); - ~SliceViewDialog(); - - void setActiveImageData(vtkImageData* image); - void setSliceNumber(int slice); - void setLookupTable(vtkScalarsToColors* lut); - - void setDarkImage(vtkImageData* image) { m_darkImage = image; } - void setWhiteImage(vtkImageData* image) { m_whiteImage = image; } - - void switchToDark(); - void switchToWhite(); - -private: - void setupConnections(); - void updateLUTRange(); - - vtkWeakPointer m_darkImage; - vtkWeakPointer m_whiteImage; - - QPointer m_glWidget; - QPointer m_darkButton; - QPointer m_whiteButton; - - vtkNew m_slice; - vtkNew m_mapper; - vtkNew m_renderer; - - vtkSmartPointer m_lut; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/ThreadedExecutor.cxx b/tomviz/ThreadedExecutor.cxx deleted file mode 100644 index 935c494e0..000000000 --- a/tomviz/ThreadedExecutor.cxx +++ /dev/null @@ -1,102 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ThreadedExecutor.h" - -namespace tomviz { - -class PipelineFutureThreadedInternal : public Pipeline::Future -{ -public: - PipelineFutureThreadedInternal(vtkImageData* imageData, - QList operators, - PipelineWorker::Future* future = nullptr, - QObject* parent = nullptr); - -private: - PipelineWorker::Future* m_future; -}; - -PipelineFutureThreadedInternal::PipelineFutureThreadedInternal( - vtkImageData* imageData, QList operators, - PipelineWorker::Future* future, QObject* parent) - : Pipeline::Future(imageData, operators, parent), m_future(future) -{ - connect(m_future, &PipelineWorker::Future::finished, this, - &Pipeline::Future::finished); - connect(m_future, &PipelineWorker::Future::canceled, this, - &Pipeline::Future::canceled); - - connect(m_future, &PipelineWorker::Future::finished, future, - [future]() { future->deleteLater(); }); - - connect(m_future, &PipelineWorker::Future::canceled, future, - [future]() { future->deleteLater(); }); -} - -ThreadPipelineExecutor::ThreadPipelineExecutor(Pipeline* pipeline) - : PipelineExecutor(pipeline) -{ - m_worker = new PipelineWorker(this); -} - -Pipeline::Future* ThreadPipelineExecutor::execute(vtkDataObject* data, - QList operators, - int start, int end) -{ - if (end == -1) { - end = operators.size(); - } - operators = operators.mid(start, end - start); - - // Cancel any running operators. TODO in the future we should be able to add - // operators to end of a running pipeline. - if (!m_future.isNull() && m_future->isRunning()) { - m_future->cancel(); - } - - auto copy = data->NewInstance(); - copy->DeepCopy(data); - - if (operators.isEmpty()) { - emit pipeline()->finished(); - auto future = new Pipeline::Future(); - future->setResult(vtkImageData::SafeDownCast(copy)); - copy->FastDelete(); - QTimer::singleShot(0, [future] { emit future->finished(); }); - return future; - } - - m_future = m_worker->run(copy, operators); - auto future = new PipelineFutureThreadedInternal( - vtkImageData::SafeDownCast(copy), operators, m_future.data(), this); - copy->FastDelete(); - - return future; -} - -void ThreadPipelineExecutor::cancel(std::function canceled) -{ - if (!m_future.isNull()) { - if (canceled) { - connect(m_future, &PipelineWorker::Future::canceled, canceled); - } - m_future->cancel(); - } -} - -bool ThreadPipelineExecutor::cancel(Operator* op) -{ - if (!m_future.isNull() && m_future->isRunning()) { - return m_future->cancel(op); - } - - return false; -} - -bool ThreadPipelineExecutor::isRunning() -{ - return !m_future.isNull() && m_future->isRunning(); -} - -} // namespace tomviz diff --git a/tomviz/ThreadedExecutor.h b/tomviz/ThreadedExecutor.h deleted file mode 100644 index 4de786856..000000000 --- a/tomviz/ThreadedExecutor.h +++ /dev/null @@ -1,39 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizThreadedExecutor_h -#define tomvizThreadedExecutor_h - -#include - -#include "PipelineExecutor.h" - -namespace tomviz { -class DataSource; -class Operator; -class Pipeline; - -/// -/// The default pipeline executor, providing execution of pipelines in a -/// background thread in order to retain interactivity in the user interface. -/// -class ThreadPipelineExecutor : public PipelineExecutor -{ - Q_OBJECT - -public: - ThreadPipelineExecutor(Pipeline* pipeline); - Pipeline::Future* execute(vtkDataObject* data, QList operators, - int start = 0, int end = -1) override; - void cancel(std::function canceled) override; - bool cancel(Operator* op) override; - bool isRunning() override; - -private: - PipelineWorker* m_worker; - QPointer m_future; -}; - -} // namespace tomviz - -#endif // tomvizThreadedExecutor_h diff --git a/tomviz/TimeSeriesLabel.cxx b/tomviz/TimeSeriesLabel.cxx index 0302a2284..f67a181ae 100644 --- a/tomviz/TimeSeriesLabel.cxx +++ b/tomviz/TimeSeriesLabel.cxx @@ -4,9 +4,12 @@ #include "TimeSeriesLabel.h" #include "ActiveObjects.h" -#include "TimeSeriesStep.h" #include "Utilities.h" +#include "pipeline/OutputPort.h" +#include "pipeline/PortData.h" +#include "pipeline/data/VolumeData.h" + #include #include @@ -30,7 +33,6 @@ class TimeSeriesLabel::Internal : public QObject vtkNew textRepresentation; vtkNew textWidget; - QPointer activeDataSource; QPointer activeView; Internal(QObject* p) : QObject(p) @@ -50,8 +52,8 @@ class TimeSeriesLabel::Internal : public QObject connect(&activeObjects(), QOverload::of(&ActiveObjects::viewChanged), this, &Internal::viewChanged); - connect(&activeObjects(), &ActiveObjects::dataSourceActivated, this, - &Internal::dataSourceActivated); + connect(&activeObjects(), &ActiveObjects::activeTipOutputPortChanged, this, + &Internal::activeDataChanged); connect(&activeObjects(), &ActiveObjects::showTimeSeriesLabelChanged, this, &Internal::updateVisibility); } @@ -77,42 +79,40 @@ class TimeSeriesLabel::Internal : public QObject render(); } - void dataSourceActivated(DataSource* ds) + // The VolumeData on the active tip output port, or null. + pipeline::VolumeDataPtr activeVolumeData() { - if (ds == activeDataSource) { - return; + auto* port = activeObjects().activeTipOutputPort(); + if (!port || !port->hasData()) { + return nullptr; } - - if (activeDataSource) { - disconnect(activeDataSource); - } - - if (ds) { - connect(ds, &DataSource::timeStepsModified, this, - &Internal::timeStepsModified); - connect(ds, &DataSource::timeStepChanged, this, - &Internal::timeStepChanged); + try { + return port->data().value(); + } catch (const std::bad_any_cast&) { + return nullptr; } - - activeDataSource = ds; - updateVisibility(); - timeStepChanged(); } - void timeStepsModified() + void activeDataChanged() { - // In case there wasn't a time series before... updateVisibility(); - timeStepChanged(); + updateLabel(); } - void timeStepChanged() + void updateLabel() { - if (!activeDataSource || !activeDataSource->hasTimeSteps()) { + auto vol = activeVolumeData(); + if (!vol || !vol->hasTimeSteps()) { + return; + } + + auto steps = vol->timeSteps(); + int index = vol->currentTimeStepIndex(); + if (index < 0 || index >= static_cast(steps.size())) { return; } - auto label = activeDataSource->currentTimeSeriesStep().label; + auto label = steps[index].label; if (label == textActor->GetInput()) { // No changes needed return; @@ -124,14 +124,16 @@ class TimeSeriesLabel::Internal : public QObject void updateVisibility() { - // If we have an interactor and the settings indicate it should + // Show if the setting is enabled, we have an interactor, and the active + // data is a time series. bool show = activeObjects().showTimeSeriesLabel(); bool hasInteractor = textWidget->GetInteractor() != nullptr; - bool hasTimeSteps = activeDataSource && activeDataSource->hasTimeSteps(); + auto vol = activeVolumeData(); + bool hasTimeSteps = vol && vol->hasTimeSteps(); bool visible = show && hasInteractor && hasTimeSteps; - if (visible != textWidget->GetEnabled()) { + if (visible != static_cast(textWidget->GetEnabled())) { textWidget->SetEnabled(visible); render(); } diff --git a/tomviz/TransformUtils.cxx b/tomviz/TransformUtils.cxx new file mode 100644 index 000000000..60f205dd7 --- /dev/null +++ b/tomviz/TransformUtils.cxx @@ -0,0 +1,326 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "TransformUtils.h" + +#include "ActiveObjects.h" +#include "MainWindow.h" + +#include "pipeline/DeferredLinkInfo.h" +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/Node.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SinkNode.h" +#include "pipeline/NodeEditDialog.h" +#include "pipeline/TransformNode.h" + +#include +#include +#include + +namespace tomviz { + +/// True for SinkNode and SinkGroupNode (terminal pipeline nodes). +static bool isTerminalNode(pipeline::Node* node) +{ + return dynamic_cast(node) || + dynamic_cast(node); +} + +/// First output port of @a transform whose type is compatible with @a +/// sinkInput's accepted types, or nullptr if none exists. +static pipeline::OutputPort* findCompatibleOutputPort( + pipeline::TransformNode* transform, pipeline::InputPort* sinkInput) +{ + for (auto* out : transform->outputPorts()) { + if (pipeline::isPortTypeCompatible(out->type(), + sinkInput->acceptedTypes())) { + return out; + } + } + return nullptr; +} + +/// All nodes reachable downstream from @a start (inclusive), following output +/// links. This is exactly the set Node::markStale() cascades over when @a +/// start is marked stale, so it is the set whose state we must snapshot to be +/// able to undo an eager insertion. +static QSet downstreamClosure(pipeline::Node* start) +{ + QSet result; + if (!start) { + return result; + } + QList stack; + stack.append(start); + while (!stack.isEmpty()) { + auto* node = stack.takeLast(); + if (result.contains(node)) { + continue; + } + result.insert(node); + for (auto* out : node->outputPorts()) { + for (auto* link : out->links()) { + if (link->to() && link->to()->node()) { + stack.append(link->to()->node()); + } + } + } + } + return result; +} + +/// Snapshot the node and output-port states of @a nodes into @a deferred so +/// they can be restored verbatim if an eager insertion is canceled. Must be +/// called before the insertion mutates the pipeline. +static void captureStates(const QSet& nodes, + pipeline::DeferredLinkInfo& deferred) +{ + for (auto* node : nodes) { + deferred.nodeStates.append({ node, node->state() }); + for (auto* out : node->outputPorts()) { + deferred.portStaleStates.append({ out, out->isStale() }); + } + } +} + +/// Append a transform at the given targetPort, moving sink/group links to a +/// compatible output of the new transform. Sinks with no compatible output +/// on the new transform are left connected to @a targetPort. +static void appendTransformAtPort( + pipeline::Pipeline* pip, + pipeline::TransformNode* transform, + pipeline::OutputPort* targetPort) +{ + pip->addNode(transform); + pip->createLink(targetPort, transform->inputPorts()[0]); + + struct Move + { + pipeline::Link* link; + pipeline::OutputPort* newOut; + }; + QList moves; + for (auto* link : targetPort->links()) { + if (link->to()->node() == transform) { + continue; // skip the link we just created + } + if (!isTerminalNode(link->to()->node())) { + continue; + } + auto* newOut = findCompatibleOutputPort(transform, link->to()); + if (!newOut) { + continue; + } + moves.append({ link, newOut }); + } + for (const auto& m : moves) { + auto* sinkInput = m.link->to(); + pip->removeLink(m.link); + pip->createLink(m.newOut, sinkInput); + } +} + +/// Deferred variant of appendTransformAtPort: performs the full append +/// eagerly (so the preview shows the final topology) and returns the info +/// needed to undo it if the user cancels. +static pipeline::DeferredLinkInfo appendTransformAtPortDeferred( + pipeline::Pipeline* pip, + pipeline::TransformNode* transform, + pipeline::OutputPort* targetPort) +{ + // Snapshot the downstream subtree before mutating anything so a cancel can + // restore it exactly (the sink moves below mark it stale). + pipeline::DeferredLinkInfo deferred; + captureStates(downstreamClosure(targetPort->node()), deferred); + + pip->addNode(transform); + pip->createLink(targetPort, transform->inputPorts()[0]); + + // Move terminal (sink) links from targetPort onto the new transform's + // compatible outputs now, instead of deferring it to commit, so the preview + // shows the sinks already routed through the transform. Record each + // original link so it can be recreated on cancel. + struct Move + { + pipeline::Link* link; + pipeline::OutputPort* newOut; + }; + QList moves; + for (auto* link : targetPort->links()) { + if (link->to()->node() == transform) { + continue; // skip the link we just created + } + if (!isTerminalNode(link->to()->node())) { + continue; + } + auto* newOut = findCompatibleOutputPort(transform, link->to()); + if (!newOut) { + continue; + } + moves.append({ link, newOut }); + } + for (const auto& m : moves) { + auto* sinkInput = m.link->to(); + deferred.linksToRestore.append({ targetPort, sinkInput }); + pip->removeLink(m.link); + pip->createLink(m.newOut, sinkInput); + // removeLink() reset this terminal sink/group's visualization. Re-show it + // (the VTK objects still hold the last data) so the downstream module + // stays on screen while the dialog is open. Nothing clears it again until + // the not-yet-run transform produces output and the sink swaps it in + // directly, so it also stays visible all through execution. Mirrors the + // restorePresentation() the dialog's cancel path performs. + if (auto* node = sinkInput->node()) { + node->restorePresentation(); + } + } + return deferred; +} + +/// Show a NodeEditDialog for a newly inserted transform with deferred +/// link info. +static void showInsertionDialog( + pipeline::TransformNode* transform, + pipeline::Pipeline* pip, + const pipeline::DeferredLinkInfo& deferred, + QWidget* parent) +{ + auto* dialog = new pipeline::NodeEditDialog( + transform, pip, deferred, parent); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle( + QString("Configure - %1").arg(transform->label())); + dialog->show(); +} + +/// Insert a transform at a link: break the existing link, connect the +/// link's "from" port to the new transform's input, and connect the new +/// transform's output to the link's "to" port. +static void insertTransformAtLink( + pipeline::Pipeline* pip, + pipeline::TransformNode* transform, + pipeline::Link* link) +{ + auto* fromPort = link->from(); + auto* toPort = link->to(); + pip->removeLink(link); + pip->addNode(transform); + pip->createLink(fromPort, transform->inputPorts()[0]); + pip->createLink(transform->outputPorts()[0], toPort); +} + +/// Deferred variant of insertTransformAtLink: performs the full insertion +/// eagerly (so the preview shows the transform in its final, inserted +/// position) and returns the info needed to undo it if the user cancels. +static pipeline::DeferredLinkInfo insertTransformAtLinkDeferred( + pipeline::Pipeline* pip, + pipeline::TransformNode* transform, + pipeline::Link* link) +{ + auto* fromPort = link->from(); + auto* toPort = link->to(); + + // Snapshot the downstream subtree before mutating anything so a cancel can + // restore it exactly (createLink() below marks this subtree stale). + pipeline::DeferredLinkInfo deferred; + captureStates(downstreamClosure(toPort->node()), deferred); + deferred.linksToRestore.append({ fromPort, toPort }); + + // Perform the full insertion now (break from->to, splice the transform in) + // rather than only connecting the input and deferring the rest to commit. + pip->removeLink(link); + pip->addNode(transform); + pip->createLink(fromPort, transform->inputPorts()[0]); + pip->createLink(transform->outputPorts()[0], toPort); + + return deferred; +} + +bool insertTransformIntoPipeline(pipeline::TransformNode* transform) +{ + auto* mainWindow = MainWindow::instance(); + auto* pip = mainWindow ? mainWindow->pipeline() : nullptr; + if (!pip) { + qCritical("insertTransformIntoPipeline: No active pipeline. " + "Load data first. (mainWindow=%p)", + static_cast(mainWindow)); + delete transform; + return false; + } + + // Ctrl held: add the node unconnected (user will link manually) + if (QApplication::keyboardModifiers() & Qt::ControlModifier) { + pip->addNode(transform); + return true; + } + + auto& ao = ActiveObjects::instance(); + auto* input = transform->inputPorts()[0]; + + // If a link is selected and its "to" is a transform, insert between them + auto* activeLink = ao.activeLink(); + if (activeLink && + dynamic_cast(activeLink->to()->node())) { + auto* fromPort = activeLink->from(); + if (!pipeline::isPortTypeCompatible(fromPort->type(), + input->acceptedTypes())) { + qCritical("Incompatible port types: transform input does not accept " + "the link's output port type."); + delete transform; + return false; + } + // Multi-input: only the first input gets connected here. Commit + // immediately and wait for the user to connect remaining inputs via + // manual linking (which triggers the MainWindow linkRequested handler). + if (transform->inputPorts().size() > 1) { + insertTransformAtLink(pip, transform, activeLink); + } else if (transform->hasPropertiesWidget()) { + auto deferred = + insertTransformAtLinkDeferred(pip, transform, activeLink); + showInsertionDialog(transform, pip, deferred, mainWindow); + } else { + insertTransformAtLink(pip, transform, activeLink); + pip->execute(); + } + return true; + } + + // Otherwise append at the tip output port + auto* tipPort = ao.activeTipOutputPort(); + if (!tipPort) { + qCritical("insertTransformIntoPipeline: No output port available. " + "Load data first."); + delete transform; + return false; + } + + if (!pipeline::isPortTypeCompatible(tipPort->type(), + input->acceptedTypes())) { + qCritical("Incompatible port types: transform input does not accept " + "the tip output port type."); + delete transform; + return false; + } + + // Multi-input: same as above — commit and wait for remaining connections. + if (transform->inputPorts().size() > 1) { + appendTransformAtPort(pip, transform, tipPort); + } else if (transform->hasPropertiesWidget()) { + qDebug("insertTransformIntoPipeline: showing insertion dialog for '%s'", + qPrintable(transform->label())); + auto deferred = appendTransformAtPortDeferred(pip, transform, tipPort); + showInsertionDialog(transform, pip, deferred, mainWindow); + } else { + qDebug("insertTransformIntoPipeline: appending '%s' at tip and executing", + qPrintable(transform->label())); + appendTransformAtPort(pip, transform, tipPort); + pip->execute(); + } + return true; +} + +} // namespace tomviz diff --git a/tomviz/TransformUtils.h b/tomviz/TransformUtils.h new file mode 100644 index 000000000..bebf97443 --- /dev/null +++ b/tomviz/TransformUtils.h @@ -0,0 +1,26 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizTransformUtils_h +#define tomvizTransformUtils_h + +namespace tomviz { +namespace pipeline { +class TransformNode; +} + +/// Insert a TransformNode into the active pipeline at the current tip port. +/// +/// Handles: +/// - Ctrl held → add node unconnected +/// - Active link selected → insert between nodes +/// - Otherwise → append at tip output port +/// - If hasPropertiesWidget() → show NodeEditDialog with deferred links +/// - Otherwise → complete insertion and execute +/// +/// Returns true on success. On failure the transform is deleted. +bool insertTransformIntoPipeline(pipeline::TransformNode* transform); + +} // namespace tomviz + +#endif diff --git a/tomviz/TransposeDataReaction.cxx b/tomviz/TransposeDataReaction.cxx index 9c0fe4999..eb782246f 100644 --- a/tomviz/TransposeDataReaction.cxx +++ b/tomviz/TransposeDataReaction.cxx @@ -3,13 +3,8 @@ #include "TransposeDataReaction.h" -#include -#include - -#include "ActiveObjects.h" -#include "TransposeDataOperator.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" +#include "TransformUtils.h" +#include "pipeline/transforms/TransposeDataTransform.h" namespace tomviz { @@ -19,19 +14,9 @@ TransposeDataReaction::TransposeDataReaction(QAction* parentObject, { } -void TransposeDataReaction::transposeData(DataSource* source) +void TransposeDataReaction::transposeData(DataSource*) { - source = source ? source : ActiveObjects::instance().activeParentDataSource(); - if (!source) { - return; - } - - Operator* Op = new TransposeDataOperator(); - - EditOperatorDialog* dialog = - new EditOperatorDialog(Op, source, true, m_mainWindow); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->show(); - connect(Op, &QObject::destroyed, dialog, &QDialog::reject); + auto* transform = new pipeline::TransposeDataTransform(); + insertTransformIntoPipeline(transform); } } // namespace tomviz diff --git a/tomviz/Tvh5Format.cxx b/tomviz/Tvh5Format.cxx index cdde754c2..2d9ea1a02 100644 --- a/tomviz/Tvh5Format.cxx +++ b/tomviz/Tvh5Format.cxx @@ -3,236 +3,738 @@ #include "Tvh5Format.h" -#include "ActiveObjects.h" -#include "DataSource.h" #include "EmdFormat.h" -#include "LoadDataReaction.h" -#include "ModuleManager.h" -#include "Pipeline.h" -#include +#include "pipeline/Node.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineStateIO.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" -#include -#include +#include -#include -#include +#include +#include #include #include #include -#include - -using std::cerr; -using std::endl; +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace tomviz { -bool Tvh5Format::write(const std::string& fileName) -{ - // First, write the standard EMD file - DataSource* source = ActiveObjects::instance().activeDataSource(); +namespace { - if (!EmdFormat::write(fileName, source)) { - cerr << "Failed to write the standard EMD node" << endl; +/// Write a numeric vtkDataArray column as a 1-D typed dataset. Returns +/// false (without warning) for VTK scalar types we don't have an h5cpp +/// primitive overload for — the caller logs. +bool writeNumericColumn(h5::H5ReadWrite& writer, + const std::string& parentGroup, + const std::string& datasetName, vtkDataArray* arr) +{ + if (!arr) { return false; } + std::vector dims = { + static_cast(arr->GetNumberOfTuples() * arr->GetNumberOfComponents()) + }; + void* raw = arr->GetVoidPointer(0); + switch (arr->GetDataType()) { + case VTK_CHAR: + case VTK_SIGNED_CHAR: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_UNSIGNED_CHAR: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_SHORT: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_UNSIGNED_SHORT: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_INT: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_UNSIGNED_INT: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_LONG: + if constexpr (sizeof(long) == sizeof(long long)) { + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + } else { + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + } + case VTK_UNSIGNED_LONG: + if constexpr (sizeof(unsigned long) == sizeof(unsigned long long)) { + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + } else { + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + } + case VTK_LONG_LONG: + case VTK_ID_TYPE: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_UNSIGNED_LONG_LONG: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_FLOAT: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + case VTK_DOUBLE: + return writer.writeData(parentGroup, datasetName, dims, + static_cast(raw)); + default: + return false; + } +} - // We will create a soft link to the active id later - auto activeId = source->id().toStdString(); +/// Write a vtkStringArray column as a JSON array stored in a `char` +/// dataset. HDF5 variable-length strings would be a more native +/// encoding, but h5cpp's writeData<> doesn't expose them — and the JSON +/// blob round-trips cleanly through readData. +bool writeStringColumn(h5::H5ReadWrite& writer, + const std::string& parentGroup, + const std::string& datasetName, vtkStringArray* arr) +{ + QJsonArray values; + vtkIdType n = arr->GetNumberOfValues(); + for (vtkIdType i = 0; i < n; ++i) { + values.append(QString::fromStdString(arr->GetValue(i))); + } + QByteArray bytes = QJsonDocument(values).toJson(QJsonDocument::Compact); + std::vector dims = { static_cast(bytes.size()) }; + return writer.writeData(parentGroup, datasetName, dims, bytes.constData()); +} - // Now, write the state file string to a dataset - QFileInfo info(fileName.c_str()); - QJsonObject stateObject; - auto success = - ModuleManager::instance().serialize(stateObject, info.dir(), false); - if (!success) { - cerr << "Failed to serialize the state of Tomviz" << endl; +/// Serialize @a table under @a portGroup. Each column is stored as a +/// sub-dataset `c0`, `c1`, … with attributes `name` (column name) and +/// `vtkDataType` (the int returned by vtkAbstractArray::GetDataType, or +/// VTK_STRING for string columns). Group-level attributes record the +/// payload type and the column count for the reader. +bool writeTablePayload(h5::H5ReadWrite& writer, const std::string& portGroup, + vtkTable* table) +{ + if (!table) { return false; } + writer.setAttribute(portGroup, "type", "table"); + + vtkIdType numColumns = table->GetNumberOfColumns(); + vtkIdType numRows = table->GetNumberOfRows(); + writer.setAttribute(portGroup, "numColumns", + static_cast(numColumns)); + writer.setAttribute(portGroup, "numRows", + static_cast(numRows)); + + for (vtkIdType i = 0; i < numColumns; ++i) { + vtkAbstractArray* col = table->GetColumn(i); + if (!col) { + continue; + } + std::string datasetName = "c" + std::to_string(i); + std::string columnPath = portGroup + "/" + datasetName; + bool wrote = false; + int vtkDataType = col->GetDataType(); + if (auto* numeric = vtkDataArray::SafeDownCast(col)) { + wrote = writeNumericColumn(writer, portGroup, datasetName, numeric); + } else if (auto* strings = vtkStringArray::SafeDownCast(col)) { + wrote = writeStringColumn(writer, portGroup, datasetName, strings); + vtkDataType = VTK_STRING; + } + if (!wrote) { + qWarning() << "Tvh5Format: skipping unsupported column" + << QString::fromStdString(col->GetName() ? col->GetName() + : "") + << "of type" << col->GetDataType(); + continue; + } + const char* name = col->GetName(); + writer.setAttribute(columnPath, "name", name ? name : ""); + writer.setAttribute(columnPath, "vtkDataType", vtkDataType); + writer.setAttribute(columnPath, "numberOfComponents", + col->GetNumberOfComponents()); + } + return true; +} - QByteArray state = QJsonDocument(stateObject).toJson(); - - // Write the state file to "tomviz_state" - using h5::H5ReadWrite; - H5ReadWrite::OpenMode mode = H5ReadWrite::OpenMode::ReadWrite; - H5ReadWrite writer(fileName, mode); +bool writeVolumePayload(h5::H5ReadWrite& writer, const std::string& portGroup, + const pipeline::PortData& data) +{ + auto volume = data.value(); + if (!volume || !volume->isValid()) { + return false; + } + return EmdFormat::writeNode(writer, portGroup, volume->imageData()); +} - if (!writer.writeData("/", "tomviz_state", { static_cast(state.size()) }, state.data())) { - cerr << "Failed to write tomviz_state" << endl; +/// Serialize @a molecule under @a portGroup. Atom positions are +/// written as a flat float dataset of length 3*numAtoms (xyz triples), +/// atomic numbers as uint16, bonds as a pair of (from, to) atom-id +/// arrays plus a uint16 bond-orders array. Mirrors the layout the +/// Python state writer emits, so files round-trip across the C++/CLI +/// boundary. +bool writeMoleculePayload(h5::H5ReadWrite& writer, + const std::string& portGroup, vtkMolecule* molecule) +{ + if (!molecule) { return false; } + writer.setAttribute(portGroup, "type", "molecule"); + + vtkIdType numAtoms = molecule->GetNumberOfAtoms(); + vtkIdType numBonds = molecule->GetNumberOfBonds(); + writer.setAttribute(portGroup, "numAtoms", + static_cast(numAtoms)); + writer.setAttribute(portGroup, "numBonds", + static_cast(numBonds)); + + if (numAtoms > 0) { + auto* nums = molecule->GetAtomicNumberArray(); + if (!nums) { + return false; + } + std::vector atomDims = { static_cast(numAtoms) }; + if (!writer.writeData(portGroup, "atomicNumbers", atomDims, + static_cast( + nums->GetVoidPointer(0)))) { + return false; + } + // Flatten atom positions into a 1-D float dataset of length 3*N + // — vtkPoints stores them as a vtkDataArray (typically vtkFloatArray) + // but we copy out via GetPoint() to be agnostic to the underlying + // storage type. + std::vector positions(static_cast(numAtoms) * 3u); + for (vtkIdType i = 0; i < numAtoms; ++i) { + double pos[3]; + molecule->GetAtomPosition(i, pos); + positions[3 * i + 0] = static_cast(pos[0]); + positions[3 * i + 1] = static_cast(pos[1]); + positions[3 * i + 2] = static_cast(pos[2]); + } + std::vector posDims = { static_cast(numAtoms), 3 }; + if (!writer.writeData(portGroup, "atomPositions", posDims, + positions.data())) { + return false; + } + } - // Now, write all the data sources - writer.createGroup("/tomviz_datasources"); - auto sources = ModuleManager::instance().allDataSources(); - for (auto* ds : sources) { - if (!ds) { - // Somehow, invalid data sources ended up in here... - continue; + if (numBonds > 0) { + // Two parallel int64 arrays of length numBonds: the from / to atom + // ids per bond. vtkMolecule exposes them via vtkBond accessors — + // there's no direct array view, so we materialize them. + std::vector bondAtoms(static_cast(numBonds) * 2u); + for (vtkIdType i = 0; i < numBonds; ++i) { + vtkBond bond = molecule->GetBond(i); + bondAtoms[2 * i + 0] = + static_cast(bond.GetBeginAtomId()); + bondAtoms[2 * i + 1] = + static_cast(bond.GetEndAtomId()); + } + std::vector bondDims = { static_cast(numBonds), 2 }; + if (!writer.writeData(portGroup, "bondAtoms", bondDims, + bondAtoms.data())) { + return false; + } + auto* orders = molecule->GetBondOrdersArray(); + if (orders && orders->GetNumberOfTuples() == numBonds) { + std::vector orderDims = { static_cast(numBonds) }; + if (!writer.writeData(portGroup, "bondOrders", orderDims, + static_cast( + orders->GetVoidPointer(0)))) { + return false; + } } + } + return true; +} + +/// For every node with a non-transient output port carrying serializable +/// data, write the payload into @a writer under `/data//` +/// and stamp `dataRef` on the matching port entry in @a pipelineJson. +/// Transient ports (typically transform outputs that get consumed and +/// released downstream) are skipped — the port's "persistent" flag is the +/// contract. Volume-, Table- and Molecule-typed ports are persisted; +/// other types fall through silently and are simply re-executed on load. +bool writePersistentPayloads(pipeline::Pipeline* pipeline, + h5::H5ReadWrite& writer, + QJsonObject& pipelineJson) +{ + writer.createGroup("/data"); - // Name the group after its id - auto id = ds->id().toStdString(); + auto nodesArray = pipelineJson.value(QStringLiteral("nodes")).toArray(); - if (id == activeId) { - // Make a soft link rather than writing the data again - writer.createSoftLink("/data/tomography", "/tomviz_datasources/" + id); + // Build an id -> index map so we can locate the right node entry fast. + QHash idToIndex; + for (int i = 0; i < nodesArray.size(); ++i) { + int id = nodesArray[i].toObject().value(QStringLiteral("id")).toInt(-1); + if (id >= 0) { + idToIndex.insert(id, i); + } + } + + for (auto* node : pipeline->nodes()) { + int nodeId = pipeline->nodeId(node); + auto nodeIt = idToIndex.constFind(nodeId); + if (nodeIt == idToIndex.constEnd()) { continue; } + auto nodeEntry = nodesArray[nodeIt.value()].toObject(); + auto outputs = nodeEntry.value(QStringLiteral("outputPorts")).toObject(); - std::string group = "/tomviz_datasources/" + id; - writer.createGroup(group); + bool modified = false; + for (auto* port : node->outputPorts()) { + if (!port->isPersistent() || !port->hasData()) { + continue; + } + pipeline::PortType declared = port->declaredType(); + bool isVolume = pipeline::isVolumeType(declared); + bool isTable = (declared == pipeline::PortType::Table); + bool isMolecule = (declared == pipeline::PortType::Molecule); + if (!isVolume && !isTable && !isMolecule) { + continue; + } - // Write the data here - if (!EmdFormat::writeNode(writer, group, ds->imageData())) { - cerr << "Failed to write data source: " << id << endl; - return false; + std::string portName = port->name().toStdString(); + std::string nodeGroup = "/data/" + std::to_string(nodeId); + std::string portGroup = nodeGroup + "/" + portName; + if (!writer.isGroup(nodeGroup)) { + writer.createGroup(nodeGroup); + } + writer.createGroup(portGroup); + + // Use materialize() rather than data(): the port may be OnDisk + // persistent and currently evicted, in which case data() would + // return empty and the save would silently miss the payload. + // materialize() loads from the disk cache as needed; the local + // handle keeps the data in memory through the write. + auto handle = port->materialize(); + pipeline::PortData payload = handle ? *handle : pipeline::PortData(); + bool wrote = false; + if (isVolume) { + wrote = writeVolumePayload(writer, portGroup, payload); + } else if (isTable) { + auto table = payload.value>(); + wrote = writeTablePayload(writer, portGroup, table.GetPointer()); + } else if (isMolecule) { + auto molecule = payload.value>(); + wrote = + writeMoleculePayload(writer, portGroup, molecule.GetPointer()); + } + if (!wrote) { + qWarning() << "Tvh5Format: failed to write data for node" << nodeId + << "port" << port->name(); + return false; + } + + QJsonObject portEntry = outputs.value(port->name()).toObject(); + QJsonObject dataRef; + dataRef[QStringLiteral("container")] = QStringLiteral("h5"); + dataRef[QStringLiteral("path")] = + QString::fromStdString(portGroup); + portEntry[QStringLiteral("dataRef")] = dataRef; + outputs[port->name()] = portEntry; + modified = true; + } + + if (modified) { + nodeEntry[QStringLiteral("outputPorts")] = outputs; + nodesArray[nodeIt.value()] = nodeEntry; } } + pipelineJson[QStringLiteral("nodes")] = nodesArray; return true; } -bool Tvh5Format::read(const std::string& fileName) -{ - // Read the state from "tomviz_state" - using h5::H5ReadWrite; - H5ReadWrite::OpenMode mode = H5ReadWrite::OpenMode::ReadOnly; - H5ReadWrite reader(fileName, mode); - - auto stateVec = reader.readData("tomviz_state"); - QString stateStr = std::string(stateVec.begin(), stateVec.end()).c_str(); +} // namespace - auto doc = QJsonDocument::fromJson(stateStr.toUtf8()); - if (doc.isNull()) { - cerr << "Failed to read state from: " << fileName << endl; +bool Tvh5Format::write(const std::string& fileName, + pipeline::Pipeline* pipeline, + const QJsonObject& extraState) +{ + if (!pipeline) { + qWarning() << "Tvh5Format::write: null pipeline"; return false; } - QJsonObject state = doc.object(); - QFileInfo info(fileName.c_str()); - auto success = - ModuleManager::instance().deserialize(state, info.dir(), false); - - if (!success) { - cerr << "Failed to deserialize state from: " << fileName << endl; + QJsonObject state; + if (!pipeline::PipelineStateIO::save(pipeline, state)) { + qWarning() << "Tvh5Format::write: PipelineStateIO::save failed"; return false; } - // Turn off automatic execution of pipelines - bool prev = ModuleManager::instance().executePipelinesOnLoad(); - ModuleManager::instance().executePipelinesOnLoad(false); + // Merge caller-supplied views/layouts/palette. Later keys in + // extraState win on conflict, matching the expected caller intent. + for (auto it = extraState.constBegin(); it != extraState.constEnd(); + ++it) { + state.insert(it.key(), it.value()); + } - // Get the active data source - DataSource* active = nullptr; + // Create the HDF5 container. + using h5::H5ReadWrite; + H5ReadWrite writer(fileName, H5ReadWrite::OpenMode::WriteOnly); - // Now load in the data sources - if (state["dataSources"].isArray()) { - auto dataSources = state["dataSources"].toArray(); - foreach (auto ds, dataSources) { - loadDataSource(reader, ds.toObject(), &active); - } + // Embed per-port voxels and stamp dataRef entries in the pipeline + // section before serializing the final JSON. + auto pipelineJson = state.value(QStringLiteral("pipeline")).toObject(); + if (!writePersistentPayloads(pipeline, writer, pipelineJson)) { + return false; } - ModuleManager::instance().executePipelinesOnLoad(prev); + state[QStringLiteral("pipeline")] = pipelineJson; + + // Write the final JSON as a string dataset at /tomviz_state. + QByteArray stateBytes = QJsonDocument(state).toJson(); + if (!writer.writeData("/", "tomviz_state", + { static_cast(stateBytes.size()) }, + stateBytes.data())) { + qWarning() << "Tvh5Format::write: failed to write /tomviz_state"; + return false; + } + + return true; +} - if (active) { - // Set the active data source if one was flagged as active - // We have to use "setSelectedDataSource" instead of - // "setActiveDataSource" or else the Histogram color map won't - // match. - ActiveObjects::instance().setSelectedDataSource(active); +QJsonObject Tvh5Format::readState(const std::string& fileName) +{ + using h5::H5ReadWrite; + H5ReadWrite reader(fileName, H5ReadWrite::OpenMode::ReadOnly); + if (!reader.isDataSet("/tomviz_state")) { + return {}; } + auto bytes = reader.readData("tomviz_state"); + QJsonDocument doc = + QJsonDocument::fromJson(QByteArray(bytes.data(), bytes.size())); + if (!doc.isObject()) { + return {}; + } + return doc.object(); +} - // Loading the modules most likely modified the view. Restore - // the view to the state given in the state file. - ModuleManager::instance().setViews(state["views"].toArray()); +namespace { - return true; +/// Read a numeric column written by writeNumericColumn back into a fresh +/// vtkAbstractArray of the requested @a vtkDataType. Returns null if the +/// type isn't supported or the read fails. +vtkSmartPointer readNumericColumn(h5::H5ReadWrite& reader, + const std::string& path, + int vtkDataType, + int numberOfComponents) +{ + auto array = + vtkSmartPointer::Take(vtkAbstractArray::CreateArray( + vtkDataType == VTK_ID_TYPE ? VTK_LONG_LONG : vtkDataType)); + if (!array) { + return nullptr; + } + auto dims = reader.getDimensions(path); + if (dims.empty()) { + return nullptr; + } + vtkIdType total = 1; + for (int d : dims) { + total *= d; + } + array->SetNumberOfComponents(numberOfComponents > 0 ? numberOfComponents : 1); + array->SetNumberOfTuples(total / array->GetNumberOfComponents()); + auto* dataArray = vtkDataArray::SafeDownCast(array); + if (!dataArray) { + return nullptr; + } + void* dst = dataArray->GetVoidPointer(0); + bool ok = false; + switch (vtkDataType) { + case VTK_CHAR: + case VTK_SIGNED_CHAR: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_UNSIGNED_CHAR: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_SHORT: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_UNSIGNED_SHORT: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_INT: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_UNSIGNED_INT: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_LONG: + if constexpr (sizeof(long) == sizeof(long long)) { + ok = reader.readData(path, static_cast(dst)); + } else { + ok = reader.readData(path, static_cast(dst)); + } + break; + case VTK_UNSIGNED_LONG: + if constexpr (sizeof(unsigned long) == sizeof(unsigned long long)) { + ok = reader.readData(path, static_cast(dst)); + } else { + ok = reader.readData(path, static_cast(dst)); + } + break; + case VTK_LONG_LONG: + case VTK_ID_TYPE: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_UNSIGNED_LONG_LONG: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_FLOAT: + ok = reader.readData(path, static_cast(dst)); + break; + case VTK_DOUBLE: + ok = reader.readData(path, static_cast(dst)); + break; + default: + return nullptr; + } + return ok ? array : nullptr; } -bool Tvh5Format::loadDataSource(h5::H5ReadWrite& reader, - const QJsonObject& dsObject, - DataSource** active, Operator* parent) +vtkSmartPointer readStringColumn(h5::H5ReadWrite& reader, + const std::string& path) { - auto id = dsObject.value("id").toString().toStdString(); - if (id.empty()) { - cerr << "Failed to obtain id from data source object" << endl; - return false; + auto bytes = reader.readData(path); + QJsonDocument doc = + QJsonDocument::fromJson(QByteArray(bytes.data(), bytes.size())); + if (!doc.isArray()) { + return nullptr; + } + auto values = doc.array(); + auto array = vtkSmartPointer::New(); + array->SetNumberOfValues(values.size()); + for (int i = 0; i < values.size(); ++i) { + array->SetValue(i, values.at(i).toString().toStdString()); } + return array; +} - // First, create the image data - std::string path = "/tomviz_datasources/" + id; - vtkNew image; - QVariantMap options = { { "askForSubsample", false } }; - if (!EmdFormat::readNode(reader, path, image, options)) { - cerr << "Failed to read data at: " << path << endl; - return false; +vtkSmartPointer readMoleculePayload(h5::H5ReadWrite& reader, + const std::string& portGroup) +{ + bool ok = false; + auto numAtoms = reader.attribute(portGroup, "numAtoms", &ok); + if (!ok) { + numAtoms = 0; + } + auto numBonds = reader.attribute(portGroup, "numBonds", &ok); + if (!ok) { + numBonds = 0; } - // Next, create the data source - DataSource::DataSourceType type = DataSource::hasTiltAngles(image) - ? DataSource::TiltSeries - : DataSource::Volume; - - auto* pipeline = parent ? parent->dataSource()->pipeline() : nullptr; - auto* dataSource = new DataSource(image, type, pipeline); - - // Save this info in case we write the data source in the future - dataSource->setFileName(reader.fileName().c_str()); - dataSource->setTvh5NodePath(path.c_str()); - - if (parent) { - // This is a child data source. Hook it up to the operator parent. - parent->setChildDataSource(dataSource); - // Don't call setHasChildDataSource(true) here. The operator's own - // initialization (JSON "children" section or constructor) is the authority - // on whether the executor should expect child data in the return dict. - parent->newChildDataSource(dataSource); - // If it has a parent, it will be deserialized later. - } else { - // This is a root data source - LoadDataReaction::dataSourceAdded(dataSource, false, false); - dataSource->deserialize(dsObject); - } - - // Set the active data source - if (dsObject.value("active").toBool()) { - *active = dataSource; - } - - // If there are operators, load child data sources too - if (dsObject["operators"].isArray()) { - auto opPtrs = dataSource->operators(); - auto operators = dsObject["operators"].toArray(); - for (int i = 0; i < operators.size() && i < opPtrs.size(); ++i) { - auto op = operators.at(i).toObject(); - if (op["dataSources"].isArray()) { - auto sources = op["dataSources"].toArray(); - foreach (auto s, sources) { - loadDataSource(reader, s.toObject(), active, opPtrs[i]); - } - } + auto molecule = vtkSmartPointer::New(); + + if (numAtoms > 0) { + std::string atomsPath = portGroup + "/atomicNumbers"; + std::string posPath = portGroup + "/atomPositions"; + if (!reader.isDataSet(atomsPath) || !reader.isDataSet(posPath)) { + qWarning() << "Tvh5Format: missing atom datasets at" + << QString::fromStdString(portGroup); + return nullptr; + } + std::vector atomicNumbers( + static_cast(numAtoms)); + if (!reader.readData(atomsPath, atomicNumbers.data())) { + return nullptr; + } + std::vector positions(static_cast(numAtoms) * 3u); + if (!reader.readData(posPath, positions.data())) { + return nullptr; + } + for (long long i = 0; i < numAtoms; ++i) { + molecule->AppendAtom(atomicNumbers[i], positions[3 * i + 0], + positions[3 * i + 1], positions[3 * i + 2]); } } - // Mark all parent operators as complete - for (auto* op : dataSource->operators()) - op->setComplete(); + if (numBonds > 0) { + std::string bondAtomsPath = portGroup + "/bondAtoms"; + if (!reader.isDataSet(bondAtomsPath)) { + qWarning() << "Tvh5Format: missing bondAtoms dataset at" + << QString::fromStdString(portGroup); + return molecule; + } + std::vector bondAtoms(static_cast(numBonds) * 2u); + if (!reader.readData(bondAtomsPath, bondAtoms.data())) { + return molecule; + } + std::vector bondOrders(static_cast(numBonds), 1); + std::string bondOrdersPath = portGroup + "/bondOrders"; + if (reader.isDataSet(bondOrdersPath)) { + if (!reader.readData(bondOrdersPath, bondOrders.data())) { + // Bad bond-orders dataset: fall back to all single bonds + // rather than dropping the whole molecule. + std::fill(bondOrders.begin(), bondOrders.end(), 1); + } + } + for (long long i = 0; i < numBonds; ++i) { + molecule->AppendBond(static_cast(bondAtoms[2 * i + 0]), + static_cast(bondAtoms[2 * i + 1]), + bondOrders[i]); + } + } + return molecule; +} - // Ensure the pipeline is not paused. DataSource::deserialize() pauses it - // but only resumes when executePipelinesOnLoad is true, which is false - // during tvh5 loading. - auto* p = dataSource->pipeline(); - if (p) { - p->resume(); - if (parent) { - // This will deserialize all children. - p->finished(); +vtkSmartPointer readTablePayload(h5::H5ReadWrite& reader, + const std::string& portGroup) +{ + bool ok = false; + auto numColumns = + reader.attribute(portGroup, "numColumns", &ok); + if (!ok) { + return nullptr; + } + auto table = vtkSmartPointer::New(); + for (long long i = 0; i < numColumns; ++i) { + std::string datasetName = "c" + std::to_string(i); + std::string columnPath = portGroup + "/" + datasetName; + if (!reader.isDataSet(columnPath)) { + qWarning() << "Tvh5Format: missing column dataset" + << QString::fromStdString(columnPath); + continue; + } + int vtkDataType = + reader.attribute(columnPath, "vtkDataType", &ok); + if (!ok) { + continue; + } + int numberOfComponents = 1; + if (reader.hasAttribute(columnPath, "numberOfComponents")) { + numberOfComponents = + reader.attribute(columnPath, "numberOfComponents", &ok); + if (!ok) { + numberOfComponents = 1; + } } + auto name = + reader.attribute(columnPath, "name", &ok); + if (!ok) { + name.clear(); + } + vtkSmartPointer column; + if (vtkDataType == VTK_STRING) { + column = readStringColumn(reader, columnPath); + } else { + column = + readNumericColumn(reader, columnPath, vtkDataType, numberOfComponents); + } + if (!column) { + qWarning() << "Tvh5Format: failed to read column" + << QString::fromStdString(columnPath); + continue; + } + if (!name.empty()) { + column->SetName(name.c_str()); + } + table->AddColumn(column); } + return table; +} - return true; +} // namespace + +void Tvh5Format::populatePayloadData(pipeline::Pipeline* pipeline, + const QJsonObject& pipelineJson, + const std::string& fileName) +{ + if (!pipeline) { + return; + } + using h5::H5ReadWrite; + H5ReadWrite reader(fileName, H5ReadWrite::OpenMode::ReadOnly); + + auto nodesJson = pipelineJson.value(QStringLiteral("nodes")).toArray(); + for (const auto& nv : nodesJson) { + auto nodeEntry = nv.toObject(); + int nodeId = nodeEntry.value(QStringLiteral("id")).toInt(-1); + if (nodeId < 0) { + continue; + } + auto* node = pipeline->nodeById(nodeId); + if (!node) { + continue; + } + auto outputs = + nodeEntry.value(QStringLiteral("outputPorts")).toObject(); + for (auto it = outputs.constBegin(); it != outputs.constEnd(); ++it) { + auto portEntry = it.value().toObject(); + auto dataRef = portEntry.value(QStringLiteral("dataRef")).toObject(); + if (dataRef.value(QStringLiteral("container")).toString() != + QLatin1String("h5")) { + continue; + } + std::string path = + dataRef.value(QStringLiteral("path")).toString().toStdString(); + if (path.empty() || !reader.isGroup(path)) { + qWarning() << "Tvh5Format::populatePayloadData: missing payload group" + << QString::fromStdString(path); + continue; + } + auto* port = node->outputPort(it.key()); + if (!port) { + continue; + } + pipeline::PortType type = port->declaredType(); + if (pipeline::isVolumeType(type)) { + vtkNew image; + QVariantMap options = { { "askForSubsample", false } }; + if (!EmdFormat::readNode(reader, path, image, options)) { + qWarning() << "Tvh5Format::populatePayloadData: failed to read" + << QString::fromStdString(path); + continue; + } + auto volume = std::make_shared(image.Get()); + port->setData(pipeline::PortData(std::any(volume), type)); + node->markCurrent(); + } else if (type == pipeline::PortType::Table) { + auto table = readTablePayload(reader, path); + if (!table) { + qWarning() << "Tvh5Format::populatePayloadData: failed to read table" + << QString::fromStdString(path); + continue; + } + port->setData(pipeline::PortData(std::any(table), type)); + node->markCurrent(); + } else if (type == pipeline::PortType::Molecule) { + auto molecule = readMoleculePayload(reader, path); + if (!molecule) { + qWarning() + << "Tvh5Format::populatePayloadData: failed to read molecule" + << QString::fromStdString(path); + continue; + } + port->setData(pipeline::PortData(std::any(molecule), type)); + node->markCurrent(); + } else { + qWarning() << "Tvh5Format::populatePayloadData: unsupported port type" + << pipeline::portTypeToString(type) << "at" + << QString::fromStdString(path); + } + } + } } } // namespace tomviz diff --git a/tomviz/Tvh5Format.h b/tomviz/Tvh5Format.h index b32a8c765..c72d9867c 100644 --- a/tomviz/Tvh5Format.h +++ b/tomviz/Tvh5Format.h @@ -4,32 +4,55 @@ #ifndef tomvizTvh5Format_h #define tomvizTvh5Format_h -#include - -class QJsonObject; +#include -namespace h5 { -class H5ReadWrite; -} +#include namespace tomviz { +namespace pipeline { +class Pipeline; +} -class DataSource; -class Operator; - +/// Saves the new-format (schemaVersion >= 2) Tomviz state file into an +/// HDF5 container. The pipeline graph JSON lives at `/tomviz_state`; +/// raw payload bytes for each source-node output port live at +/// `/data//`. Port entries in the JSON gain a +/// `dataRef: { container: "h5", path: ... }` field pointing at those +/// groups so a future loader can resolve them. +/// +/// @a extraState is merged into the final JSON before writing — callers +/// that want views/layouts/paletteColor in the file must assemble them +/// via ViewsLayoutsSerializer (the running session requires +/// pqApplicationCore, which unit tests don't initialize). +/// +/// Load is not yet implemented — old `.tvh5` files still go through +/// `LegacyStateLoader::loadFromH5`. class Tvh5Format { public: - static bool write(const std::string& fileName); - static bool read(const std::string& fileName); - -private: - // Load a data source from data in a Tvh5 file - // If the active data source is found, it is set to @param active - static bool loadDataSource(h5::H5ReadWrite& reader, - const QJsonObject& dsObject, DataSource** active, - Operator* parent = nullptr); + static bool write(const std::string& fileName, + pipeline::Pipeline* pipeline, + const QJsonObject& extraState = QJsonObject()); + + /// Read the `/tomviz_state` JSON blob from a .tvh5 file. Returns an + /// empty object on failure or if the dataset is missing. + static QJsonObject readState(const std::string& fileName); + + /// Walk @a pipelineJson.nodes, and for every output port whose + /// `dataRef` points at an HDF5 group inside @a fileName, read the + /// payload from that group and attach it to the matching port on + /// @a pipeline. Volume-typed ports are restored as VolumeData (via + /// EmdFormat::readNode); Table-typed ports as vtkTable; + /// Molecule-typed ports as vtkMolecule. Covers source nodes (their + /// saved input), transforms whose outputs the user persisted (so + /// downstream doesn't need to re-run), and any other node with a + /// `persistent` port. + /// Suitable as a PipelineStateIO::PreExecuteHook closure. + static void populatePayloadData(pipeline::Pipeline* pipeline, + const QJsonObject& pipelineJson, + const std::string& fileName); }; + } // namespace tomviz #endif // tomvizTvh5Format_h diff --git a/tomviz/Utilities.cxx b/tomviz/Utilities.cxx index 37f4092a1..1726fb3e3 100644 --- a/tomviz/Utilities.cxx +++ b/tomviz/Utilities.cxx @@ -4,7 +4,6 @@ #include "Utilities.h" #include "ActiveObjects.h" -#include "DataSource.h" #include "tomvizConfig.h" #include @@ -62,6 +61,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -298,19 +300,25 @@ bool deserialize(vtkDiscretizableColorTransferFunction* func, auto opacityFunc = func->GetScalarOpacityFunction(); deserialize(opacityFunc, json); auto colors = json["colors"].toArray(); - double* values = new double[colors.size()]; + std::vector values(colors.size()); for (int i = 0; i < colors.size(); ++i) { values[i] = colors[i].toDouble(); } - func->FillFromDataPointer(colors.size(), values); + // FillFromDataPointer's first argument is the number of control + // points, not the number of doubles in the buffer. Each control + // point contributes 4 doubles (x, r, g, b). + func->FillFromDataPointer(static_cast(colors.size() / 4), + values.data()); if (json.contains("colorSpace")) { - QMap colorSpaceToIntMap = { - { "RGB", 0 }, { "HSV", 1 }, { "CIELAB", 2 }, - { "CIEDE2000", 4 }, { "Step", 5 }, + static const QMap colorSpaceToIntMap = { + { "RGB", 0 }, { "HSV", 1 }, { "CIELAB", 2 }, + { "Lab", 2 }, { "Diverging", 3 }, { "CIEDE2000", 4 }, + { "Step", 5 }, }; - if (colorSpaceToIntMap.contains(json["colorSpace"].toString())) { - func->SetColorSpace(colorSpaceToIntMap[json["colorSpace"].toString()]); + auto name = json["colorSpace"].toString(); + if (colorSpaceToIntMap.contains(name)) { + func->SetColorSpace(colorSpaceToIntMap.value(name)); } } return true; @@ -578,14 +586,13 @@ vtkPVArrayInformation* scalarArrayInformation(vtkSMSourceProxy* proxy) : nullptr; } -bool rescaleColorMap(vtkSMProxy* colorMap, DataSource* dataSource) +bool rescaleColorMap(vtkSMProxy* colorMap, vtkSMSourceProxy* dataProxy) { // rescale the color/opacity maps for the data source. vtkSMProxy* cmap = colorMap; vtkSMProxy* omap = vtkSMPropertyHelper(cmap, "ScalarOpacityFunction").GetAsProxy(); - vtkPVArrayInformation* ainfo = - tomviz::scalarArrayInformation(dataSource->proxy()); + vtkPVArrayInformation* ainfo = tomviz::scalarArrayInformation(dataProxy); if (ainfo != nullptr && vtkSMPropertyHelper(cmap, "AutomaticRescaleRangeMode").GetAsInt() != vtkSMTransferFunctionManager::NEVER) { @@ -761,7 +768,7 @@ void snapAnimationToTimeSteps(const std::vector& timeSteps) auto* timeKeeper = ActiveObjects::instance().activeTimeKeeper(); auto* proxy = timeKeeper->getProxy(); vtkSMPropertyHelper(proxy, "TimestepValues") - .Set(&timeSteps[0], timeSteps.size()); + .Set(&timeSteps[0], static_cast(timeSteps.size())); vtkSMPropertyHelper(proxy, "TimeRange").Set(&timeRange[0], 2); } @@ -981,6 +988,20 @@ QWidget* mainWidget() return pqCoreUtilities::mainWidget(); } +void floatAboveMainWindow(QWidget* widget) +{ + if (!widget) { + return; + } + // Replace the window type (e.g. Qt::Dialog) with Qt::Tool while preserving + // the hint flags, so the window floats above the main window on macOS rather + // than slipping behind it. + auto flags = widget->windowFlags(); + flags &= ~Qt::WindowType_Mask; + flags |= Qt::Tool; + widget->setWindowFlags(flags); +} + QJsonValue toJson(vtkVariant variant) { auto type = variant.GetType(); @@ -1230,6 +1251,15 @@ bool moleculeToFile(vtkMolecule* molecule) fileName = QString("%1.xyz").arg(fileName); } + return moleculeToXyzFile(molecule, fileName); +} + +bool moleculeToXyzFile(vtkMolecule* molecule, const QString& fileName) +{ + if (molecule == nullptr) { + return false; + } + QFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { qCritical() << QString("Error opening file for writing: %1").arg(fileName); @@ -1457,7 +1487,7 @@ static bool nodeYsMatch(double* a, double* b) } // namespace -void addPlaceholderNodes(vtkColorTransferFunction* lut, DataSource* ds) +void addPlaceholderNodes(vtkColorTransferFunction* lut, const double range[2]) { // Add nodes on the data ends as placeholders auto numNodes = lut->GetSize(); @@ -1465,9 +1495,6 @@ void addPlaceholderNodes(vtkColorTransferFunction* lut, DataSource* ds) return; } - double range[2]; - ds->getRange(range); - auto* dataArray = lut->GetDataPointer(); int nodeStride = 4; double* firstNode = dataArray; @@ -1523,7 +1550,7 @@ void removePlaceholderNodes(vtkColorTransferFunction* lut) } } -void addPlaceholderNodes(vtkPiecewiseFunction* opacity, DataSource* ds) +void addPlaceholderNodes(vtkPiecewiseFunction* opacity, const double range[2]) { // Add nodes on the data ends as placeholders auto numNodes = opacity->GetSize(); @@ -1531,9 +1558,6 @@ void addPlaceholderNodes(vtkPiecewiseFunction* opacity, DataSource* ds) return; } - double range[2]; - ds->getRange(range); - auto* dataArray = opacity->GetDataPointer(); int nodeStride = 2; double* firstNode = dataArray; @@ -1655,12 +1679,11 @@ void rescaleNodes(vtkPiecewiseFunction* opacity, double newMin, double newMax) } } -void removePointsOutOfRange(vtkColorTransferFunction* lut, DataSource* ds) +void removePointsOutOfRange(vtkColorTransferFunction* lut, + const double range[2]) { - // Remove points outside the range of the data source + // Remove points outside the range // This will also add points on the ends of the range - double range[2]; - ds->getRange(range); // Make sure there are points on the ends of the data double startColor[3], endColor[3]; @@ -1686,14 +1709,12 @@ void removePointsOutOfRange(vtkColorTransferFunction* lut, DataSource* ds) lut->AddRGBPoint(range[1], endColor[0], endColor[1], endColor[2]); } -void removePointsOutOfRange(vtkPiecewiseFunction* opacity, DataSource* ds) +void removePointsOutOfRange(vtkPiecewiseFunction* opacity, + const double range[2]) { - // Remove points outside the range of the data source + // Remove points outside the range // This will also add points on the ends of the range - double range[2]; - ds->getRange(range); - // Make sure there are points on the ends of the data double startY = opacity->GetValue(range[0]); double endY = opacity->GetValue(range[1]); diff --git a/tomviz/Utilities.h b/tomviz/Utilities.h index b342369dc..08a1dc1d1 100644 --- a/tomviz/Utilities.h +++ b/tomviz/Utilities.h @@ -53,8 +53,6 @@ struct Attributes static const char* FILENAME; }; -class DataSource; - //=========================================================================== // Functions for converting from pqProxy to vtkSMProxy and vice-versa. //=========================================================================== @@ -163,7 +161,7 @@ vtkPVArrayInformation* scalarArrayInformation(vtkSMSourceProxy* proxy); /// Rescales the colorMap (and associated opacityMap) using the transformed data /// range from the data source. This will respect the "LockScalarRange" property /// on the colorMap i.e. if user locked the scalar range, it won't be rescaled. -bool rescaleColorMap(vtkSMProxy* colorMap, DataSource* dataSource); +bool rescaleColorMap(vtkSMProxy* colorMap, vtkSMSourceProxy* dataProxy); // Given the root of a file and an extension, reades the file fileName + // extension and returns the content in a QString. @@ -226,6 +224,15 @@ QString findPrefix(const QStringList& fileNames); /// Convenience function to get the main widget (useful for dialog parenting). QWidget* mainWidget(); +/// Promote a top-level widget to a Qt::Tool window so it reliably floats above +/// the main window. On macOS a parented modeless QDialog is only hinted (not +/// guaranteed) to stack above its parent and can slip behind the main window +/// when the user clicks it; a Qt::Tool window floats above the application's +/// windows while leaving them interactive. Call this before the widget is +/// first shown. Any existing window hint flags (frameless, title-only, etc.) +/// are preserved. +void floatAboveMainWindow(QWidget* widget); + QJsonValue toJson(vtkVariant variant); QJsonValue toJson(vtkSMProperty* prop); bool setProperties(const QJsonObject& props, vtkSMProxy* proxy); @@ -243,6 +250,9 @@ bool csvToFile(const QString& csv); /// Write a vtkMolecule to json file bool moleculeToFile(vtkMolecule* molecule); + +/// Write a vtkMolecule as XYZ to an explicit path, without prompting. +bool moleculeToXyzFile(vtkMolecule* molecule, const QString& fileName); extern double offWhite[3]; /// Open a url in the user's default browser @@ -289,18 +299,20 @@ QString getSizeNearestThousand(T num, bool labelAsBytes = false) return ret; } -void addPlaceholderNodes(vtkColorTransferFunction* lut, DataSource* ds); +void addPlaceholderNodes(vtkColorTransferFunction* lut, const double range[2]); void removePlaceholderNodes(vtkColorTransferFunction* lut); -void addPlaceholderNodes(vtkPiecewiseFunction* opacity, DataSource* ds); +void addPlaceholderNodes(vtkPiecewiseFunction* opacity, const double range[2]); void removePlaceholderNodes(vtkPiecewiseFunction* opacity); double rescale(double val, double oldMin, double oldMax, double newMin, double newMax); void rescaleNodes(vtkColorTransferFunction* lut, double newMin, double newMax); void rescaleNodes(vtkPiecewiseFunction* opacity, double newMin, double newMax); -void removePointsOutOfRange(vtkColorTransferFunction* lut, DataSource* ds); -void removePointsOutOfRange(vtkPiecewiseFunction* opacity, DataSource* ds); +void removePointsOutOfRange(vtkColorTransferFunction* lut, + const double range[2]); +void removePointsOutOfRange(vtkPiecewiseFunction* opacity, + const double range[2]); // Load a plugin by path bool loadPlugin(QString path); diff --git a/tomviz/ViewMenuManager.cxx b/tomviz/ViewMenuManager.cxx index af24c2e92..59e8c8d79 100644 --- a/tomviz/ViewMenuManager.cxx +++ b/tomviz/ViewMenuManager.cxx @@ -33,10 +33,16 @@ #include "ActiveObjects.h" #include "CameraReaction.h" -#include "DataSource.h" -#include "ModuleManager.h" -#include "ModuleSlice.h" -#include "SliceViewDialog.h" +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PortType.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SinkNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/sinks/SliceSink.h" #include "Utilities.h" namespace tomviz { @@ -46,19 +52,18 @@ class PreviousImageViewerSettings public: vtkNew camera; QString projection; - bool newSliceModule; - QPointer sliceModule; - QJsonObject sliceModuleSettings; - QList> visibleModules; - int interactionMode; + bool newSliceSink = false; + QPointer sliceSink; + QJsonObject sliceSinkSettings; + QList> visibleSinks; + int interactionMode = 0; void clear() { - // Only clear whatever needs to be cleared - visibleModules.clear(); - newSliceModule = false; - sliceModule = nullptr; - sliceModuleSettings = QJsonObject(); + visibleSinks.clear(); + newSliceSink = false; + sliceSink = nullptr; + sliceSinkSettings = QJsonObject(); }; }; @@ -77,11 +82,6 @@ ViewMenuManager::ViewMenuManager(QMainWindow* mainWindow, QMenu* menu) &ActiveObjects::viewChanged), this, &ViewMenuManager::onViewChanged); - connect(&ActiveObjects::instance(), &ActiveObjects::dataSourceActivated, this, - &ViewMenuManager::updateDataSource); - connect(&ActiveObjects::instance(), - &ActiveObjects::transformedDataSourceActivated, this, - &ViewMenuManager::updateDataSource); connect(&ActiveObjects::instance(), &ActiveObjects::setImageViewerMode, this, &ViewMenuManager::setImageViewerMode); @@ -117,21 +117,14 @@ ViewMenuManager::ViewMenuManager(QMainWindow* mainWindow, QMenu* menu) Menu->addSeparator(); + // FIXME: staged for removal m_imageViewerModeAction = Menu->addAction("Image Viewer Mode"); m_imageViewerModeAction->setCheckable(true); m_imageViewerModeAction->setChecked(false); + m_imageViewerModeAction->setVisible(false); connect(m_imageViewerModeAction, &QAction::triggered, this, &ViewMenuManager::setImageViewerMode); - Menu->addSeparator(); - - m_showDarkWhiteDataAction = Menu->addAction("Show Dark/White Data"); - m_showDarkWhiteDataAction->setEnabled(false); - connect(m_showDarkWhiteDataAction, &QAction::triggered, this, - &ViewMenuManager::showDarkWhiteData); - - Menu->addSeparator(); - m_previousImageViewerSettings.reset(new PreviousImageViewerSettings); if (hasLookingGlassPlugin()) { @@ -340,78 +333,163 @@ void ViewMenuManager::setImageViewerMode(bool enable) } if (!enable && enable == m_imageViewerMode) { - // Just return, nothing to do... return; } m_imageViewerMode = enable; if (!enable) { emit imageViewerModeToggled(enable); - // Restore the state to where it was before we began image viewer mode restoreImageViewerSettings(); return; } - auto* ds = ActiveObjects::instance().activeDataSource(); + auto* pip = ActiveObjects::instance().pipeline(); + auto* tipPort = ActiveObjects::instance().activeTipOutputPort(); + if (!pip || !tipPort) { + return; + } + auto* view = vtkSMRenderViewProxy::SafeDownCast(ActiveObjects::instance().activeView()); auto* camera = view->GetActiveCamera(); - auto& moduleManager = ModuleManager::instance(); - - // Save some of the old settings to restore them later auto& oldSettings = m_previousImageViewerSettings; oldSettings->clear(); oldSettings->camera->ShallowCopy(camera); oldSettings->projection = projectionMode(); oldSettings->interactionMode = interactionMode(); - // Do the following: - // 1. Set the projection to orthographic - // 2. Set the render interactor mode to 2D - // 3. Find the first slice module, or create one. Hide all other modules. - // 4. Align the camera so the single slice is filling the screen - // 5. Show the image viewer slider widget setProjectionModeToOrthographic(); setInteractionMode(vtkPVRenderView::INTERACTION_MODE_2D); - auto sliceModules = moduleManager.findModules(ds, view); - auto* sliceModule = !sliceModules.empty() ? sliceModules[0] : nullptr; + // Find an existing SliceSink connected to the tip port + pipeline::SliceSink* sliceSink = nullptr; + for (auto* link : tipPort->links()) { + auto* sg = + qobject_cast(link->to()->node()); + if (!sg) { + continue; + } + for (auto* sinkNode : sg->sinks()) { + auto* candidate = qobject_cast(sinkNode); + if (candidate) { + sliceSink = candidate; + break; + } + } + if (sliceSink) { + break; + } + } - oldSettings->newSliceModule = !sliceModule; - if (sliceModule) { - // Save it's settings before modifying it... - oldSettings->sliceModuleSettings = sliceModule->serialize(); - // Make sure it is visible - sliceModule->show(); + oldSettings->newSliceSink = !sliceSink; + if (sliceSink) { + oldSettings->sliceSinkSettings = sliceSink->serialize(); + sliceSink->setVisibility(true); } else { - // If there are no slice modules, create one - sliceModule = qobject_cast( - moduleManager.createAndAddModule("Slice", ds, view)); + sliceSink = new pipeline::SliceSink(); + sliceSink->setLabel("Slice"); + if (!sliceSink->initialize(view)) { + delete sliceSink; + m_imageViewerMode = false; + QSignalBlocker blocked(m_imageViewerModeAction); + m_imageViewerModeAction->setChecked(false); + return; + } + pip->addNode(sliceSink); + + auto* input = sliceSink->inputPorts()[0]; + pipeline::OutputPort* connectTo = nullptr; + for (auto* link : tipPort->links()) { + auto* sg = + qobject_cast(link->to()->node()); + if (sg) { + int idx = sg->inputPorts().indexOf(link->to()); + if (idx >= 0 && idx < sg->outputPorts().size() && + pipeline::isPortTypeCompatible(sg->outputPorts()[idx]->type(), + input->acceptedTypes())) { + connectTo = sg->outputPorts()[idx]; + break; + } + } + } + if (!connectTo) { + auto* group = new pipeline::SinkGroupNode(); + pipeline::PortType groupType = + pipeline::isVolumeType(tipPort->type()) + ? pipeline::PortType::ImageData + : tipPort->type(); + group->addPassthrough(tipPort->name(), groupType); + pip->addNode(group); + pip->createLink(tipPort, group->inputPorts()[0]); + connectTo = group->outputPorts()[0]; + } + pip->createLink(connectTo, input); + pip->executeWhenIdle(); } - oldSettings->sliceModule = sliceModule; - - // Hide all other modules on this data source - for (auto* module : moduleManager.findModulesGeneric(ds, view)) { - if (module != sliceModule && module->visibility()) { - oldSettings->visibleModules.append(module); - module->hide(); + oldSettings->sliceSink = sliceSink; + + // Hide other sinks on the same port + for (auto* link : tipPort->links()) { + auto* sg = + qobject_cast(link->to()->node()); + if (!sg) { + continue; + } + for (auto* sinkNode : sg->sinks()) { + auto* legacySink = + qobject_cast(sinkNode); + if (legacySink && legacySink != sliceSink && + legacySink->visibility()) { + oldSettings->visibleSinks.append(legacySink); + legacySink->setVisibility(false); + } } } - // Use XY direction, set the index to 0, and hide the arrow - sliceModule->onDirectionChanged(ModuleSlice::XY); - sliceModule->onSliceChanged(0); - sliceModule->setShowArrow(false); + if (oldSettings->newSliceSink) { + sliceSink->setDirection(pipeline::SliceSink::XY); + sliceSink->setSlice(0); + } + sliceSink->setShowArrow(false); - CameraReaction::resetNegativeZ(); + int axis = 2; + switch (sliceSink->direction()) { + case pipeline::SliceSink::YZ: axis = 0; break; + case pipeline::SliceSink::XZ: axis = 1; break; + default: axis = 2; break; + } - double bounds[6]; - sliceModule->planeBounds(bounds); - resize2DCameraToFit(view, bounds, 2); + double bounds[6] = { 0, 0, 0, 0, 0, 0 }; + auto vol = sliceSink->volumeData(); + if (vol && vol->isValid()) { + vol->imageData()->GetBounds(bounds); + } else { + auto portData = tipPort->data(); + if (portData.isValid() && pipeline::isVolumeType(portData.type())) { + auto tipVol = portData.value(); + if (tipVol && tipVol->isValid()) { + tipVol->imageData()->GetBounds(bounds); + } + } + } + + switch (axis) { + case 0: CameraReaction::resetPositiveX(); break; + case 1: CameraReaction::resetPositiveY(); break; + default: CameraReaction::resetNegativeZ(); break; + } + resize2DCameraToFit(view, bounds, axis); + + double depth = bounds[2 * axis + 1] - bounds[2 * axis]; + if (depth < 1.0) { + depth = 1.0; + } + double dist = camera->GetDistance(); + camera->SetClippingRange(0.01, dist + depth); emit imageViewerModeToggled(enable); - emit moduleManager.pipelineViewRenderNeeded(); + view->GetRenderWindow()->Render(); } void ViewMenuManager::restoreImageViewerSettings() @@ -421,71 +499,32 @@ void ViewMenuManager::restoreImageViewerSettings() auto* view = vtkSMRenderViewProxy::SafeDownCast(ActiveObjects::instance().activeView()); auto* camera = view->GetActiveCamera(); - auto& moduleManager = ModuleManager::instance(); setInteractionMode(settings->interactionMode); setProjectionMode(settings->projection); camera->ShallowCopy(settings->camera); - if (settings->sliceModule) { - if (settings->newSliceModule) { - // Remove the newly created slice module - moduleManager.removeModule(settings->sliceModule); + if (settings->sliceSink) { + if (settings->newSliceSink) { + auto* pip = ActiveObjects::instance().pipeline(); + if (pip) { + pip->removeNode(settings->sliceSink); + } } else { - // Restore the settings on the slice module we grabbed - settings->sliceModule->deserialize(settings->sliceModuleSettings); + settings->sliceSink->deserialize(settings->sliceSinkSettings); } } - // Restore visible modules - for (auto module : settings->visibleModules) { - if (module) { - module->show(); + for (auto sink : settings->visibleSinks) { + if (sink) { + sink->setVisibility(true); } } - emit moduleManager.pipelineViewRenderNeeded(); - // FIXME: at this point, the center is in a different place, and view - // is not updated to match the camera position. As a quick fix, just - // reset the camera. We can improve this in the future if needed. view->ResetCamera(); + view->StillRender(); } -void ViewMenuManager::updateDataSource(DataSource* s) -{ - m_dataSource = s; - updateDataSourceEnableStates(); -} - -void ViewMenuManager::updateDataSourceEnableStates() -{ - // Currently, both white and dark are required to use this - // We can change this in the future if needed... - m_showDarkWhiteDataAction->setEnabled( - m_dataSource && m_dataSource->darkData() && m_dataSource->whiteData()); -} - -void ViewMenuManager::showDarkWhiteData() -{ - if (!m_dataSource || !m_dataSource->darkData() || - !m_dataSource->whiteData()) { - return; - } - - if (!m_sliceViewDialog) { - m_sliceViewDialog.reset(new SliceViewDialog); - } - - auto* lut = vtkColorTransferFunction::SafeDownCast( - m_dataSource->colorMap()->GetClientSideObject()); - - m_sliceViewDialog->setLookupTable(lut); - m_sliceViewDialog->setDarkImage(m_dataSource->darkData()); - m_sliceViewDialog->setWhiteImage(m_dataSource->whiteData()); - m_sliceViewDialog->switchToDark(); - - m_sliceViewDialog->exec(); -} void ViewMenuManager::setupLookingGlassPlaceholder(QMainWindow* mainWindow) { diff --git a/tomviz/ViewMenuManager.h b/tomviz/ViewMenuManager.h index e56c90899..e4e73dc7d 100644 --- a/tomviz/ViewMenuManager.h +++ b/tomviz/ViewMenuManager.h @@ -17,9 +17,12 @@ class vtkSMViewProxy; namespace tomviz { -class DataSource; class PreviousImageViewerSettings; -class SliceViewDialog; + +namespace pipeline { +class SliceSink; +class Pipeline; +} // namespace pipeline enum class ScaleLegendStyle : unsigned int; @@ -50,15 +53,10 @@ private slots: void setShowOrientationAxes(bool show); void setImageViewerMode(bool b); - void showDarkWhiteData(); - private: void setScaleLegendStyle(ScaleLegendStyle); void setScaleLegendVisibility(bool); - void updateDataSource(DataSource* s); - void updateDataSourceEnableStates(); - void render(); void restoreImageViewerSettings(); @@ -70,12 +68,9 @@ private slots: QPointer m_showCenterAxesAction; QPointer m_showOrientationAxesAction; QPointer m_imageViewerModeAction; - QPointer m_showDarkWhiteDataAction; QScopedPointer m_previousImageViewerSettings; - QScopedPointer m_sliceViewDialog; - DataSource* m_dataSource = nullptr; vtkSMViewProxy* m_view; unsigned long m_viewObserverId; bool m_imageViewerMode = false; diff --git a/tomviz/ViewsLayoutsSerializer.cxx b/tomviz/ViewsLayoutsSerializer.cxx new file mode 100644 index 000000000..d6f2d9433 --- /dev/null +++ b/tomviz/ViewsLayoutsSerializer.cxx @@ -0,0 +1,198 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ViewsLayoutsSerializer.h" + +#include "ActiveObjects.h" +#include "Utilities.h" + +#include +#include +#include +#include +#include + +#include + +#include + +namespace tomviz { + +namespace { + +QJsonArray jsonArrayFromXml(pugi::xml_node node) +{ + QJsonArray array; + for (auto element = node.child("Element"); element; + element = element.next_sibling("Element")) { + array.append(element.attribute("value").as_int(-1)); + } + return array; +} + +QJsonArray jsonArrayFromXmlDouble(pugi::xml_node node) +{ + QJsonArray array; + for (auto element = node.child("Element"); element; + element = element.next_sibling("Element")) { + array.append(element.attribute("value").as_double(-1)); + } + return array; +} + +} // namespace + +void ViewsLayoutsSerializer::saveActive(QJsonObject& doc) +{ + save(doc, ActiveObjects::instance().proxyManager(), + ActiveObjects::instance().activeView()); +} + +void ViewsLayoutsSerializer::save(QJsonObject& doc, + vtkSMSessionProxyManager* pxm, + vtkSMProxy* activeView) +{ + if (!pxm) { + return; + } + + // Palette color + if (auto* paletteProxy = pxm->GetProxy("settings", "ColorPalette")) { + double paletteColor[3]; + vtkSMPropertyHelper(paletteProxy->GetProperty("BackgroundColor")) + .Get(paletteColor, 3); + doc["paletteColor"] = + QJsonArray({ paletteColor[0], paletteColor[1], paletteColor[2] }); + } + + // Serialize layouts + vtkNew iter; + iter->SetSessionProxyManager(pxm); + iter->SetModeToOneGroup(); + QJsonArray jLayouts; + for (iter->Begin("layouts"); !iter->IsAtEnd(); iter->Next()) { + if (vtkSMProxy* layout = iter->GetProxy()) { + QJsonObject jLayout; + jLayout["id"] = static_cast(layout->GetGlobalID()); + jLayout["xmlGroup"] = layout->GetXMLGroup(); + jLayout["xmlName"] = layout->GetXMLName(); + + pugi::xml_document document; + auto proxyNode = document.append_child("ParaViewXML"); + tomviz::serialize(layout, proxyNode); + auto layoutProxy = document.child("ParaViewXML").child("Proxy"); + jLayout["servers"] = layoutProxy.attribute("servers").as_int(0); + QJsonArray layoutArray; + for (auto node = layoutProxy.child("Layout"); node; + node = node.next_sibling("Layout")) { + QJsonArray itemArray; + for (auto itemNode = node.child("Item"); itemNode; + itemNode = itemNode.next_sibling("Item")) { + QJsonObject itemObj; + itemObj["direction"] = itemNode.attribute("direction").as_int(0); + itemObj["fraction"] = itemNode.attribute("fraction").as_double(0); + itemObj["viewId"] = itemNode.attribute("view").as_int(0); + itemArray.append(itemObj); + } + layoutArray.append(itemArray); + } + jLayout["items"] = layoutArray; + jLayouts.append(jLayout); + } + } + if (!jLayouts.isEmpty()) { + doc["layouts"] = jLayouts; + } + + // Serialize views + QJsonArray jViews; + for (iter->Begin("views"); !iter->IsAtEnd(); iter->Next()) { + if (vtkSMProxy* view = iter->GetProxy()) { + QJsonObject jView; + jView["id"] = static_cast(view->GetGlobalID()); + jView["xmlGroup"] = view->GetXMLGroup(); + jView["xmlName"] = view->GetXMLName(); + if (activeView && view == activeView) { + jView["active"] = true; + } + + if (view->GetProperty("UseColorPaletteForBackground")) { + jView["useColorPaletteForBackground"] = + vtkSMPropertyHelper(view, "UseColorPaletteForBackground").GetAsInt(); + } + + pugi::xml_document document; + pugi::xml_node proxyNode = document.append_child("ParaViewXML"); + tomviz::serialize(view, proxyNode); + + QJsonObject camera; + + auto viewProxy = document.child("ParaViewXML").child("Proxy"); + jView["servers"] = viewProxy.attribute("servers").as_int(0); + QJsonArray backgroundColor; + for (pugi::xml_node node = viewProxy.child("Property"); node; + node = node.next_sibling("Property")) { + std::string name = node.attribute("name").as_string(""); + if (name == "ViewSize") { + jView["viewSize"] = jsonArrayFromXml(node); + } else if (name == "CameraFocalPoint") { + camera["focalPoint"] = jsonArrayFromXmlDouble(node); + } else if (name == "CameraPosition") { + camera["position"] = jsonArrayFromXmlDouble(node); + } else if (name == "CameraViewUp") { + camera["viewUp"] = jsonArrayFromXmlDouble(node); + } else if (name == "CameraViewAngle") { + camera["viewAngle"] = jsonArrayFromXmlDouble(node)[0]; + } else if (name == "EyeAngle") { + camera["eyeAngle"] = jsonArrayFromXmlDouble(node)[0]; + } else if (name == "CenterOfRotation") { + jView["centerOfRotation"] = jsonArrayFromXmlDouble(node); + } else if (name == "Background") { + backgroundColor.append(jsonArrayFromXmlDouble(node)); + } else if (name == "Background2") { + vtkSMPropertyHelper helper(view, "BackgroundColorMode"); + if (helper.GetAsString() == std::string("Gradient")) { + backgroundColor.append(jsonArrayFromXmlDouble(node)); + } + } else if (name == "CameraParallelScale") { + camera["parallelScale"] = jsonArrayFromXmlDouble(node)[0]; + } else if (name == "CameraParallelProjection") { + vtkSMPropertyHelper helper(view, "CameraParallelProjection"); + jView["isOrthographic"] = helper.GetAsInt() != 0; + } else if (name == "InteractionMode") { + vtkSMPropertyHelper helper(view, "InteractionMode"); + QString mode = "3D"; + if (helper.GetAsInt() == 1) { + mode = "2D"; + } else if (helper.GetAsInt() == 2) { + mode = "selection"; + } + jView["interactionMode"] = mode; + } else if (name == "CenterAxesVisibility") { + vtkSMPropertyHelper helper(view, "CenterAxesVisibility"); + jView["centerAxesVisible"] = helper.GetAsInt() == 1; + } else if (name == "OrientationAxesVisibility") { + vtkSMPropertyHelper helper(view, "OrientationAxesVisibility"); + jView["orientationAxesVisible"] = helper.GetAsInt() == 1; + } + } + if (view->GetProperty("AxesGrid")) { + vtkSMPropertyHelper helper(view, "AxesGrid"); + vtkSMProxy* axesGridProxy = helper.GetAsProxy(); + if (axesGridProxy) { + vtkSMPropertyHelper visibilityHelper(axesGridProxy, "Visibility"); + jView["axesGridVisibility"] = visibilityHelper.GetAsInt() != 0; + } + } + jView["camera"] = camera; + jView["backgroundColor"] = backgroundColor; + + jViews.append(jView); + } + } + if (!jViews.isEmpty()) { + doc["views"] = jViews; + } +} + +} // namespace tomviz diff --git a/tomviz/ViewsLayoutsSerializer.h b/tomviz/ViewsLayoutsSerializer.h new file mode 100644 index 000000000..dbf5e0385 --- /dev/null +++ b/tomviz/ViewsLayoutsSerializer.h @@ -0,0 +1,37 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizViewsLayoutsSerializer_h +#define tomvizViewsLayoutsSerializer_h + +#include + +class vtkSMProxy; +class vtkSMSessionProxyManager; + +namespace tomviz { + +/// Serialization helper for the top-level ParaView views / layouts / palette +/// sections of a Tomviz state file. The legacy saver (ModuleManager) and the +/// new saver (PipelineStateIO) share this code so both formats emit the same +/// byte-for-byte views[]/layouts[]/paletteColor entries; the legacy loader +/// (LegacyStateLoader) is the authoritative read path for both. +class ViewsLayoutsSerializer +{ +public: + /// Write `views`, `layouts`, and `paletteColor` into @a doc. + /// @a pxm may be null in headless / test contexts, in which case this + /// is a no-op. @a activeView is used to mark the corresponding view + /// entry as active; may be null. + static void save(QJsonObject& doc, vtkSMSessionProxyManager* pxm, + vtkSMProxy* activeView = nullptr); + + /// Convenience overload that pulls @a pxm and @a activeView from + /// ActiveObjects. Safe only in contexts where pqApplicationCore has + /// been initialized (i.e. the running application, not unit tests). + static void saveActive(QJsonObject& doc); +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/WelcomeDialog.cxx b/tomviz/WelcomeDialog.cxx index 4f1f6a4b5..84331a89a 100644 --- a/tomviz/WelcomeDialog.cxx +++ b/tomviz/WelcomeDialog.cxx @@ -6,7 +6,6 @@ #include "ActiveObjects.h" #include "MainWindow.h" -#include "ModuleManager.h" #include #include @@ -33,15 +32,8 @@ void WelcomeDialog::onLoadSampleDataClicked() { auto mw = qobject_cast(this->parent()); mw->openRecon(); - auto& mm = ModuleManager::instance(); - auto& ao = ActiveObjects::instance(); - // Remove the orthogonal slice that is automatically created - mm.removeModule(ao.activeModule()); - // Add a volume module - if (auto module = mm.createAndAddModule("Volume", ao.activeDataSource(), - ao.activeView())) { - ao.setActiveModule(module); - } + // TODO: migrate to new pipeline — remove auto-created slice and add + // volume sink via the new pipeline API hide(); } diff --git a/tomviz/acquisition/AcquisitionWidget.cxx b/tomviz/acquisition/AcquisitionWidget.cxx index 248e2fd7f..f8ad6fc49 100644 --- a/tomviz/acquisition/AcquisitionWidget.cxx +++ b/tomviz/acquisition/AcquisitionWidget.cxx @@ -22,7 +22,6 @@ #include #include #include -#include #include #include @@ -231,16 +230,6 @@ void AcquisitionWidget::previewReady(QString mimeType, QByteArray result) resetCamera(); m_ui->imageWidget->renderWindow()->Render(); - if (ActiveObjects::instance().activeDataSource()) { - auto proxy = ActiveObjects::instance().activeDataSource()->colorMap(); - m_lut = vtkScalarsToColors::SafeDownCast(proxy->GetClientSideObject()); - } else { - // m_lut = vtkSmartPointer::New(); - } - if (m_lut) { - m_imageSlice->GetProperty()->SetLookupTable(m_lut); - } - m_ui->previewButton->setEnabled(true); m_ui->acquireButton->setEnabled(true); } diff --git a/tomviz/acquisition/AcquisitionWidget.h b/tomviz/acquisition/AcquisitionWidget.h index 1554d0dbb..91509be87 100644 --- a/tomviz/acquisition/AcquisitionWidget.h +++ b/tomviz/acquisition/AcquisitionWidget.h @@ -17,7 +17,6 @@ class vtkImageSliceMapper; class vtkInteractorStyleRubberBand2D; class vtkInteractorStyleRubberBandZoom; class vtkRenderer; -class vtkScalarsToColors; namespace Ui { class AcquisitionWidget; @@ -67,7 +66,6 @@ private slots: vtkSmartPointer m_imageData; vtkNew m_imageSlice; vtkNew m_imageSliceMapper; - vtkSmartPointer m_lut; double m_tiltAngle = 0.0; QString m_units = "unknown"; diff --git a/tomviz/acquisition/AdvancedFormatWidget.cxx b/tomviz/acquisition/AdvancedFormatWidget.cxx deleted file mode 100644 index 3cb9080d9..000000000 --- a/tomviz/acquisition/AdvancedFormatWidget.cxx +++ /dev/null @@ -1,120 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "AdvancedFormatWidget.h" - -#include "ui_AdvancedFormatWidget.h" -#include - -namespace tomviz { - -AdvancedFormatWidget::AdvancedFormatWidget(QWidget* parent) - : QWidget(parent), m_ui(new Ui::AdvancedFormatWidget) -{ - m_ui->setupUi(this); - - // Default file name regex - m_fileNameRegex = QString(".*_([n,p]{1}[\\d,\\.]+)degree.*\\.dm3"); - m_ui->fileNameRegexLineEdit->setText(m_fileNameRegex); - - connect(m_ui->fileNameRegexLineEdit, &QLineEdit::textChanged, - [this](QString pattern) { - setEnabledRegexGroupsWidget(!pattern.isEmpty()); - m_fileNameRegex = pattern; - emit regexChanged(pattern); - }); - setEnabledRegexGroupsWidget(!m_ui->fileNameRegexLineEdit->text().isEmpty()); - - connect(m_ui->regexGroupsWidget, &RegexGroupsWidget::groupsChanged, [this]() { - emit regexChanged(m_fileNameRegex); - setEnabledRegexGroupsSubstitutionsWidget( - !m_ui->regexGroupsWidget->regexGroups().isEmpty()); - }); - setEnabledRegexGroupsSubstitutionsWidget( - !m_ui->regexGroupsWidget->regexGroups().isEmpty()); - - // Setup regex error label - auto palette = m_regexErrorLabel.palette(); - palette.setColor(m_regexErrorLabel.foregroundRole(), Qt::red); - m_regexErrorLabel.setPalette(palette); - - connect(m_ui->fileNameRegexLineEdit, &QLineEdit::textChanged, [this]() { - m_ui->gridLayout->removeWidget(&m_regexErrorLabel); - m_regexErrorLabel.setText(""); - }); -} - -AdvancedFormatWidget::~AdvancedFormatWidget() = default; - -void AdvancedFormatWidget::setEnabledRegexGroupsWidget(bool enabled) -{ - m_ui->regexGroupsLabel->setEnabled(enabled); - m_ui->regexGroupsWidget->setEnabled(enabled); -} - -void AdvancedFormatWidget::setEnabledRegexGroupsSubstitutionsWidget( - bool enabled) -{ - m_ui->regexGroupsSubstitutionsLabel->setEnabled(enabled); - m_ui->regexGroupsSubstitutionsWidget->setEnabled(enabled); -} - -MatchInfo AdvancedFormatWidget::matchFileName(QString fileName) const -{ - QRegExp regex = QRegExp(m_fileNameRegex); - bool match = regex.exactMatch(fileName); - MatchInfo result; - result.matched = match; - QList groups; - QStringList namedGroups = m_ui->regexGroupsWidget->regexGroups(); - - if (namedGroups.size() == 0) { - groups << CapGroup(QString("Full match"), regex.cap(0)); - } else { - for (int i = 0; i < namedGroups.size(); ++i) { - groups << CapGroup(namedGroups[i], regex.cap(i + 1)); - } - } - - result.groups = groups; - return result; -} - -QJsonArray AdvancedFormatWidget::getRegexGroups() const -{ - auto regexGroups = m_ui->regexGroupsWidget->regexGroups(); - return QJsonArray::fromStringList(regexGroups); -} - -QJsonObject AdvancedFormatWidget::getRegexSubsitutions() const -{ - auto regexSubstitutions = - m_ui->regexGroupsSubstitutionsWidget->substitutions(); - QJsonObject substitutions; - foreach (RegexGroupSubstitution sub, regexSubstitutions) { - QJsonArray regexToSubs; - if (substitutions.contains(sub.groupName())) { - regexToSubs = substitutions.value(sub.groupName()).toArray(); - } - QJsonObject mapping; - mapping[sub.regex()] = sub.substitution(); - regexToSubs.append(mapping); - substitutions[sub.groupName()] = regexToSubs; - } - return substitutions; -} - -QString AdvancedFormatWidget::getRegex() const -{ - return m_fileNameRegex; -} - -QString AdvancedFormatWidget::getPythonRegex() const -{ - QString regex = m_fileNameRegex; - // Let's not replace it if it isn't needed - regex.replace(QString(".*?"), QString(".*")); - regex.replace(QString(".*"), QString(".*?")); - return regex; -} -} diff --git a/tomviz/acquisition/AdvancedFormatWidget.h b/tomviz/acquisition/AdvancedFormatWidget.h deleted file mode 100644 index 904e1d3aa..000000000 --- a/tomviz/acquisition/AdvancedFormatWidget.h +++ /dev/null @@ -1,54 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizAdvancedFormatWidget_h -#define tomvizAdvancedFormatWidget_h - -#include - -#include "MatchInfo.h" - -#include -#include -#include -#include - -namespace Ui { -class AdvancedFormatWidget; -} - -namespace tomviz { -class AdvancedFormatWidget : public QWidget -{ - Q_OBJECT - -public: - AdvancedFormatWidget(QWidget* parent = nullptr); - ~AdvancedFormatWidget() override; - - QString getRegex() const; - QString getPythonRegex() const; - MatchInfo matchFileName(QString) const; - QJsonArray getRegexGroups() const; - QJsonObject getRegexSubsitutions() const; - -public slots: - -private slots: - -signals: - void regexChanged(QString); - -private: - QScopedPointer m_ui; - - QLabel m_regexErrorLabel; - - QString m_fileNameRegex; - - void setEnabledRegexGroupsWidget(bool enabled); - void setEnabledRegexGroupsSubstitutionsWidget(bool enabled); -}; -} - -#endif diff --git a/tomviz/acquisition/AdvancedFormatWidget.ui b/tomviz/acquisition/AdvancedFormatWidget.ui deleted file mode 100644 index 9992a4a61..000000000 --- a/tomviz/acquisition/AdvancedFormatWidget.ui +++ /dev/null @@ -1,62 +0,0 @@ - - - AdvancedFormatWidget - - - - - - File Name Regex - - - - - - - - - - - Regex Group Names - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - - - - - - - Group Substitutions - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - - - - - - - tomviz::RegexGroupsWidget - QWidget -
RegexGroupsWidget.h
- 1 -
- - tomviz::RegexGroupsSubstitutionsWidget - QWidget -
RegexGroupsSubstitutionsWidget.h
- 1 -
-
- - -
diff --git a/tomviz/acquisition/BasicFormatWidget.cxx b/tomviz/acquisition/BasicFormatWidget.cxx deleted file mode 100644 index 0c7f79530..000000000 --- a/tomviz/acquisition/BasicFormatWidget.cxx +++ /dev/null @@ -1,386 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "BasicFormatWidget.h" - -#include "ui_BasicFormatWidget.h" - -#include -#include - -namespace tomviz { - -BasicFormatWidget::BasicFormatWidget(QWidget* parent) - : QWidget(parent), m_ui(new Ui::BasicFormatWidget), - m_defaultFormatOrder(makeDefaultFormatOrder()), - m_defaultExtensionOrder(makeDefaultExtensionOrder()), - m_defaultFileNames(makeDefaultFileNames()), - m_defaultFormatLabels(makeDefaultFormatLabels()), - m_defaultExtensionLabels(makeDefaultExtensionLabels()), - m_defaultRegexParams(makeDefaultRegexParams()) -{ - m_ui->setupUi(this); - - connect(m_ui->fileFormatCombo, static_cast( - &QComboBox::currentIndexChanged), - this, &BasicFormatWidget::onFormatChanged); - - connect(m_ui->fileExtensionCombo, static_cast( - &QComboBox::currentIndexChanged), - this, &BasicFormatWidget::onExtensionChanged); - - setupFileFormatCombo(); - setupFileExtensionCombo(); - setupRegexDisplayLine(); - - connect(m_ui->customFormatWidget, &CustomFormatWidget::fieldsChanged, this, - &BasicFormatWidget::customFileRegex); -} - -BasicFormatWidget::~BasicFormatWidget() = default; - -void BasicFormatWidget::setupFileFormatCombo() -{ - foreach (auto format, m_defaultFormatOrder) { - m_ui->fileFormatCombo->addItem(m_defaultFormatLabels[format]); - } -} - -void BasicFormatWidget::setupFileExtensionCombo() -{ - foreach (auto format, m_defaultExtensionOrder) { - m_ui->fileExtensionCombo->addItem(m_defaultExtensionLabels[format]); - } -} - -void BasicFormatWidget::onFormatChanged(int index) -{ - if (index >= m_defaultFormatOrder.size() || index < 0) { - return; - } - - m_format = m_defaultFormatOrder[index]; - - switch (m_format) { - case RegexFormat::Custom: - m_ui->customGroupBox->setVisible(true); - m_ui->customGroupBox->setEnabled(true); - m_ui->fileExtensionCombo->setEnabled(false); - break; - default: - m_ui->customGroupBox->setVisible(true); - m_ui->customGroupBox->setEnabled(false); - m_ui->fileExtensionCombo->setEnabled(true); - } - updateRegex(); - emit fileFormatChanged(); -} - -void BasicFormatWidget::onExtensionChanged(int index) -{ - if (index >= m_defaultExtensionOrder.size() || index < 0) { - return; - } - m_extension = m_defaultExtensionOrder[index]; - updateRegex(); - emit fileFormatChanged(); -} - -void BasicFormatWidget::updateRegex() -{ - QStringList params = - m_defaultRegexParams[std::make_pair(m_format, m_extension)]; - if (params.size() != 5) { - qCritical() << "Default Regex Parameters size not equal to 5"; - return; - } - buildFileRegex(params[0], params[1], params[2], params[3], params[4]); - - m_ui->customFormatWidget->setFields(params); -} - -void BasicFormatWidget::buildFileRegex(QString prefix, QString negChar, - QString posChar, QString suffix, - QString extension) -{ - // Greediness differences between Qt regex engine in the client, - // and the python regex engine on the server, - // require adjustments to the pattern - if (prefix.trimmed() == "") { - prefix = QString("*"); - } - QString pythonPrefix = prefix; - prefix.replace(QString("*"), QString(".*")); - pythonPrefix.replace(QString("*"), QString(".*?")); - - if (suffix.trimmed() == "") { - suffix = QString("*"); - } - QString pythonSuffix = suffix; - suffix.replace(QString("*"), QString(".*")); - pythonSuffix.replace(QString("*"), QString(".*?")); - - if (extension.trimmed() == "" || extension.trimmed() == "*") { - extension = QString(".+"); - } - if (negChar.trimmed() == "") { - negChar = QString("n"); - } - if (posChar.trimmed() == "") { - posChar = QString("p"); - } - - m_fileNameRegex = QString("^%1((%2|%3)(\\d+(\\.\\d+)?))%4(\\.%5)$") - .arg(prefix) - .arg(QRegExp::escape(negChar)) - .arg(QRegExp::escape(posChar)) - .arg(suffix) - .arg(extension); - - m_pythonFileNameRegex = QString("^%1((%2|%3)(\\d+(\\.\\d+)?))%4(\\.%5)$") - .arg(pythonPrefix) - .arg(QRegExp::escape(negChar)) - .arg(QRegExp::escape(posChar)) - .arg(pythonSuffix) - .arg(extension); - m_negChar = negChar; - m_posChar = posChar; - - m_ui->regexDisplay->setText(m_fileNameRegex); - emit regexChanged(m_fileNameRegex); -} - -void BasicFormatWidget::customFileRegex() -{ - QStringList fields = m_ui->customFormatWidget->getFields(); - if (fields.size() != 5) { - qCritical() << "Field Regex Parameters size not equal to 5"; - return; - } - buildFileRegex(fields[0], fields[1], fields[2], fields[3], fields[4]); -} - -MatchInfo BasicFormatWidget::matchFileName(QString fileName) const -{ - QString prefix; - QString suffix; - QString sign; - QString numStr; - QString ext; - double num; - bool valid = false; - QRegExp regex = QRegExp(m_fileNameRegex); - bool match = regex.exactMatch(fileName); - - if (match) { - valid = true; - - sign = regex.cap(2); - numStr = regex.cap(3); - ext = regex.cap(5); - - int start = 0; - int stop = 0; - stop = regex.pos(1); - prefix = fileName.left(stop); - start = regex.pos(1) + regex.cap(1).size(); - stop = regex.pos(5); - suffix = fileName.mid(start, stop - start); - - if (sign == m_posChar) { - sign = "+"; - } else if (sign == m_negChar) { - sign = "-"; - } - num = (sign + numStr).toFloat(); - - // Special case: only 0 can be missing a positive/negative identifier - if (sign.trimmed() == "" && num != 0) { - valid = false; - } - } - - if (!valid) { - prefix = ""; - suffix = ""; - sign = ""; - numStr = ""; - ext = ""; - } - - MatchInfo result; - result.matched = valid; - QList groups; - groups << CapGroup("Prefix", prefix); - groups << CapGroup("Angle", sign + numStr); - groups << CapGroup("Suffix", suffix); - groups << CapGroup("Ext", ext); - result.groups = groups; - return result; -} - -QString BasicFormatWidget::getRegex() const -{ - return m_fileNameRegex; -} - -QString BasicFormatWidget::getPythonRegex() const -{ - return m_pythonFileNameRegex; -} - -QJsonArray BasicFormatWidget::getRegexGroups() const -{ - return QJsonArray::fromStringList(QStringList(QString("angle"))); -} - -QJsonObject BasicFormatWidget::getRegexSubsitutions() const -{ - QJsonObject substitutions; - QJsonArray regexToSubs; - QJsonObject sub0; - sub0[QRegExp::escape(m_posChar)] = "+"; - QJsonObject sub1; - sub1[QRegExp::escape(m_negChar)] = "-"; - regexToSubs.append(sub0); - regexToSubs.append(sub1); - substitutions["angle"] = regexToSubs; - return substitutions; -} - -bool BasicFormatWidget::isDefaultFilename(const QString& fileName) const -{ - foreach (auto format, m_defaultFormatOrder) { - foreach (auto extension, m_defaultExtensionOrder) { - if (fileName == m_defaultFileNames[std::make_pair(format, extension)]) { - return true; - } - } - } - return false; -} - -QString BasicFormatWidget::getDefaultFilename() const -{ - return m_defaultFileNames[std::make_pair(m_format, m_extension)]; -} - -void BasicFormatWidget::setupRegexDisplayLine() -{ - QLineEdit* display = m_ui->regexDisplay; - display->setReadOnly(true); - QPalette pal = display->palette(); - QColor disabledBg = pal.color(QPalette::Disabled, QPalette::Base); - pal.setColor(QPalette::Active, QPalette::Base, disabledBg); - pal.setColor(QPalette::Inactive, QPalette::Base, disabledBg); - display->setPalette(pal); -} - -QMap, QString> -BasicFormatWidget::makeDefaultFileNames() const -{ - QMap, QString> defaultFilenames; - QString fileBase; - QString fileExt; - foreach (auto format, m_defaultFormatOrder) { - foreach (auto extension, m_defaultExtensionOrder) { - fileBase = QString(""); - fileExt = QString(""); - if (format == RegexFormat::NegativePositive) { - fileBase = QString("Prefix_n12.3_Suffix"); - } else if (format == RegexFormat::PlusMinus) { - fileBase = QString("Prefix_+12.3_Suffix"); - } - - if (format != RegexFormat::Custom) { - if (extension == RegexExtension::dm3) { - fileExt = QString(".dm3"); - } else if (extension == RegexExtension::tiff) { - fileExt = QString(".tiff"); - } - } - - defaultFilenames[std::make_pair(format, extension)] = - QString("%1%2").arg(fileBase).arg(fileExt); - } - } - return defaultFilenames; -} - -QMap BasicFormatWidget::makeDefaultFormatLabels() const -{ - QMap defaultLabels; - defaultLabels[RegexFormat::NegativePositive] = - QString("[n|p]"); - defaultLabels[RegexFormat::PlusMinus] = - QString("[-|+]"); - defaultLabels[RegexFormat::Custom] = QString("Custom"); - - return defaultLabels; -} - -QMap BasicFormatWidget::makeDefaultExtensionLabels() - const -{ - QMap defaultLabels; - defaultLabels[RegexExtension::dm3] = QString(".dm3"); - defaultLabels[RegexExtension::tiff] = QString(".tiff"); - - return defaultLabels; -} - -QMap, QStringList> -BasicFormatWidget::makeDefaultRegexParams() const -{ - QMap, QStringList> defaultRegexParams; - foreach (auto format, m_defaultFormatOrder) { - foreach (auto extension, m_defaultExtensionOrder) { - QStringList params; - - if (format == RegexFormat::NegativePositive) { - params << "*" - << "n" - << "p" - << "*"; - } else if (format == RegexFormat::PlusMinus) { - params << "*" - << "-" - << "+" - << "*"; - } else if (format == RegexFormat::Custom) { - params << "*" - << "n" - << "p" - << "*"; - } - - if (format == RegexFormat::Custom) { - params << "*"; - } else if (extension == RegexExtension::dm3) { - params << "dm3"; - } else if (extension == RegexExtension::tiff) { - params << "tif[f]?"; - } - - defaultRegexParams[std::make_pair(format, extension)] = params; - } - } - - return defaultRegexParams; -} - -QList BasicFormatWidget::makeDefaultFormatOrder() const -{ - QList defaultOrder; - defaultOrder << RegexFormat::NegativePositive << RegexFormat::PlusMinus - << RegexFormat::Custom; - return defaultOrder; -} - -QList BasicFormatWidget::makeDefaultExtensionOrder() const -{ - QList defaultOrder; - defaultOrder << RegexExtension::dm3 << RegexExtension::tiff; - return defaultOrder; -} -} diff --git a/tomviz/acquisition/BasicFormatWidget.h b/tomviz/acquisition/BasicFormatWidget.h deleted file mode 100644 index 30ed493ee..000000000 --- a/tomviz/acquisition/BasicFormatWidget.h +++ /dev/null @@ -1,101 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizBasicFormatWidget_h -#define tomvizBasicFormatWidget_h - -#include - -#include "MatchInfo.h" - -#include -#include -#include -#include -#include -#include - -namespace Ui { -class BasicFormatWidget; -} - -namespace tomviz { - -enum class RegexFormat -{ - NegativePositive, - PlusMinus, - Custom -}; - -enum class RegexExtension -{ - tiff, - dm3 -}; - -class BasicFormatWidget : public QWidget -{ - Q_OBJECT - -public: - BasicFormatWidget(QWidget* parent = nullptr); - ~BasicFormatWidget() override; - - QString getRegex() const; - QString getPythonRegex() const; - MatchInfo matchFileName(QString) const; - QJsonArray getRegexGroups() const; - QJsonObject getRegexSubsitutions() const; - bool isDefaultFilename(const QString& fileName) const; - QString getDefaultFilename() const; - -private slots: - void onFormatChanged(int); - void onExtensionChanged(int); - -signals: - void fileFormatChanged(); - void regexChanged(QString); - -private: - QScopedPointer m_ui; - - QString m_fileNameRegex; - QString m_negChar; - QString m_posChar; - QString m_pythonFileNameRegex; - QString m_testFileName; - RegexFormat m_format = RegexFormat::NegativePositive; - RegexExtension m_extension = RegexExtension::dm3; - - void setupFileFormatCombo(); - void setupFileExtensionCombo(); - void setupRegexDisplayLine(); - - QList makeDefaultFormatOrder() const; - QList makeDefaultExtensionOrder() const; - QMap, QString> makeDefaultFileNames() - const; - QMap makeDefaultFormatLabels() const; - QMap makeDefaultExtensionLabels() const; - QMap, QStringList> - makeDefaultRegexParams() const; - - const QList m_defaultFormatOrder; - const QList m_defaultExtensionOrder; - const QMap, QString> - m_defaultFileNames; - const QMap m_defaultFormatLabels; - const QMap m_defaultExtensionLabels; - const QMap, QStringList> - m_defaultRegexParams; - - void updateRegex(); - void buildFileRegex(QString, QString, QString, QString, QString); - void customFileRegex(); - void validateFileNameRegex(); -}; -} - -#endif diff --git a/tomviz/acquisition/BasicFormatWidget.ui b/tomviz/acquisition/BasicFormatWidget.ui deleted file mode 100644 index 7ed6f760c..000000000 --- a/tomviz/acquisition/BasicFormatWidget.ui +++ /dev/null @@ -1,92 +0,0 @@ - - - BasicFormatWidget - - - - 0 - 0 - 296 - 166 - - - - - - - File Name Format - - - - - - - - - true - - - - 0 - 0 - - - - - - - - true - - - - - - - - - Resulting Regex - - - - - - - - - - Custom format - - - - - - - - - - - - Qt::Vertical - - - - 0 - 30 - - - - - - - - - tomviz::CustomFormatWidget - QWidget -
CustomFormatWidget.h
- 1 -
-
- - -
diff --git a/tomviz/acquisition/ConnectionsWidget.cxx b/tomviz/acquisition/ConnectionsWidget.cxx index 1efb9fa70..8a6c2ded5 100644 --- a/tomviz/acquisition/ConnectionsWidget.cxx +++ b/tomviz/acquisition/ConnectionsWidget.cxx @@ -7,7 +7,6 @@ #include "ActiveObjects.h" #include "MainWindow.h" -#include "ModuleManager.h" #include #include @@ -28,7 +27,7 @@ ConnectionsWidget::ConnectionsWidget(QWidget* parent) // New connect(m_ui->newConnectionButton, &QPushButton::clicked, [this]() { - ConnectionDialog dialog; + ConnectionDialog dialog("", "", 8080, this); auto r = dialog.exec(); if (r != QDialog::Accepted) { @@ -150,7 +149,7 @@ void ConnectionsWidget::sortConnections() void ConnectionsWidget::editConnection(Connection conn, size_t row) { - ConnectionDialog dialog(conn.name(), conn.hostName(), conn.port()); + ConnectionDialog dialog(conn.name(), conn.hostName(), conn.port(), this); auto r = dialog.exec(); if (r != QDialog::Accepted) { @@ -173,7 +172,7 @@ void ConnectionsWidget::editConnection(Connection conn, size_t row) // First delete the current connection, to prevent always adding an extra one // when editing this->m_connections.removeAt(row); - auto item = this->m_ui->connectionsWidget->takeItem(row); + auto item = this->m_ui->connectionsWidget->takeItem(static_cast(row)); delete item; bool replaced = false; diff --git a/tomviz/acquisition/CustomFormatWidget.cxx b/tomviz/acquisition/CustomFormatWidget.cxx deleted file mode 100644 index 04869d2d8..000000000 --- a/tomviz/acquisition/CustomFormatWidget.cxx +++ /dev/null @@ -1,98 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "CustomFormatWidget.h" - -#include "ui_CustomFormatWidget.h" - -namespace tomviz { - -CustomFormatWidget::CustomFormatWidget(QWidget* parent) - : QWidget(parent), m_ui(new Ui::CustomFormatWidget) -{ - m_ui->setupUi(this); - m_ui->prefixEdit->setText(m_prefix); - m_ui->suffixEdit->setText(m_suffix); - m_ui->extensionEdit->setText(m_ext); - m_ui->negativeEdit->setText(m_negChar); - m_ui->positiveEdit->setText(m_posChar); - - connect(m_ui->prefixEdit, &QLineEdit::textEdited, this, - &CustomFormatWidget::onPrefixChanged); - connect(m_ui->suffixEdit, &QLineEdit::textEdited, this, - &CustomFormatWidget::onSuffixChanged); - connect(m_ui->extensionEdit, &QLineEdit::textEdited, this, - &CustomFormatWidget::onExtensionChanged); - connect(m_ui->negativeEdit, &QLineEdit::textEdited, this, - &CustomFormatWidget::onNegChanged); - connect(m_ui->positiveEdit, &QLineEdit::textEdited, this, - &CustomFormatWidget::onPosChanged); -} - -CustomFormatWidget::~CustomFormatWidget() = default; - -void CustomFormatWidget::onPrefixChanged(QString val) -{ - m_prefix = val; - emit fieldsChanged(); -} - -void CustomFormatWidget::onNegChanged(QString val) -{ - m_negChar = val; - emit fieldsChanged(); -} - -void CustomFormatWidget::onPosChanged(QString val) -{ - m_posChar = val; - emit fieldsChanged(); -} - -void CustomFormatWidget::onSuffixChanged(QString val) -{ - m_suffix = val; - emit fieldsChanged(); -} - -void CustomFormatWidget::onExtensionChanged(QString val) -{ - m_ext = val; - emit fieldsChanged(); -} - -QStringList CustomFormatWidget::getFields() const -{ - QStringList fields; - fields << m_prefix << m_negChar << m_posChar << m_suffix << m_ext; - return fields; -} - -void CustomFormatWidget::setFields(const QStringList& fields) -{ - if (fields.size() != 5) { - return; - } - m_prefix = fields[0]; - m_negChar = fields[1]; - m_posChar = fields[2]; - m_suffix = fields[3]; - m_ext = fields[4]; - - m_ui->prefixEdit->setText(m_prefix); - m_ui->suffixEdit->setText(m_suffix); - m_ui->extensionEdit->setText(m_ext); - m_ui->negativeEdit->setText(m_negChar); - m_ui->positiveEdit->setText(m_posChar); -} - -void CustomFormatWidget::setAllowEdit(bool allow) -{ - m_ui->prefixEdit->setEnabled(allow); - m_ui->suffixEdit->setEnabled(allow); - m_ui->extensionEdit->setEnabled(allow); - m_ui->negativeEdit->setEnabled(allow); - m_ui->positiveEdit->setEnabled(allow); -} - -} // namespace tomviz diff --git a/tomviz/acquisition/CustomFormatWidget.h b/tomviz/acquisition/CustomFormatWidget.h deleted file mode 100644 index 7805f6708..000000000 --- a/tomviz/acquisition/CustomFormatWidget.h +++ /dev/null @@ -1,48 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizCustomFormatWidget_h -#define tomvizCustomFormatWidget_h - -#include - -#include - -namespace Ui { -class CustomFormatWidget; -} - -namespace tomviz { - -class CustomFormatWidget : public QWidget -{ - Q_OBJECT - -public: - CustomFormatWidget(QWidget* parent = nullptr); - ~CustomFormatWidget() override; - QStringList getFields() const; - void setFields(const QStringList&); - void setAllowEdit(bool); - -signals: - void fieldsChanged(); - -private slots: - void onPrefixChanged(QString prefix); - void onNegChanged(QString prefix); - void onPosChanged(QString prefix); - void onSuffixChanged(QString prefix); - void onExtensionChanged(QString prefix); - -private: - QScopedPointer m_ui; - QString m_prefix = "*"; - QString m_suffix = "*"; - QString m_ext = "*"; - QString m_negChar = "n"; - QString m_posChar = "p"; -}; -} - -#endif diff --git a/tomviz/acquisition/CustomFormatWidget.ui b/tomviz/acquisition/CustomFormatWidget.ui deleted file mode 100644 index 8eaedd027..000000000 --- a/tomviz/acquisition/CustomFormatWidget.ui +++ /dev/null @@ -1,187 +0,0 @@ - - - CustomFormatWidget - - - - - - - 0 - 0 - - - - - 70 - 0 - - - - File prefix (* catches all) - - - - - - - QLayout::SetMaximumSize - - - - - [ - - - - - - - - 0 - 0 - - - - - 20 - 0 - - - - - 50 - 16777215 - - - - Negative angle character identifier - - - - - - - | - - - - - - - - 0 - 0 - - - - - 20 - 0 - - - - - 50 - 16777215 - - - - Positive angle character identifier - - - - - - - ] - - - - - - - 123.4 - - - - - - - - - - 0 - 0 - - - - - 70 - 0 - - - - File suffix (* catches all) - - - - - - - . - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - - - - File extension (* catches all) - - - - - - - Prefix - - - - - - - Angle - - - - - - - Suffix - - - - - - - Ext - - - - - - - - diff --git a/tomviz/acquisition/MatchInfo.h b/tomviz/acquisition/MatchInfo.h deleted file mode 100644 index 5b588b9fd..000000000 --- a/tomviz/acquisition/MatchInfo.h +++ /dev/null @@ -1,28 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizMatchInfo_h -#define tomvizMatchInfo_h - -#include - -namespace tomviz { - -struct CapGroup -{ - CapGroup(const QString& name_, const QString& text) - : name(name_), capturedText(text) - { - } - QString name; - QString capturedText; -}; - -struct MatchInfo -{ - bool matched; - QList groups; -}; -} - -#endif diff --git a/tomviz/acquisition/PassiveAcquisitionWidget.cxx b/tomviz/acquisition/PassiveAcquisitionWidget.cxx deleted file mode 100644 index eb42b2608..000000000 --- a/tomviz/acquisition/PassiveAcquisitionWidget.cxx +++ /dev/null @@ -1,533 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PassiveAcquisitionWidget.h" - -#include "ui_PassiveAcquisitionWidget.h" - -#include "AcquisitionClient.h" -#include "ActiveObjects.h" -#include "ConnectionDialog.h" -#include "InterfaceBuilder.h" -#include "StartServerDialog.h" - -#include "DataSource.h" -#include "ModuleManager.h" -#include "Pipeline.h" -#include "PipelineManager.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -const char* PASSIVE_ADAPTER = - "tomviz.acquisition.vendors.passive.PassiveWatchSource"; - -PassiveAcquisitionWidget::PassiveAcquisitionWidget(QWidget* parent) - : QDialog(parent), m_ui(new Ui::PassiveAcquisitionWidget), - m_client(new AcquisitionClient("http://localhost:8080/acquisition", this)), - m_connectParamsWidget(new QWidget), m_watchTimer(new QTimer) -{ - m_ui->setupUi(this); - - // Default to home directory - QStringList locations = - QStandardPaths::standardLocations(QStandardPaths::HomeLocation); - m_ui->watchPathLineEdit->setText(locations[0]); - - setupTestTable(); - - readSettings(); - - connect(m_ui->watchPathLineEdit, &QLineEdit::textChanged, this, - &PassiveAcquisitionWidget::checkEnableWatchButton); - connect(m_ui->connectionsWidget, &ConnectionsWidget::selectionChanged, this, - &PassiveAcquisitionWidget::checkEnableWatchButton); - - connect(m_ui->formatTabWidget, &QTabWidget::currentChanged, this, - &PassiveAcquisitionWidget::formatTabChanged); - - connect(m_ui->testFileFormatEdit, &QLineEdit::textChanged, this, - &PassiveAcquisitionWidget::testFileNameChanged); - - connect(m_ui->basicTab, &BasicFormatWidget::regexChanged, this, - &PassiveAcquisitionWidget::onRegexChanged); - - connect(m_ui->basicTab, &BasicFormatWidget::fileFormatChanged, this, - &PassiveAcquisitionWidget::onBasicFormatChanged); - - connect(m_ui->advancedTab, &AdvancedFormatWidget::regexChanged, this, - &PassiveAcquisitionWidget::onRegexChanged); - - connect(m_ui->watchButton, &QPushButton::clicked, [this]() { - m_retryCount = 5; - connectToServer(); - }); - - connect(m_ui->stopWatchingButton, &QPushButton::clicked, this, - &PassiveAcquisitionWidget::stopWatching); - - checkEnableWatchButton(); - - // Connect signal to clean up any servers we start. - auto app = QCoreApplication::instance(); - connect(app, &QApplication::aboutToQuit, [this]() { - if (m_serverProcess != nullptr) { - // First disconnect the error signal as we are about pull the rug from - // under - // the process! - disconnect(m_serverProcess, &QProcess::errorOccurred, nullptr, nullptr); - m_serverProcess->terminate(); - } - }); - - // Initialize the fileName line edit with a default example - onBasicFormatChanged(); -} - -PassiveAcquisitionWidget::~PassiveAcquisitionWidget() = default; - -void PassiveAcquisitionWidget::closeEvent(QCloseEvent* event) -{ - writeSettings(); - event->accept(); -} - -void PassiveAcquisitionWidget::readSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - if (!settings->contains("acquisition/watchPath")) { - return; - } - settings->beginGroup("acquisition"); - setGeometry(settings->value("passive.geometry").toRect()); - - auto watchPath = settings->value("watchPath").toString(); - if (!watchPath.isEmpty()) { - m_ui->watchPathLineEdit->setText(watchPath); - } - - settings->endGroup(); -} - -void PassiveAcquisitionWidget::writeSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - settings->setValue("passive.geometry", geometry()); - settings->setValue("watchPath", m_ui->watchPathLineEdit->text()); - settings->endGroup(); -} - -void PassiveAcquisitionWidget::connectToServer(bool startServer) -{ - if (m_retryCount == 0) { - displayError("Retry count excceed trying to connect to server."); - return; - } - - m_client->setUrl(url()); - auto request = m_client->connect(connectParams()); - connect(request, &AcquisitionClientRequest::finished, [this]() { - // Now check that we are connected to server that has the right adapter - // loaded. - auto describeRequest = m_client->describe(); - connect(describeRequest, &AcquisitionClientRequest::error, this, - &PassiveAcquisitionWidget::onError); - connect( - describeRequest, &AcquisitionClientRequest::finished, - [this](const QJsonValue& result) { - if (!result.isObject()) { - onError("Invalid response to describe request:", result); - return; - } - - if (result.toObject()["name"] != PASSIVE_ADAPTER) { - onError( - "The server is not running the passive acquisition " - "adapter, please restart the server with the correct adapter.", - QJsonValue()); - return; - } - - // Now we can start watching. - watchSource(); - }); - }); - - connect(request, &AcquisitionClientRequest::error, - [startServer, this](const QString& errorMessage, - const QJsonValue& errorData) { - auto connection = m_ui->connectionsWidget->selectedConnection(); - // If we are getting a connection refused error and we are trying to - // connect - // to localhost, try to start the server. - if (startServer && - errorData.toInt() == QNetworkReply::ConnectionRefusedError && - connection->hostName() == "localhost") { - startLocalServer(); - } else { - onError(errorMessage, errorData); - } - }); -} - -void PassiveAcquisitionWidget::imageReady(QString mimeType, QByteArray result, - float angle, bool hasAngle) -{ - if (mimeType != "image/tiff") { - qDebug() << "image/tiff is the only supported mime type right now.\n" - << mimeType << "\n"; - return; - } - - QDir dir(QDir::homePath() + "/tomviz-data"); - if (!dir.exists()) { - dir.mkpath(dir.path()); - } - - QString path = "/tomviz_"; - - if (angle > 0.0) { - path.append('+'); - } - path.append(QString::number(angle, 'g', 2)); - path.append(".tiff"); - - QFile file(dir.path() + path); - if (!file.open(QIODevice::WriteOnly)) { - qWarning() << "Failed to open file for writing:" << file.fileName(); - return; - } - file.write(result); - qDebug() << "Data file:" << file.fileName(); - file.close(); - - vtkNew reader; - reader->SetFileName(file.fileName().toLatin1()); - reader->Update(); - m_imageData = reader->GetOutput(); - - // If we haven't added it, add our live data source to the pipeline. - if (!m_dataSource) { - DataSource::DataSourceType t = - hasAngle ? DataSource::TiltSeries : DataSource::Volume; - m_dataSource = new DataSource(m_imageData, t); - m_dataSource->setLabel("Live!"); - auto pipeline = new Pipeline(m_dataSource); - PipelineManager::instance().addPipeline(pipeline); - ModuleManager::instance().addDataSource(m_dataSource); - pipeline->addDefaultModules(m_dataSource); - } else { - m_dataSource->appendSlice(m_imageData); - } - - if (m_dataSource->type() == DataSource::TiltSeries) { - auto tiltAngles = m_dataSource->getTiltAngles(); - tiltAngles << angle; - m_dataSource->setTiltAngles(tiltAngles); - } -} - -void PassiveAcquisitionWidget::onError(const QString& errorMessage, - const QJsonValue& errorData) -{ - auto message = errorMessage; - if (!errorData.toString().isEmpty()) { - message = QString("%1\n%2").arg(message).arg(errorData.toString()); - } - - stopWatching(); - displayError(message); -} - -void PassiveAcquisitionWidget::displayError(const QString& errorMessage) -{ - QMessageBox::warning(this, "Acquisition Error", errorMessage, - QMessageBox::Ok); -} - -QString PassiveAcquisitionWidget::url() const -{ - auto connection = m_ui->connectionsWidget->selectedConnection(); - - return QString("http://%1:%2/acquisition") - .arg(connection->hostName()) - .arg(connection->port()); -} - -void PassiveAcquisitionWidget::watchSource() -{ - m_ui->watchButton->setEnabled(false); - m_ui->stopWatchingButton->setEnabled(true); - connect(m_watchTimer, &QTimer::timeout, this, - [this]() { - auto request = m_client->stem_acquire(); - connect(request, &AcquisitionClientImageRequest::finished, - [this](const QString mimeType, const QByteArray& result, - const QJsonObject& meta) { - if (!result.isNull()) { - float angle = 0; - bool hasAngle = false; - if (meta.contains("angle")) { - angle = meta["angle"].toString().toFloat(); - hasAngle = true; - } - imageReady(mimeType, result, angle, hasAngle); - } - }); - connect(request, &AcquisitionClientRequest::error, this, - &PassiveAcquisitionWidget::onError); - }, - Qt::UniqueConnection); - m_watchTimer->start(1000); -} - -QJsonObject PassiveAcquisitionWidget::connectParams() -{ - /* - Let's write some json in C++ !! - - Structure: - - connectParams = { - "path": "/directory/to/be/watched/", - "fileNameRegex": "^.*((n|p)?(\d+(\.\d+)?)).*(\.tif[f]?)$", - "fileNameRegexGroups": [ - "angle" - ], - "groupRegexSubstitutions": { - "angle": [ - {"n": "-"}, - {"p": "+"}, - ] - } - } - */ - - QJsonObject connectParams{ - { "path", m_ui->watchPathLineEdit->text() }, - }; - - QString regex; - QJsonArray groups; - QJsonObject substitutions; - - int tabIndex = m_ui->formatTabWidget->currentIndex(); - if (tabIndex == 0) { - regex = m_ui->basicTab->getPythonRegex(); - groups = m_ui->basicTab->getRegexGroups(); - substitutions = m_ui->basicTab->getRegexSubsitutions(); - } else { - regex = m_ui->advancedTab->getPythonRegex(); - groups = m_ui->advancedTab->getRegexGroups(); - substitutions = m_ui->advancedTab->getRegexSubsitutions(); - } - - connectParams["fileNameRegex"] = regex; - connectParams["fileNameRegexGroups"] = groups; - connectParams["groupRegexSubstitutions"] = substitutions; - - return connectParams; -} - -void PassiveAcquisitionWidget::startLocalServer() -{ - StartServerDialog dialog; - auto r = dialog.exec(); - if (r != QDialog::Accepted) { - return; - } - auto pythonExecutablePath = dialog.pythonExecutablePath(); - - QStringList arguments; - arguments << "-m" - << "tomviz" - << "-a" << PASSIVE_ADAPTER << "-r"; - - m_serverProcess = new QProcess(this); - m_serverProcess->setProgram(pythonExecutablePath); - m_serverProcess->setArguments(arguments); - connect(m_serverProcess, &QProcess::errorOccurred, - [this](QProcess::ProcessError error) { - Q_UNUSED(error); - auto message = QString("Error starting local acquisition: '%1'") - .arg(m_serverProcess->errorString()); - - QMessageBox::warning(this, "Server Start Error", message, - QMessageBox::Ok); - }); - - connect(m_serverProcess, &QProcess::started, [this]() { - // Now try to connect and watch. Note we are not asking for server to be - // started if the connection fails, this is to prevent us getting into a - // connect loop. - QTimer::singleShot(200, [this]() { - --m_retryCount; - connectToServer(false); - }); - }); - - connect( - m_serverProcess, static_cast(&QProcess::finished), - [](int exitCode, QProcess::ExitStatus exitStatus) { - qWarning() << QString( - "The acquisition server has exited with exit code: %1 %2") - .arg(exitCode, exitStatus); - }); - - connect(m_serverProcess, &QProcess::readyReadStandardError, - [this]() { qWarning() << m_serverProcess->readAllStandardError(); }); - - connect(m_serverProcess, &QProcess::readyReadStandardOutput, - [this]() { qInfo() << m_serverProcess->readAllStandardOutput(); }); - - qInfo() << QString("Starting server with following command: %1 %2") - .arg(m_serverProcess->program()) - .arg(m_serverProcess->arguments().join(" ")); - QStringList locations = - QStandardPaths::standardLocations(QStandardPaths::HomeLocation); - qInfo() - << QString( - "Server logs are written to the following path: %1%2.tomviz%2logs%2") - .arg(locations[0]) - .arg(QDir::separator()); - - m_serverProcess->start(); -} - -void PassiveAcquisitionWidget::checkEnableWatchButton() -{ - auto path = m_ui->watchPathLineEdit->text(); - m_ui->watchButton->setEnabled(!path.isEmpty() && - m_ui->connectionsWidget->selectedConnection() != - nullptr); -} - -void PassiveAcquisitionWidget::stopWatching() -{ - m_watchTimer->stop(); - m_ui->stopWatchingButton->setEnabled(false); - m_ui->watchButton->setEnabled(true); -} - -void PassiveAcquisitionWidget::formatTabChanged(int tab) -{ - if (tab == 0) { - onBasicFormatChanged(); - } - validateTestFileName(); -} - -void PassiveAcquisitionWidget::onRegexChanged(QString) -{ - validateTestFileName(); -} - -void PassiveAcquisitionWidget::onBasicFormatChanged() -{ - if (m_ui->basicTab->isDefaultFilename(m_testFileName)) { - m_testFileName = m_ui->basicTab->getDefaultFilename(); - m_ui->testFileFormatEdit->setText(m_testFileName); - } -} - -void PassiveAcquisitionWidget::validateTestFileName() -{ - int tabIndex = m_ui->formatTabWidget->currentIndex(); - MatchInfo result; - - if (tabIndex == 0) { - result = m_ui->basicTab->matchFileName(m_testFileName); - } else { - result = m_ui->advancedTab->matchFileName(m_testFileName); - } - if (result.matched) { - m_ui->testFileFormatEdit->setStyleSheet("background-color : #C8E6C9;"); - } else { - m_ui->testFileFormatEdit->setStyleSheet("background-color : #FFCCBC;"); - } - if (m_testFileName.isEmpty()) { - m_ui->testFileFormatEdit->setStyleSheet(""); - } - - QStringList tableHeaders; - m_ui->testTableWidget->setColumnCount(result.groups.size()); - for (int i = 0; i < result.groups.size(); ++i) { - tableHeaders << result.groups[i].name; - m_ui->testTableWidget->setItem( - 0, i, new QTableWidgetItem(result.groups[i].capturedText)); - } - m_ui->testTableWidget->setHorizontalHeaderLabels(tableHeaders); - resizeTestTable(); -} - -void PassiveAcquisitionWidget::testFileNameChanged(QString fileName) -{ - m_testFileName = fileName; - validateTestFileName(); -} - -void PassiveAcquisitionWidget::setupTestTable() -{ - m_ui->testTableWidget->setRowCount(1); - m_ui->testTableWidget->setColumnCount(1); - QStringList tableHeaders; - m_ui->testTableWidget->verticalHeader()->setVisible(false); - m_ui->testTableWidget->setEditTriggers(QAbstractItemView::NoEditTriggers); - m_ui->testTableWidget->setSelectionMode(QAbstractItemView::NoSelection); - - m_ui->testTableWidget->setVisible(true); - m_ui->testTablePlaceholder->setVisible(false); - resizeTestTable(); - validateTestFileName(); -} - -void PassiveAcquisitionWidget::resizeTestTable() -{ - m_ui->testTableWidget->resizeColumnsToContents(); - m_ui->testTableWidget->horizontalHeader()->setSectionResizeMode( - 0, QHeaderView::Stretch); - int horizontalHeaderHeight = - m_ui->testTableWidget->horizontalHeader()->height(); - int rowTotalHeight = m_ui->testTableWidget->verticalHeader()->sectionSize(0); - int vSize = horizontalHeaderHeight + rowTotalHeight; - - m_ui->testTableWidget->setMinimumHeight(vSize); - m_ui->testTableWidget->setMaximumHeight(vSize); - m_ui->testTablePlaceholder->setMinimumHeight(vSize); - m_ui->testTablePlaceholder->setMaximumHeight(vSize); -} - -} // namespace tomviz diff --git a/tomviz/acquisition/PassiveAcquisitionWidget.h b/tomviz/acquisition/PassiveAcquisitionWidget.h deleted file mode 100644 index d6eee0959..000000000 --- a/tomviz/acquisition/PassiveAcquisitionWidget.h +++ /dev/null @@ -1,107 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPassiveAcquisitionWidget_h -#define tomvizPassiveAcquisitionWidget_h - -#include - -#include "MatchInfo.h" - -#include -#include -#include -#include - -#include -#include - -class vtkImageData; -class vtkImageSlice; -class vtkImageSliceMapper; -class vtkInteractorStyleRubberBand2D; -class vtkInteractorStyleRubberBandZoom; -class vtkRenderer; -class vtkScalarsToColors; -class QProcess; - -namespace Ui { -class PassiveAcquisitionWidget; -} - -namespace tomviz { - -class AcquisitionClient; -class DataSource; - -class PassiveAcquisitionWidget : public QDialog -{ - Q_OBJECT - -public: - PassiveAcquisitionWidget(QWidget* parent = nullptr); - ~PassiveAcquisitionWidget() override; - -protected: - void closeEvent(QCloseEvent* event) override; - - void readSettings(); - void writeSettings(); - -private slots: - void connectToServer(bool startServer = true); - - void imageReady(QString mimeType, QByteArray result, float angle = 0, - bool hasAngle = false); - - void onError(const QString& errorMessage, const QJsonValue& errorData); - void watchSource(); - - void formatTabChanged(int index); - void testFileNameChanged(QString); - void onBasicFormatChanged(); - - void onRegexChanged(QString); - -signals: - void connectParameterDescription(QJsonValue params); - -private: - QScopedPointer m_ui; - QScopedPointer m_client; - - QString m_testFileName; - - vtkNew m_renderer; - vtkNew m_defaultInteractorStyle; - vtkSmartPointer m_imageData; - vtkNew m_imageSlice; - vtkNew m_imageSliceMapper; - vtkSmartPointer m_lut; - - DataSource* m_dataSource = nullptr; - - QString m_units = "unknown"; - double m_calX = 0.0; - double m_calY = 0.0; - QPointer m_connectParamsWidget; - QPointer m_watchTimer; - int m_retryCount = 5; - QProcess* m_serverProcess = nullptr; - - QString url() const; - void introspectSource(); - QJsonObject connectParams(); - QVariantMap settings(); - void checkEnableWatchButton(); - void startLocalServer(); - void displayError(const QString& errorMessage); - void stopWatching(); - void validateTestFileName(); - - void setupTestTable(); - void resizeTestTable(); -}; -} // namespace tomviz - -#endif // tomvizPassiveAcquisitionWidget_h diff --git a/tomviz/acquisition/PassiveAcquisitionWidget.ui b/tomviz/acquisition/PassiveAcquisitionWidget.ui deleted file mode 100644 index 63014c12a..000000000 --- a/tomviz/acquisition/PassiveAcquisitionWidget.ui +++ /dev/null @@ -1,173 +0,0 @@ - - - PassiveAcquisitionWidget - - - Passive Data Acquisition - - - - - - Server Connection (required) - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - - - - 0 - 0 - - - - - - - - Qt::Vertical - - - - 0 - 30 - - - - - - - - Watch Path (required) - - - - - - - - - - - Basic - - - - - Advanced - - - - - - - - Qt::Vertical - - - - 0 - 30 - - - - - - - - Sample filename - - - - - - - Paste a sample filename - - - - - - - - - - - - - Qt::Vertical - - - - 0 - 30 - - - - - - - - - - Qt::Horizontal - - - - 0 - 0 - - - - - - - - false - - - Stop Watching - - - - - - - false - - - Watch - - - - - - - - - - tomviz::ConnectionsWidget - QWidget -
ConnectionsWidget.h
- 1 -
- - tomviz::BasicFormatWidget - QWidget -
BasicFormatWidget.h
- 1 -
- - tomviz::AdvancedFormatWidget - QWidget -
AdvancedFormatWidget.h
- 1 -
-
- - -
diff --git a/tomviz/acquisition/RegexGroupDialog.cxx b/tomviz/acquisition/RegexGroupDialog.cxx deleted file mode 100644 index b20c055e5..000000000 --- a/tomviz/acquisition/RegexGroupDialog.cxx +++ /dev/null @@ -1,23 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "RegexGroupDialog.h" - -#include "ui_RegexGroupDialog.h" - -namespace tomviz { - -RegexGroupDialog::RegexGroupDialog(const QString name, QWidget* parent) - : QDialog(parent), m_ui(new Ui::RegexGroupDialog) -{ - m_ui->setupUi(this); - m_ui->nameLineEdit->setText(name); -} - -RegexGroupDialog::~RegexGroupDialog() = default; - -QString RegexGroupDialog::name() -{ - return m_ui->nameLineEdit->text(); -} -} // namespace tomviz diff --git a/tomviz/acquisition/RegexGroupDialog.h b/tomviz/acquisition/RegexGroupDialog.h deleted file mode 100644 index c7391f86b..000000000 --- a/tomviz/acquisition/RegexGroupDialog.h +++ /dev/null @@ -1,32 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizRegexGroupDialog_h -#define tomvizRegexGroupDialog_h - -#include - -#include - -namespace Ui { -class RegexGroupDialog; -} - -namespace tomviz { - -class RegexGroupDialog : public QDialog -{ - Q_OBJECT - -public: - explicit RegexGroupDialog(const QString name = "", QWidget* parent = nullptr); - ~RegexGroupDialog() override; - - QString name(); - -private: - QScopedPointer m_ui; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/acquisition/RegexGroupDialog.ui b/tomviz/acquisition/RegexGroupDialog.ui deleted file mode 100644 index 832e4f4b3..000000000 --- a/tomviz/acquisition/RegexGroupDialog.ui +++ /dev/null @@ -1,93 +0,0 @@ - - - RegexGroupDialog - - - - 0 - 0 - 384 - 134 - - - - New Regex Group Name - - - - - 30 - 90 - 341 - 32 - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - 9 - 9 - 361 - 71 - - - - - QFormLayout::AllNonFixedFieldsGrow - - - - - Group Name - - - - - - - - - - - - - buttonBox - accepted() - RegexGroupDialog - accept() - - - 200 - 255 - - - 191 - 147 - - - - - buttonBox - rejected() - RegexGroupDialog - reject() - - - 200 - 255 - - - 191 - 147 - - - - - diff --git a/tomviz/acquisition/RegexGroupSubstitution.cxx b/tomviz/acquisition/RegexGroupSubstitution.cxx deleted file mode 100644 index d333116e0..000000000 --- a/tomviz/acquisition/RegexGroupSubstitution.cxx +++ /dev/null @@ -1,46 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "RegexGroupSubstitution.h" - -#include - -namespace tomviz { - -RegexGroupSubstitution::RegexGroupSubstitution() = default; - -RegexGroupSubstitution::RegexGroupSubstitution(const QString& groupName, - const QString& regex, - const QString& substitution) - : m_groupName(groupName), m_regex(regex), m_substitution(substitution) -{} - -RegexGroupSubstitution::~RegexGroupSubstitution() = default; - -void RegexGroupSubstitution::registerType() -{ - - static bool registered = false; - if (!registered) { - registered = true; - } -} - -QDataStream& operator<<(QDataStream& out, const RegexGroupSubstitution& conn) -{ - out << conn.groupName() << conn.regex() << conn.substitution(); - - return out; -} - -QDataStream& operator>>(QDataStream& in, RegexGroupSubstitution& conn) -{ - QString groupName, regex, substitution; - in >> groupName >> regex >> substitution; - conn.setGroupName(groupName); - conn.setRegex(regex); - conn.setSubstitution(substitution); - - return in; -} -} // namespace tomviz diff --git a/tomviz/acquisition/RegexGroupSubstitution.h b/tomviz/acquisition/RegexGroupSubstitution.h deleted file mode 100644 index c183e1395..000000000 --- a/tomviz/acquisition/RegexGroupSubstitution.h +++ /dev/null @@ -1,41 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizRegexGroupSubstitution_h -#define tomvizRegexGroupSubstitution_h - -#include -#include - -namespace tomviz { - -class RegexGroupSubstitution -{ -public: - RegexGroupSubstitution(); - RegexGroupSubstitution(const QString& groupName, const QString& regex, - const QString& substitution); - ~RegexGroupSubstitution(); - - QString groupName() const { return m_groupName; } - void setGroupName(const QString& groupName) { m_groupName = groupName; } - QString regex() const { return m_regex; } - void setRegex(const QString& regex) { m_regex = regex; } - QString substitution() const { return m_substitution; } - void setSubstitution(const QString& substitution) - { - m_substitution = substitution; - } - - static void registerType(); - -private: - QString m_groupName; - QString m_regex; - QString m_substitution; -}; -} // namespace tomviz - -Q_DECLARE_METATYPE(tomviz::RegexGroupSubstitution) - -#endif diff --git a/tomviz/acquisition/RegexGroupSubstitutionDialog.cxx b/tomviz/acquisition/RegexGroupSubstitutionDialog.cxx deleted file mode 100644 index 8e10794b2..000000000 --- a/tomviz/acquisition/RegexGroupSubstitutionDialog.cxx +++ /dev/null @@ -1,64 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "RegexGroupSubstitutionDialog.h" - -#include "ui_RegexGroupSubstitutionDialog.h" - -#include -#include - -namespace tomviz { - -RegexGroupSubstitutionDialog::RegexGroupSubstitutionDialog( - const QString groupName, const QString regex, const QString substitution, - QWidget* parent) - : QDialog(parent), m_ui(new Ui::RegexGroupSubstitutionDialog) -{ - m_ui->setupUi(this); - - m_ui->groupNameLineEdit->setText(groupName); - m_ui->regexLineEdit->setText(regex); - m_ui->substitutionLineEdit->setText(substitution); - - auto palette = m_regexErrorLabel.palette(); - palette.setColor(m_regexErrorLabel.foregroundRole(), Qt::red); - m_regexErrorLabel.setPalette(palette); - - connect(m_ui->regexLineEdit, &QLineEdit::textChanged, [this]() { - m_ui->formLayout->removeWidget(&m_regexErrorLabel); - m_regexErrorLabel.setText(""); - }); -} - -RegexGroupSubstitutionDialog::~RegexGroupSubstitutionDialog() = default; - -QString RegexGroupSubstitutionDialog::groupName() -{ - return m_ui->groupNameLineEdit->text(); -} - -QString RegexGroupSubstitutionDialog::regex() -{ - return m_ui->regexLineEdit->text(); -} - -QString RegexGroupSubstitutionDialog::substitution() -{ - return m_ui->substitutionLineEdit->text(); -} - -void RegexGroupSubstitutionDialog::done(int r) -{ - if (QDialog::Accepted == r) { - QRegExp regExp(m_ui->regexLineEdit->text()); - if (!regExp.isValid()) { - m_regexErrorLabel.setText(regExp.errorString()); - m_ui->formLayout->insertRow(2, "", &m_regexErrorLabel); - return; - } - } - - QDialog::done(r); -} -} // namespace tomviz diff --git a/tomviz/acquisition/RegexGroupSubstitutionDialog.h b/tomviz/acquisition/RegexGroupSubstitutionDialog.h deleted file mode 100644 index 5ccf270bd..000000000 --- a/tomviz/acquisition/RegexGroupSubstitutionDialog.h +++ /dev/null @@ -1,42 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizRegexGroupSubstitutionDialog_h -#define tomvizRegexGroupSubstitutionDialog_h - -#include - -#include -#include - -namespace Ui { -class RegexGroupSubstitutionDialog; -} - -namespace tomviz { - -class RegexGroupSubstitutionDialog : public QDialog -{ - Q_OBJECT - -public: - explicit RegexGroupSubstitutionDialog(const QString groupName = "", - const QString regex = "", - const QString substitution = "", - QWidget* parent = nullptr); - ~RegexGroupSubstitutionDialog() override; - - QString groupName(); - QString regex(); - QString substitution(); - -public slots: - void done(int r) override; - -private: - QScopedPointer m_ui; - QLabel m_regexErrorLabel; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/acquisition/RegexGroupSubstitutionDialog.ui b/tomviz/acquisition/RegexGroupSubstitutionDialog.ui deleted file mode 100644 index 3e1e9e083..000000000 --- a/tomviz/acquisition/RegexGroupSubstitutionDialog.ui +++ /dev/null @@ -1,113 +0,0 @@ - - - RegexGroupSubstitutionDialog - - - - 0 - 0 - 384 - 201 - - - - New Substitution - - - - - 30 - 160 - 341 - 32 - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - 9 - 9 - 361 - 111 - - - - - QFormLayout::AllNonFixedFieldsGrow - - - - - Group Name - - - - - - - - - - Regex - - - - - - - - - - Substitution: - - - - - - - - - - - - - buttonBox - accepted() - RegexGroupSubstitutionDialog - accept() - - - 200 - 255 - - - 191 - 147 - - - - - buttonBox - rejected() - RegexGroupSubstitutionDialog - reject() - - - 200 - 255 - - - 191 - 147 - - - - - diff --git a/tomviz/acquisition/RegexGroupsSubstitutionsWidget.cxx b/tomviz/acquisition/RegexGroupsSubstitutionsWidget.cxx deleted file mode 100644 index 21cbfd7ed..000000000 --- a/tomviz/acquisition/RegexGroupsSubstitutionsWidget.cxx +++ /dev/null @@ -1,188 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "RegexGroupsSubstitutionsWidget.h" -#include "RegexGroupSubstitutionDialog.h" -#include "ui_RegexGroupsSubstitutionsWidget.h" - -#include "ActiveObjects.h" -#include "MainWindow.h" -#include "ModuleManager.h" - -#include -#include - -#include -#include - -#include - -namespace tomviz { - -RegexGroupsSubstitutionsWidget::RegexGroupsSubstitutionsWidget(QWidget* parent) - : QWidget(parent), m_ui(new Ui::RegexGroupsSubstitutionsWidget) -{ - m_ui->setupUi(this); - autoResizeTable(); - - readSettings(); - - foreach (const RegexGroupSubstitution& substitution, m_substitutions) { - addRegexGroupSubstitution(substitution); - } - - // New - connect(m_ui->newSubstitutionButton, &QPushButton::clicked, [this]() { - RegexGroupSubstitutionDialog dialog; - auto r = dialog.exec(); - if (r != QDialog::Accepted) { - return; - } - - RegexGroupSubstitution newSubstitution(dialog.groupName(), dialog.regex(), - dialog.substitution()); - - m_substitutions.append(newSubstitution); - sortRegexGroupSubstitutions(); - addRegexGroupSubstitution(newSubstitution); - - writeSettings(); - }); - - // Edit - connect(m_ui->regexGroupsSubstitutionsWidget, - &QTableWidget::itemDoubleClicked, [this](QTableWidgetItem* item) { - auto row = m_ui->regexGroupsSubstitutionsWidget->row(item); - editRegexGroupSubstitution(row); - writeSettings(); - }); - - // Delete - connect(m_ui->regexGroupsSubstitutionsWidget, - &QWidget::customContextMenuRequested, [this](const QPoint& pos) { - auto globalPos = - this->m_ui->regexGroupsSubstitutionsWidget->mapToGlobal(pos); - QMenu contextMenu; - contextMenu.addAction("Delete", this, [this, &pos]() { - auto item = - this->m_ui->regexGroupsSubstitutionsWidget->itemAt(pos); - auto row = this->m_ui->regexGroupsSubstitutionsWidget->row(item); - this->m_ui->regexGroupsSubstitutionsWidget->removeRow(row); - this->m_substitutions.removeAt(row); - this->writeSettings(); - autoResizeTable(); - }); - - // Show context menu at handling position - contextMenu.exec(globalPos); - }); -} - -RegexGroupsSubstitutionsWidget::~RegexGroupsSubstitutionsWidget() = default; - -void RegexGroupsSubstitutionsWidget::readSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - auto substitutions = settings->value("regexGroupsSubstitutions").toList(); - // If setting doesn't exist use the defaults - if (!settings->contains("regexGroupsSubstitutions")) { - QVariant pos; - pos.setValue(RegexGroupSubstitution("angle", "n", "-")); - substitutions.append(pos); - QVariant neg; - neg.setValue(RegexGroupSubstitution("angle", "p", "+")); - substitutions.append(neg); - } - foreach (const QVariant conn, substitutions) { - m_substitutions.append(conn.value()); - } - settings->endGroup(); -} - -void RegexGroupsSubstitutionsWidget::writeSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - - QVariantList substitutions; - foreach (const RegexGroupSubstitution& substitution, m_substitutions) { - QVariant variant; - variant.setValue(substitution); - substitutions.append(variant); - } - settings->setValue("regexGroupsSubstitutions", substitutions); - settings->endGroup(); -} - -void RegexGroupsSubstitutionsWidget::sortRegexGroupSubstitutions() -{ - std::sort(m_substitutions.begin(), m_substitutions.end(), - [](RegexGroupSubstitution a, RegexGroupSubstitution b) { - return a.groupName() < b.groupName(); - }); -} - -void RegexGroupsSubstitutionsWidget::editRegexGroupSubstitution(int row) -{ - RegexGroupSubstitution substitution = m_substitutions[row]; - RegexGroupSubstitutionDialog dialog(substitution.groupName(), - substitution.regex(), - substitution.substitution()); - dialog.exec(); - RegexGroupSubstitution newSubstitution(dialog.groupName(), dialog.regex(), - dialog.substitution()); - - m_substitutions[row] = newSubstitution; - setRegexGroupSubstitution(row, newSubstitution); -} - -void RegexGroupsSubstitutionsWidget::addRegexGroupSubstitution( - RegexGroupSubstitution substitution) -{ - auto row = m_ui->regexGroupsSubstitutionsWidget->rowCount(); - m_ui->regexGroupsSubstitutionsWidget->insertRow(row); - setRegexGroupSubstitution(row, substitution); - autoResizeTable(); -} - -void RegexGroupsSubstitutionsWidget::setRegexGroupSubstitution( - int row, RegexGroupSubstitution substitution) -{ - QTableWidgetItem* groupNameItem = - new QTableWidgetItem(substitution.groupName()); - QTableWidgetItem* regexItem = new QTableWidgetItem(substitution.regex()); - QTableWidgetItem* substitutionItem = - new QTableWidgetItem(substitution.substitution()); - m_ui->regexGroupsSubstitutionsWidget->setItem(row, 0, groupNameItem); - m_ui->regexGroupsSubstitutionsWidget->setItem(row, 1, regexItem); - m_ui->regexGroupsSubstitutionsWidget->setItem(row, 2, substitutionItem); -} - -QList RegexGroupsSubstitutionsWidget::substitutions() -{ - return m_substitutions; -} - -void RegexGroupsSubstitutionsWidget::autoResizeTable() -{ - // Auto resize the size when adding/deleting entries. - // Keep the size between is between 0 and 2 rows. - m_ui->regexGroupsSubstitutionsWidget->resizeColumnsToContents(); - m_ui->regexGroupsSubstitutionsWidget->horizontalHeader() - ->setSectionResizeMode(0, QHeaderView::Stretch); - int vSize = - m_ui->regexGroupsSubstitutionsWidget->horizontalHeader()->height(); - for (int i = 0; i < m_substitutions.size(); ++i) { - vSize += - m_ui->regexGroupsSubstitutionsWidget->verticalHeader()->sectionSize(i); - if (i >= 1) { - break; - } - } - vSize += 2; - m_ui->regexGroupsSubstitutionsWidget->setMinimumHeight(vSize); - m_ui->regexGroupsSubstitutionsWidget->setMaximumHeight(vSize); -} - -} // namespace tomviz diff --git a/tomviz/acquisition/RegexGroupsSubstitutionsWidget.h b/tomviz/acquisition/RegexGroupsSubstitutionsWidget.h deleted file mode 100644 index acf210eaf..000000000 --- a/tomviz/acquisition/RegexGroupsSubstitutionsWidget.h +++ /dev/null @@ -1,46 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizRegexGroupsSubstitutionsWidget_h -#define tomvizRegexGroupsSubstitutionsWidget_h - -#include - -#include -#include -#include - -#include "RegexGroupSubstitution.h" - -namespace Ui { -class RegexGroupsSubstitutionsWidget; -} - -namespace tomviz { - -class RegexGroupsSubstitutionsWidget : public QWidget -{ - Q_OBJECT - -public: - RegexGroupsSubstitutionsWidget(QWidget* parent); - ~RegexGroupsSubstitutionsWidget() override; - - QList substitutions(); - -private: - QScopedPointer m_ui; - QList m_substitutions; - - void readSettings(); - void writeSettings(); - void setRegexGroupSubstitutions(const QVariantList& substitutions); - void sortRegexGroupSubstitutions(); - void editRegexGroupSubstitution(int row); - void addRegexGroupSubstitution(RegexGroupSubstitution substitution); - void setRegexGroupSubstitution(int row, RegexGroupSubstitution substitution); - void autoResizeTable(); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/acquisition/RegexGroupsSubstitutionsWidget.ui b/tomviz/acquisition/RegexGroupsSubstitutionsWidget.ui deleted file mode 100644 index b9fc33f79..000000000 --- a/tomviz/acquisition/RegexGroupsSubstitutionsWidget.ui +++ /dev/null @@ -1,134 +0,0 @@ - - - RegexGroupsSubstitutionsWidget - - - - 0 - 0 - 537 - 282 - - - - Qt::DefaultContextMenu - - - Form - - - - 6 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::CustomContextMenu - - - QAbstractItemView::NoEditTriggers - - - QAbstractItemView::SingleSelection - - - QAbstractItemView::SelectRows - - - true - - - 3 - - - true - - - false - - - - Group Name - - - - - Regex - - - - - Substitution - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - New Substitution - - - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - diff --git a/tomviz/acquisition/RegexGroupsWidget.cxx b/tomviz/acquisition/RegexGroupsWidget.cxx deleted file mode 100644 index 63247261d..000000000 --- a/tomviz/acquisition/RegexGroupsWidget.cxx +++ /dev/null @@ -1,105 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "RegexGroupsWidget.h" - -#include "ui_RegexGroupsWidget.h" - -#include "ActiveObjects.h" -#include "MainWindow.h" -#include "ModuleManager.h" -#include "RegexGroupDialog.h" - -#include -#include - -#include -#include - -#include - -namespace tomviz { - -RegexGroupsWidget::RegexGroupsWidget(QWidget* parent) - : QWidget(parent), m_ui(new Ui::RegexGroupsWidget) -{ - m_ui->setupUi(this); - - readSettings(); - - // New - connect(m_ui->newRegexGroupButton, &QPushButton::clicked, [this]() { - RegexGroupDialog dialog; - auto r = dialog.exec(); - if (r != QDialog::Accepted || dialog.name().isEmpty()) { - return; - } - - if (m_ui->regexGroupsWidget->findItems(dialog.name(), Qt::MatchExactly) - .isEmpty()) { - m_ui->regexGroupsWidget->addItem(dialog.name()); - } - - writeSettings(); - emit groupsChanged(); - }); - - // Delete - connect(m_ui->regexGroupsWidget, &QWidget::customContextMenuRequested, - [this](const QPoint& pos) { - auto globalPos = m_ui->regexGroupsWidget->mapToGlobal(pos); - QMenu contextMenu; - contextMenu.addAction("Delete", this, [this, &pos]() { - auto item = m_ui->regexGroupsWidget->itemAt(pos); - delete item; - writeSettings(); - emit groupsChanged(); - }); - - // Show context menu at handling position - contextMenu.exec(globalPos); - }); -} - -RegexGroupsWidget::~RegexGroupsWidget() = default; - -void RegexGroupsWidget::readSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - auto groups = settings->value("regexGroupNames").toStringList(); - // Add the default - if (!settings->contains("regexGroupNames")) { - groups.append("angle"); - } - foreach (const QString& group, groups) { - m_ui->regexGroupsWidget->addItem(group); - } - settings->endGroup(); -} - -void RegexGroupsWidget::writeSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - - QStringList groups; - for (int i = 0; i < m_ui->regexGroupsWidget->count(); i++) { - QListWidgetItem* group = m_ui->regexGroupsWidget->item(i); - groups.append(group->text()); - } - settings->setValue("regexGroupNames", groups); - settings->endGroup(); -} - -QStringList RegexGroupsWidget::regexGroups() -{ - QStringList groups; - for (int i = 0; i < m_ui->regexGroupsWidget->count(); i++) { - QListWidgetItem* group = m_ui->regexGroupsWidget->item(i); - groups.append(group->text()); - } - - return groups; -} -} // namespace tomviz diff --git a/tomviz/acquisition/RegexGroupsWidget.h b/tomviz/acquisition/RegexGroupsWidget.h deleted file mode 100644 index da2f2885d..000000000 --- a/tomviz/acquisition/RegexGroupsWidget.h +++ /dev/null @@ -1,39 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizRegexGroupsWidget_h -#define tomvizRegexGroupsWidget_h - -#include - -#include -#include - -namespace Ui { -class RegexGroupsWidget; -} - -namespace tomviz { - -class RegexGroupsWidget : public QWidget -{ - Q_OBJECT - -public: - RegexGroupsWidget(QWidget* parent); - ~RegexGroupsWidget() override; - - QStringList regexGroups(); - -signals: - void groupsChanged(); - -private: - QScopedPointer m_ui; - - void readSettings(); - void writeSettings(); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/acquisition/RegexGroupsWidget.ui b/tomviz/acquisition/RegexGroupsWidget.ui deleted file mode 100644 index fca0b3b93..000000000 --- a/tomviz/acquisition/RegexGroupsWidget.ui +++ /dev/null @@ -1,101 +0,0 @@ - - - RegexGroupsWidget - - - - 0 - 0 - 604 - 250 - - - - - 0 - 0 - - - - Form - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - Qt::CustomContextMenu - - - false - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - New Regex Group - - - - - - - - - - - Qt::Horizontal - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - diff --git a/tomviz/acquisition/StartServerDialog.cxx b/tomviz/acquisition/StartServerDialog.cxx deleted file mode 100644 index 63d8801d7..000000000 --- a/tomviz/acquisition/StartServerDialog.cxx +++ /dev/null @@ -1,75 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "StartServerDialog.h" -#include "ui_StartServerDialog.h" - -#include -#include - -#include -#include - -const char* PYTHON_PATH_DEFAULT = "Enter Python path ..."; - -namespace tomviz { - -StartServerDialog::StartServerDialog(QWidget* parent) - : QDialog(parent), m_ui(new Ui::StartServerDialog) -{ - m_ui->setupUi(this); - - connect(m_ui->pythonPathLineEdit, &QLineEdit::textChanged, [this]() { - auto currentText = m_ui->pythonPathLineEdit->text(); - auto valid = !currentText.isEmpty() && currentText != PYTHON_PATH_DEFAULT; - m_ui->startButton->setEnabled(valid); - if (valid) { - setPythonExecutablePath(currentText); - } - }); - - readSettings(); - - connect(m_ui->browseButton, &QPushButton::clicked, [this]() { - QFileInfo info(m_pythonExecutablePath); - auto pythonExecutablePath = QFileDialog::getOpenFileName( - this, "Select Python Executable", info.dir().path()); - - if (!pythonExecutablePath.isEmpty()) { - setPythonExecutablePath(pythonExecutablePath); - writeSettings(); - } - }); - - connect(m_ui->cancelButton, &QPushButton::clicked, this, &QDialog::reject); - connect(m_ui->startButton, &QPushButton::clicked, this, &QDialog::accept); -} - -StartServerDialog::~StartServerDialog() = default; - -void StartServerDialog::readSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - if (!settings->contains("pythonExecutablePath")) { - setPythonExecutablePath(PYTHON_PATH_DEFAULT); - } else { - setPythonExecutablePath(settings->value("pythonExecutablePath").toString()); - } - settings->endGroup(); -} - -void StartServerDialog::writeSettings() -{ - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("acquisition"); - settings->setValue("pythonExecutablePath", m_pythonExecutablePath); - settings->endGroup(); -} - -void StartServerDialog::setPythonExecutablePath(const QString& path) -{ - m_pythonExecutablePath = path; - m_ui->pythonPathLineEdit->setText(path); -} -} // namespace tomviz diff --git a/tomviz/acquisition/StartServerDialog.h b/tomviz/acquisition/StartServerDialog.h deleted file mode 100644 index 1f2bfb85b..000000000 --- a/tomviz/acquisition/StartServerDialog.h +++ /dev/null @@ -1,36 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizStartServerDialog_h -#define tomvizStartServerDialog_h - -#include - -#include - -namespace Ui { -class StartServerDialog; -} - -namespace tomviz { - -class StartServerDialog : public QDialog -{ - Q_OBJECT - -public: - explicit StartServerDialog(QWidget* parent = nullptr); - ~StartServerDialog() override; - QString pythonExecutablePath() { return m_pythonExecutablePath; } - -private: - QScopedPointer m_ui; - QString m_pythonExecutablePath; - - void readSettings(); - void writeSettings(); - void setPythonExecutablePath(const QString& path); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/acquisition/StartServerDialog.ui b/tomviz/acquisition/StartServerDialog.ui deleted file mode 100644 index 7004ce028..000000000 --- a/tomviz/acquisition/StartServerDialog.ui +++ /dev/null @@ -1,108 +0,0 @@ - - - StartServerDialog - - - - 0 - 0 - 384 - 149 - - - - - 0 - 0 - - - - Start Server? - - - - - - The acquisition server is not running locally. To start that server please provide the path to the python environment that has the acquisition server installed. - - - false - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - true - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - Cancel - - - - - - - false - - - Start - - - - - - - - - - - Enter Python Path ... - - - - - - - Browse - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - diff --git a/tomviz/animations/ContourAnimation.h b/tomviz/animations/ContourAnimation.h index 32153187f..b98e98371 100644 --- a/tomviz/animations/ContourAnimation.h +++ b/tomviz/animations/ContourAnimation.h @@ -6,7 +6,7 @@ #include "ModuleAnimation.h" -#include "ModuleContour.h" +#include "pipeline/sinks/ContourSink.h" namespace tomviz { @@ -18,22 +18,24 @@ class ContourAnimation : public ModuleAnimation double startValue = 0; double stopValue = 0; - ContourAnimation(ModuleContour* module, double start, double stop) - : ModuleAnimation(module), startValue(start), stopValue(stop) + ContourAnimation(pipeline::ContourSink* sink, double start, double stop) + : ModuleAnimation(sink), startValue(start), stopValue(stop) { } - ModuleContour* module() { return qobject_cast(baseModule); } + pipeline::ContourSink* sink() + { + return qobject_cast(baseNode.data()); + } void onTimeChanged() override { - if (!timeKeeper()) { + if (!timeKeeper() || !sink()) { return; } - // Simple interpolation double value = (stopValue - startValue) * progress() + startValue; - module()->setIsoValue(value); + sink()->setIsoValue(value); } }; diff --git a/tomviz/animations/ModuleAnimation.h b/tomviz/animations/ModuleAnimation.h index c850fe3cd..77964e419 100644 --- a/tomviz/animations/ModuleAnimation.h +++ b/tomviz/animations/ModuleAnimation.h @@ -5,12 +5,12 @@ #define tomvizModuleAnimation_h #include "ActiveObjects.h" -#include "Module.h" -#include "ModuleManager.h" +#include "pipeline/Node.h" #include #include +#include namespace tomviz { @@ -19,9 +19,12 @@ class ModuleAnimation : public QObject Q_OBJECT public: - Module* baseModule = nullptr; + QPointer baseNode; - ModuleAnimation(Module* module) : baseModule(module) { setupConnections(); } + ModuleAnimation(pipeline::Node* node) : baseNode(node) + { + setupConnections(); + } virtual void setupConnections() { @@ -30,17 +33,10 @@ class ModuleAnimation : public QObject &ModuleAnimation::onTimeChanged); } - connect(&moduleManager(), &ModuleManager::moduleRemoved, this, - &ModuleAnimation::onModuleRemoved); - } - - virtual void onModuleRemoved(Module* module) - { - if (module != baseModule) { - return; + if (baseNode) { + connect(baseNode.data(), &QObject::destroyed, this, + &QObject::deleteLater); } - - this->disconnect(); } virtual ActiveObjects& activeObjects() { return ActiveObjects::instance(); } @@ -66,7 +62,6 @@ class ModuleAnimation : public QObject auto timeSteps = timeKeeper()->getTimeSteps(); if (timeSteps.empty()) { - // It's just a 0 to 1 default. timeSteps.append(0); timeSteps.append(1); } @@ -78,8 +73,6 @@ class ModuleAnimation : public QObject virtual double timeStop() { return timeSteps().back(); } virtual double progress() { return time() / (timeStop() - timeStart()); } - virtual ModuleManager& moduleManager() { return ModuleManager::instance(); } - virtual void onTimeChanged() {} }; diff --git a/tomviz/animations/SliceAnimation.h b/tomviz/animations/SliceAnimation.h index 1a2e6dfc8..d98261c66 100644 --- a/tomviz/animations/SliceAnimation.h +++ b/tomviz/animations/SliceAnimation.h @@ -6,7 +6,7 @@ #include "ModuleAnimation.h" -#include "ModuleSlice.h" +#include "pipeline/sinks/SliceSink.h" namespace tomviz { @@ -18,22 +18,24 @@ class SliceAnimation : public ModuleAnimation double startValue = 0; double stopValue = 0; - SliceAnimation(ModuleSlice* module, double start, double stop) - : ModuleAnimation(module), startValue(start), stopValue(stop) + SliceAnimation(pipeline::SliceSink* sink, double start, double stop) + : ModuleAnimation(sink), startValue(start), stopValue(stop) { } - ModuleSlice* module() { return qobject_cast(baseModule); } + pipeline::SliceSink* sink() + { + return qobject_cast(baseNode.data()); + } void onTimeChanged() override { - if (!timeKeeper()) { + if (!timeKeeper() || !sink()) { return; } - // Simple interpolation double value = (stopValue - startValue) * progress() + startValue; - module()->onSliceChanged(value); + sink()->setSlice(static_cast(value)); } }; diff --git a/tomviz/core/CMakeLists.txt b/tomviz/core/CMakeLists.txt index ee126e1e6..677254845 100644 --- a/tomviz/core/CMakeLists.txt +++ b/tomviz/core/CMakeLists.txt @@ -1,6 +1,6 @@ include(GenerateExportHeader) include_directories(${CMAKE_CURRENT_BINARY_DIR}) -add_library(tomvizcore SHARED PythonFactory.cxx Variant.cxx) +add_library(tomvizcore SHARED Variant.cxx) target_compile_definitions(tomvizcore PRIVATE IS_TOMVIZ_CORE_BUILD) generate_export_header(tomvizcore) target_link_libraries(tomvizcore PRIVATE ${PYTHON_LIBRARIES}) diff --git a/tomviz/core/DataSourceBase.h b/tomviz/core/DataSourceBase.h deleted file mode 100644 index da679f704..000000000 --- a/tomviz/core/DataSourceBase.h +++ /dev/null @@ -1,40 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizDataSourceBase_h -#define tomvizDataSourceBase_h - -#include "Variant.h" - -class vtkImageData; - -namespace tomviz { - -using MetadataType = std::map; - -/** Pure virtual base class providing a proxy to the DataSource class. */ -class DataSourceBase -{ -public: - void setDarkData(vtkImageData* data) { m_dark = data; } - vtkImageData* darkData() const { return m_dark; } - - void setWhiteData(vtkImageData* data) { m_white = data; } - vtkImageData* whiteData() const { return m_white; } - - void setFileName(const std::string& name) { m_fileName = name; } - std::string fileName() const { return m_fileName; } - - void setMetadata(const MetadataType& meta) { m_metadata = meta; } - MetadataType metadata() const { return m_metadata; } - -private: - vtkImageData* m_dark = nullptr; - vtkImageData* m_white = nullptr; - std::string m_fileName; - MetadataType m_metadata; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/core/OperatorProxyBase.h b/tomviz/core/OperatorProxyBase.h deleted file mode 100644 index 3b394ca63..000000000 --- a/tomviz/core/OperatorProxyBase.h +++ /dev/null @@ -1,48 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorProxyBase_h -#define tomvizOperatorProxyBase_h - -#include - -class vtkImageData; - -namespace tomviz { - -/** Pure virtual base class providing a proxy to the operator class. */ -class OperatorProxyBase -{ -public: - OperatorProxyBase(void*) {} - virtual ~OperatorProxyBase() = default; - - virtual bool canceled() = 0; - - virtual bool completed() = 0; - - virtual void setTotalProgressSteps(int progress) = 0; - - virtual int totalProgressSteps() = 0; - - virtual void setProgressStep(int progress) = 0; - - virtual int progressStep() = 0; - - virtual void setProgressMessage(const std::string& message) = 0; - - virtual std::string progressMessage() = 0; - - virtual void setProgressData(vtkImageData* object) = 0; -}; - -class OperatorProxyBaseFactory -{ -public: - virtual ~OperatorProxyBaseFactory() = default; - virtual OperatorProxyBase* create(void* o) = 0; -}; - -} // namespace tomviz - -#endif \ No newline at end of file diff --git a/tomviz/core/PipelineProxyBase.h b/tomviz/core/PipelineProxyBase.h deleted file mode 100644 index afe079b03..000000000 --- a/tomviz/core/PipelineProxyBase.h +++ /dev/null @@ -1,70 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPipelineProxyBase_h -#define tomvizPipelineProxyBase_h - -#include -#include - -namespace tomviz { - -/** Pure virtual base class providing a proxy to the operator class. */ -class PipelineProxyBase -{ -public: - virtual ~PipelineProxyBase() = default; - virtual std::string serialize() = 0; - virtual void load(const std::string& state, - const std::string& stateRelDir) = 0; - virtual std::string modulesJson() = 0; - virtual std::string operatorsJson() = 0; - virtual std::string serializeOperator(const std::string& path, - const std::string& id) = 0; - virtual void updateOperator(const std::string& path, - const std::string& state) = 0; - virtual std::string serializeModule(const std::string& path, - const std::string& id) = 0; - virtual void updateModule(const std::string& path, - const std::string& state) = 0; - virtual std::string serializeDataSource(const std::string& path, - const std::string& id) = 0; - virtual void updateDataSource(const std::string& path, - const std::string& state) = 0; - virtual std::string addModule(const std::string& dataSourcePath, - const std::string& dataSourceId, - const std::string& moduleType) = 0; - virtual std::string addOperator(const std::string& dataSourcePath, - const std::string& dataSourceId, - const std::string& opState) = 0; - virtual std::string addDataSource(const std::string& dataSourceState) = 0; - virtual void removeOperator(const std::string& opPath, - const std::string& dataSourceId, - const std::string& opId = "") = 0; - virtual void removeModule(const std::string& modulePath, - const std::string& dataSourceId, - const std::string& moduleId = "") = 0; - virtual void removeDataSource(const std::string& dataSourcePath, - const std::string& dataSourceId = "") = 0; - virtual void modified(std::vector opPaths, - std::vector modulePaths) = 0; - virtual void syncToPython() = 0; - virtual void syncViewsToPython() = 0; - virtual void enableSyncToPython() = 0; - virtual void disableSyncToPython() = 0; - virtual void pausePipeline(const std::string& dataSourcePath) = 0; - virtual void resumePipeline(const std::string& dataSourcePath) = 0; - virtual void executePipeline(const std::string& dataSourcePath) = 0; - virtual bool pipelinePaused(const std::string& dataSourcePath) = 0; -}; - -class PipelineProxyBaseFactory -{ -public: - virtual ~PipelineProxyBaseFactory() = default; - virtual PipelineProxyBase* create() = 0; -}; - -} // namespace tomviz - -#endif \ No newline at end of file diff --git a/tomviz/core/PythonFactory.cxx b/tomviz/core/PythonFactory.cxx deleted file mode 100644 index 7638a27be..000000000 --- a/tomviz/core/PythonFactory.cxx +++ /dev/null @@ -1,36 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "PythonFactory.h" - -#include "OperatorProxyBase.h" -#include "PipelineProxyBase.h" - -namespace tomviz { - -PythonFactory::~PythonFactory() -{ - delete m_operatorFactory; -} - -PythonFactory& PythonFactory::instance() -{ - static PythonFactory theInstance; - return theInstance; -} - -OperatorProxyBase* PythonFactory::createOperatorProxy(void* o) -{ - if (m_operatorFactory) - return m_operatorFactory->create(o); - return nullptr; -} - -PipelineProxyBase* PythonFactory::createPipelineProxy() -{ - if (m_pipelineFactory) - return m_pipelineFactory->create(); - return nullptr; -} - -} // namespace tomviz \ No newline at end of file diff --git a/tomviz/core/PythonFactory.h b/tomviz/core/PythonFactory.h deleted file mode 100644 index 06ae451c5..000000000 --- a/tomviz/core/PythonFactory.h +++ /dev/null @@ -1,52 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizPythonFactory_h -#define tomvizPythonFactory_h - -#include "tomvizcore_export.h" - -namespace tomviz { - -class OperatorProxyBase; -class OperatorProxyBaseFactory; -class PipelineProxyBase; -class PipelineProxyBaseFactory; - -class TOMVIZCORE_EXPORT PythonFactory -{ -public: - /// Returns reference to the singleton instance. - static PythonFactory& instance(); - - /// Get an instance of the OperatorProxy class for this runtime. - OperatorProxyBase* createOperatorProxy(void* o); - - /// Set the proxy factory for the singleton. - void setOperatorProxyFactory(OperatorProxyBaseFactory* factory) - { - m_operatorFactory = factory; - } - - /// Get an instance of the PipelineProxy class for this runtime. - PipelineProxyBase* createPipelineProxy(); - - /// Set the proxy factory for the singleton. - void setPipelineProxyFactory(PipelineProxyBaseFactory* factory) - { - m_pipelineFactory = factory; - } - -private: - PythonFactory() = default; - ~PythonFactory(); - PythonFactory(const PythonFactory&) = delete; - PythonFactory& operator=(const PythonFactory&) = delete; - - OperatorProxyBaseFactory* m_operatorFactory = nullptr; - PipelineProxyBaseFactory* m_pipelineFactory = nullptr; -}; - -} // namespace tomviz - -#endif \ No newline at end of file diff --git a/tomviz/h5cpp/h5readwrite.cpp b/tomviz/h5cpp/h5readwrite.cpp index 715a50a21..a8c1daa1a 100644 --- a/tomviz/h5cpp/h5readwrite.cpp +++ b/tomviz/h5cpp/h5readwrite.cpp @@ -304,7 +304,8 @@ class H5ReadWrite::H5ReadWriteImpl stridesVector.data(), countsVector.data(), nullptr); // Finally, create the mem space - memSpace = H5Screate_simple(ndims, countsVector.data(), nullptr); + memSpace = + H5Screate_simple(static_cast(ndims), countsVector.data(), nullptr); memSpaceCloser.reset(memSpace); } @@ -696,7 +697,7 @@ int H5ReadWrite::dimensionCount(const string& path) return -1; } - return dims.size(); + return static_cast(dims.size()); } template @@ -954,7 +955,8 @@ template unsigned long long H5ReadWrite::attribute(const string&, const string&, bool*); template float H5ReadWrite::attribute(const string&, const string&, bool*); template double H5ReadWrite::attribute(const string&, const string&, bool*); -template string H5ReadWrite::attribute(const string&, const string&, bool*); +// attribute is explicitly specialized above, so no explicit +// instantiation is needed (and one here would have no effect). // readData(): single-dimensional template vector H5ReadWrite::readData(const string&); @@ -1012,10 +1014,8 @@ template bool H5ReadWrite::setAttribute(const string&, const string&, unsigned long long); template bool H5ReadWrite::setAttribute(const string&, const string&, float); template bool H5ReadWrite::setAttribute(const string&, const string&, double); -template bool H5ReadWrite::setAttribute(const string&, const string&, - const string&); -template bool H5ReadWrite::setAttribute(const string&, const string&, - const char*); +// setAttribute and setAttribute are explicitly +// specialized above, so explicit instantiations here would have no effect. // writeData template bool H5ReadWrite::writeData(const string&, const string&, diff --git a/tomviz/icons/breakpoint.png b/tomviz/icons/breakpoint.png deleted file mode 100644 index 6c7abaf5b..000000000 Binary files a/tomviz/icons/breakpoint.png and /dev/null differ diff --git a/tomviz/icons/breakpoint.svg b/tomviz/icons/breakpoint.svg new file mode 100644 index 000000000..499809635 --- /dev/null +++ b/tomviz/icons/breakpoint.svg @@ -0,0 +1,13 @@ + + + + diff --git a/tomviz/icons/breakpoint@2x.png b/tomviz/icons/breakpoint@2x.png deleted file mode 100644 index 0114dfadf..000000000 Binary files a/tomviz/icons/breakpoint@2x.png and /dev/null differ diff --git a/tomviz/icons/filter.svg b/tomviz/icons/filter.svg new file mode 100644 index 000000000..05cbe068a --- /dev/null +++ b/tomviz/icons/filter.svg @@ -0,0 +1,11 @@ + + + + diff --git a/tomviz/icons/filter_disabled.svg b/tomviz/icons/filter_disabled.svg new file mode 100644 index 000000000..157e10331 --- /dev/null +++ b/tomviz/icons/filter_disabled.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/tomviz/icons/icon_join.svg b/tomviz/icons/icon_join.svg new file mode 100644 index 000000000..1dbd0f499 --- /dev/null +++ b/tomviz/icons/icon_join.svg @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/tomviz/icons/icon_leave.svg b/tomviz/icons/icon_leave.svg new file mode 100644 index 000000000..5bb97fec0 --- /dev/null +++ b/tomviz/icons/icon_leave.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/tomviz/icons/play.png b/tomviz/icons/play.png deleted file mode 100644 index 5c801f27d..000000000 Binary files a/tomviz/icons/play.png and /dev/null differ diff --git a/tomviz/icons/play@2x.png b/tomviz/icons/play@2x.png deleted file mode 100644 index b8acb444f..000000000 Binary files a/tomviz/icons/play@2x.png and /dev/null differ diff --git a/tomviz/icons/port_data_disk.svg b/tomviz/icons/port_data_disk.svg new file mode 100644 index 000000000..7c6dba7b7 --- /dev/null +++ b/tomviz/icons/port_data_disk.svg @@ -0,0 +1,10 @@ + + + + + diff --git a/tomviz/icons/port_data_ram.svg b/tomviz/icons/port_data_ram.svg new file mode 100644 index 000000000..54a8677b4 --- /dev/null +++ b/tomviz/icons/port_data_ram.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/tomviz/icons/port_imagedata.svg b/tomviz/icons/port_imagedata.svg new file mode 100644 index 000000000..689b3fffd --- /dev/null +++ b/tomviz/icons/port_imagedata.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + diff --git a/tomviz/icons/port_labelmap.svg b/tomviz/icons/port_labelmap.svg new file mode 100644 index 000000000..308d9dad0 --- /dev/null +++ b/tomviz/icons/port_labelmap.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/tomviz/icons/port_molecule.svg b/tomviz/icons/port_molecule.svg new file mode 100644 index 000000000..d2dad1bde --- /dev/null +++ b/tomviz/icons/port_molecule.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + diff --git a/tomviz/icons/port_persistent_disk.svg b/tomviz/icons/port_persistent_disk.svg new file mode 100644 index 000000000..27d973f41 --- /dev/null +++ b/tomviz/icons/port_persistent_disk.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + diff --git a/tomviz/icons/port_persistent_pin.svg b/tomviz/icons/port_persistent_pin.svg new file mode 100644 index 000000000..e70305afb --- /dev/null +++ b/tomviz/icons/port_persistent_pin.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/tomviz/icons/port_persistent_ram.svg b/tomviz/icons/port_persistent_ram.svg new file mode 100644 index 000000000..09b77a2c6 --- /dev/null +++ b/tomviz/icons/port_persistent_ram.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tomviz/icons/port_table.svg b/tomviz/icons/port_table.svg new file mode 100644 index 000000000..5d2fb01dd --- /dev/null +++ b/tomviz/icons/port_table.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + diff --git a/tomviz/icons/port_tiltseries.svg b/tomviz/icons/port_tiltseries.svg new file mode 100644 index 000000000..67fb71ba5 --- /dev/null +++ b/tomviz/icons/port_tiltseries.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + diff --git a/tomviz/icons/port_transient.svg b/tomviz/icons/port_transient.svg new file mode 100644 index 000000000..c4b547112 --- /dev/null +++ b/tomviz/icons/port_transient.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + diff --git a/tomviz/icons/pqVcrStop.svg b/tomviz/icons/pqVcrStop.svg new file mode 100644 index 000000000..2bc694cb7 --- /dev/null +++ b/tomviz/icons/pqVcrStop.svg @@ -0,0 +1,22 @@ + + + + + + diff --git a/tomviz/loguru/loguru.cpp b/tomviz/loguru/loguru.cpp index 431a97159..d54912df4 100644 --- a/tomviz/loguru/loguru.cpp +++ b/tomviz/loguru/loguru.cpp @@ -54,6 +54,11 @@ #ifdef _WIN32 #include + #define WIN32_LEAN_AND_MEAN + #define NOMINMAX + #include + #include + #include #define localtime_r(a, b) localtime_s(b, a) // No localtime_r with MSVC, but arguments are swapped for localtime_s #else @@ -1900,10 +1905,279 @@ namespace loguru #ifdef _WIN32 namespace loguru { + + static bool create_directory_recursive(const wchar_t* path) + { + DWORD attrs = GetFileAttributesW(path); + if (attrs != INVALID_FILE_ATTRIBUTES) { + return (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0; + } + + wchar_t parent[MAX_PATH]; + wcscpy_s(parent, path); + wchar_t* last_sep = wcsrchr(parent, L'\\'); + if (!last_sep) last_sep = wcsrchr(parent, L'/'); + if (last_sep) { + *last_sep = L'\0'; + if (!create_directory_recursive(parent)) return false; + } + + return CreateDirectoryW(path, NULL) || GetLastError() == ERROR_ALREADY_EXISTS; + } + + static bool get_crash_dump_dir(wchar_t* out_path, size_t max_len) + { + wchar_t appdata[MAX_PATH]; + if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, appdata))) { + _snwprintf_s(out_path, max_len, _TRUNCATE, L"%s\\tomviz\\crashes", appdata); + return create_directory_recursive(out_path); + } + return false; + } + + static const char* exception_code_name(DWORD code) + { + switch (code) { + case EXCEPTION_ACCESS_VIOLATION: return "EXCEPTION_ACCESS_VIOLATION"; + case EXCEPTION_ARRAY_BOUNDS_EXCEEDED: return "EXCEPTION_ARRAY_BOUNDS_EXCEEDED"; + case EXCEPTION_BREAKPOINT: return "EXCEPTION_BREAKPOINT"; + case EXCEPTION_DATATYPE_MISALIGNMENT: return "EXCEPTION_DATATYPE_MISALIGNMENT"; + case EXCEPTION_FLT_DENORMAL_OPERAND: return "EXCEPTION_FLT_DENORMAL_OPERAND"; + case EXCEPTION_FLT_DIVIDE_BY_ZERO: return "EXCEPTION_FLT_DIVIDE_BY_ZERO"; + case EXCEPTION_FLT_INEXACT_RESULT: return "EXCEPTION_FLT_INEXACT_RESULT"; + case EXCEPTION_FLT_INVALID_OPERATION: return "EXCEPTION_FLT_INVALID_OPERATION"; + case EXCEPTION_FLT_OVERFLOW: return "EXCEPTION_FLT_OVERFLOW"; + case EXCEPTION_FLT_STACK_CHECK: return "EXCEPTION_FLT_STACK_CHECK"; + case EXCEPTION_FLT_UNDERFLOW: return "EXCEPTION_FLT_UNDERFLOW"; + case EXCEPTION_GUARD_PAGE: return "EXCEPTION_GUARD_PAGE"; + case EXCEPTION_ILLEGAL_INSTRUCTION: return "EXCEPTION_ILLEGAL_INSTRUCTION"; + case EXCEPTION_IN_PAGE_ERROR: return "EXCEPTION_IN_PAGE_ERROR"; + case EXCEPTION_INT_DIVIDE_BY_ZERO: return "EXCEPTION_INT_DIVIDE_BY_ZERO"; + case EXCEPTION_INT_OVERFLOW: return "EXCEPTION_INT_OVERFLOW"; + case EXCEPTION_INVALID_DISPOSITION: return "EXCEPTION_INVALID_DISPOSITION"; + case EXCEPTION_NONCONTINUABLE_EXCEPTION: return "EXCEPTION_NONCONTINUABLE_EXCEPTION"; + case EXCEPTION_PRIV_INSTRUCTION: return "EXCEPTION_PRIV_INSTRUCTION"; + case EXCEPTION_SINGLE_STEP: return "EXCEPTION_SINGLE_STEP"; + case EXCEPTION_STACK_OVERFLOW: return "EXCEPTION_STACK_OVERFLOW"; + default: return "UNKNOWN_EXCEPTION"; + } + } + + static LONG WINAPI windows_exception_handler(EXCEPTION_POINTERS* exception_info) + { + DWORD code = exception_info->ExceptionRecord->ExceptionCode; + + // Skip C++ exceptions (0xE06D7363 = "msc" in little-endian) and + // non-fatal exceptions like OUTPUT_DEBUG_STRING + if (code == 0xE06D7363 || (code & 0x80000000) == 0) { + return EXCEPTION_CONTINUE_SEARCH; + } + + wchar_t crash_dir[MAX_PATH]; + if (!get_crash_dump_dir(crash_dir, MAX_PATH)) { + return EXCEPTION_CONTINUE_SEARCH; + } + + SYSTEMTIME st; + GetLocalTime(&st); + + // Write minidump + wchar_t dump_path[MAX_PATH]; + _snwprintf_s(dump_path, MAX_PATH, _TRUNCATE, + L"%s\\tomviz_%04d%02d%02d_%02d%02d%02d.dmp", + crash_dir, st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond); + + HANDLE dump_file = CreateFileW(dump_path, GENERIC_WRITE, 0, NULL, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + + if (dump_file != INVALID_HANDLE_VALUE) { + MINIDUMP_EXCEPTION_INFORMATION mei; + mei.ThreadId = GetCurrentThreadId(); + mei.ExceptionPointers = exception_info; + mei.ClientPointers = FALSE; + + MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), + dump_file, + static_cast(MiniDumpWithDataSegs | MiniDumpWithHandleData), + &mei, NULL, NULL); + CloseHandle(dump_file); + } + + // Write human-readable crash log + wchar_t log_path[MAX_PATH]; + _snwprintf_s(log_path, MAX_PATH, _TRUNCATE, + L"%s\\tomviz_%04d%02d%02d_%02d%02d%02d.log", + crash_dir, st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond); + + HANDLE log_file = CreateFileW(log_path, GENERIC_WRITE, 0, NULL, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + + if (log_file != INVALID_HANDLE_VALUE) { + char buf[2048]; + int len = 0; + + len += sprintf_s(buf + len, sizeof(buf) - len, + "Tomviz Crash Report\r\n"); + len += sprintf_s(buf + len, sizeof(buf) - len, + "====================\r\n\r\n"); + len += sprintf_s(buf + len, sizeof(buf) - len, + "Time: %04d-%02d-%02d %02d:%02d:%02d\r\n", + st.wYear, st.wMonth, st.wDay, + st.wHour, st.wMinute, st.wSecond); + len += sprintf_s(buf + len, sizeof(buf) - len, + "Exception Code: 0x%08lX (%s)\r\n", + code, exception_code_name(code)); + len += sprintf_s(buf + len, sizeof(buf) - len, + "Exception Address: 0x%p\r\n", + exception_info->ExceptionRecord->ExceptionAddress); + + if (code == EXCEPTION_ACCESS_VIOLATION && + exception_info->ExceptionRecord->NumberParameters >= 2) { + const char* op = exception_info->ExceptionRecord->ExceptionInformation[0] == 0 + ? "reading" : "writing"; + len += sprintf_s(buf + len, sizeof(buf) - len, + "Access violation %s address: 0x%llX\r\n", + op, (unsigned long long)exception_info->ExceptionRecord->ExceptionInformation[1]); + } + + DWORD written; + WriteFile(log_file, buf, (DWORD)len, &written, NULL); + CloseHandle(log_file); + } + + // Walk the stack into a reusable buffer + char stack_buf[8192]; + int stack_len = 0; + int num_frames = 0; + { + HANDLE process = GetCurrentProcess(); + HANDLE thread = GetCurrentThread(); + SymInitialize(process, NULL, TRUE); + + CONTEXT ctx = *exception_info->ContextRecord; + STACKFRAME64 frame = {}; + frame.AddrPC.Offset = ctx.Rip; + frame.AddrPC.Mode = AddrModeFlat; + frame.AddrFrame.Offset = ctx.Rbp; + frame.AddrFrame.Mode = AddrModeFlat; + frame.AddrStack.Offset = ctx.Rsp; + frame.AddrStack.Mode = AddrModeFlat; + + char sym_buf[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)]; + SYMBOL_INFO* symbol = (SYMBOL_INFO*)sym_buf; + symbol->SizeOfStruct = sizeof(SYMBOL_INFO); + symbol->MaxNameLen = MAX_SYM_NAME; + + for (int i = 0; i < 64; ++i) { + if (!StackWalk64(IMAGE_FILE_MACHINE_AMD64, process, thread, + &frame, &ctx, NULL, + SymFunctionTableAccess64, SymGetModuleBase64, NULL)) { + break; + } + if (frame.AddrPC.Offset == 0) break; + + DWORD64 displacement = 0; + int remaining = (int)sizeof(stack_buf) - stack_len - 1; + if (remaining < 128) break; + + // Get module name + HMODULE hModule = NULL; + char mod_name[MAX_PATH] = "???"; + if (GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCSTR)(uintptr_t)frame.AddrPC.Offset, &hModule)) { + GetModuleFileNameA(hModule, mod_name, MAX_PATH); + char* slash = strrchr(mod_name, '\\'); + if (slash) memmove(mod_name, slash + 1, strlen(slash + 1) + 1); + } + + if (SymFromAddr(process, frame.AddrPC.Offset, &displacement, symbol)) { + IMAGEHLP_LINE64 file_line = {}; + file_line.SizeOfStruct = sizeof(IMAGEHLP_LINE64); + DWORD line_disp = 0; + if (SymGetLineFromAddr64(process, frame.AddrPC.Offset, &line_disp, &file_line)) { + stack_len += sprintf_s(stack_buf + stack_len, remaining, + " [%2d] %s!%s+0x%llX (%s:%lu)\r\n", + i, mod_name, symbol->Name, + (unsigned long long)displacement, + file_line.FileName, file_line.LineNumber); + } else { + stack_len += sprintf_s(stack_buf + stack_len, remaining, + " [%2d] %s!%s+0x%llX\r\n", + i, mod_name, symbol->Name, + (unsigned long long)displacement); + } + } else { + stack_len += sprintf_s(stack_buf + stack_len, remaining, + " [%2d] %s!0x%llX\r\n", + i, mod_name, + (unsigned long long)frame.AddrPC.Offset); + } + num_frames++; + } + + SymCleanup(process); + } + + // Append stack trace to the log file + HANDLE log_reopen = CreateFileW(log_path, FILE_APPEND_DATA, 0, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + if (log_reopen != INVALID_HANDLE_VALUE) { + DWORD written; + const char* hdr = "\r\nStack Trace:\r\n"; + WriteFile(log_reopen, hdr, (DWORD)strlen(hdr), &written, NULL); + WriteFile(log_reopen, stack_buf, (DWORD)stack_len, &written, NULL); + const char* footer = + "\r\nA minidump file has been saved alongside this log.\r\n" + "Please send both files to the tomviz developers for analysis.\r\n"; + WriteFile(log_reopen, footer, (DWORD)strlen(footer), &written, NULL); + CloseHandle(log_reopen); + } + + // Show dialog with stack trace (truncated to first 8 frames for display) + const char* code_name = exception_code_name(code); + wchar_t wcode_name[64]; + MultiByteToWideChar(CP_UTF8, 0, code_name, -1, wcode_name, 64); + + // Convert stack trace to wide chars, truncated to first 8 frames + // (most recent / crash-site frames) for display + char display_stack[8192]; + int display_len = 0; + int display_frames = 0; + const int max_display_frames = 8; + for (int i = 0; i < stack_len && display_frames < max_display_frames; ++i) { + display_stack[display_len++] = stack_buf[i]; + if (stack_buf[i] == '\n') display_frames++; + } + if (num_frames > max_display_frames) { + display_len += sprintf_s(display_stack + display_len, + sizeof(display_stack) - display_len, + " ... (%d more frames in log file)\r\n", + num_frames - max_display_frames); + } + display_stack[display_len] = '\0'; + wchar_t wstack[4096]; + MultiByteToWideChar(CP_UTF8, 0, display_stack, -1, wstack, 4096); + + wchar_t wmsg[5120]; + _snwprintf_s(wmsg, 5120, _TRUNCATE, + L"Tomviz has crashed (%s).\n\n" + L"Stack Trace:\n%s\n" + L"Crash dump files have been saved to:\n%s\n\n" + L"Please send these files to the developers.", + wcode_name, wstack, crash_dir); + MessageBoxW(NULL, wmsg, L"Tomviz Crash", MB_OK | MB_ICONERROR); + + return EXCEPTION_EXECUTE_HANDLER; + } + void install_signal_handlers(const SignalOptions& signal_options) { - (void)signal_options; - // TODO: implement signal handlers on windows + s_signal_options = signal_options; + SetUnhandledExceptionFilter(windows_exception_handler); } } // namespace loguru diff --git a/tomviz/main.cxx b/tomviz/main.cxx index f7f142efa..4a24ba547 100644 --- a/tomviz/main.cxx +++ b/tomviz/main.cxx @@ -3,6 +3,7 @@ #include +#include #include #include @@ -22,12 +23,43 @@ #include +#if __has_include() +#include +#define TOMVIZ_HAS_VISKORES +#endif + +#ifdef _WIN32 +// On hybrid-graphics laptops, ask the NVIDIA and AMD drivers to run tomviz +// on the discrete GPU instead of the power-saving integrated one. The +// drivers look for these exported symbols in the executable. +extern "C" { +__declspec(dllexport) unsigned long NvOptimusEnablement = 0x00000001; +__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1; +} +#endif + int main(int argc, char** argv) { // Set up loguru, for printing stack traces on crashes loguru::g_stderr_verbosity = loguru::Verbosity_ERROR; loguru::init(argc, argv); +#ifdef TOMVIZ_HAS_VISKORES + // Initialize Viskores (VTK-m) runtime before any VTK filters use it. + viskores::cont::Initialize(); +#endif + +#ifdef Q_OS_LINUX + // VTK's render windows use X11/GLX, which conflicts with the Qt Wayland + // platform plugin and causes BadAccess on glXMakeCurrent the second time a + // render view is created. Force xcb on Wayland sessions unless the user has + // already chosen a platform. + if (qgetenv("XDG_SESSION_TYPE") == "wayland" && + !qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "xcb"); + } +#endif + QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QCoreApplication::setApplicationName("tomviz"); @@ -38,6 +70,18 @@ int main(int argc, char** argv) QApplication app(argc, argv); +#ifdef Q_OS_MAC + // macOS has no font family named "Monospace". ParaView's + // pqPythonSyntaxHighlighter constructs a QFont("Monospace") and realizes it + // (via QFontMetrics) in its constructor, which forces Qt to scan the entire + // font-family alias table and emit a "Populating font family aliases took + // ..." warning. We don't control that ParaView code, and a font + // substitution does not help because Qt only consults substitutions after + // the failed direct lookup that triggers the scan. Silence the warning + // category; the one-time scan still happens but is harmless. + QLoggingCategory::setFilterRules("qt.qpa.fonts.warning=false"); +#endif + QPixmap pixmap(":/icons/tomvizfull.png"); QSplashScreen splash(pixmap); splash.show(); diff --git a/tomviz/modules/Module.cxx b/tomviz/modules/Module.cxx deleted file mode 100644 index c6eb3f36f..000000000 --- a/tomviz/modules/Module.cxx +++ /dev/null @@ -1,366 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "Module.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "ModuleFactory.h" -#include "MoleculeSource.h" -#include "OperatorResult.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -class Module::MInternals -{ - vtkSmartPointer m_detachedColorMap; - vtkSmartPointer m_detachedOpacityMap; - vtkRectd m_detachedTransferFunction2DBox; - -public: - vtkWeakPointer m_colorMap; - vtkWeakPointer m_opacityMap; - vtkNew m_gradientOpacityMap; - - Module::TransferMode m_transferMode; - vtkNew m_transfer2D; - vtkSMProxy* detachedColorMap() - { - if (!m_detachedColorMap) { - static unsigned int colorMapCounter = 0; - ++colorMapCounter; - - auto pxm = ActiveObjects::instance().proxyManager(); - - vtkNew tfmgr; - m_detachedColorMap = tfmgr->GetColorTransferFunction( - QString("ModuleColorMap%1").arg(colorMapCounter).toLatin1().data(), - pxm); - m_detachedOpacityMap = - vtkSMPropertyHelper(m_detachedColorMap, "ScalarOpacityFunction") - .GetAsProxy(); - // The widget interprests negative width as uninitialized. - m_detachedTransferFunction2DBox.Set(0, 0, -1, -1); - } - return m_detachedColorMap; - } - - vtkSMProxy* detachedOpacityMap() - { - detachedColorMap(); - return m_detachedOpacityMap; - } - - vtkRectd* detachedTransferFunction2DBox() - { - detachedColorMap(); - return &m_detachedTransferFunction2DBox; - } -}; - -Module::Module(QObject* parentObject) - : QObject(parentObject), d(new Module::MInternals()), - m_activeScalars(Module::defaultScalarsIdx()) -{} - -Module::~Module() = default; - -bool Module::initialize(OperatorResult* result, vtkSMViewProxy* vtkView) -{ - m_view = vtkView; - m_operatorResult = result; - m_activeDataSource = ActiveObjects::instance().activeDataSource(); - return (m_view && m_view->IsA("vtkSMRenderViewProxy") && m_operatorResult); -} - -bool Module::initialize(MoleculeSource* data, vtkSMViewProxy* vtkView) -{ - m_view = vtkView; - m_activeMoleculeSource = data; - m_activeDataSource = ActiveObjects::instance().activeDataSource(); - return (m_view && m_view->IsA("vtkSMRenderViewProxy") && - m_activeMoleculeSource); -} - -bool Module::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - m_view = vtkView; - m_activeDataSource = data; - d->m_gradientOpacityMap->RemoveAllPoints(); - d->m_transfer2D->SetDimensions(1, 1, 1); - d->m_transfer2D->AllocateScalars(VTK_FLOAT, 4); - - if (m_view && m_view->IsA("vtkSMRenderViewProxy") && m_activeDataSource) { - // FIXME: we're connecting this too many times. Fix it. - connect(m_activeDataSource, &DataSource::dataChanged, - tomviz::convert(vtkView), &pqView::render); - connect(m_activeDataSource, &DataSource::dataChanged, this, - &Module::dataSourceChanged); - connect(m_activeDataSource, &DataSource::displayPositionChanged, this, - &Module::dataSourceMoved); - connect(m_activeDataSource, &DataSource::displayOrientationChanged, this, - &Module::dataSourceRotated); - } - return (m_view && m_view->IsA("vtkSMRenderViewProxy") && m_activeDataSource); -} - -vtkSMViewProxy* Module::view() const -{ - return m_view; -} - -DataSource* Module::dataSource() const -{ - return m_activeDataSource; -} - -MoleculeSource* Module::moleculeSource() const -{ - return m_activeMoleculeSource; -} - -OperatorResult* Module::operatorResult() const -{ - return m_operatorResult; -} - -void Module::addToPanel(QWidget* vtkNotUsed(panel)) {} - -void Module::prepareToRemoveFromPanel(QWidget* vtkNotUsed(panel)) {} - -void Module::setActiveScalars(int scalars) -{ - m_activeScalars = scalars; - emit dataSourceChanged(); -} - -void Module::setUseDetachedColorMap(bool val) -{ - m_useDetachedColorMap = val; - if (isColorMapNeeded() == false) { - return; - } - - if (m_useDetachedColorMap) { - d->m_colorMap = d->detachedColorMap(); - d->m_opacityMap = d->detachedOpacityMap(); - - tomviz::rescaleColorMap(d->m_colorMap, dataSource()); - pqCoreUtilities::connect(d->m_colorMap, vtkCommand::ModifiedEvent, this, - SLOT(onColorMapChanged())); - } else { - d->m_colorMap = nullptr; - d->m_opacityMap = nullptr; - } - updateColorMap(); - emit colorMapChanged(); -} - -vtkSMProxy* Module::colorMap() const -{ - if (useDetachedColorMap()) { - return d->m_colorMap.GetPointer(); - } - - if (colorMapDataSource()) { - return colorMapDataSource()->colorMap(); - } - - return nullptr; -} - -vtkSMProxy* Module::opacityMap() const -{ - Q_ASSERT(d->m_colorMap || !m_useDetachedColorMap); - return useDetachedColorMap() ? d->m_opacityMap.GetPointer() - : dataSource()->opacityMap(); -} - -vtkPiecewiseFunction* Module::gradientOpacityMap() const -{ - vtkPiecewiseFunction* gof = useDetachedColorMap() - ? d->m_gradientOpacityMap.GetPointer() - : dataSource()->gradientOpacityMap(); - - // Set default values - const int numPoints = gof->GetSize(); - if (numPoints == 0) { - vtkColorTransferFunction* lut = - vtkColorTransferFunction::SafeDownCast(colorMap()->GetClientSideObject()); - - double range[2]; - lut->GetRange(range); - - // For gradient magnitude, the volume mapper's fragment shader expects a - // range of [0, DataMax/4]. - const double maxValue = (range[1] - range[0]) / 4.0; - gof->AddPoint(0.0, 0.0); - gof->AddPoint(maxValue, 1.0); - } - - return gof; -} - -vtkImageData* Module::transferFunction2D() const -{ - if (useDetachedColorMap()) { - return d->m_transfer2D.GetPointer(); - } - - if (colorMapDataSource()) { - return colorMapDataSource()->transferFunction2D(); - } - - return nullptr; -} - -vtkRectd* Module::transferFunction2DBox() const -{ - return useDetachedColorMap() ? d->detachedTransferFunction2DBox() - : dataSource()->transferFunction2DBox(); -} - -QJsonObject Module::serialize() const -{ - QJsonObject json; - QJsonObject props; - props["visibility"] = visibility(); - if (isColorMapNeeded()) { - json["useDetachedColorMap"] = m_useDetachedColorMap; - if (m_useDetachedColorMap) { - json["colorOpacityMap"] = tomviz::serialize(d->detachedColorMap()); - json["gradientOpacityMap"] = tomviz::serialize(gradientOpacityMap()); - QJsonObject boxJson; - auto transfer2DBox = d->detachedTransferFunction2DBox(); - boxJson["x"] = transfer2DBox->GetX(); - boxJson["y"] = transfer2DBox->GetY(); - boxJson["width"] = transfer2DBox->GetWidth(); - boxJson["height"] = transfer2DBox->GetHeight(); - json["colorMap2DBox"] = boxJson; - } - } - json["properties"] = props; - json["activeScalars"] = activeScalarsName(); - json["id"] = QString::asprintf("%p", static_cast(this)); - json["type"] = ModuleFactory::moduleType(this); - json["viewId"] = static_cast(this->view()->GetGlobalID()); - - return json; -} - -bool Module::deserialize(const QJsonObject& json) -{ - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - setVisibility(props["visibility"].toBool()); - } - - if (isColorMapNeeded() && json.contains("useDetachedColorMap")) { - bool useDetachedColorMap = json["useDetachedColorMap"].toBool(); - if (useDetachedColorMap) { - if (json.contains("colorOpacityMap")) { - auto colorMap = json["colorOpacityMap"].toObject(); - tomviz::deserialize(d->detachedColorMap(), colorMap); - } - if (json.contains("gradientOpacityMap")) { - auto gradientOpacityMap = json["gradientOpacityMap"].toObject(); - tomviz::deserialize(d->m_gradientOpacityMap, gradientOpacityMap); - } - if (json.contains("colorMap2DBox")) { - auto boxJson = json["colorMap2DBox"].toObject(); - auto transfer2DBox = d->detachedTransferFunction2DBox(); - transfer2DBox->Set(boxJson["x"].toDouble(), boxJson["y"].toDouble(), - boxJson["width"].toDouble(), - boxJson["height"].toDouble()); - } - } - setUseDetachedColorMap(useDetachedColorMap); - } - - if (json.contains("activeScalars")) { - auto activeScalarsName = json["activeScalars"].toString(); - if (activeScalarsName == Module::defaultScalarsName()) { - m_activeScalars = Module::defaultScalarsIdx(); - } else { - m_activeScalars = dataSource()->scalarsIdx(activeScalarsName); - } - } - - return true; -} - -void Module::onColorMapChanged() -{ - emit colorMapChanged(); -} - -void Module::setTransferMode(const TransferMode mode) -{ - d->m_transferMode = static_cast(mode); - this->updateColorMap(); - - emit transferModeChanged(static_cast(mode)); -} - -Module::TransferMode Module::getTransferMode() const -{ - return d->m_transferMode; -} - -vtkDataObject* Module::dataToExport() -{ - return nullptr; -} - -bool Module::setVisibility(bool val) { - emit visibilityChanged(val); - - return true; -} - -bool Module::updateClippingPlane(vtkPlane*, bool) -{ - return false; -} - -QString Module::defaultScalarsName() -{ - return "tomviz::DefaultScalars"; -} - -int Module::defaultScalarsIdx() -{ - return -1; -} - -QString Module::activeScalarsName() const -{ - if (m_activeScalars == Module::defaultScalarsIdx()) { - return Module::defaultScalarsName(); - } else { - return dataSource()->scalarsName(m_activeScalars); - } -} -} // end of namespace tomviz diff --git a/tomviz/modules/Module.h b/tomviz/modules/Module.h deleted file mode 100644 index 351651f3e..000000000 --- a/tomviz/modules/Module.h +++ /dev/null @@ -1,210 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModule_h -#define tomvizModule_h - -#include - -#include -#include -#include - -#include -#include - -class QWidget; -class vtkImageData; -class vtkPlane; -class vtkSMProxy; -class vtkSMViewProxy; -class vtkPiecewiseFunction; -class vtkDataObject; - -namespace tomviz { - -class DataSource; -class MoleculeSource; -class OperatorResult; - -/// Abstract parent class for all Modules in tomviz. -class Module : public QObject -{ - Q_OBJECT - -public: - Module(QObject* parent = nullptr); - ~Module() override; - - /// Transfer function mode (1D or 2D). This enum needs to be synchronized with - /// the order of the pages in ui->swTransferMode. - enum TransferMode - { - SCALAR = 0, - GRADIENT_1D, - GRADIENT_2D - }; - void setTransferMode(const TransferMode mode); - TransferMode getTransferMode() const; - - /// Returns a label for this module. - virtual QString label() const = 0; - - /// Returns an icon to use for this module. - virtual QIcon icon() const = 0; - - /// Initialize the module for the data source and view. This is called after a - /// new module is instantiated. Subclasses override this method to setup the - /// visualization pipeline for this module. - virtual bool initialize(DataSource* dataSource, vtkSMViewProxy* view); - virtual bool initialize(MoleculeSource* moleculeSource, vtkSMViewProxy* view); - virtual bool initialize(OperatorResult* result, vtkSMViewProxy* view); - - /// Finalize the module. Subclasses should override this method to delete and - /// release all proxies (and data) created for this module. - virtual bool finalize() = 0; - - /// Returns the visibility for the module. - virtual bool visibility() const = 0; - - /// Accessors for the data-source and view associated with this Plot. - DataSource* dataSource() const; - MoleculeSource* moleculeSource() const; - vtkSMViewProxy* view() const; - // Modules can alternatively use an OperatorResult as DataSource - OperatorResult* operatorResult() const; - - /// Some modules act on one data source and apply coloring from another data - /// source (e.g. ModuleContour). This method allows a module to designate a - /// separate data source for coloring, if desired. - virtual DataSource* colorMapDataSource() const { return dataSource(); } - - /// serialize the state of the module. - virtual QJsonObject serialize() const; - virtual bool deserialize(const QJsonObject& json); - - /// Modules that use transfer functions should override this method to return - /// true. - virtual bool isColorMapNeeded() const { return false; } - virtual bool isOpacityMapped() const { return false; } - virtual bool areScalarsMapped() const { return false; } - - /// Flag indicating whether the module uses a "detached" color map or not. - /// This is only applicable when isColorMapNeeded() return true. - void setUseDetachedColorMap(bool); - bool useDetachedColorMap() const { return m_useDetachedColorMap; } - - /// This will either return the maps from the data source or detached ones - /// based on the UseDetachedColorMap flag. - vtkSMProxy* colorMap() const; - vtkSMProxy* opacityMap() const; - vtkPiecewiseFunction* gradientOpacityMap() const; - vtkImageData* transferFunction2D() const; - vtkRectd* transferFunction2DBox() const; - - virtual bool supportsGradientOpacity() { return false; } - - /// A description of the data type that will be exported. For instance if - /// exporting a mesh, this would return "Mesh". Returning an empty string - /// indicates that this module has nothing of interest to be exported. - virtual QString exportDataTypeString() { return ""; } - - /// Returns the data to export for this visualization module. - virtual vtkDataObject* dataToExport(); - - /// Returns the active scalars of the module - int activeScalars() const { return m_activeScalars; } - QString activeScalarsName() const; - static QString defaultScalarsName(); - static int defaultScalarsIdx(); - -signals: - - /// Emitted when the transfer function mode changed in the concrete - /// module (which would require external UI components to be adjusted). - void transferModeChanged(const int mode); - -public slots: - /// Set the visibility for this module. Subclasses should override this method - /// show/hide all representations created for this module. - virtual bool setVisibility(bool val); - - bool show() { return setVisibility(true); } - bool hide() { return setVisibility(false); } - - /// This method is called add the proxies in this module to a - /// pqProxiesWidget instance. Default implementation simply adds the view - /// properties. Subclasses should override to add proxies and relevant - /// properties to the panel. - virtual void addToPanel(QWidget* panel); - - /// This method is called just prior to removing this Module's properties from - /// the panel. Subclasses can use it to perform any necessary cleanup such as - /// disconnecting some signals and slots. - virtual void prepareToRemoveFromPanel(QWidget* panel); - - /// This method is called when the data source's display position changes. - virtual void dataSourceMoved(double newX, double newY, double newZ) = 0; - - /// This method is called when the data source's display orientation changes. - virtual void dataSourceRotated(double newX, double newY, double newZ) = 0; - - // This method is called when the active scalars for the module change - virtual void setActiveScalars(int scalars); - - /// Subclasses should override this method in order to use the clipping - /// plane on a module. - virtual bool updateClippingPlane(vtkPlane* plane, bool newFilter); - -protected: - /// Modules that use transfer functions for color/opacity should override this - /// method to set the color map on appropriate representations. This will be - /// called when the color map proxy is changed, for example, when - /// setUseDetachedColorMap is toggled. - virtual void updateColorMap() {} - -signals: - /// Emitted when the represented DataSource is updated. - void dataSourceChanged(); - - /// Emitted when the UseDetachedColorMap state changes or the detached color - /// map is modified - void colorMapChanged(); - - /// Emitted when the module properties are changed in a way that would require - /// a re-render of the scene to take effect. - void renderNeeded(); - - /// Emitted when the client side view needs to be updated (such as geometry - /// bounds being re-computed), but this was not done automatically. Most - /// widgets don't need this, but the slice module does for some reason. - void updateClientSideViewNeeded(); - - /// Emitted when the mouse is over a voxel - void mouseOverVoxel(const vtkVector3i& ijk, double v); - - /// Emitted when the module explicitly requires the opacity of the color map - /// to be enforced. This will cause the colormap to be detached, and the - /// "Separate Color Map" box to be checked and disabled. In practice, this is - /// only useful to make Orthogonal and regular slices transparent. - void opacityEnforced(bool); - - void visibilityChanged(bool); - -private slots: - void onColorMapChanged(); - -private: - Q_DISABLE_COPY(Module) - QPointer m_activeDataSource; - QPointer m_activeMoleculeSource; - QPointer m_operatorResult; - vtkWeakPointer m_view; - bool m_useDetachedColorMap = false; - - class MInternals; - const QScopedPointer d; - int m_activeScalars; -}; -} // namespace tomviz -#endif diff --git a/tomviz/modules/ModuleClip.cxx b/tomviz/modules/ModuleClip.cxx deleted file mode 100644 index 3eb38a86c..000000000 --- a/tomviz/modules/ModuleClip.cxx +++ /dev/null @@ -1,764 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleClip.h" - -#include "DataSource.h" -#include "DoubleSliderWidget.h" -#include "IntSliderWidget.h" -#include "ModuleManager.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -ModuleClip::ModuleClip(QObject* parentObject) : Module(parentObject) {} - -ModuleClip::~ModuleClip() -{ - finalize(); -} - -QIcon ModuleClip::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqClip.svg"); -} - -bool ModuleClip::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - vtkNew controller; - auto producer = data->proxy(); - auto pxm = producer->GetSessionProxyManager(); - - vtkSmartPointer proxy; - proxy.TakeReference(pxm->NewProxy("filters", "PassThrough")); - - // Create the Properties panel proxy - m_propsPanelProxy.TakeReference( - pxm->NewProxy("tomviz_proxies", "NonOrthogonalClip")); - - pqCoreUtilities::connect(m_propsPanelProxy, vtkCommand::PropertyModifiedEvent, - this, SLOT(onPropertyChanged())); - - m_clip = vtkSMSourceProxy::SafeDownCast(proxy); - Q_ASSERT(m_clip); - controller->PreInitializeProxy(m_clip); - vtkSMPropertyHelper(m_clip, "Input").Set(producer); - controller->PostInitializeProxy(m_clip); - controller->RegisterPipelineProxy(m_clip); - - // Give the proxy a friendly name for the GUI/Python world. - if (auto p = convert(proxy)) { - p->rename(label()); - } - - const bool widgetSetup = setupWidget(vtkView); - - if (widgetSetup) { - m_widget->SetDisplayOffset(data->displayPosition()); - m_widget->SetDisplayOrientation(data->displayOrientation()); - m_widget->On(); - onDirectionChanged(m_direction); - pqCoreUtilities::connect(m_widget, vtkCommand::InteractionEvent, this, - SLOT(onPlaneChanged())); - connect(data, &DataSource::dataChanged, this, &ModuleClip::dataUpdated); - foreach (Module* module, - ModuleManager::instance().findModulesGeneric(data, nullptr)) { - if (module->dataSource() == data) { - connect(this, &ModuleClip::clipFilterUpdated, module, - &Module::updateClippingPlane); - } - } - connect(&ModuleManager::instance(), &ModuleManager::moduleAdded, this, - [this, data](Module* module) { - if (module->dataSource() == data) { - connect(this, &ModuleClip::clipFilterUpdated, - module, &Module::updateClippingPlane); - emit clipFilterUpdated(m_clippingPlane, false); - } - }); - } - - Q_ASSERT(m_widget); - return widgetSetup; -} - -// Should only be called from initialize after Clip has been setup -bool ModuleClip::setupWidget(vtkSMViewProxy* vtkView) -{ - auto clipAlg = vtkAlgorithm::SafeDownCast(m_clip->GetClientSideObject()); - - vtkRenderWindowInteractor* rwi = vtkView->GetRenderWindow()->GetInteractor(); - - if (!rwi || !clipAlg) { - return false; - } - - m_widget = vtkSmartPointer::New(); - m_widget->SetLookupTable(createLookupTable()); - double color[3] = { 0.0 }; - m_widget->GetTexturePlaneProperty()->SetColor(color); - - // Set the interactor on the widget to be what the current - // render window is using. - m_widget->SetInteractor(rwi); - - m_clippingPlane = vtkSmartPointer::New(); - m_clippingPlane->SetOrigin(m_widget->GetCenter()); - m_clippingPlane->SetNormal(m_widget->GetNormal()); - - m_widget->SetInputConnection(clipAlg->GetOutputPort()); - - Q_ASSERT(rwi); - Q_ASSERT(clipAlg); - onPlaneChanged(); - return true; -} - -void ModuleClip::updatePlaneWidget() -{ - if (!m_planeSlider || !imageData()) - return; - - int dims[3]; - imageData()->GetDimensions(dims); - - int axis = directionAxis(m_direction); - int maxPlane = dims[axis] - 1; - - m_planeSlider->setMinimum(0); - m_planeSlider->setMaximum(maxPlane); -} - -bool ModuleClip::finalize() -{ - emit clipFilterUpdated(m_clippingPlane, true); - vtkNew controller; - controller->UnRegisterProxy(m_clip); - - m_clip = nullptr; - - if (m_widget != nullptr) { - m_widget->Off(); - } - - return true; -} - -bool ModuleClip::setVisibility(bool val) -{ - Q_ASSERT(m_widget); - m_widget->SetEnabled(val ? 1 : 0); - // If the state of the plane is set to visible, update the state of the arrow - // as well since it cannot update when the widget is not enabled - vtkSMPropertyHelper showPlaneProperty(m_propsPanelProxy, "ShowPlane"); - if (val && showPlaneProperty.GetAsInt()) { - vtkSMPropertyHelper showArrowProperty(m_propsPanelProxy, "ShowArrow"); - // Not this: it hides the plane as well as the arrow... - // Widget->SetEnabled(showArrowProperty.GetAsInt()); - m_widget->SetArrowVisibility(showArrowProperty.GetAsInt()); - m_widget->SetInteraction(showArrowProperty.GetAsInt()); - m_widget->SetTextureVisibility(showPlaneProperty.GetAsInt()); - } - val ? emit clipFilterUpdated(m_clippingPlane, false) : emit clipFilterUpdated(m_clippingPlane, true); - return true; -} - -bool ModuleClip::visibility() const -{ - if (m_widget) { - return m_widget->GetEnabled() != 0; - } else { - return false; - } -} - -void ModuleClip::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - - QVBoxLayout* layout = new QVBoxLayout; - QFormLayout* formLayout = new QFormLayout; - - QWidget* container = new QWidget; - container->setLayout(formLayout); - layout->addWidget(container); - formLayout->setContentsMargins(0, 0, 0, 0); - - m_opacitySlider = new DoubleSliderWidget(true); - m_opacitySlider->setLineEditWidth(50); - m_opacitySlider->setMinimum(0); - m_opacitySlider->setMaximum(1); - m_opacitySlider->setValue(m_opacity); - onOpacityChanged(m_opacity); - formLayout->addRow("Opacity", m_opacitySlider); - - connect(m_opacitySlider, &DoubleSliderWidget::valueEdited, this, - &ModuleClip::onOpacityChanged); - connect(m_opacitySlider, &DoubleSliderWidget::valueChanged, this, - &ModuleClip::onOpacityChanged); - - m_colorSelector = new pqColorChooserButton(panel); - m_colorSelector->setShowAlphaChannel(false); - m_colorSelector->setChosenColor(m_planeColor); - formLayout->addRow("Select Color", m_colorSelector); - - connect(m_colorSelector, &pqColorChooserButton::chosenColorChanged, this, - &ModuleClip::onUpdateColor); - connect(m_colorSelector, &pqColorChooserButton::chosenColorChanged, this, - &ModuleClip::dataUpdated); - - QHBoxLayout* displayRowLayout = new QHBoxLayout; - QCheckBox* showPlane = new QCheckBox("Show Plane"); - displayRowLayout->addWidget(showPlane); - - m_Links.addPropertyLink(showPlane, "checked", SIGNAL(toggled(bool)), - m_propsPanelProxy, - m_propsPanelProxy->GetProperty("ShowPlane"), 0); - connect(showPlane, &QCheckBox::toggled, this, &ModuleClip::dataUpdated); - - QCheckBox* showArrow = new QCheckBox("Show Arrow"); - displayRowLayout->addWidget(showArrow); - formLayout->addRow(displayRowLayout); - - m_Links.addPropertyLink(showArrow, "checked", SIGNAL(toggled(bool)), - m_propsPanelProxy, - m_propsPanelProxy->GetProperty("ShowArrow"), 0); - connect(showArrow, &QCheckBox::toggled, this, &ModuleClip::dataUpdated); - connect(showPlane, &QCheckBox::toggled, showArrow, &QCheckBox::setEnabled); - - QCheckBox* invertPlane = new QCheckBox("Invert Plane Direction"); - formLayout->addRow(invertPlane); - - m_Links.addPropertyLink(invertPlane, "checked", SIGNAL(toggled(bool)), - m_propsPanelProxy, - m_propsPanelProxy->GetProperty("InvertPlane"), 0); - connect(invertPlane, &QCheckBox::toggled, this, - &ModuleClip::onInvertPlaneChanged); - connect(invertPlane, &QCheckBox::toggled, this, &ModuleClip::dataUpdated); - - m_directionCombo = new QComboBox(); - m_directionCombo->addItem("XY Plane", QVariant(Direction::XY)); - m_directionCombo->addItem("YZ Plane", QVariant(Direction::YZ)); - m_directionCombo->addItem("XZ Plane", QVariant(Direction::XZ)); - m_directionCombo->addItem("Custom", QVariant(Direction::Custom)); - m_directionCombo->setCurrentIndex(static_cast(m_direction)); - formLayout->addRow("Direction", m_directionCombo); - - m_planeSlider = new IntSliderWidget(true); - m_planeSlider->setLineEditWidth(50); - m_planeSlider->setPageStep(1); - m_planeSlider->setMinimum(0); - int axis = directionAxis(m_direction); - bool isOrtho = axis >= 0; - if (isOrtho) { - int dims[3]; - imageData()->GetDimensions(dims); - m_planeSlider->setMaximum(dims[axis] - 1); - } - - // Sanity check: make sure the plane value is within the bounds - if (m_planePosition < m_planeSlider->minimum()) - m_planePosition = m_planeSlider->minimum(); - else if (m_planePosition > m_planeSlider->maximum()) - m_planePosition = m_planeSlider->maximum(); - - m_planeSlider->setValue(m_planePosition); - - formLayout->addRow("Plane", m_planeSlider); - - auto line = new QFrame; - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Sunken); - formLayout->addRow(line); - - QLabel* label = new QLabel("Point on Plane"); - layout->addWidget(label); - QHBoxLayout* row = new QHBoxLayout; - const char* labels[] = { "X:", "Y:", "Z:" }; - for (int i = 0; i < 3; ++i) { - label = new QLabel(labels[i]); - row->addWidget(label); - pqLineEdit* inputBox = new pqLineEdit; - inputBox->setEnabled(!isOrtho); - inputBox->setValidator(new QDoubleValidator(inputBox)); - m_Links.addPropertyLink( - inputBox, "text2", SIGNAL(textChanged(const QString&)), m_propsPanelProxy, - m_propsPanelProxy->GetProperty("PointOnPlane"), i); - connect(inputBox, &pqLineEdit::textChangedAndEditingFinished, this, - &ModuleClip::dataUpdated); - row->addWidget(inputBox); - m_pointInputs[i] = inputBox; - } - layout->addItem(row); - - label = new QLabel("Plane Normal"); - layout->addWidget(label); - row = new QHBoxLayout; - for (int i = 0; i < 3; ++i) { - label = new QLabel(labels[i]); - row->addWidget(label); - pqLineEdit* inputBox = new pqLineEdit; - inputBox->setEnabled(!isOrtho); - inputBox->setValidator(new QDoubleValidator(inputBox)); - m_Links.addPropertyLink( - inputBox, "text2", SIGNAL(textChanged(const QString&)), m_propsPanelProxy, - m_propsPanelProxy->GetProperty("PlaneNormal"), i); - connect(inputBox, &pqLineEdit::textChangedAndEditingFinished, this, - &ModuleClip::dataUpdated); - row->addWidget(inputBox); - m_normalInputs[i] = inputBox; - } - layout->addItem(row); - - layout->addStretch(); - - panel->setLayout(layout); - - connect(m_directionCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this](int idx) { - Direction dir = m_directionCombo->itemData(idx).value(); - onDirectionChanged(dir); - }); - - connect(m_planeSlider, &IntSliderWidget::valueEdited, this, - QOverload::of(&ModuleClip::onPlaneChanged)); - connect(m_planeSlider, &IntSliderWidget::valueChanged, this, - QOverload::of(&ModuleClip::onPlaneChanged)); -} - -void ModuleClip::dataUpdated() -{ - m_Links.accept(); - // In case there are new planes, update min and max - updatePlaneWidget(); - m_widget->UpdatePlacement(); - emit renderNeeded(); -} - -QJsonObject ModuleClip::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - vtkSMPropertyHelper showArrowProperty(m_propsPanelProxy, "ShowArrow"); - props["showArrow"] = showArrowProperty.GetAsInt() != 0; - - vtkSMPropertyHelper showPlaneProperty(m_propsPanelProxy, "ShowPlane"); - props["showPlane"] = showPlaneProperty.GetAsInt() != 0; - - vtkSMPropertyHelper invertPlaneProperty(m_propsPanelProxy, "InvertPlane"); - props["invertPlane"] = invertPlaneProperty.GetAsInt() != 0; - - QJsonArray selection = { m_planeColor.redF(), m_planeColor.greenF(), - m_planeColor.blueF() }; - props["selectedColor"] = selection; - props["opacity"] = m_opacity; - - // Serialize the plane - double point[3]; - m_widget->GetOrigin(point); - QJsonArray origin = { point[0], point[1], point[2] }; - m_widget->GetPoint1(point); - QJsonArray point1 = { point[0], point[1], point[2] }; - m_widget->GetPoint2(point); - QJsonArray point2 = { point[0], point[1], point[2] }; - - props["origin"] = origin; - props["point1"] = point1; - props["point2"] = point2; - - props["plane"] = m_planePosition; - QVariant qData; - qData.setValue(m_direction); - props["direction"] = qData.toString(); - - json["properties"] = props; - return json; -} - -bool ModuleClip::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - vtkSMPropertyHelper showArrowProperty(m_propsPanelProxy, "ShowArrow"); - showArrowProperty.Set(props["showArrow"].toBool() ? 1 : 0); - vtkSMPropertyHelper showPlaneProperty(m_propsPanelProxy, "ShowPlane"); - showPlaneProperty.Set(props["showPlane"].toBool() ? 1 : 0); - vtkSMPropertyHelper invertPlaneProperty(m_propsPanelProxy, "InvertPlane"); - invertPlaneProperty.Set(props["invertPlane"].toBool() ? 1 : 0); - if (props.contains("origin") && props.contains("point1") && - props.contains("point2")) { - auto o = props["origin"].toArray(); - auto p1 = props["point1"].toArray(); - auto p2 = props["point2"].toArray(); - double origin[3] = { o[0].toDouble(), o[1].toDouble(), o[2].toDouble() }; - double point1[3] = { p1[0].toDouble(), p1[1].toDouble(), - p1[2].toDouble() }; - double point2[3] = { p2[0].toDouble(), p2[1].toDouble(), - p2[2].toDouble() }; - m_widget->SetOrigin(origin); - m_widget->SetPoint1(point1); - m_widget->SetPoint2(point2); - } - if (props.contains("selectedColor")) { - auto selection = props["selectedColor"].toArray(); - double color[3] = { selection[0].toDouble(), selection[1].toDouble(), - selection[2].toDouble() }; - if (m_colorSelector) { - setPlaneColor(color); - } - } - if (props.contains("opacity")) { - m_opacity = props["opacity"].toDouble(); - onOpacityChanged(m_opacity); - if (m_opacitySlider) { - m_opacitySlider->setValue(m_opacity); - } - } - - m_widget->UpdatePlacement(); - // If deserializing a former OrthogonalPlane, the direction is encoded in - // the property "planeMode" as an int - if (props.contains("planeMode")) { - Direction direction = modeToDirection(props["planeMode"].toInt()); - onDirectionChanged(direction); - } - if (props.contains("direction")) { - Direction direction = stringToDirection(props["direction"].toString()); - onDirectionChanged(direction); - } - if (props.contains("plane")) { - m_planePosition = props["plane"].toInt(); - onPlaneChanged(m_planePosition); - } - onPlaneChanged(); - return true; - } - return false; -} - -void ModuleClip::onPropertyChanged() -{ - // Avoid recursive clobbering of the plane position - if (m_ignoreSignals) { - return; - } - m_ignoreSignals = true; - vtkSMPropertyHelper showPlaneProperty(m_propsPanelProxy, "ShowPlane"); - if (m_widget->GetEnabled()) { - m_widget->SetTextureVisibility(showPlaneProperty.GetAsInt()); - m_widget->SetArrowVisibility(showPlaneProperty.GetAsInt()); - } - vtkSMPropertyHelper showArrowProperty(m_propsPanelProxy, "ShowArrow"); - if (m_widget->GetEnabled() && showPlaneProperty.GetAsInt()) { - // Not this: it hides the plane as well as the arrow... - // Widget->SetEnabled(showArrowProperty.GetAsInt()); - m_widget->SetArrowVisibility(showArrowProperty.GetAsInt()); - m_widget->SetInteraction(showArrowProperty.GetAsInt()); - } - vtkSMPropertyHelper pointProperty(m_propsPanelProxy, "PointOnPlane"); - std::vector centerPoint = pointProperty.GetDoubleArray(); - m_widget->SetCenter(¢erPoint[0]); - m_clippingPlane->SetOrigin(¢erPoint[0]); - vtkSMPropertyHelper normalProperty(m_propsPanelProxy, "PlaneNormal"); - std::vector normalVector = normalProperty.GetDoubleArray(); - m_widget->UpdatePlacement(); - m_ignoreSignals = false; - - if (m_widget->GetEnabled()) { - emit clipFilterUpdated(m_clippingPlane, false); - } -} - -void ModuleClip::onPlaneChanged() -{ - // Avoid recursive clobbering of the plane position - if (m_ignoreSignals) { - return; - } - m_ignoreSignals = true; - vtkSMPropertyHelper pointProperty(m_propsPanelProxy, "PointOnPlane"); - double* centerPoint = m_widget->GetCenter(); - pointProperty.Set(centerPoint, 3); - m_clippingPlane->SetOrigin(centerPoint); - vtkSMPropertyHelper normalProperty(m_propsPanelProxy, "PlaneNormal"); - double* normalVector = m_widget->GetNormal(); - normalProperty.Set(normalVector, 3); - m_clippingPlane->SetNormal(normalVector); - - // Adjust the plane slider if the plane has changed from dragging the arrow - onPlaneChanged(centerPoint); - - m_ignoreSignals = false; -} - -void ModuleClip::dataSourceMoved(double newX, double newY, double newZ) -{ - double pos[3] = { newX, newY, newZ }; - m_widget->SetDisplayOffset(pos); - // FIXME: we need to get m_clippingPlane to be aware of non-default - // data source origins. -} - -void ModuleClip::dataSourceRotated(double newX, double newY, double newZ) -{ - double orientation[3] = { newX, newY, newZ }; - m_widget->SetDisplayOrientation(orientation); - // FIXME: we need to get m_clippingPlane to be aware of non-default - // data source orientations. -} - -vtkImageData* ModuleClip::imageData() const -{ - vtkImageData* data = vtkImageData::SafeDownCast( - dataSource()->producer()->GetOutputDataObject(0)); - Q_ASSERT(data); - return data; -} - -void ModuleClip::onDirectionChanged(Direction direction) -{ - m_direction = direction; - int axis = directionAxis(direction); - - bool isOrtho = axis >= 0; - - for (int i = 0; i < 3; ++i) { - if (m_pointInputs[i]) { - m_pointInputs[i]->setEnabled(!isOrtho); - } - if (m_normalInputs[i]) { - m_normalInputs[i]->setEnabled(!isOrtho); - } - } - if (m_planeSlider) { - m_planeSlider->setVisible(isOrtho); - } - - m_widget->SetPlaneOrientation(axis); - - if (m_directionCombo) { - if (direction != m_directionCombo->currentData().value()) { - for (int i = 0; i < m_directionCombo->count(); ++i) { - Direction data = m_directionCombo->itemData(i).value(); - if (data == direction) { - m_directionCombo->setCurrentIndex(i); - } - } - } - } - - if (!isOrtho) { - emit clipFilterUpdated(m_clippingPlane, false); - return; - } - - int dims[3]; - imageData()->GetDimensions(dims); - - double normal[3] = { 0, 0, 0 }; - int planePosition = 1; - int maxPlane = dims[axis] - 1;; - - vtkSMPropertyHelper invertPlaneProperty(m_propsPanelProxy, "InvertPlane"); - if (invertPlaneProperty.GetAsInt() && isOrtho) { - normal[axis] = -1; - planePosition = maxPlane; - } else { - normal[axis] = 1; - } - - m_widget->SetNormal(normal); - if (m_planeSlider) { - m_planeSlider->setMinimum(0); - m_planeSlider->setMaximum(maxPlane); - } - - onPlaneChanged(planePosition); - onPlaneChanged(); - dataUpdated(); - - emit clipFilterUpdated(m_clippingPlane, false); -} - -void ModuleClip::onPlaneChanged(int planePosition) -{ - m_planePosition = planePosition; - int axis = directionAxis(m_direction); - - if (axis < 0) { - return; - } - - m_widget->SetSliceIndex(planePosition); - if (m_planeSlider) { - m_planeSlider->setValue(planePosition); - } - - onPlaneChanged(); - dataUpdated(); - - emit clipFilterUpdated(m_clippingPlane, false); -} - -void ModuleClip::onPlaneChanged(double* point) -{ - int axis = directionAxis(m_direction); - if (axis < 0) { - return; - } - - int dims[3]; - imageData()->GetDimensions(dims); - double bounds[6]; - imageData()->GetBounds(bounds); - int planePosition; - - planePosition = (dims[axis] - 1) * (point[axis] - bounds[2 * axis]) / - (bounds[2 * axis + 1] - bounds[2 * axis]); - - onPlaneChanged(planePosition); - - emit clipFilterUpdated(m_clippingPlane, false); -} - -void ModuleClip::onInvertPlaneChanged() -{ - double normal[3]; - m_widget->GetNormal(normal); - for (auto i = 0; i < 3; ++i) { - normal[i] *= -1; - } - m_widget->SetNormal(normal); - m_clippingPlane->SetNormal(normal); - onPropertyChanged(); -} - -void ModuleClip::onUpdateColor(const QColor& color) -{ - double rgb[3] = { color.redF(), color.greenF(), color.blueF() }; - m_widget->GetTexturePlaneProperty()->SetColor(rgb); - m_planeColor.setRgbF(rgb[0], rgb[1], rgb[2]); - emit renderNeeded(); -} - -void ModuleClip::setPlaneColor(double rgb[3]) -{ - QColor color(static_cast(rgb[0] * 255.0 + 0.5), - static_cast(rgb[1] * 255.0 + 0.5), - static_cast(rgb[2] * 255.0 + 0.5)); - m_colorSelector->setChosenColor(color); - onUpdateColor(color); -} - -void ModuleClip::onOpacityChanged(double opacity) -{ - m_opacity = opacity; - m_widget->SetOpacity(opacity); - emit renderNeeded(); -} - -vtkScalarsToColors* ModuleClip::createLookupTable() -{ - vtkLookupTable* lut = vtkLookupTable::New(); - lut->Register(m_widget); - lut->Delete(); - lut->SetNumberOfColors(256); - lut->SetSaturationRange(0, 0); - lut->SetValueRange(1, 1); - lut->Build(); - return lut; -} - -int ModuleClip::directionAxis(Direction direction) -{ - switch (direction) { - case Direction::XY: { - return 2; - } - case Direction::YZ: { - return 0; - } - case Direction::XZ: { - return 1; - } - default: { - return -1; - } - } -} - -ModuleClip::Direction ModuleClip::stringToDirection(const QString& name) -{ - if (name == "XY") { - return Direction::XY; - } else if (name == "YZ") { - return Direction::YZ; - } else if (name == "XZ") { - return Direction::XZ; - } else { - return Direction::Custom; - } -} - -ModuleClip::Direction ModuleClip::modeToDirection(int planeMode) -{ - switch (planeMode) { - case 5: { - return Direction::XY; - } - case 6: { - return Direction::YZ; - } - case 7: { - return Direction::XZ; - } - default: { - return Direction::Custom; - } - } -} - -} // namespace tomviz diff --git a/tomviz/modules/ModuleClip.h b/tomviz/modules/ModuleClip.h deleted file mode 100644 index bbbd96151..000000000 --- a/tomviz/modules/ModuleClip.h +++ /dev/null @@ -1,114 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleClip_h -#define tomvizModuleClip_h - -#include "Module.h" - -#include -#include - -#include - -class QComboBox; -class pqColorChooserButton; -class pqLineEdit; -class vtkPlane; -class vtkScalarsToColors; -class vtkSMProxy; -class vtkSMSourceProxy; -class vtkNonOrthoImagePlaneWidget; - -namespace tomviz { - -class IntSliderWidget; -class DoubleSliderWidget; - -class ModuleClip : public Module -{ - Q_OBJECT - -public: - ModuleClip(QObject* parent = nullptr); - virtual ~ModuleClip(); - - QString label() const override { return "Clip"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - void addToPanel(QWidget* panel) override; - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - - QString exportDataTypeString() override { return "Image"; } - - enum Direction - { - XY = 0, - YZ = 1, - XZ = 2, - Custom = 3 - }; - Q_ENUM(Direction) - -signals: - void clipFilterUpdated(vtkPlane*, bool); - -protected: - void updatePlaneWidget(); - static Direction stringToDirection(const QString& name); - static Direction modeToDirection(int planeMode); - vtkImageData* imageData() const; - vtkScalarsToColors* createLookupTable(); - -private slots: - void onPropertyChanged(); - void onPlaneChanged(); - void onInvertPlaneChanged(); - - void dataUpdated(); - void onUpdateColor(const QColor& color); - void setPlaneColor(double rgb[3]); - - void onOpacityChanged(double opacity); - void onDirectionChanged(Direction direction); - void onPlaneChanged(int plane); - void onPlaneChanged(double* point); - int directionAxis(Direction direction); - -private: - // Should only be called from initialize after the ClipFilter has been setup. - bool setupWidget(vtkSMViewProxy* view); - - Q_DISABLE_COPY(ModuleClip) - - vtkWeakPointer m_clip; - vtkSmartPointer m_propsPanelProxy; - vtkSmartPointer m_widget; - vtkSmartPointer m_clippingPlane; - bool m_ignoreSignals = false; - - pqPropertyLinks m_Links; - - QPointer m_directionCombo; - QPointer m_planeSlider; - Direction m_direction = Direction::XY; - int m_planePosition = 0; - QPointer m_opacitySlider; - double m_opacity = 0.1; - QColor m_planeColor = QColor(0, 0, 0); - - QPointer m_colorSelector; - QPointer m_pointInputs[3]; - QPointer m_normalInputs[3]; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleContour.cxx b/tomviz/modules/ModuleContour.cxx deleted file mode 100644 index 421c3562e..000000000 --- a/tomviz/modules/ModuleContour.cxx +++ /dev/null @@ -1,611 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleContour.h" -#include "ModuleContourWidget.h" - -#include "DataSource.h" - -#include "vtkActiveScalarsProducer.h" -#include "vtkActor.h" -#include "vtkColorTransferFunction.h" -#include "vtkDataSetMapper.h" -#include "vtkFlyingEdges3D.h" -#include "vtkPVRenderView.h" -#include "vtkPointData.h" -#include "vtkProbeFilter.h" -#include "vtkProperty.h" -#include "vtkSMViewProxy.h" - -#include -#include -#include - -#include - -namespace tomviz { - -class ModuleContour::Private -{ -public: - bool ColorByArray = false; - bool UseSolidColor = false; - QString ColorArrayName; - vtkNew ColorArrayProducer; - vtkNew ContourArrayProducer; -}; - -ModuleContour::ModuleContour(QObject* parentObject) : Module(parentObject) -{ - d = new Private; -} - -ModuleContour::~ModuleContour() -{ - finalize(); - - delete d; - d = nullptr; -} - -QIcon ModuleContour::icon() const -{ - return QIcon(":pqWidgets/Icons/pqIsosurface.svg"); -} - -bool ModuleContour::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - d->ColorArrayName = data->activeScalars(); - - updateContourArrayProducer(); - - m_flyingEdges->SetInputConnection(d->ContourArrayProducer->GetOutputPort()); - resetIsoValue(); - - auto* displayPosition = data->displayPosition(); - m_actor->SetPosition(displayPosition[0], displayPosition[1], - displayPosition[2]); - auto* displayOrientation = data->displayOrientation(); - m_actor->SetOrientation(displayOrientation[0], displayOrientation[1], - displayOrientation[2]); - - m_mapper->SetInputConnection(m_flyingEdges->GetOutputPort()); - m_mapper->SetScalarModeToUsePointFieldData(); - onColorMapDataToggled(true); - updateColorMap(); - m_actor->SetMapper(m_mapper); - - m_actor->SetProperty(m_property); - m_property->SetSpecularPower(100); - - m_view = vtkPVRenderView::SafeDownCast(vtkView->GetClientSideView()); - m_view->AddPropToRenderer(m_actor); - m_view->Update(); - - connect(data, &DataSource::dataChanged, this, &ModuleContour::onDataChanged); - connect(data, &DataSource::dataPropertiesChanged, this, - &ModuleContour::onDataPropertiesChanged); - connect(data, &DataSource::activeScalarsChanged, this, - &ModuleContour::onActiveScalarsChanged); - - emit renderNeeded(); - return true; -} - -bool ModuleContour::finalize() -{ - if (m_view) { - m_view->RemovePropFromRenderer(m_actor); - } - return true; -} - -void ModuleContour::onDataChanged() -{ - // FIXME: Implementing the vtkActiveScalarsProducer as a producer breaks the - // vtk pipeline, requiring manual updates to keep in sync like here. - // It really should be implemented as a filter. - onDataPropertiesChanged(); -} - -void ModuleContour::onDataPropertiesChanged() -{ - updateContourArrayProducer(); - updateContourByArrayOptions(); - updateColorArrayProducer(); - updateColorByArrayOptions(); -} - -void ModuleContour::onActiveScalarsChanged() -{ - // We only need to update if we are using the default option - if (activeScalars() != Module::defaultScalarsIdx()) - return; - - updateContourArrayProducer(); - updateIsoRange(); - resetIsoValue(); - updateColorMap(); - emit renderNeeded(); -} - -void ModuleContour::updateContourArrayProducer() -{ - d->ContourArrayProducer->SetOutput(dataSource()->dataObject()); - auto name = contourByArrayName().toStdString(); - d->ContourArrayProducer->SetActiveScalars(name.c_str()); -} - -void ModuleContour::updateColorArrayProducer() -{ - if (!d->ColorByArray) { - clearColorArrayProducer(); - return; - } - - d->ColorArrayProducer->SetOutput(dataSource()->dataObject()); - auto activeName = colorByArrayName().toStdString(); - d->ColorArrayProducer->SetActiveScalars(activeName.c_str()); -} - -void ModuleContour::clearColorArrayProducer() -{ - d->ColorArrayProducer->SetOutput(nullptr); -} - -void ModuleContour::updateColorMap() -{ - auto* lut = - vtkColorTransferFunction::SafeDownCast(colorMap()->GetClientSideObject()); - m_mapper->SetLookupTable(lut); - - updateColorArray(); -} - -void ModuleContour::updateColorArray() -{ - std::string name; - if (colorByArray()) { - name = colorByArrayName().toStdString(); - } else if (useSolidColor()) { - // Have to set the color array to "" to use a solid color - // Diffuse color should already be set on the property - name = ""; - } else { - name = contourByArrayName().toStdString(); - } - - m_mapper->SelectColorArray(name.c_str()); -} - -bool ModuleContour::setVisibility(bool val) -{ - m_actor->SetVisibility(val ? 1 : 0); - Module::setVisibility(val); - return true; -} - -bool ModuleContour::visibility() const -{ - return m_actor->GetVisibility() == 1; -} - -void ModuleContour::addToPanel(QWidget* panel) -{ - if (panel->layout()) - delete panel->layout(); - - QVBoxLayout* layout = new QVBoxLayout; - panel->setLayout(layout); - - // Create, update and connect - m_controllers = new ModuleContourWidget; - layout->addWidget(m_controllers); - - updatePanel(); - - connect(m_controllers, &ModuleContourWidget::colorMapDataToggled, this, - &ModuleContour::onColorMapDataToggled); - connect(m_controllers, &ModuleContourWidget::ambientChanged, this, - &ModuleContour::onAmbientChanged); - connect(m_controllers, &ModuleContourWidget::diffuseChanged, this, - &ModuleContour::onDiffuseChanged); - connect(m_controllers, &ModuleContourWidget::specularChanged, this, - &ModuleContour::onSpecularChanged); - connect(m_controllers, &ModuleContourWidget::specularPowerChanged, this, - &ModuleContour::onSpecularPowerChanged); - connect(m_controllers, &ModuleContourWidget::isoChanged, this, - &ModuleContour::onIsoChanged); - connect(m_controllers, &ModuleContourWidget::representationChanged, this, - &ModuleContour::onRepresentationChanged); - connect(m_controllers, &ModuleContourWidget::opacityChanged, this, - &ModuleContour::onOpacityChanged); - connect(m_controllers, &ModuleContourWidget::colorChanged, this, - &ModuleContour::onColorChanged); - connect(m_controllers, &ModuleContourWidget::useSolidColorToggled, this, - &ModuleContour::onUseSolidColorToggled); - connect(m_controllers, &ModuleContourWidget::contourByArrayValueChanged, this, - &ModuleContour::onContourByArrayValueChanged); - connect(m_controllers, &ModuleContourWidget::colorByArrayToggled, this, - &ModuleContour::onColorByArrayToggled); - connect(m_controllers, &ModuleContourWidget::colorByArrayNameChanged, this, - &ModuleContour::onColorByArrayNameChanged); -} - -void ModuleContour::updatePanel() -{ - if (!m_controllers) - return; - - QSignalBlocker blocker(m_controllers); - - updateIsoRange(); - updateContourByArrayOptions(); - updateColorByArrayOptions(); - - m_controllers->setColorMapData(colorMapData()); - m_controllers->setAmbient(ambient()); - m_controllers->setDiffuse(diffuse()); - m_controllers->setSpecular(specular()); - m_controllers->setSpecularPower(specularPower()); - m_controllers->setIso(iso()); - m_controllers->setRepresentation(representation()); - m_controllers->setOpacity(opacity()); - m_controllers->setColor(color()); - m_controllers->setUseSolidColor(useSolidColor()); - m_controllers->setContourByArrayValue(activeScalars()); - m_controllers->setColorByArray(colorByArray()); - m_controllers->setColorByArrayName(colorByArrayName()); -} - -namespace { - -void getRange(vtkAlgorithm* alg, double range[2]) -{ - for (int i = 0; i < 2; ++i) - range[i] = 0.0; - - if (!alg) - return; - - auto* data = vtkDataSet::SafeDownCast(alg->GetOutputDataObject(0)); - if (!data) - return; - - auto* arrayPtr = data->GetPointData()->GetScalars(); - if (!arrayPtr) - return; - - arrayPtr->GetFiniteRange(range, -1); -} - -} // namespace - -void ModuleContour::updateIsoRange() -{ - if (!m_controllers) - return; - - double range[2]; - getRange(d->ContourArrayProducer, range); - m_controllers->setIsoRange(range); -} - -void ModuleContour::updateContourByArrayOptions() -{ - if (!m_controllers) - return; - - QSignalBlocker blocker(m_controllers); - m_controllers->setContourByArrayOptions(dataSource(), this); -} - -void ModuleContour::updateColorByArrayOptions() -{ - if (!m_controllers) - return; - - QSignalBlocker blocker(m_controllers); - QStringList options = dataSource()->listScalars(); - m_controllers->setColorByArrayOptions(options); - - if (!options.contains(colorByArrayName())) - onColorByArrayNameChanged(dataSource()->activeScalars()); - - m_controllers->setColorByArrayName(colorByArrayName()); -} - -void ModuleContour::resetIsoValue() -{ - double range[2]; - getRange(d->ContourArrayProducer, range); - - // Use 2/3 of the range by default - double val = (range[1] - range[0]) * 2 / 3 + range[0]; - setIsoValue(val); -} - -void ModuleContour::isoRange(double range[2]) -{ - getRange(d->ContourArrayProducer, range); -} - -QJsonObject ModuleContour::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - props["useSolidColor"] = useSolidColor(); - props["color"] = color().name(); - props["contourValue"] = iso(); - - QJsonObject lighting; - lighting["ambient"] = ambient(); - lighting["diffuse"] = diffuse(); - lighting["specular"] = specular(); - lighting["specularPower"] = specularPower(); - props["lighting"] = lighting; - - props["representation"] = representation(); - props["opacity"] = opacity(); - props["mapScalars"] = colorMapData(); - props["colorByArray"] = colorByArray(); - props["colorByArrayName"] = colorByArrayName(); - - json["properties"] = props; - - return json; -} - -bool ModuleContour::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - - if (!json["properties"].isObject()) - return false; - - auto props = json["properties"].toObject(); - onUseSolidColorToggled(props["useSolidColor"].toBool()); - onColorChanged(QColor(props["color"].toString())); - - QJsonObject lighting = props["lighting"].toObject(); - onAmbientChanged(lighting["ambient"].toDouble()); - onDiffuseChanged(lighting["diffuse"].toDouble()); - onSpecularChanged(lighting["specular"].toDouble()); - onSpecularPowerChanged(lighting["specularPower"].toDouble()); - - onRepresentationChanged(props["representation"].toString()); - onOpacityChanged(props["opacity"].toDouble()); - onColorMapDataToggled(props["mapScalars"].toBool()); - onColorByArrayToggled(props["colorByArray"].toBool()); - onColorByArrayNameChanged(props["colorByArrayName"].toString()); - - // Some of the above operations modify the contour value. - // Set this at the end. - onIsoChanged(props["contourValue"].toDouble()); - - updatePanel(); - - return true; -} - -void ModuleContour::dataSourceMoved(double newX, double newY, double newZ) -{ - m_actor->SetPosition(newX, newY, newZ); -} - -void ModuleContour::dataSourceRotated(double newX, double newY, double newZ) -{ - m_actor->SetOrientation(newX, newY, newZ); -} - -vtkDataObject* ModuleContour::dataToExport() -{ - return m_flyingEdges->GetOutputDataObject(0); -} - -void ModuleContour::setIsoValue(double value) -{ - onIsoChanged(value); - if (m_controllers) { - QSignalBlocker blocker(m_controllers); - m_controllers->setIso(value); - } -} - -bool ModuleContour::colorMapData() const -{ - return m_mapper->GetColorMode() == VTK_COLOR_MODE_MAP_SCALARS; -} - -double ModuleContour::ambient() const -{ - return m_property->GetAmbient(); -} - -double ModuleContour::diffuse() const -{ - return m_property->GetDiffuse(); -} - -double ModuleContour::specular() const -{ - return m_property->GetSpecular(); -} - -double ModuleContour::iso() const -{ - return m_flyingEdges->GetValue(0); -} - -double ModuleContour::specularPower() const -{ - return m_property->GetSpecularPower(); -} - -QString ModuleContour::representation() const -{ - return m_property->GetRepresentationAsString(); -} - -double ModuleContour::opacity() const -{ - return m_property->GetOpacity(); -} - -QColor ModuleContour::color() const -{ - double rgb[3]; - m_property->GetDiffuseColor(rgb); - return QColor(static_cast(rgb[0] * 255.0 + 0.5), - static_cast(rgb[1] * 255.0 + 0.5), - static_cast(rgb[2] * 255.0 + 0.5)); -} - -bool ModuleContour::useSolidColor() const -{ - return d->UseSolidColor; -} - -QString ModuleContour::contourByArrayName() const -{ - if (activeScalars() == Module::defaultScalarsIdx()) - return dataSource()->activeScalars(); - - return dataSource()->scalarsName(activeScalars()); -} - -bool ModuleContour::colorByArray() const -{ - return d->ColorByArray; -} - -QString ModuleContour::colorByArrayName() const -{ - return d->ColorArrayName; -} - -void ModuleContour::onColorMapDataToggled(const bool state) -{ - int mode = state ? VTK_COLOR_MODE_MAP_SCALARS : VTK_COLOR_MODE_DIRECT_SCALARS; - m_mapper->SetColorMode(mode); - emit renderNeeded(); -} - -void ModuleContour::onAmbientChanged(const double value) -{ - m_property->SetAmbient(value); - emit renderNeeded(); -} - -void ModuleContour::onDiffuseChanged(const double value) -{ - m_property->SetDiffuse(value); - emit renderNeeded(); -} - -void ModuleContour::onSpecularChanged(const double value) -{ - m_property->SetSpecular(value); - emit renderNeeded(); -} - -void ModuleContour::onSpecularPowerChanged(const double value) -{ - m_property->SetSpecularPower(value); - emit renderNeeded(); -} - -void ModuleContour::onIsoChanged(const double value) -{ - m_flyingEdges->SetValue(0, value); - emit renderNeeded(); -} - -void ModuleContour::onRepresentationChanged(const QString& representation) -{ - if (representation == "Surface") - m_property->SetRepresentationToSurface(); - else if (representation == "Points") - m_property->SetRepresentationToPoints(); - else if (representation == "Wireframe") - m_property->SetRepresentationToWireframe(); - - emit renderNeeded(); -} - -void ModuleContour::onOpacityChanged(const double value) -{ - m_property->SetOpacity(value); - emit renderNeeded(); -} - -void ModuleContour::onColorChanged(const QColor& c) -{ - double rgb[3] = { c.red() / 255.0, c.green() / 255.0, c.blue() / 255.0 }; - m_property->SetDiffuseColor(rgb); - emit renderNeeded(); -} - -void ModuleContour::onUseSolidColorToggled(const bool state) -{ - d->UseSolidColor = state; - updateColorMap(); - emit renderNeeded(); -} - -void ModuleContour::onContourByArrayValueChanged(int i) -{ - setActiveScalars(i); - updateContourArrayProducer(); - updateIsoRange(); - resetIsoValue(); - updateColorMap(); - emit renderNeeded(); -} - -void ModuleContour::onColorByArrayToggled(const bool state) -{ - d->ColorByArray = state; - updateColorArrayProducer(); - if (state) { - m_probeFilter->SetInputConnection(m_flyingEdges->GetOutputPort()); - m_probeFilter->SetSourceConnection(d->ColorArrayProducer->GetOutputPort()); - m_mapper->SetInputConnection(m_probeFilter->GetOutputPort()); - } else { - m_probeFilter->RemoveAllInputs(); - m_mapper->SetInputConnection(m_flyingEdges->GetOutputPort()); - } - updateColorMap(); - emit renderNeeded(); -} - -void ModuleContour::onColorByArrayNameChanged(const QString& name) -{ - d->ColorArrayName = name; - updateColorArrayProducer(); - updateColorMap(); - emit renderNeeded(); -} - -bool ModuleContour::updateClippingPlane(vtkPlane* plane, bool newFilter) -{ - if (m_mapper->GetNumberOfClippingPlanes()) { - m_mapper->RemoveClippingPlane(plane); - } - if (!newFilter) { - m_mapper->AddClippingPlane(plane); - } - - emit renderNeeded(); - - return true; -} - -} // end of namespace tomviz diff --git a/tomviz/modules/ModuleContour.h b/tomviz/modules/ModuleContour.h deleted file mode 100644 index 2075e094d..000000000 --- a/tomviz/modules/ModuleContour.h +++ /dev/null @@ -1,116 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleContour_h -#define tomvizModuleContour_h - -#include "Module.h" - -#include - -class vtkActor; -class vtkDataSetMapper; -class vtkFlyingEdges3D; -class vtkProbeFilter; -class vtkProperty; -class vtkPVRenderView; - -namespace tomviz { - -class ModuleContourWidget; - -class ModuleContour : public Module -{ - Q_OBJECT - -public: - ModuleContour(QObject* parent = nullptr); - ~ModuleContour() override; - - QString label() const override { return "Contour"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - void addToPanel(QWidget*) override; - bool setVisibility(bool val) override; - bool visibility() const override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - bool isColorMapNeeded() const override { return true; } - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - - void setIsoValue(double value); - void resetIsoValue(); - void isoRange(double range[2]); - - QString exportDataTypeString() override { return "Mesh"; } - - vtkDataObject* dataToExport() override; - - bool colorMapData() const; - double ambient() const; - double diffuse() const; - double specular() const; - double specularPower() const; - double iso() const; - QString representation() const; - double opacity() const; - QColor color() const; - bool useSolidColor() const; - QString contourByArrayName() const; - bool colorByArray() const; - QString colorByArrayName() const; - bool updateClippingPlane(vtkPlane* plane, bool newFilter) override; - -protected: - void updatePanel(); - void updateColorMap() override; - void updateColorArray(); - void updateContourArrayProducer(); - void updateColorArrayProducer(); - void clearColorArrayProducer(); - void updateIsoRange(); - void updateContourByArrayOptions(); - void updateColorByArrayOptions(); - - vtkNew m_actor; - vtkNew m_mapper; - vtkNew m_property; - vtkNew m_flyingEdges; - vtkNew m_probeFilter; - vtkWeakPointer m_view; - - class Private; - Private* d; - - QPointer m_controllers; - - QString m_representation; - -private slots: - void onDataChanged(); - void onDataPropertiesChanged(); - void onActiveScalarsChanged(); - void onColorMapDataToggled(const bool state); - void onAmbientChanged(const double value); - void onDiffuseChanged(const double value); - void onSpecularChanged(const double value); - void onSpecularPowerChanged(const double value); - void onIsoChanged(const double value); - void onRepresentationChanged(const QString& representation); - void onOpacityChanged(const double value); - void onColorChanged(const QColor& color); - void onUseSolidColorToggled(const bool state); - void onContourByArrayValueChanged(int i); - void onColorByArrayToggled(const bool state); - void onColorByArrayNameChanged(const QString& name); - -private: - Q_DISABLE_COPY(ModuleContour) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleFactory.cxx b/tomviz/modules/ModuleFactory.cxx deleted file mode 100644 index 69e186e2d..000000000 --- a/tomviz/modules/ModuleFactory.cxx +++ /dev/null @@ -1,264 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleFactory.h" - -#include "DataSource.h" -#include "ModuleClip.h" -#include "ModuleContour.h" -#include "ModuleMolecule.h" -#include "ModuleOutline.h" -#include "ModulePlot.h" -#include "ModuleRuler.h" -#include "ModuleScaleCube.h" -#include "ModuleSegment.h" -#include "ModuleSlice.h" -#include "ModuleThreshold.h" -#include "ModuleVolume.h" -#include "OperatorResult.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include - -#include -#include - -namespace tomviz { - -ModuleFactory::ModuleFactory() {} - -ModuleFactory::~ModuleFactory() {} - -QList ModuleFactory::moduleTypes() -{ - QList reply; - reply << "Outline" - << "Slice" - << "Ruler" - << "Scale Cube" - << "Contour" - << "Volume" - << "Threshold" - << "Molecule" - << "Clip" - << "Plot"; - std::sort(reply.begin(), reply.end()); - return reply; -} - -bool ModuleFactory::moduleApplicable(const QString& moduleName, - DataSource* dataSource, - vtkSMViewProxy* view) -{ - Q_UNUSED(view); - - if (moduleName == "Molecule" || moduleName == "Plot") { - return false; - } - - if (dataSource) { - if (dataSource->getNumberOfComponents() > 1) { - if (moduleName == "Contour" || moduleName == "Threshold") { - return false; - } - } - return true; - } - return false; -} - -bool ModuleFactory::moduleApplicable(const QString& moduleName, - MoleculeSource* moleculeSource, - vtkSMViewProxy* view) -{ - Q_UNUSED(view); - - if (moduleName == "Molecule") { - if (moleculeSource) { - return true; - } - } - return false; -} - -bool ModuleFactory::moduleApplicable(const QString& moduleName, - OperatorResult* operatorResult, - vtkSMViewProxy* view) -{ - Q_UNUSED(view); - - if (moduleName == "Plot") { - return ( - operatorResult && - vtkTable::SafeDownCast(operatorResult->dataObject()) - ); - } else if (moduleName == "Molecule") { - return ( - operatorResult && - vtkMolecule::SafeDownCast(operatorResult->dataObject()) - ); - } - - return false; -} - -Module* ModuleFactory::allocateModule(const QString& type) -{ - Module* module = nullptr; - if (type == "Outline") { - module = new ModuleOutline(); - } else if (type == "Contour") { - module = new ModuleContour(); - } else if (type == "Volume") { - module = new ModuleVolume(); - } else if (type == "Slice") { - module = new ModuleSlice(); - } else if (type == "Orthogonal Slice") { - // Keep this to be able to open older state files. - module = new ModuleSlice(); - } else if (type == "Threshold") { - module = new ModuleThreshold(); - } else if (type == "Ruler") { - module = new ModuleRuler(); - } else if (type == "Scale Cube") { - module = new ModuleScaleCube(); - } else if (type == "Molecule") { - module = new ModuleMolecule(); - } else if (type== "Clip") { - module = new ModuleClip(); - } else if (type == "Plot") { - module = new ModulePlot(); - } - return module; -} - -Module* ModuleFactory::createModule(const QString& type, DataSource* dataSource, - vtkSMViewProxy* view) -{ - auto module = allocateModule(type); - if (module) { - // sanity check. - Q_ASSERT(type == moduleType(module)); - if (dataSource == nullptr && view == nullptr) { - // don't initialize module if args are NULL. - return module; - } - - bool success; - success = module->initialize(dataSource, view); - if (!success) { - delete module; - return nullptr; - } - pqView* pqview = tomviz::convert(view); - pqview->render(); - } - return module; -} - -Module* ModuleFactory::createModule(const QString& type, - MoleculeSource* moleculeSource, - vtkSMViewProxy* view) -{ - auto module = allocateModule(type); - if (module) { - // sanity check. - Q_ASSERT(type == moduleType(module)); - if (moleculeSource == nullptr && view == nullptr) { - // don't initialize module if args are NULL. - return module; - } - - bool success; - success = module->initialize(moleculeSource, view); - if (!success) { - delete module; - return nullptr; - } - pqView* pqview = tomviz::convert(view); - pqview->render(); - } - return module; -} - -Module* ModuleFactory::createModule(const QString& type, OperatorResult* result, - vtkSMViewProxy* view) -{ - auto module = allocateModule(type); - if (module) { - // sanity check. - Q_ASSERT(type == moduleType(module)); - if (result == nullptr && view == nullptr) { - // don't initialize module if args are NULL. - return module; - } - - bool success; - success = module->initialize(result, view); - if (!success) { - delete module; - return nullptr; - } - pqView* pqview = tomviz::convert(view); - pqview->render(); - } - return module; -} - -QIcon ModuleFactory::moduleIcon(const QString& type) -{ - QIcon icon; - Module* mdl = ModuleFactory::allocateModule(type); - if (mdl) { - icon = mdl->icon(); - delete mdl; - } - return icon; -} - -const char* ModuleFactory::moduleType(const Module* module) -{ - // WARNING: to ensure the most useful result is returned from this - // function, the if statements should be ordered so that child - // classes are checked before parent classes. Otherwise, the module - // type may be reported to be a class's parent. - - if (qobject_cast(module)) { - return "Outline"; - } - if (qobject_cast(module)) { - return "Contour"; - } - if (qobject_cast(module)) { - return "Volume"; - } - if (qobject_cast(module)) { - return "Slice"; - } - if (qobject_cast(module)) { - return "Threshold"; - } - if (qobject_cast(module)) { - return "Ruler"; - } - if (qobject_cast(module)) { - return "Scale Cube"; - } - if (qobject_cast(module)) { - return "Molecule"; - } - if (qobject_cast(module)) { - return "Plot"; - } - if (qobject_cast(module)) { - return "Clip"; - } - return nullptr; -} - -} // end of namespace tomviz diff --git a/tomviz/modules/ModuleFactory.h b/tomviz/modules/ModuleFactory.h deleted file mode 100644 index a28e3b6ce..000000000 --- a/tomviz/modules/ModuleFactory.h +++ /dev/null @@ -1,61 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleFactory_h -#define tomvizModuleFactory_h - -#include -#include - -class vtkSMViewProxy; - -namespace tomviz { -class DataSource; -class MoleculeSource; -class Module; -class OperatorResult; - -class ModuleFactory -{ - typedef QObject Superclass; - -public: - /// Returns a list of module types that can be created for the data source - /// in the provided view. - static QList moduleTypes(); - - /// Returns whether the module of the given name is applicable to the - /// DataSource and View. - static bool moduleApplicable(const QString& moduleName, - DataSource* dataSource, vtkSMViewProxy* view); - static bool moduleApplicable(const QString& moduleName, - MoleculeSource* moleculeSource, - vtkSMViewProxy* view); - static bool moduleApplicable(const QString& moduleName, - OperatorResult* operatorResult, - vtkSMViewProxy* view); - - /// Creates a module of the given type to show the dataSource in the view. - static Module* createModule(const QString& type, DataSource* dataSource, - vtkSMViewProxy* view); - static Module* createModule(const QString& type, - MoleculeSource* moleculeSource, - vtkSMViewProxy* view); - static Module* createModule(const QString& type, OperatorResult* result, - vtkSMViewProxy* view); - - /// Returns the type for a module instance. - static const char* moduleType(const Module* module); - - /// Returns the icon for a module. - static QIcon moduleIcon(const QString& type); - -private: - ModuleFactory(); - ~ModuleFactory(); - Q_DISABLE_COPY(ModuleFactory) - static Module* allocateModule(const QString& type); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleManager.cxx b/tomviz/modules/ModuleManager.cxx deleted file mode 100644 index a72687aeb..000000000 --- a/tomviz/modules/ModuleManager.cxx +++ /dev/null @@ -1,1484 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleManager.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "LoadDataReaction.h" -#include "ModuleFactory.h" -#include "MoleculeSource.h" -#include "Pipeline.h" -#include "PythonGeneratedDatasetReaction.h" -#include "Utilities.h" -#include "tomvizConfig.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace tomviz { - -static QList toObjectList(const QJsonArray& array) -{ - QList ret; - for (const auto& item : array) { - ret.append(item.toObject()); - } - return ret; -} - -class ModuleManager::MMInternals -{ -public: - // TODO Should only hold top level roots of pipeline - QList> DataSources; - QList> MoleculeSources; - QList> ChildDataSources; - QList> Modules; - QMap> RenderViewCameras; - - // Map from view proxies to modules. Used to keep track of how many modules - // have been added to a view. - QMultiMap ViewModules; - - // State for the "state finished loading signal" - int RemaningPipelinesToWaitFor; - bool LastStateLoadSuccess; - - // Ensure all pipelines created when restoring the state are not executed - bool ExecutePipelinesOnLoad = true; - - // Only used by onPVStateLoaded for the second half of deserialize - QDir dir; - QMap ViewIdMap; - void relativeFilePaths(DataSource* ds, const QDir& stateDir, - QJsonObject& dataSourceState) - { - QJsonObject readerProps; - if (dataSourceState.contains("reader") && - dataSourceState["reader"].isObject()) { - readerProps = dataSourceState["reader"].toObject(); - } - - // Make any reader fileName properties relative to the state file being - // written. - if (readerProps.contains("fileNames")) { - // Exclude transient data sources. - // ( ones without a file. i.e. output data sources ) - if (!ds->isTransient()) { - auto fileNames = readerProps["fileNames"].toArray(); - QJsonArray relativeNames; - foreach (QJsonValue name, fileNames) { - relativeNames.append(stateDir.relativeFilePath(name.toString())); - } - readerProps["fileNames"] = relativeNames; - } - dataSourceState["reader"] = readerProps; - } - } - - void relativeFilePaths(MoleculeSource*, const QDir& stateDir, - QJsonObject& dataSourceState) - { - QJsonObject readerProps; - if (dataSourceState.contains("reader") && - dataSourceState["reader"].isObject()) { - readerProps = dataSourceState["reader"].toObject(); - } - - // Make any reader fileName properties relative to the state file being - // written. - if (readerProps.contains("fileName")) { - auto fileName = readerProps["fileName"].toString(); - readerProps["fileName"] = stateDir.relativeFilePath(fileName); - dataSourceState["reader"] = readerProps; - } - } - - void absoluteFilePaths(QJsonObject& dataSourceState) - { - - std::function absolute = [this](QString path) { - if (!path.isEmpty()) { - path = QDir::cleanPath(this->dir.absoluteFilePath(path)); - } - - return path; - }; - - if (dataSourceState.contains("reader") && - dataSourceState["reader"].isObject()) { - auto reader = dataSourceState["reader"].toObject(); - if (reader.contains("fileNames") && reader["fileNames"].isArray()) { - auto fileNames = reader["fileNames"].toArray(); - QJsonArray absoluteFileNames; - foreach (const QJsonValue& path, fileNames) { - absoluteFileNames.append(absolute(path.toString())); - } - reader["fileNames"] = absoluteFileNames; - } - if (reader.contains("fileName") && reader["fileName"].isString()) { - QString absoluteFileName = absolute(reader["fileName"].toString()); - reader["fileName"] = absoluteFileName; - } - dataSourceState["reader"] = reader; - } - } - - QStringList dataSourceDependencies(const QJsonObject& ds) - { - // Traverse recursively through the operators and find any - // data source dependencies. Returns a list of their ids. - QStringList deps; - for (auto opVal : ds.value("operators").toArray()) { - auto op = opVal.toObject(); - auto args = op.value("arguments").toObject(); - for (auto& key : args.keys()) { - auto value = args[key]; - if (value.isString() && value.toString().startsWith("0x")) { - // This is a dependency, add it. - deps.append(value.toString()); - } - } - - for (auto childVal : op.value("dataSources").toArray()) { - // Recursively add child dependencies. - deps.append(dataSourceDependencies(childVal.toObject())); - } - } - - return deps; - } - - QJsonObject rootDataSourceDependency(QString dep, - QList& dataSources) - { - // Find the root data source of this dependency. - // Searches through the list of dataSources to find this information. - for (auto& ds : dataSources) { - if (ds.value("id").toString() == dep) { - return ds; - } - - for (auto opVal : ds.value("operators").toArray()) { - auto op = opVal.toObject(); - auto children = toObjectList(op.value("dataSources").toArray()); - - // Recursively search for it in the children - auto result = rootDataSourceDependency(dep, children); - if (!result.isEmpty()) { - return ds; - } - } - } - - // Didn't find it... - return {}; - } - - bool isChildDataSourceDependency(QString dep, QList& dataSources) - { - auto root = rootDataSourceDependency(dep, dataSources); - return root.value("id").toString() != dep; - } - - void removeInvalidDataSources() - { - int i = 0; - while (i < DataSources.size()) { - if (DataSources[i]) { - // Valid - i += 1; - continue; - } else { - // Invalid. Remove. - DataSources.removeAt(i); - } - } - } - - void removeInvalidChildDataSources() - { - int i = 0; - while (i < ChildDataSources.size()) { - if (ChildDataSources[i]) { - // Valid - i += 1; - continue; - } else { - // Invalid. Remove. - ChildDataSources.removeAt(i); - } - } - } -}; - -ModuleManager::ModuleManager(QObject* parentObject) - : Superclass(parentObject), d(new ModuleManager::MMInternals()) -{ - connect(pqApplicationCore::instance()->getServerManagerModel(), - &pqServerManagerModel::viewRemoved, this, - &ModuleManager::onViewRemoved); -} - -ModuleManager::~ModuleManager() = default; - -ModuleManager& ModuleManager::instance() -{ - static ModuleManager theInstance; - return theInstance; -} - -void ModuleManager::reset() -{ - removeAllModules(); - removeAllDataSources(); - removeAllMoleculeSources(); - pqDeleteReaction::deleteAll(); -} - -bool ModuleManager::hasRunningOperators() -{ - d->removeInvalidDataSources(); - for (QPointer dsource : d->DataSources) { - if (dsource->pipeline()->isRunning()) { - return true; - } - } - return false; -} - -QList ModuleManager::dataSources() -{ - // Somehow we have invalid data sources around. Make sure - // those are removed. - d->removeInvalidDataSources(); - QList ret; - std::copy(d->DataSources.begin(), d->DataSources.end(), - std::back_inserter(ret)); - return ret; -} - -QList ModuleManager::childDataSources() -{ - // Somehow we have invalid data sources around. Make sure - // those are removed. - d->removeInvalidChildDataSources(); - QList ret; - std::copy(d->ChildDataSources.begin(), d->ChildDataSources.end(), - std::back_inserter(ret)); - return ret; -} - -QList ModuleManager::allDataSources() -{ - return dataSources() + childDataSources(); -} - -QList ModuleManager::allDataSourcesDepthFirst() -{ - // Return the data sources in a Depth First Search (DFS) order, - // so that child data sources will come immediately after their - // parent data source in the list. - // This *should* match the order of data sources in the pipeline view. - QList result; - for (auto* rootSource : dataSources()) { - result.append(rootSource); - if (rootSource->operators().isEmpty()) { - // If there are no operators, there are no child data sources... - continue; - } - - auto* lastOperator = rootSource->operators().back(); - auto* childSource = lastOperator->childDataSource(); - if (childSource) { - result.append(childSource); - } - } - - return result; -} - -QStringList ModuleManager::createUniqueLabels(const QList& sources) -{ - QStringList ret; - for (auto* ds : sources) { - auto label = ds->label(); - if (ret.contains(label)) { - auto original = label; - int index = 1; - do { - label = original + QString(" (%1)").arg(++index); - } while (ret.contains(label)); - } - ret.append(label); - } - return ret; -} - -void ModuleManager::addDataSource(DataSource* dataSource) -{ - d->removeInvalidDataSources(); - if (dataSource && !d->DataSources.contains(dataSource)) { - d->DataSources.push_back(dataSource); - emit dataSourceAdded(dataSource); - } -} - -void ModuleManager::addChildDataSource(DataSource* dataSource) -{ - d->removeInvalidChildDataSources(); - if (dataSource && !d->ChildDataSources.contains(dataSource)) { - d->ChildDataSources.push_back(dataSource); - emit childDataSourceAdded(dataSource); - } -} - -void ModuleManager::removeDataSource(DataSource* dataSource) -{ - d->removeInvalidDataSources(); - d->removeInvalidChildDataSources(); - if (d->DataSources.removeOne(dataSource) || - d->ChildDataSources.removeOne(dataSource)) { - emit dataSourceRemoved(dataSource); - dataSource->deleteLater(); - } -} - -void ModuleManager::removeChildDataSource(DataSource* dataSource) -{ - d->removeInvalidChildDataSources(); - if (d->ChildDataSources.removeOne(dataSource)) { - emit childDataSourceRemoved(dataSource); - dataSource->deleteLater(); - } -} - -void ModuleManager::removeAllDataSources() -{ - d->removeInvalidDataSources(); - foreach (DataSource* dataSource, d->DataSources) { - emit dataSourceRemoved(dataSource); - dataSource->deleteLater(); - } - d->DataSources.clear(); -} - -void ModuleManager::removeAllMoleculeSources() -{ - foreach (MoleculeSource* moleculeSource, d->MoleculeSources) { - emit moleculeSourceRemoved(moleculeSource); - moleculeSource->deleteLater(); - } - d->MoleculeSources.clear(); -} - -void ModuleManager::addMoleculeSource(MoleculeSource* moleculeSource) -{ - if (moleculeSource && !d->MoleculeSources.contains(moleculeSource)) { - d->MoleculeSources.push_back(moleculeSource); - emit moleculeSourceAdded(moleculeSource); - } -} - -void ModuleManager::removeMoleculeSource(MoleculeSource* moleculeSource) -{ - if (d->MoleculeSources.removeOne(moleculeSource)) { - emit moleculeSourceRemoved(moleculeSource); - } -} - -void ModuleManager::removeOperator(Operator* op) -{ - if (op) { - emit operatorRemoved(op); - } -} - -bool ModuleManager::isChild(DataSource* source) const -{ - d->removeInvalidChildDataSources(); - return (d->ChildDataSources.indexOf(source) >= 0); -} - -void ModuleManager::addModule(Module* module) -{ - if (!d->Modules.contains(module)) { - module->setParent(this); - d->Modules.push_back(module); - - // Reset display if this is the first module in the view. - if (d->ViewModules.count(module->view()) == 0) { - auto pqview = tomviz::convert(module->view()); - pqview->resetDisplay(); - pqview->render(); - } - d->ViewModules.insert(module->view(), module); - - // Prevent the user from adding more than 6 clipping planes - // to a data source - int count = 0; - foreach (Module* m, d->Modules) { - if (m->dataSource() == module->dataSource()) { - if (strcmp(ModuleFactory::moduleType(m), "Clip") == 0) { - ++count; - } - } - } - if (count > 6) { - QMessageBox::warning(tomviz::mainWidget(), - tr("Max Clipping Planes Reached"), - tr("No more than 6 clipping planes can be added to " - "a single data source."), - QMessageBox::Ok); - removeModule(module); - return; - } - - emit moduleAdded(module); - connect(module, &Module::renderNeeded, this, &ModuleManager::render); - connect(module, &Module::updateClientSideViewNeeded, this, - &ModuleManager::updateClientSideView); - if (strcmp(ModuleFactory::moduleType(module), "Slice") == 0) { - connect(module, &Module::mouseOverVoxel, this, - &ModuleManager::mouseOverVoxel); - } - connect(module, &Module::visibilityChanged, this, &ModuleManager::visibilityChanged); - } -} - -void ModuleManager::removeModule(Module* module) -{ - if (d->Modules.removeOne(module)) { - d->ViewModules.remove(module->view(), module); - emit moduleRemoved(module); - module->deleteLater(); - } -} - -void ModuleManager::removeAllModules() -{ - foreach (Module* module, d->Modules) { - emit moduleRemoved(module); - module->deleteLater(); - } - d->Modules.clear(); -} - -void ModuleManager::removeAllModules(DataSource* source) -{ - Q_ASSERT(source); - QList modules; - foreach (Module* module, d->Modules) { - if (module->dataSource() == source) { - modules.push_back(module); - } - } - foreach (Module* module, modules) { - removeModule(module); - } -} - -Module* ModuleManager::createAndAddModule(const QString& type, - DataSource* dataSource, - vtkSMViewProxy* view) -{ - if (!view || !dataSource) { - return nullptr; - } - - // Create an outline module for the source in the active view. - auto module = ModuleFactory::createModule(type, dataSource, view); - if (module) { - addModule(module); - } - return module; -} - -Module* ModuleManager::createAndAddModule(const QString& type, - MoleculeSource* moleculeSource, - vtkSMViewProxy* view) -{ - if (!view || !moleculeSource) { - return nullptr; - } - - // Create an outline module for the source in the active view. - auto module = ModuleFactory::createModule(type, moleculeSource, view); - if (module) { - addModule(module); - } - return module; -} - -Module* ModuleManager::createAndAddModule(const QString& type, - OperatorResult* result, - vtkSMViewProxy* view) -{ - if (!view || !result) { - return nullptr; - } - - // Create an outline module for the source in the active view. - auto module = ModuleFactory::createModule(type, result, view); - if (module) { - addModule(module); - } - return module; -} - -QList ModuleManager::findModulesGeneric(const DataSource* dataSource, - const vtkSMViewProxy* view) -{ - QList modules; - foreach (Module* module, d->Modules) { - if (module && module->dataSource() == dataSource && - (view == nullptr || view == module->view()) && - module->label() != "Molecule") { - modules.push_back(module); - } - } - return modules; -} - -QList ModuleManager::findModulesGeneric( - const MoleculeSource* dataSource, const vtkSMViewProxy* view) -{ - QList modules; - foreach (Module* module, d->Modules) { - if (module && module->moleculeSource() == dataSource && - (view == nullptr || view == module->view())) { - modules.push_back(module); - } - } - return modules; -} - -QJsonArray jsonArrayFromXml(pugi::xml_node node) -{ - // Simple function, just iterates through the elements and fills the array. - // This assumes the ParaView Element nodes, values stored in value attributes. - QJsonArray array; - for (auto element = node.child("Element"); element; - element = element.next_sibling("Element")) { - array.append(element.attribute("value").as_int(-1)); - } - return array; -} - -QJsonArray jsonArrayFromXmlDouble(pugi::xml_node node) -{ - // Simple function, just iterates through the elements and fills the array. - // This assumes the ParaView Element nodes, values stored in value attributes. - QJsonArray array; - for (auto element = node.child("Element"); element; - element = element.next_sibling("Element")) { - array.append(element.attribute("value").as_double(-1)); - } - return array; -} - -bool ModuleManager::serialize(QJsonObject& doc, const QDir& stateDir, - bool interactive) const -{ - d->removeInvalidDataSources(); - d->removeInvalidChildDataSources(); - - QJsonObject tvObj; - tvObj["version"] = QString(TOMVIZ_VERSION); - if (QString(TOMVIZ_VERSION_EXTRA).size() > 0) { - tvObj["versionExtra"] = QString(TOMVIZ_VERSION_EXTRA); - } - - auto pvVer = QString("%1.%2.%3") - .arg(vtkSMProxyManager::GetVersionMajor()) - .arg(vtkSMProxyManager::GetVersionMinor()) - .arg(vtkSMProxyManager::GetVersionPatch()); - tvObj["paraViewVersion"] = pvVer; - - doc["tomviz"] = tvObj; - - if (interactive) { - // Iterate over all data sources and check is there are any that are not - // currently saved. - int modified = 0; - foreach (const QPointer& ds, d->DataSources) { - if (ds != nullptr && - ds->persistenceState() == DataSource::PersistenceState::Modified) { - ++modified; - } - } - foreach (const QPointer& ds, d->ChildDataSources) { - if (ds != nullptr && - ds->persistenceState() == DataSource::PersistenceState::Modified) { - ++modified; - } - } - - if (modified > 0) { - QMessageBox modifiedMessageBox; - modifiedMessageBox.setIcon(QMessageBox::Warning); - QString text = QString("Warning: unsaved data - %1 data source%2") - .arg(modified) - .arg(modified > 1 ? "s" : ""); - QString infoText = - "Unsaved data is marked in the pipeline italic text " - "with an asterisk. You may continue to save the state, " - "and any unsaved data (along with operators/modules) " - "will be skipped."; - modifiedMessageBox.setText(text); - modifiedMessageBox.setInformativeText(infoText); - modifiedMessageBox.setStandardButtons(QMessageBox::Save | - QMessageBox::Cancel); - modifiedMessageBox.setDefaultButton(QMessageBox::Save); - - if (modifiedMessageBox.exec() == QMessageBox::Cancel) { - return false; - } - } - } - - QJsonArray jDataSources; - foreach (DataSource* ds, d->DataSources) { - auto jDataSource = ds->serialize(); - d->relativeFilePaths(ds, stateDir, jDataSource); - jDataSources.append(jDataSource); - } - doc["dataSources"] = jDataSources; - - QJsonArray jMoleculeSources; - foreach (MoleculeSource* ms, d->MoleculeSources) { - auto jMoleculeSource = ms->serialize(); - if (ms == ActiveObjects::instance().activeMoleculeSource()) { - jMoleculeSource["active"] = true; - } - - d->relativeFilePaths(ms, stateDir, jMoleculeSource); - - jMoleculeSources.append(jMoleculeSource); - } - doc["moleculeSources"] = jMoleculeSources; - - // Save the palette color - auto* pxm = ActiveObjects::instance().proxyManager(); - auto* paletteProxy = pxm->GetProxy("settings", "ColorPalette"); - double paletteColor[3]; - vtkSMPropertyHelper(paletteProxy->GetProperty("BackgroundColor")) - .Get(paletteColor, 3); - doc["paletteColor"] = - QJsonArray({ paletteColor[0], paletteColor[1], paletteColor[2] }); - - // Now serialize the views and layouts. - vtkNew iter; - iter->SetSessionProxyManager(pxm); - iter->SetModeToOneGroup(); - QJsonArray jLayouts; - for (iter->Begin("layouts"); !iter->IsAtEnd(); iter->Next()) { - if (vtkSMProxy* layout = iter->GetProxy()) { - QJsonObject jLayout; - jLayout["id"] = static_cast(layout->GetGlobalID()); - jLayout["xmlGroup"] = layout->GetXMLGroup(); - jLayout["xmlName"] = layout->GetXMLName(); - - // I suspect this is a huge amount of overkill to get the servers... - pugi::xml_document document; - auto proxyNode = document.append_child("ParaViewXML"); - tomviz::serialize(layout, proxyNode); - auto layoutProxy = document.child("ParaViewXML").child("Proxy"); - jLayout["servers"] = layoutProxy.attribute("servers").as_int(0); - // Iterate through the layout nodes. - QJsonArray layoutArray; - for (auto node = layoutProxy.child("Layout"); node; - node = node.next_sibling("Layout")) { - QJsonArray itemArray; - for (auto itemNode = node.child("Item"); itemNode; - itemNode = itemNode.next_sibling("Item")) { - QJsonObject itemObj; - itemObj["direction"] = itemNode.attribute("direction").as_int(0); - itemObj["fraction"] = itemNode.attribute("fraction").as_double(0); - itemObj["viewId"] = itemNode.attribute("view").as_int(0); - itemArray.append(itemObj); - } - layoutArray.append(itemArray); - } - jLayout["items"] = layoutArray; - jLayouts.append(jLayout); - } - } - if (!jLayouts.isEmpty()) { - doc["layouts"] = jLayouts; - } - QJsonArray jViews; - for (iter->Begin("views"); !iter->IsAtEnd(); iter->Next()) { - if (vtkSMProxy* view = iter->GetProxy()) { - QJsonObject jView; - jView["id"] = static_cast(view->GetGlobalID()); - jView["xmlGroup"] = view->GetXMLGroup(); - jView["xmlName"] = view->GetXMLName(); - if (view == ActiveObjects::instance().activeView()) { - jView["active"] = true; - } - - if (view->GetProperty("UseColorPaletteForBackground")) { - jView["useColorPaletteForBackground"] = - vtkSMPropertyHelper(view, "UseColorPaletteForBackground").GetAsInt(); - } - - // Now to get some more specific information about the view! - pugi::xml_document document; - pugi::xml_node proxyNode = document.append_child("ParaViewXML"); - tomviz::serialize(view, proxyNode); - - QJsonObject camera; - - // Curate the pieces we want from the XML produced. - auto viewProxy = document.child("ParaViewXML").child("Proxy"); - jView["servers"] = viewProxy.attribute("servers").as_int(0); - QJsonArray backgroundColor; - // Iterate through the properties... - for (pugi::xml_node node = viewProxy.child("Property"); node; - node = node.next_sibling("Property")) { - std::string name = node.attribute("name").as_string(""); - if (name == "ViewSize") { - jView["viewSize"] = jsonArrayFromXml(node); - } else if (name == "CameraFocalPoint") { - camera["focalPoint"] = jsonArrayFromXmlDouble(node); - } else if (name == "CameraPosition") { - camera["position"] = jsonArrayFromXmlDouble(node); - } else if (name == "CameraViewUp") { - camera["viewUp"] = jsonArrayFromXmlDouble(node); - } else if (name == "CameraViewAngle") { - camera["viewAngle"] = jsonArrayFromXmlDouble(node)[0]; - } else if (name == "EyeAngle") { - camera["eyeAngle"] = jsonArrayFromXmlDouble(node)[0]; - } else if (name == "CenterOfRotation") { - jView["centerOfRotation"] = jsonArrayFromXmlDouble(node); - } else if (name == "Background") { - backgroundColor.append(jsonArrayFromXmlDouble(node)); - } else if (name == "Background2") { - vtkSMPropertyHelper helper(view, "BackgroundColorMode"); - if (helper.GetAsString() == std::string("Gradient")) { - backgroundColor.append(jsonArrayFromXmlDouble(node)); - } - } else if (name == "CameraParallelScale") { - vtkSMPropertyHelper helper(view, "CameraParallelScale"); - camera["parallelScale"] = jsonArrayFromXmlDouble(node)[0]; - } else if (name == "CameraParallelProjection") { - // orthographic or perspective projection - vtkSMPropertyHelper helper(view, "CameraParallelProjection"); - jView["isOrthographic"] = helper.GetAsInt() != 0; - } else if (name == "InteractionMode") { - vtkSMPropertyHelper helper(view, "InteractionMode"); - QString mode = "3D"; - if (helper.GetAsInt() == 1) { - mode = "2D"; - } else if (helper.GetAsInt() == 2) { - mode = "selection"; - } - jView["interactionMode"] = mode; - } else if (name == "CenterAxesVisibility") { - vtkSMPropertyHelper helper(view, "CenterAxesVisibility"); - jView["centerAxesVisible"] = helper.GetAsInt() == 1; - } else if (name == "OrientationAxesVisibility") { - vtkSMPropertyHelper helper(view, "OrientationAxesVisibility"); - jView["orientationAxesVisible"] = helper.GetAsInt() == 1; - } - } - if (view->GetProperty("AxesGrid")) { - vtkSMPropertyHelper helper(view, "AxesGrid"); - vtkSMProxy* axesGridProxy = helper.GetAsProxy(); - if (axesGridProxy) { - vtkSMPropertyHelper visibilityHelper(axesGridProxy, "Visibility"); - jView["axesGridVisibility"] = visibilityHelper.GetAsInt() != 0; - } - } - jView["camera"] = camera; - jView["backgroundColor"] = backgroundColor; - - jViews.append(jView); - /* - if (!tomviz::serialize(view, vnode)) { - qWarning("Failed to serialize view."); - ns.remove_child(vnode); - } - - if (view->GetProperty("AxesGrid")) { - vtkSMProxy* axesGrid = - vtkSMPropertyHelper(view, "AxesGrid").GetAsProxy(); - pugi::xml_node axesGridNode = vnode.append_child("AxesGrid"); - axesGridNode.append_attribute("id").set_value( - axesGrid->GetGlobalIDAsString()); - axesGridNode.append_attribute("xmlgroup") - .set_value(axesGrid->GetXMLGroup()); - axesGridNode.append_attribute("xmlname").set_value( - axesGrid->GetXMLName()); - if (!tomviz::serialize(axesGrid, axesGridNode)) { - qWarning("Failed to serialize axes grid"); - ns.remove_child(axesGridNode); - } - } - */ - } - } - if (!jViews.isEmpty()) { - doc["views"] = jViews; - } - - return true; -} - -void createXmlProperty(pugi::xml_node& n, const char* name, int id) -{ - n.set_name("Property"); - n.append_attribute("name").set_value(name); - QString idStr = QString::number(id) + "." + name; - n.append_attribute("id").set_value(idStr.toStdString().c_str()); -} - -template -void createXmlProperty(pugi::xml_node& n, const char* name, int id, T value) -{ - createXmlProperty(n, name, id); - n.append_attribute("number_of_elements").set_value(1); - auto element = n.append_child("Element"); - element.append_attribute("index").set_value(0); - element.append_attribute("value").set_value(value); -} - -void createXmlProperty(pugi::xml_node& n, const char* name, int id, - QJsonArray arr) -{ - createXmlProperty(n, name, id); - n.append_attribute("number_of_elements").set_value(arr.size()); - for (int i = 0; i < arr.size(); ++i) { - auto element = n.append_child("Element"); - element.append_attribute("index").set_value(i); - element.append_attribute("value").set_value(arr[i].toDouble(-1)); - } -} - -void createXmlLayout(pugi::xml_node& n, QJsonArray arr) -{ - n.set_name("Layout"); - n.append_attribute("number_of_elements").set_value(arr.size()); - for (int i = 0; i < arr.size(); ++i) { - auto obj = arr[i].toObject(); - auto item = n.append_child("Item"); - item.append_attribute("direction").set_value(obj["direction"].toInt()); - item.append_attribute("fraction").set_value(obj["fraction"].toDouble()); - item.append_attribute("view").set_value(obj["viewId"].toInt()); - } -} - -bool ModuleManager::deserialize(const QJsonObject& doc, const QDir& stateDir, - bool loadDataSources) -{ - m_isDeserializing = true; - - // Get back to a known state. - reset(); - d->LastStateLoadSuccess = true; - - // Disable the contour module's dialog, re-enable it when the state loading is - // finished. - QSettings* settings = pqApplicationCore::instance()->settings(); - bool userConfirmInitialValue = - settings->value("ContourSettings.UserConfirmInitialValue", true).toBool(); - settings->setValue("ContourSettings.UserConfirmInitialValue", false); - connect(this, &ModuleManager::stateDoneLoading, this, - [settings, userConfirmInitialValue]() { - settings->setValue("ContourSettings.UserConfirmInitialValue", - userConfirmInitialValue); - }); - - // High level game plan - construct some XML for ParaView, restore the - // layouts, the views, links, etc. Once they are ready then restore the data - // pipeline, using the nested layout to assure the order is correct. - QJsonArray views = doc["views"].toArray(); - QJsonArray layouts = doc["layouts"].toArray(); - QJsonArray links = doc["links"].toArray(); - - // ParaView must load all views and layouts first. - pugi::xml_document document; - auto pvxml = document.append_child("ParaView"); - auto pvState = pvxml.append_child("ServerManagerState"); - // Hardwire the ParaView version to avoid issues with a hardwired check for - // versions less than 4.0.1 in the state version controller in ParaView. - pvState.append_attribute("version").set_value("5.5.0"); - auto pvViews = pvState.append_child("ProxyCollection"); - pvViews.append_attribute("name").set_value("views"); - auto pvLayouts = pvState.append_child("ProxyCollection"); - pvLayouts.append_attribute("name").set_value("layouts"); - int numViews = 0, numLayouts = 0; - - // Check for a palette color - if (doc.contains("paletteColor")) { - auto paletteColorArray = doc["paletteColor"].toArray(); - double paletteColor[3] = { paletteColorArray[0].toDouble(), - paletteColorArray[1].toDouble(), - paletteColorArray[2].toDouble() }; - - auto* pxm = ActiveObjects::instance().proxyManager(); - auto* paletteProxy = pxm->GetProxy("settings", "ColorPalette"); - vtkSMPropertyHelper(paletteProxy->GetProperty("BackgroundColor")) - .Set(paletteColor, 3); - paletteProxy->UpdateVTKObjects(); - } - - // First see if we have views, and unpack them. - for (int i = 0; i < views.size(); ++i) { - QJsonObject view = views[i].toObject(); - auto viewId = view["id"].toInt(); - auto proxyNode = pvState.append_child("Proxy"); - proxyNode.append_attribute("group").set_value("views"); - auto xmlName = view["xmlName"].toString("RenderView"); - proxyNode.append_attribute("type").set_value( - xmlName.toStdString().c_str()); - proxyNode.append_attribute("id").set_value(viewId); - proxyNode.append_attribute("servers").set_value(view["servers"].toInt()); - - auto propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "CenterOfRotation", viewId, - view["centerOfRotation"].toArray()); - // Let's do the camera now... - QJsonObject camera = view["camera"].toObject(); - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "CameraFocalPoint", viewId, - camera["focalPoint"].toArray()); - - if (view.contains("useColorPaletteForBackground")) { - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "UseColorPaletteForBackground", viewId, - view["useColorPaletteForBackground"].toInt()); - } - - if (view.contains("backgroundColor")) { - if (!view.contains("useColorPaletteForBackground")) { - // Assume this is an older state file where we should turn this off - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "UseColorPaletteForBackground", viewId, 0); - } - - auto backgroundColor = view["backgroundColor"].toArray(); - // Restore the background color - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "Background", viewId, - backgroundColor.at(0).toArray()); - - // If we have more than one element, we have a gradient so also restore - // Background2 and set UseGradientBackground. - if (backgroundColor.size() > 1) { - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "Background2", viewId, - backgroundColor.at(1).toArray()); - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "UseGradientBackground", viewId, 1); - } - } - if (view.contains("isOrthographic")) { - auto parallelProjection = view["isOrthographic"].toBool() ? 1 : 0; - propNode = proxyNode.append_child("Property"); - createXmlProperty(propNode, "CameraParallelProjection", viewId, - parallelProjection); - } - if (view.contains("interactionMode")) { - propNode = proxyNode.append_child("Property"); - auto modeString = view["interactionMode"].toString(); - int mode = 0; // default to 3D - if (modeString == "2D") { - mode = 1; - } else if (modeString == "selection") { - mode = 2; - } - createXmlProperty(propNode, "InteractionMode", viewId, mode); - } - - // Create an entry in the views thing... - pugi::xml_node viewSummary = pvViews.append_child("Item"); - viewSummary.append_attribute("id").set_value(viewId); - viewSummary.append_attribute("name").set_value( - QString("View%1").arg(++numViews).toStdString().c_str()); - } - // Now the layouts - should only ever be one, but go through the motions... - for (int i = 0; i < layouts.size(); ++i) { - QJsonObject layout = layouts[i].toObject(); - auto layoutId = layout["id"].toInt(); - auto proxyNode = pvState.append_child("Proxy"); - proxyNode.append_attribute("group").set_value("misc"); - proxyNode.append_attribute("type").set_value("ViewLayout"); - proxyNode.append_attribute("id").set_value(layoutId); - proxyNode.append_attribute("servers").set_value(layout["servers"].toInt()); - - QJsonArray items = layout["items"].toArray(); - for (int j = 0; j < items.size(); ++j) { - auto layoutNode = proxyNode.append_child("Layout"); - createXmlLayout(layoutNode, items[j].toArray()); - } - - // Create an entry in the layouts thing... - auto layoutSummary = pvLayouts.append_child("Item"); - layoutSummary.append_attribute("id").set_value(layoutId); - layoutSummary.append_attribute("name").set_value( - QString("Layout%1").arg(++numLayouts).toStdString().c_str()); - } - - d->dir = stateDir; - m_stateObject = doc; - m_loadDataSources = loadDataSources; - connect(pqApplicationCore::instance(), - &pqApplicationCore::stateLoaded, this, - &ModuleManager::onPVStateLoaded); - // Set up call to ParaView to load state - std::ostringstream stream; - document.first_child().print(stream); - - // qDebug() << "\nPV XML:" << stream.str().c_str() << "\n"; - vtkNew parser; - if (!parser->Parse(stream.str().c_str())) { - d->LastStateLoadSuccess = false; - return false; - } - pqActiveObjects* activeObjects = &pqActiveObjects::instance(); - pqServer* server = activeObjects->activeServer(); - - pqApplicationCore::instance()->loadState(parser->GetRootElement(), server); - // Clean up the state -- since the Qt slot call should be synchronous - // it should be done before the code returns to here. - disconnect(pqApplicationCore::instance(), - &pqApplicationCore::stateLoaded, this, - &ModuleManager::onPVStateLoaded); - - d->dir = QDir(); - m_stateObject = QJsonObject(); - - // Restore the views to their state before the modules were added - setViews(views); - - d->LastStateLoadSuccess = true; - - if (d->RemaningPipelinesToWaitFor == 0) { - emit stateDoneLoading(); - } - return true; -} - -void ModuleManager::setViews(const QJsonArray& views) -{ - // This sets all views according to their settings in the view proxy. - // It should be called after the views have been deserialized. - for (int i = 0; i < views.size(); ++i) { - auto view = views[i].toObject(); - auto viewProxy = - vtkSMRenderViewProxy::SafeDownCast(lookupView(view["id"].toInt())); - if (!viewProxy) { - continue; - } - double tmp[3]; - auto camera = view["camera"].toObject(); - auto jsonArray = camera["position"].toArray(); - for (int j = 0; j < jsonArray.size(); ++j) { - tmp[j] = jsonArray[j].toDouble(); - } - vtkSMPropertyHelper(viewProxy, "CameraPosition").Set(tmp, 3); - jsonArray = camera["focalPoint"].toArray(); - for (int j = 0; j < jsonArray.size(); ++j) { - tmp[j] = jsonArray[j].toDouble(); - } - vtkSMPropertyHelper(viewProxy, "CameraFocalPoint").Set(tmp, 3); - jsonArray = camera["viewUp"].toArray(); - for (int j = 0; j < jsonArray.size(); ++j) { - tmp[j] = jsonArray[j].toDouble(); - } - vtkSMPropertyHelper(viewProxy, "CameraViewUp").Set(tmp, 3); - vtkSMPropertyHelper(viewProxy, "CameraViewAngle") - .Set(camera["viewAngle"].toDouble()); - vtkSMPropertyHelper(viewProxy, "EyeAngle") - .Set(camera["EyeAngle"].toDouble()); - vtkSMPropertyHelper(viewProxy, "CameraParallelScale") - .Set(camera["parallelScale"].toDouble()); - - // restore axis grid visibility - if (viewProxy->GetProperty("AxesGrid")) { - vtkSMPropertyHelper axesGridProp(viewProxy, "AxesGrid"); - vtkSMProxy* proxy = axesGridProp.GetAsProxy(); - if (!proxy) { - vtkSMSessionProxyManager* pxm = viewProxy->GetSessionProxyManager(); - proxy = pxm->NewProxy("annotations", "GridAxes3DActor"); - axesGridProp.Set(proxy); - proxy->Delete(); - } - vtkSMPropertyHelper(proxy, "Visibility") - .Set(view["axesGridVisibility"].toBool() ? 1 : 0); - proxy->UpdateVTKObjects(); - } - if (view.contains("centerAxesVisible")) { - vtkSMPropertyHelper(viewProxy, "CenterAxesVisibility") - .Set(view["centerAxesVisible"].toBool() ? 1 : 0); - } - if (view.contains("orientationAxesVisible")) { - vtkSMPropertyHelper(viewProxy, "OrientationAxesVisibility") - .Set(view["orientationAxesVisible"].toBool() ? 1 : 0); - } - viewProxy->UpdateVTKObjects(); - } - // force the view menu to update its state based on the settings we have - // restored to the view - ActiveObjects::instance().viewChanged(ActiveObjects::instance().activeView()); -} - -bool ModuleManager::lastLoadStateSucceeded() -{ - return d->LastStateLoadSuccess; -} - -void ModuleManager::onPVStateLoaded(vtkPVXMLElement*, - vtkSMProxyLocator* locator) -{ - auto pxm = ActiveObjects::instance().proxyManager(); - Q_ASSERT(pxm); - - // Populate the view id map, needed to create modules with the restored views. - d->ViewIdMap.clear(); - if (m_stateObject["views"].isArray()) { - auto viewArray = m_stateObject["views"].toArray(); - for (int i = 0; i < viewArray.size(); ++i) { - auto view = viewArray[i].toObject(); - auto viewId = view["id"].toInt(); - d->ViewIdMap[viewId] = - vtkSMViewProxy::SafeDownCast(locator->LocateProxy(viewId)); - } - } - - // Load up all of the data sources. - if (m_loadDataSources && m_stateObject["dataSources"].isArray()) { - auto dataSources = m_stateObject["dataSources"].toArray(); - loadDataSources(dataSources); - } - - // Load up all of the molecule sources. - if (m_stateObject["moleculeSources"].isArray()) { - auto moleculeSources = m_stateObject["moleculeSources"].toArray(); - foreach (auto ds, moleculeSources) { - auto dsObject = ds.toObject(); - QJsonObject options; - options["defaultModules"] = false; - options["addToRecent"] = false; - d->absoluteFilePaths(dsObject); - - QString fileName; - if (dsObject.contains("reader")) { - auto reader = dsObject["reader"].toObject(); - - if (reader.contains("fileName")) { - fileName = reader["fileName"].toString(); - // Verify the file exists. - if (!QFileInfo::exists(fileName)) { - // If the file cannot be found in the path relative to the state - // file, make another attempt to locate it in the same directory - fileName = d->dir.absoluteFilePath(QFileInfo(fileName).fileName()); - if (!QFileInfo::exists(fileName)) { - qCritical() << "File" << fileName << "not found, skipping."; - fileName = ""; - } - } - } else { - qCritical() << "Unable to locate file name."; - } - } - MoleculeSource* moleculeSource = - LoadDataReaction::loadMolecule(fileName, options); - if (moleculeSource) { - moleculeSource->deserialize(dsObject); - // FIXME: I think we need to collect the active objects and set them at - // the end, as the act of adding generally implies setting to active. - if (dsObject["active"].toBool()) { - ActiveObjects::instance().setActiveMoleculeSource(moleculeSource); - } - } - } - } - - m_isDeserializing = false; - - if (!executePipelinesOnLoad() || d->RemaningPipelinesToWaitFor == 0) { - // If there are no pipelines left to wait for, most likely, the - // pipelines finished before deserialization finished. Go ahead and - // emit the signal. - emit stateDoneLoading(); - } -} - -void ModuleManager::incrementPipelinesToWaitFor() -{ - ++d->RemaningPipelinesToWaitFor; -} - -void ModuleManager::onPipelineFinished() -{ - --d->RemaningPipelinesToWaitFor; - - // It could still be deserializing if the pipeline finishes early. - // In this case, the signal will be emitted after deserialization - // finishes. - if (d->RemaningPipelinesToWaitFor == 0 && !m_isDeserializing) { - emit stateDoneLoading(); - } - if (d->RemaningPipelinesToWaitFor <= 0) { - Pipeline* p = qobject_cast(sender()); - disconnect(p, &Pipeline::finished, this, - &ModuleManager::onPipelineFinished); - } -} - -void ModuleManager::executePipelinesOnLoad(bool execute) -{ - d->ExecutePipelinesOnLoad = execute; -} - -bool ModuleManager::executePipelinesOnLoad() const -{ - return d->ExecutePipelinesOnLoad; -} - -void ModuleManager::onViewRemoved(pqView* view) -{ - Q_ASSERT(view); - auto viewProxy = view->getViewProxy(); - QList modules; - foreach (Module* module, d->Modules) { - if (module->view() == viewProxy) { - modules.push_back(module); - } - } - foreach (Module* module, modules) { - removeModule(module); - } -} - -void ModuleManager::render() -{ - auto view = tomviz::convert(ActiveObjects::instance().activeView()); - if (view) { - view->render(); - } -} - -void ModuleManager::updateClientSideView() -{ - auto* view = ActiveObjects::instance().activeView(); - if (!view) { - return; - } - - auto* clientView = vtkPVRenderView::SafeDownCast(view->GetClientSideView()); - if (!clientView) { - return; - } - - clientView->Update(); -} - -vtkSMViewProxy* ModuleManager::lookupView(int id) -{ - return d->ViewIdMap.value(id); -} - -bool ModuleManager::hasDataSources() -{ - d->removeInvalidDataSources(); - return !d->DataSources.empty(); -} - -bool ModuleManager::hasMoleculeSources() -{ - return !d->MoleculeSources.empty(); -} - -void ModuleManager::loadDataSources(const QJsonArray& dataSourcesArray) -{ - // Data source arguments in operators make it so that we have to - // load in the data sources in a proper order, or else the arguments - // will not be able to be resolved for the operator to use. - - auto dataSources = toObjectList(dataSourcesArray); - QMap alreadyLoaded; - - // This local load data source function ensures we load dependencies - // before we load in the requested data source. It also ensures that - // we do not load in any data sources twice. - std::function localLoadDataSource; - localLoadDataSource = [&dataSources, &alreadyLoaded, &localLoadDataSource, - this](QJsonObject ds) { - auto id = ds.value("id").toString(); - if (alreadyLoaded.contains(id)) { - // Already have this one - return; - } - - // First, see if it has any dependencies. - auto deps = d->dataSourceDependencies(ds); - - // Load the dependencies first. - for (auto& dep : deps) { - // Load the root data source of this dependency - auto rootObj = d->rootDataSourceDependency(dep, dataSources); - localLoadDataSource(rootObj); - - // If it is a child dependency, wait for the pipeline to finish - if (d->isChildDataSourceDependency(dep, dataSources)) { - auto rootId = rootObj.value("id").toString(); - auto* depDataSource = alreadyLoaded[rootId]; - if (depDataSource->pipeline()->isRunning()) { - QEventLoop loop; - connect(depDataSource->pipeline(), &Pipeline::finished, &loop, - &QEventLoop::quit); - loop.exec(); - }; - } - } - - // Now that the dependencies are loaded, we can load the data source... - alreadyLoaded[id] = loadDataSource(ds); - }; - - for (auto& ds : dataSources) { - localLoadDataSource(ds); - } -} - -DataSource* ModuleManager::loadDataSource(QJsonObject& dsObject) -{ - QJsonObject options; - options["defaultModules"] = false; - options["addToRecent"] = false; - options["child"] = false; - d->absoluteFilePaths(dsObject); - - QStringList fileNames; - if (dsObject.contains("reader")) { - auto reader = dsObject["reader"].toObject(); - - if (reader.contains("fileNames")) { - foreach (const QJsonValue& value, reader["fileNames"].toArray()) { - // Verify the file exists before adding it to the list. - if (QFileInfo::exists(value.toString())) { - fileNames << value.toString(); - } else { - // If the file cannot be found in the path relative to the state - // file, make another attempt to locate it in the same directory - QString altLocation = - d->dir.absoluteFilePath(QFileInfo(value.toString()).fileName()); - if (QFileInfo::exists(altLocation)) { - fileNames << altLocation; - } else { - qCritical() << "File" << value.toString() << "not found, skipping."; - } - } - } - reader["fileNames"] = QJsonArray::fromStringList(fileNames); - } else { - qCritical() << "Unable to locate file name(s)."; - } - if (reader.contains("name")) { - options["reader"] = reader; - } - if (reader.contains("subsampleSettings")) { - options["subsampleSettings"] = reader["subsampleSettings"]; - } - if (reader.contains("tvh5NodePath")) { - options["tvh5NodePath"] = reader["tvh5NodePath"]; - } - } - - if (!options.contains("subsampleSettings")) { - if (dsObject.contains("subsampleSettings")) { - // Make sure subsample settings get communicated to the readers - options["subsampleSettings"] = dsObject["subsampleSettings"]; - } else { - // Never prompt the user for subsample setting when loading state - // files. This will prevent the prompt from happening. - options["subsampleSettings"] = QJsonObject(); - } - } - - DataSource* dataSource = nullptr; - if (dsObject.find("sourceInformation") != dsObject.end()) { - dataSource = PythonGeneratedDatasetReaction::createDataSource( - dsObject["sourceInformation"].toObject()); - LoadDataReaction::dataSourceAdded(dataSource, false, false); - } else if (fileNames.size() > 0) { - dataSource = LoadDataReaction::loadData(fileNames, options); - } else { - qCritical() << "Files not found on disk for data source, check paths."; - } - - if (dataSource) { - if (executePipelinesOnLoad() && dsObject.contains("operators") && - dsObject["operators"].toArray().size() > 0) { - connect(dataSource->pipeline(), &Pipeline::finished, this, - &ModuleManager::onPipelineFinished); - ++d->RemaningPipelinesToWaitFor; - } - dataSource->deserialize(dsObject); - if (fileNames.isEmpty()) { - dataSource->setPersistenceState(DataSource::PersistenceState::Transient); - } - } - // FIXME: I think we need to collect the active objects and set them at - // the end, as the act of adding generally implies setting to active. - if (dsObject["active"].toBool()) { - ActiveObjects::instance().setActiveDataSource(dataSource); - } - - return dataSource; -} - -void ModuleManager::setMostRecentStateFile(const QString& s) -{ - m_mostRecentStateFile = s; - emit mostRecentStateFileChanged(m_mostRecentStateFile); -} - -} // namespace tomviz diff --git a/tomviz/modules/ModuleManager.h b/tomviz/modules/ModuleManager.h deleted file mode 100644 index e14af7436..000000000 --- a/tomviz/modules/ModuleManager.h +++ /dev/null @@ -1,219 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleManager_h -#define tomvizModuleManager_h - -#include - -#include "Module.h" - -#include -#include -#include - -class pqView; -class vtkSMSourceProxy; -class vtkSMViewProxy; -class vtkPVXMLElement; -class vtkSMProxyLocator; -class vtkSMRepresentationProxy; -class QDir; - -namespace tomviz { -class DataSource; -class MoleculeSource; -class Module; -class Operator; -class Pipeline; - -/// Singleton akin to ProxyManager, but to keep track (and -/// serialize/deserialze) modules. -class ModuleManager : public QObject -{ - Q_OBJECT - - typedef QObject Superclass; - -public: - static ModuleManager& instance(); - - /// Returns a list of modules of the specified type showing the dataSource in - /// the give view. If view is NULL, all modules for the dataSource will be - /// returned. - template - QList findModules(DataSource* dataSource, vtkSMViewProxy* view) - { - QList modulesT; - QList modules = this->findModulesGeneric(dataSource, view); - foreach (Module* module, modules) { - // Don't include modules that are actually children of an OperatorResult - if (module->operatorResult() == nullptr) { - if (T moduleT = qobject_cast(module)) { - modulesT.push_back(moduleT); - } - } - } - return modulesT; - } - - QList dataSources(); - QList childDataSources(); - QList allDataSources(); - - // Return the data sources in a Depth First Search (DFS) order, - // so that child data sources will come immediately after their - // parent data source in the list. - // This *should* match the order of data sources in the pipeline view. - QList allDataSourcesDepthFirst(); - - // Generate a list of unique labels from the list of data sources. - // Duplicate labels will have a space and number appended to them, - // such as "Label", "Label (2)", "Label (3)", etc. - // The labels will be in the same order as the input DataSource list. - static QStringList createUniqueLabels(const QList& sources); - - QList findModulesGeneric(const DataSource* dataSource, - const vtkSMViewProxy* view); - - QList findModulesGeneric(const MoleculeSource* dataSource, - const vtkSMViewProxy* view); - - /// Save the application state as JSON, use stateDir as the base for relative - /// paths. - bool serialize(QJsonObject& doc, const QDir& stateDir, - bool interactive = true) const; - bool deserialize(const QJsonObject& doc, const QDir& stateDir, - bool loadDataSources = true); - - /// Set the views from the provided state. - /// Usually, this should be done after ModuleManager::deserialize(). - void setViews(const QJsonArray& views); - - /// Test if any data source has running operators - bool hasRunningOperators(); - - /// Return whether a DataSource is a child DataSource - bool isChild(DataSource*) const; - - /// Used to lookup a view by id, only intended for use during deserialization. - vtkSMViewProxy* lookupView(int id); - - /// Used to test if there is data loaded (i.e. not an empty session) - bool hasDataSources(); - bool hasMoleculeSources(); - - /// Used when loading a model. If there are additional child pipelines that - /// need to finish processing before stateDoneLoading is emitted, then this - /// must be called for each of them so the module manager knows how many - /// pipelineFinsihed signals to wait for. - void incrementPipelinesToWaitFor(); - - bool lastLoadStateSucceeded(); - - void executePipelinesOnLoad(bool execute); - bool executePipelinesOnLoad() const; - DataSource* loadDataSource(QJsonObject& ds); - - /// Used to keep track of the most recent state file - void setMostRecentStateFile(const QString& s); - QString mostRecentStateFile() const { return m_mostRecentStateFile; } - - // Keep a record of state ids to data sources - void addStateIdToDataSource(QString id, DataSource* dataSource) - { - m_stateIdToDataSource[id] = dataSource; - } - - DataSource* dataSourceForStateId(QString id) - { - return m_stateIdToDataSource.value(id, nullptr); - } - -public slots: - void addModule(Module*); - - /// Use these methods to delete/remove modules. - void removeModule(Module*); - void removeAllModules(); - void removeAllModules(DataSource* source); - - /// Creates and add a new module. - Module* createAndAddModule(const QString& type, DataSource* dataSource, - vtkSMViewProxy* view); - Module* createAndAddModule(const QString& type, MoleculeSource* dataSource, - vtkSMViewProxy* view); - Module* createAndAddModule(const QString& type, OperatorResult* result, - vtkSMViewProxy* view); - - /// Register/Unregister data sources with the ModuleManager. - void addDataSource(DataSource*); - void addMoleculeSource(MoleculeSource*); - void addChildDataSource(DataSource*); - void removeDataSource(DataSource*); - void removeMoleculeSource(MoleculeSource*); - void removeChildDataSource(DataSource*); - void removeAllDataSources(); - void removeAllMoleculeSources(); - void removeOperator(Operator*); - - /// Removes all modules and data sources. - void reset(); - -private slots: - /// Used when loading state - void onPVStateLoaded(vtkPVXMLElement*, vtkSMProxyLocator*); - - /// Delete modules when the view that they are in is removed. - void onViewRemoved(pqView*); - - void render(); - void updateClientSideView(); - - void onPipelineFinished(); - -signals: - void moduleAdded(Module*); - void moduleRemoved(Module*); - - void dataSourceAdded(DataSource*); - void childDataSourceAdded(DataSource*); - void dataSourceRemoved(DataSource*); - void moleculeSourceRemoved(MoleculeSource*); - void childDataSourceRemoved(DataSource*); - - void moleculeSourceAdded(MoleculeSource*); - - void operatorRemoved(Operator*); - - void stateDoneLoading(); - - void enablePythonConsole(bool enable); - - void mostRecentStateFileChanged(const QString& s); - - void visibilityChanged(bool); - - void mouseOverVoxel(const vtkVector3i& ijk, double v); - - void pipelineViewRenderNeeded(); - -private: - Q_DISABLE_COPY(ModuleManager) - ModuleManager(QObject* parent = nullptr); - ~ModuleManager(); - - void loadDataSources(const QJsonArray& dataSources); - - class MMInternals; - QScopedPointer d; - - QMap m_stateIdToDataSource; - QString m_mostRecentStateFile = ""; - QJsonObject m_stateObject; - bool m_loadDataSources = true; - bool m_isDeserializing = false; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleMenu.cxx b/tomviz/modules/ModuleMenu.cxx deleted file mode 100644 index 81ca8b8f3..000000000 --- a/tomviz/modules/ModuleMenu.cxx +++ /dev/null @@ -1,211 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleMenu.h" - -#include "ActiveObjects.h" -#include "ModuleFactory.h" -#include "ModuleManager.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace tomviz { - -static vtkSMViewProxy* resolveView(const QString& moduleType) -{ - // Determine which view type this module needs. - bool needsChart = (moduleType == "Plot"); - QString viewTypeName = needsChart ? "XYChartView" : "RenderView"; - - // Check if the active view is already the right type. - auto* activeView = ActiveObjects::instance().activeView(); - if (activeView) { - bool activeIsChart = vtkSMContextViewProxy::SafeDownCast(activeView); - bool activeIsRender = vtkSMRenderViewProxy::SafeDownCast(activeView); - if ((needsChart && activeIsChart) || (!needsChart && activeIsRender)) { - return activeView; - } - } - - // Enumerate all views and find matching ones. - auto* smModel = - pqApplicationCore::instance()->getServerManagerModel(); - QList allViews = smModel->findItems(); - - QList matching; - for (auto* v : allViews) { - auto* proxy = v->getViewProxy(); - bool isChart = vtkSMContextViewProxy::SafeDownCast(proxy); - bool isRender = vtkSMRenderViewProxy::SafeDownCast(proxy); - if ((needsChart && isChart) || (!needsChart && isRender)) { - matching.append(v); - } - } - - if (matching.isEmpty()) { - // No matching view exists — ask to create one. - QString label = needsChart ? "Line Chart View" : "Render View"; - auto answer = QMessageBox::question( - nullptr, "Create View?", - QString("No %1 is available. Create one?").arg(label), - QMessageBox::Yes | QMessageBox::No); - if (answer != QMessageBox::Yes) { - return nullptr; - } - // Split the current layout to make room for the new view. - int emptyCell = -1; - vtkSMViewLayoutProxy* layout = nullptr; - if (activeView) { - layout = vtkSMViewLayoutProxy::FindLayout(activeView); - if (layout) { - int location = layout->GetViewLocation(activeView); - // Split returns index of left child; existing view moves there. - // The empty cell is at leftChild + 1. - int leftChild = layout->Split( - location, vtkSMViewLayoutProxy::HORIZONTAL, 0.5); - if (leftChild >= 0) { - emptyCell = leftChild + 1; - } - } - } - - auto* builder = pqApplicationCore::instance()->getObjectBuilder(); - auto* server = pqApplicationCore::instance()->getActiveServer(); - auto* newView = builder->createView(viewTypeName, server); - if (!newView) { - return nullptr; - } - auto* proxy = newView->getViewProxy(); - - // Explicitly assign the new view to the empty cell we created. - if (layout && emptyCell >= 0) { - layout->AssignView(emptyCell, proxy); - } - - ActiveObjects::instance().setActiveView(proxy); - return proxy; - } - - if (matching.size() == 1) { - auto* proxy = matching.first()->getViewProxy(); - ActiveObjects::instance().setActiveView(proxy); - return proxy; - } - - // Multiple matches — let the user pick. - QStringList names; - for (auto* v : matching) { - names << v->getSMName(); - } - bool ok = false; - QString chosen = QInputDialog::getItem( - nullptr, "Select View", - QString("Multiple views available. Select one:"), - names, 0, false, &ok); - if (!ok) { - return nullptr; - } - int idx = names.indexOf(chosen); - auto* proxy = matching[idx]->getViewProxy(); - ActiveObjects::instance().setActiveView(proxy); - return proxy; -} - -ModuleMenu::ModuleMenu(QToolBar* toolBar, QMenu* menu, QObject* parentObject) - : QObject(parentObject), m_menu(menu), m_toolBar(toolBar) -{ - Q_ASSERT(menu); - Q_ASSERT(toolBar); - connect(menu, &QMenu::triggered, this, &ModuleMenu::triggered); - connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::dataSourceChanged), this, &ModuleMenu::updateActions); - connect(&ActiveObjects::instance(), &ActiveObjects::moleculeSourceChanged, this, &ModuleMenu::updateActions); - connect(&ActiveObjects::instance(), &ActiveObjects::resultChanged, this, &ModuleMenu::updateActions); - connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::viewChanged), this, &ModuleMenu::updateActions); - updateActions(); -} - -ModuleMenu::~ModuleMenu() {} - -void ModuleMenu::updateActions() -{ - QMenu* menu = m_menu; - QToolBar* toolBar = m_toolBar; - Q_ASSERT(menu); - Q_ASSERT(toolBar); - - menu->clear(); - toolBar->clear(); - - auto activeDataSource = ActiveObjects::instance().activeDataSource(); - auto activeMoleculeSource = ActiveObjects::instance().activeMoleculeSource(); - auto activeOperatorResult = ActiveObjects::instance().activeOperatorResult(); - auto activeView = ActiveObjects::instance().activeView(); - QList modules = ModuleFactory::moduleTypes(); - - if (modules.size() > 0) { - foreach (const QString& txt, modules) { - auto actn = menu->addAction(ModuleFactory::moduleIcon(txt), txt); - actn->setEnabled( - ModuleFactory::moduleApplicable(txt, activeDataSource, activeView) || - ModuleFactory::moduleApplicable(txt, activeMoleculeSource, activeView) || - ModuleFactory::moduleApplicable(txt, activeOperatorResult, activeView)); - toolBar->addAction(actn); - actn->setData(txt); - } - } else { - auto action = menu->addAction("No modules available"); - action->setEnabled(false); - toolBar->addAction(action); - } -} - -void ModuleMenu::triggered(QAction* maction) -{ - auto type = maction->data().toString(); - auto dataSource = ActiveObjects::instance().activeDataSource(); - auto moleculeSource = ActiveObjects::instance().activeMoleculeSource(); - auto operatorResult = ActiveObjects::instance().activeOperatorResult(); - - auto* view = resolveView(type); - if (!view) { - return; - } - - Module* module; - if (type == "Molecule") { - if (operatorResult) { - module = - ModuleManager::instance().createAndAddModule(type, operatorResult, view); - } else { - module = - ModuleManager::instance().createAndAddModule(type, moleculeSource, view); - } - } else if (type == "Plot") { - module = - ModuleManager::instance().createAndAddModule(type, operatorResult, view); - } else { - module = - ModuleManager::instance().createAndAddModule(type, dataSource, view); - } - - if (module) { - ActiveObjects::instance().setActiveModule(module); - } else { - qCritical("Failed to create requested module."); - } -} - -} // end of namespace tomviz diff --git a/tomviz/modules/ModuleMenu.h b/tomviz/modules/ModuleMenu.h deleted file mode 100644 index b6df17075..000000000 --- a/tomviz/modules/ModuleMenu.h +++ /dev/null @@ -1,38 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleMenu_h -#define tomvizModuleMenu_h - -#include -#include - -class QAction; -class QMenu; -class QToolBar; - -namespace tomviz { - -/// ModuleMenu is manager for the Modules menu. It fills it up with actions -/// and handles their triggers based on available modules reported by -/// ModuleFactory. -class ModuleMenu : public QObject -{ - Q_OBJECT - -public: - ModuleMenu(QToolBar* toolBar, QMenu* parentMenu, QObject* parent = nullptr); - virtual ~ModuleMenu(); - -private slots: - void updateActions(); - void triggered(QAction* maction); - -private: - Q_DISABLE_COPY(ModuleMenu) - QPointer m_menu; - QPointer m_toolBar; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleMolecule.cxx b/tomviz/modules/ModuleMolecule.cxx deleted file mode 100644 index bfeb4fa0f..000000000 --- a/tomviz/modules/ModuleMolecule.cxx +++ /dev/null @@ -1,194 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleMolecule.h" - -#include "DataSource.h" -#include "DoubleSliderWidget.h" -#include "MoleculeSource.h" -#include "OperatorResult.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include - -#include -#include - -#include -#include -#include -#include - -namespace tomviz { - -ModuleMolecule::ModuleMolecule(QObject* parentObject) : Module(parentObject) -{ -} - -ModuleMolecule::~ModuleMolecule() -{ - finalize(); -} - -QIcon ModuleMolecule::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqGroup.svg"); -} - -bool ModuleMolecule::initialize(DataSource*, vtkSMViewProxy*) -{ - return false; -} - -bool ModuleMolecule::initialize(OperatorResult* result, vtkSMViewProxy* view) -{ - if (!Module::initialize(result, view)) { - return false; - } - - m_molecule = vtkMolecule::SafeDownCast(result->dataObject()); - if (m_molecule == nullptr) { - return false; - } - - addMoleculeToView(view); - - return true; -} - -bool ModuleMolecule::initialize(MoleculeSource* moleculeSource, - vtkSMViewProxy* view) -{ - if (!Module::initialize(moleculeSource, view)) { - return false; - } - - m_molecule = vtkMolecule::SafeDownCast(moleculeSource->molecule()); - if (m_molecule == nullptr) { - return false; - } - - addMoleculeToView(view); - - return true; -} - -void ModuleMolecule::addMoleculeToView(vtkSMViewProxy* view) -{ - m_moleculeMapper->SetInputData(m_molecule); - m_moleculeActor->SetMapper(m_moleculeMapper); - - m_view = vtkPVRenderView::SafeDownCast(view->GetClientSideView()); - m_view->GetRenderer()->AddActor(m_moleculeActor); - m_view->Update(); -} - -bool ModuleMolecule::finalize() -{ - if (m_view) { - m_view->GetRenderer()->RemoveActor(m_moleculeActor); - } - return true; -} - -bool ModuleMolecule::setVisibility(bool val) -{ - m_moleculeActor->SetVisibility(val); - Module::setVisibility(val); - return true; -} - -bool ModuleMolecule::visibility() const -{ - return m_moleculeActor->GetVisibility(); -} - -void ModuleMolecule::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - - QFormLayout* layout = new QFormLayout; - - auto ballSlider = new DoubleSliderWidget(true); - ballSlider->setLineEditWidth(50); - ballSlider->setMaximum(4.0); - ballSlider->setValue(m_moleculeMapper->GetAtomicRadiusScaleFactor()); - layout->addRow("Ball Radius", ballSlider); - - auto stickSlider = new DoubleSliderWidget(true); - stickSlider->setLineEditWidth(50); - stickSlider->setMaximum(2.0); - stickSlider->setValue(m_moleculeMapper->GetBondRadius()); - layout->addRow("Stick Radius", stickSlider); - - panel->setLayout(layout); - - connect(ballSlider, &DoubleSliderWidget::valueEdited, this, - &ModuleMolecule::ballRadiusChanged); - - connect(stickSlider, &DoubleSliderWidget::valueEdited, this, - &ModuleMolecule::bondRadiusChanged); -} - -void ModuleMolecule::ballRadiusChanged(double val) -{ - m_moleculeMapper->SetAtomicRadiusScaleFactor(val); - if (m_view) { - m_view->GetRenderer()->Render(); - } -} - -void ModuleMolecule::bondRadiusChanged(double val) -{ - m_moleculeMapper->SetBondRadius(val); - if (m_view) { - m_view->GetRenderer()->Render(); - } -} - -QJsonObject ModuleMolecule::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - props["ballRadius"] = m_moleculeMapper->GetAtomicRadiusScaleFactor(); - props["stickRadius"] = m_moleculeMapper->GetBondRadius(); - - json["properties"] = props; - return json; -} - -bool ModuleMolecule::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - ballRadiusChanged(props["ballRadius"].toDouble()); - bondRadiusChanged(props["stickRadius"].toDouble()); - return true; - } - return false; -} - -void ModuleMolecule::dataSourceMoved(double, double, double) -{ -} - -void ModuleMolecule::dataSourceRotated(double, double, double) -{ -} - -vtkDataObject* ModuleMolecule::dataToExport() -{ - return m_molecule; -} - -} // namespace tomviz diff --git a/tomviz/modules/ModuleMolecule.h b/tomviz/modules/ModuleMolecule.h deleted file mode 100644 index 451a8205e..000000000 --- a/tomviz/modules/ModuleMolecule.h +++ /dev/null @@ -1,64 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleMolecule_h -#define tomvizModuleMolecule_h - -#include "Module.h" - -class QCheckBox; - -class vtkActor; -class vtkMolecule; -class vtkMoleculeMapper; - -class vtkPVRenderView; - -namespace tomviz { - -class MoleculeSource; -class OperatorResult; - -class ModuleMolecule : public Module -{ - Q_OBJECT - -public: - ModuleMolecule(QObject* parent = nullptr); - ~ModuleMolecule() override; - - QString label() const override { return "Molecule"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, - vtkSMViewProxy* view) override; - bool initialize(MoleculeSource* moleculeSource, - vtkSMViewProxy* view) override; - bool initialize(OperatorResult* result, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - void addToPanel(QWidget*) override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - QString exportDataTypeString() override { return "Molecule"; } - vtkDataObject* dataToExport() override; - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - -private slots: - void ballRadiusChanged(double val); - void bondRadiusChanged(double val); - -private: - Q_DISABLE_COPY(ModuleMolecule) - void addMoleculeToView(vtkSMViewProxy* view); - vtkWeakPointer m_view; - vtkMolecule* m_molecule; - vtkNew m_moleculeMapper; - vtkNew m_moleculeActor; -}; -} // namespace tomviz -#endif diff --git a/tomviz/modules/ModuleOutline.cxx b/tomviz/modules/ModuleOutline.cxx deleted file mode 100644 index 04719a23f..000000000 --- a/tomviz/modules/ModuleOutline.cxx +++ /dev/null @@ -1,434 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleOutline.h" - -#include "DataSource.h" -#include "Utilities.h" -#include "pqProxiesWidget.h" -#include "vtkNew.h" -#include "vtkSMParaViewPipelineControllerWithRendering.h" -#include "vtkSMProperty.h" -#include "vtkSMPropertyHelper.h" -#include "vtkSMSessionProxyManager.h" -#include "vtkSMSourceProxy.h" -#include "vtkSMViewProxy.h" -#include "vtkSmartPointer.h" -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -ModuleOutline::ModuleOutline(QObject* parentObject) : Module(parentObject) {} - -ModuleOutline::~ModuleOutline() -{ - finalize(); -} - -QIcon ModuleOutline::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqProbeLocation.svg"); -} - -bool ModuleOutline::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - vtkNew controller; - - auto pxm = data->proxy()->GetSessionProxyManager(); - - // Create the outline filter. - vtkSmartPointer proxy; - proxy.TakeReference(pxm->NewProxy("filters", "OutlineFilter")); - - m_outlineFilter = vtkSMSourceProxy::SafeDownCast(proxy); - Q_ASSERT(m_outlineFilter); - controller->PreInitializeProxy(m_outlineFilter); - vtkSMPropertyHelper(m_outlineFilter, "Input").Set(data->proxy()); - controller->PostInitializeProxy(m_outlineFilter); - controller->RegisterPipelineProxy(m_outlineFilter); - - // Create the representation for it. - m_outlineRepresentation = controller->Show(m_outlineFilter, 0, vtkView); - vtkSMPropertyHelper(m_outlineRepresentation, "Translation") - .Set(data->displayPosition(), 3); - vtkSMPropertyHelper(m_outlineRepresentation, "Orientation") - .Set(data->displayOrientation(), 3); - Q_ASSERT(m_outlineRepresentation); - // vtkSMPropertyHelper(OutlineRepresentation, - // "Representation").Set("Outline"); - m_outlineRepresentation->UpdateVTKObjects(); - - // Give the proxy a friendly name for the GUI/Python world. - if (auto p = convert(proxy)) { - p->rename(label()); - } - - // Init the grid axes - initializeGridAxes(data, vtkView); - updateGridAxesColor(offWhite); - - return true; -} - -bool ModuleOutline::finalize() -{ - vtkNew controller; - controller->UnRegisterProxy(m_outlineRepresentation); - controller->UnRegisterProxy(m_outlineFilter); - - if (m_view) { - m_view->GetRenderer()->RemoveActor(m_gridAxes); - } - - m_outlineFilter = nullptr; - m_outlineRepresentation = nullptr; - return true; -} - -QJsonObject ModuleOutline::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - props["gridVisibility"] = m_gridAxes->GetVisibility() > 0; - props["gridLines"] = m_gridAxes->GetGenerateGrid(); - - QJsonArray color; - double rgb[3]; - m_gridAxes->GetProperty()->GetDiffuseColor(rgb); - color << rgb[0] << rgb[1] << rgb[2]; - props["gridColor"] = color; - - props["useCustomAxesTitles"] = m_useCustomAxesTitles; - props["customXTitle"] = m_customXTitle; - props["customYTitle"] = m_customYTitle; - props["customZTitle"] = m_customZTitle; - - json["properties"] = props; - return json; -} - -bool ModuleOutline::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - m_gridAxes->SetVisibility(props["gridVisibility"].toBool() ? 1 : 0); - m_gridAxes->SetGenerateGrid(props["gridLines"].toBool()); - auto color = props["gridColor"].toArray(); - double rgb[3] = { color[0].toDouble(), color[1].toDouble(), - color[2].toDouble() }; - updateGridAxesColor(rgb); - - m_useCustomAxesTitles = props["useCustomAxesTitles"].toBool(); - if (props.contains("customXTitle")) { - m_customXTitle = props["customXTitle"].toString(); - } - if (props.contains("customYTitle")) { - m_customYTitle = props["customYTitle"].toString(); - } - if (props.contains("customZTitle")) { - m_customZTitle = props["customZTitle"].toString(); - } - updateGridAxesTitles(); - return true; - } - return false; -} - -bool ModuleOutline::setVisibility(bool val) -{ - Q_ASSERT(m_outlineRepresentation); - vtkSMPropertyHelper(m_outlineRepresentation, "Visibility").Set(val ? 1 : 0); - m_outlineRepresentation->UpdateVTKObjects(); - if (!val || m_axesVisibility) { - m_gridAxes->SetVisibility(val ? 1 : 0); - } - Module::setVisibility(val); - return true; -} - -bool ModuleOutline::visibility() const -{ - if (m_outlineRepresentation) { - return vtkSMPropertyHelper(m_outlineRepresentation, "Visibility") - .GetAsInt() != 0; - } else { - return false; - } -} - -void ModuleOutline::addToPanel(QWidget* panel) -{ - Q_ASSERT(panel && m_outlineRepresentation); - - if (panel->layout()) { - delete panel->layout(); - } - - QHBoxLayout* layout = new QHBoxLayout; - QLabel* label = new QLabel("Color"); - layout->addWidget(label); - layout->addStretch(); - pqColorChooserButton* colorSelector = new pqColorChooserButton(panel); - colorSelector->setShowAlphaChannel(false); - layout->addWidget(colorSelector); - - // Show Grid? - QHBoxLayout* showGridLayout = new QHBoxLayout; - QCheckBox* showGrid = new QCheckBox(QString("Show Grid")); - showGrid->setChecked(m_gridAxes->GetGenerateGrid()); - - connect(showGrid, &QCheckBox::checkStateChanged, this, [this](int state) { - m_gridAxes->SetGenerateGrid(state == Qt::Checked); - emit renderNeeded(); - }); - - showGridLayout->addWidget(showGrid); - - // Custom axes titles - auto* customAxesTitlesGroupBox = new QGroupBox(panel); - customAxesTitlesGroupBox->setVisible(m_useCustomAxesTitles); - auto* customAxesTitlesLayout = new QVBoxLayout(customAxesTitlesGroupBox); - - auto* customXTitleLayout = new QHBoxLayout; - auto* customYTitleLayout = new QHBoxLayout; - auto* customZTitleLayout = new QHBoxLayout; - customAxesTitlesLayout->addLayout(customXTitleLayout); - customAxesTitlesLayout->addLayout(customYTitleLayout); - customAxesTitlesLayout->addLayout(customZTitleLayout); - - customXTitleLayout->addWidget(new QLabel("X:")); - customYTitleLayout->addWidget(new QLabel("Y:")); - customZTitleLayout->addWidget(new QLabel("Z:")); - - auto* customXTitleEditor = new QLineEdit(m_customXTitle); - auto* customYTitleEditor = new QLineEdit(m_customYTitle); - auto* customZTitleEditor = new QLineEdit(m_customZTitle); - - customXTitleLayout->addWidget(customXTitleEditor); - customYTitleLayout->addWidget(customYTitleEditor); - customZTitleLayout->addWidget(customZTitleEditor); - - connect(customXTitleEditor, &QLineEdit::textChanged, this, - [this](const QString& text) { - m_customXTitle = text; - updateGridAxesTitles(); - emit renderNeeded(); - }); - - connect(customYTitleEditor, &QLineEdit::textChanged, this, - [this](const QString& text) { - m_customYTitle = text; - updateGridAxesTitles(); - emit renderNeeded(); - }); - - connect(customZTitleEditor, &QLineEdit::textChanged, this, - [this](const QString& text) { - m_customZTitle = text; - updateGridAxesTitles(); - emit renderNeeded(); - }); - - // Use custom axes titles? - auto* useCustomAxesTitlesLayout = new QHBoxLayout; - auto* useCustomAxesTitles = new QCheckBox("Custom Axes Titles"); - useCustomAxesTitles->setChecked(m_useCustomAxesTitles); - useCustomAxesTitlesLayout->addWidget(useCustomAxesTitles); - connect(useCustomAxesTitles, &QCheckBox::toggled, this, - [this, customAxesTitlesGroupBox](bool b) { - m_useCustomAxesTitles = b; - customAxesTitlesGroupBox->setVisible(b); - updateGridAxesTitles(); - emit renderNeeded(); - }); - - // Show Axes? - QHBoxLayout* showAxesLayout = new QHBoxLayout; - QCheckBox* showAxes = new QCheckBox(QString("Show Axes")); - showAxes->setChecked(m_gridAxes->GetVisibility()); - // Disable "Show Grid" if axes not enabled - if (!showAxes->isChecked()) { - showGrid->setEnabled(false); - useCustomAxesTitles->setEnabled(false); - customAxesTitlesGroupBox->setVisible(false); - } - connect(showAxes, &QCheckBox::checkStateChanged, this, - [this, showGrid, useCustomAxesTitles](int state) { - m_gridAxes->SetVisibility(state == Qt::Checked); - m_axesVisibility = state == Qt::Checked; - // Uncheck "Show Grid" and disable it - if (state == Qt::Unchecked) { - showGrid->setChecked(false); - useCustomAxesTitles->setChecked(false); - } - showGrid->setEnabled(state == Qt::Checked); - useCustomAxesTitles->setEnabled(state == Qt::Checked); - - emit renderNeeded(); - }); - showAxesLayout->addWidget(showAxes); - - QVBoxLayout* panelLayout = new QVBoxLayout; - panelLayout->addItem(layout); - panelLayout->addItem(showAxesLayout); - panelLayout->addItem(showGridLayout); - panelLayout->addItem(useCustomAxesTitlesLayout); - panelLayout->addWidget(customAxesTitlesGroupBox); - panelLayout->addStretch(); - panel->setLayout(panelLayout); - - m_links.addPropertyLink(colorSelector, "chosenColorRgbF", - SIGNAL(chosenColorChanged(const QColor&)), - m_outlineRepresentation, - m_outlineRepresentation->GetProperty("DiffuseColor")); - - connect(colorSelector, &pqColorChooserButton::chosenColorChanged, - [this](const QColor& color) { - double rgb[3]; - rgb[0] = color.redF(); - rgb[1] = color.greenF(); - rgb[2] = color.blueF(); - updateGridAxesColor(rgb); - }); - connect(colorSelector, &pqColorChooserButton::chosenColorChanged, this, - &ModuleOutline::dataUpdated); -} - -void ModuleOutline::dataUpdated() -{ - m_links.accept(); - emit renderNeeded(); -} - -void ModuleOutline::dataSourceMoved(double newX, double newY, double newZ) -{ - double pos[3] = { newX, newY, newZ }; - vtkSMPropertyHelper(m_outlineRepresentation, "Translation").Set(pos, 3); - m_outlineRepresentation->UpdateVTKObjects(); - m_gridAxes->SetPosition(newX, newY, newZ); -} - -void ModuleOutline::dataSourceRotated(double newX, double newY, double newZ) -{ - double orientation[3] = { newX, newY, newZ }; - vtkSMPropertyHelper(m_outlineRepresentation, "Orientation") - .Set(orientation, 3); - m_outlineRepresentation->UpdateVTKObjects(); - m_gridAxes->SetOrientation(newX, newY, newZ); -} - -void ModuleOutline::updateGridAxesBounds(DataSource* dataSource) -{ - Q_ASSERT(dataSource); - double bounds[6]; - dataSource->getBounds(bounds); - m_gridAxes->SetGridBounds(bounds); -} - -void ModuleOutline::initializeGridAxes(DataSource* data, - vtkSMViewProxy* vtkView) -{ - updateGridAxesBounds(data); - m_gridAxes->SetVisibility(0); - m_gridAxes->SetGenerateGrid(false); - - // Work around a bug in vtkGridAxesActor3D. GetProperty() returns the - // vtkProperty associated with a single face, so to get a property associated - // with all the faces, we need to create a new one and set it. - vtkNew prop; - prop->DeepCopy(m_gridAxes->GetProperty()); - m_gridAxes->SetProperty(prop); - - // Set mask to show labels on all axes - m_gridAxes->SetLabelMask(vtkGridAxesHelper::LabelMasks::MIN_X | - vtkGridAxesHelper::LabelMasks::MIN_Y | - vtkGridAxesHelper::LabelMasks::MIN_Z | - vtkGridAxesHelper::LabelMasks::MAX_X | - vtkGridAxesHelper::LabelMasks::MAX_Y | - vtkGridAxesHelper::LabelMasks::MAX_Z); - - // Set mask to render all faces - m_gridAxes->SetFaceMask(vtkGridAxesHelper::Faces::MAX_XY | - vtkGridAxesHelper::Faces::MAX_YZ | - vtkGridAxesHelper::Faces::MAX_ZX | - vtkGridAxesHelper::Faces::MIN_XY | - vtkGridAxesHelper::Faces::MIN_YZ | - vtkGridAxesHelper::Faces::MIN_ZX); - - // Enable front face culling - prop->SetFrontfaceCulling(1); - - // Disable back face culling - prop->SetBackfaceCulling(0); - - // Set the titles - updateGridAxesTitles(); - - m_view = vtkPVRenderView::SafeDownCast(vtkView->GetClientSideView()); - m_view->GetRenderer()->AddActor(m_gridAxes); - - connect(data, &DataSource::dataPropertiesChanged, this, [this]() { - auto dataSource = qobject_cast(sender()); - updateGridAxesBounds(dataSource); - updateGridAxesTitles(); - dataSource->proxy()->MarkModified(nullptr); - dataSource->proxy()->UpdatePipeline(); - emit renderNeeded(); - }); -} - -void ModuleOutline::updateGridAxesColor(double* color) -{ - for (int i = 0; i < 6; i++) { - vtkNew prop; - prop->SetColor(color); - m_gridAxes->SetTitleTextProperty(i, prop); - m_gridAxes->SetLabelTextProperty(i, prop); - } - m_gridAxes->GetProperty()->SetDiffuseColor(color); - vtkSMPropertyHelper(m_outlineRepresentation, "DiffuseColor").Set(color, 3); - m_outlineRepresentation->UpdateVTKObjects(); -} - -void ModuleOutline::updateGridAxesTitles() -{ - QString xTitle, yTitle, zTitle; - if (m_useCustomAxesTitles) { - xTitle = m_customXTitle; - yTitle = m_customYTitle; - zTitle = m_customZTitle; - } else { - auto units = dataSource()->getUnits(); - xTitle = QString("X (%1)").arg(units); - yTitle = QString("Y (%1)").arg(units); - zTitle = QString("Z (%1)").arg(units); - } - - m_gridAxes->SetXTitle(xTitle.toUtf8().data()); - m_gridAxes->SetYTitle(yTitle.toUtf8().data()); - m_gridAxes->SetZTitle(zTitle.toUtf8().data()); -} - -} // end of namespace tomviz diff --git a/tomviz/modules/ModuleOutline.h b/tomviz/modules/ModuleOutline.h deleted file mode 100644 index 50692d270..000000000 --- a/tomviz/modules/ModuleOutline.h +++ /dev/null @@ -1,67 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleOutline_h -#define tomvizModuleOutline_h - -#include "Module.h" -#include - -#include - -class vtkSMSourceProxy; -class vtkSMProxy; -class vtkPVRenderView; -class vtkGridAxesActor3D; - -namespace tomviz { - -/// A simple module to show the outline for any dataset. -class ModuleOutline : public Module -{ - Q_OBJECT - -public: - ModuleOutline(QObject* parent = nullptr); - ~ModuleOutline() override; - - QString label() const override { return "Outline"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - void addToPanel(QWidget*) override; - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - -private slots: - void dataUpdated(); - - void initializeGridAxes(DataSource* dataSource, vtkSMViewProxy* vtkView); - void updateGridAxesBounds(DataSource* dataSource); - void updateGridAxesColor(double* color); - void updateGridAxesTitles(); - -private: - Q_DISABLE_COPY(ModuleOutline) - vtkWeakPointer m_outlineFilter; - vtkWeakPointer m_outlineRepresentation; - vtkWeakPointer m_view; - vtkNew m_gridAxes; - pqPropertyLinks m_links; - - bool m_axesVisibility = false; - bool m_useCustomAxesTitles = false; - QString m_customXTitle = "X"; - QString m_customYTitle = "Y"; - QString m_customZTitle = "Z"; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModulePlot.cxx b/tomviz/modules/ModulePlot.cxx deleted file mode 100644 index fef6e5f28..000000000 --- a/tomviz/modules/ModulePlot.cxx +++ /dev/null @@ -1,341 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModulePlot.h" - -#include "OperatorResult.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -ModulePlot::ModulePlot(QObject* parentObject) - : Module(parentObject) - , m_visible(true) - , m_view(nullptr) - , m_table(nullptr) - , m_chart(nullptr) - , m_producer(nullptr) -{ - m_result_modified_cb->SetCallback(&ModulePlot::onResultModified); - m_result_modified_cb->SetClientData(this); -} - -ModulePlot::~ModulePlot() -{ - finalize(); -} - -QIcon ModulePlot::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqLineChart16.png"); -} - -bool ModulePlot::initialize(DataSource* data, vtkSMViewProxy* view) -{ - Q_UNUSED(data); - Q_UNUSED(view); - - return false; -} - -bool ModulePlot::initialize(MoleculeSource* data, vtkSMViewProxy* view) -{ - Q_UNUSED(data); - Q_UNUSED(view); - - return false; -} - -bool ModulePlot::initialize(OperatorResult* result, vtkSMViewProxy* view) -{ - Module::initialize(result, view); - - m_table = vtkTable::SafeDownCast(result->dataObject()); - m_producer = vtkTrivialProducer::SafeDownCast(result->producerProxy()->GetClientSideObject()); - m_view = vtkPVContextView::SafeDownCast(view->GetClientSideView()); - m_chart = nullptr; - - if (m_table == nullptr || m_producer == nullptr || m_view == nullptr) { - return false; - } - - auto context_view = m_view->GetContextView(); - - m_chart = vtkChartXY::SafeDownCast(context_view->GetScene()->GetItem(0)); - - if (m_chart == nullptr) { - return false; - } - - // Detect when the result dataobject changes, i.e. when the pipeline re-runs - m_producer->AddObserver(vtkCommand::ModifiedEvent, m_result_modified_cb); - - addAllPlots(); - - return true; -} - -bool ModulePlot::finalize() -{ - if (m_producer) { - m_producer->RemoveObserver(m_result_modified_cb); - } - - removeAllPlots(); - - m_plots.clear(); - - return true; -} - -void ModulePlot::addAllPlots() -{ - removeAllPlots(); - - if (m_table == nullptr || m_chart == nullptr) { - return; - } - - vtkIdType num_cols = m_table->GetNumberOfColumns(); - - auto fieldData = m_table->GetFieldData(); - auto labelsArray = vtkStringArray::SafeDownCast( - fieldData->GetAbstractArray("axes_labels")); - if (labelsArray && labelsArray->GetNumberOfTuples() >= 2) { - auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); - auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); - x_axis->SetTitle(labelsArray->GetValue(0)); - y_axis->SetTitle(labelsArray->GetValue(1)); - } - - auto logScaleArray = vtkUnsignedCharArray::SafeDownCast( - fieldData->GetAbstractArray("axes_log_scale")); - if (logScaleArray && logScaleArray->GetNumberOfTuples() >= 2) { - auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); - auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); - x_axis->SetLogScale(logScaleArray->GetValue(0) != 0); - y_axis->SetLogScale(logScaleArray->GetValue(1) != 0); - } - - // Start color index from the number of plots already in the chart - // so that multiple modules sharing a view get distinct colors. - int colorOffset = m_chart->GetNumberOfPlots(); - - for (vtkIdType col = 1; col < num_cols; col++) { - auto line = vtkSmartPointer::New(); - int idx = colorOffset + col - 1; - // Golden angle spacing in hue for maximum color separation - int hue = (idx * 137) % 360; - QColor color = QColor::fromHsv(hue, 200, 200); - line->SetInputData(m_table, 0, col); - line->SetColor(color.red(), color.green(), color.blue(), 255); - line->SetWidth(3.0); - m_chart->AddPlot(line); - m_plots.append(line); - } -} - -void ModulePlot::removeAllPlots() -{ - if (m_chart == nullptr) { - return; - } - - for (auto iter = m_plots.begin(); iter != m_plots.end(); iter++) { - m_chart->RemovePlotInstance(*iter); - } - - m_plots.clear(); -} - -bool ModulePlot::setVisibility(bool val) -{ - m_visible = val; - - if (val) { - addAllPlots(); - } else { - removeAllPlots(); - } - - Module::setVisibility(val); - - return true; -} - -bool ModulePlot::visibility() const -{ - return m_visible; -} - -void ModulePlot::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - - QFormLayout* layout = new QFormLayout; - - QString xLabel, yLabel; - bool xLogScale = false; - bool yLogScale = false; - - if (m_chart) { - xLabel = m_chart->GetAxis(vtkAxis::BOTTOM)->GetTitle().c_str(); - yLabel = m_chart->GetAxis(vtkAxis::LEFT)->GetTitle().c_str(); - xLogScale = m_chart->GetAxis(vtkAxis::BOTTOM)->GetLogScale(); - yLogScale = m_chart->GetAxis(vtkAxis::LEFT)->GetLogScale(); - } - - m_xLabelEdit = new QLineEdit(xLabel); - m_yLabelEdit = new QLineEdit(yLabel); - layout->addRow("X Label", m_xLabelEdit); - layout->addRow("Y Label", m_yLabelEdit); - - m_xLogCheckBox = new QCheckBox("X Log Scale"); - m_yLogCheckBox = new QCheckBox("Y Log Scale"); - m_xLogCheckBox->setChecked(xLogScale); - m_yLogCheckBox->setChecked(yLogScale); - layout->addRow(m_xLogCheckBox); - layout->addRow(m_yLogCheckBox); - - connect(m_xLabelEdit, &QLineEdit::textChanged, this, - &ModulePlot::onXLabelChanged); - connect(m_yLabelEdit, &QLineEdit::textChanged, this, - &ModulePlot::onYLabelChanged); - connect(m_xLogCheckBox, &QCheckBox::toggled, this, - &ModulePlot::onXLogScaleChanged); - connect(m_yLogCheckBox, &QCheckBox::toggled, this, - &ModulePlot::onYLogScaleChanged); - - panel->setLayout(layout); -} - -QJsonObject ModulePlot::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - // Save the operator result name so the module can be recreated on - // deserialization by finding the matching OperatorResult. - if (operatorResult()) { - json["operatorResultName"] = operatorResult()->name(); - } - - json["properties"] = props; - return json; -} - -bool ModulePlot::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - return true; - } - return false; -} - -void ModulePlot::dataSourceMoved(double, double, double) -{ -} - -void ModulePlot::dataSourceRotated(double, double, double) -{ -} - -vtkDataObject* ModulePlot::dataToExport() -{ - return nullptr; -} - -void ModulePlot::onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData) -{ - Q_UNUSED(caller); - Q_UNUSED(eventId); - Q_UNUSED(callData); - - auto self = reinterpret_cast(clientData); - auto result = self->operatorResult(); - self->m_table = vtkTable::SafeDownCast(result->dataObject()); - - self->removeAllPlots(); - self->m_plots.clear(); - - if (self->visibility()) { - self->addAllPlots(); - } -} - -void ModulePlot::onXLogScaleChanged(bool logScale) -{ - if (m_chart == nullptr) { - return; - } - - auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); - x_axis->SetLogScale(logScale); - - m_view->Update(); -} - -void ModulePlot::onYLogScaleChanged(bool logScale) -{ - if (m_chart == nullptr) { - return; - } - - auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); - y_axis->SetLogScale(logScale); - - m_view->Update(); -} - -void ModulePlot::onXLabelChanged(const QString& label) -{ - if (m_chart == nullptr) { - return; - } - - auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); - x_axis->SetTitle(label.toStdString()); - - m_view->Update(); -} - -void ModulePlot::onYLabelChanged(const QString& label) -{ - if (m_chart == nullptr) { - return; - } - - auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); - y_axis->SetTitle(label.toStdString()); - - m_view->Update(); -} - -} // namespace tomviz diff --git a/tomviz/modules/ModulePlot.h b/tomviz/modules/ModulePlot.h deleted file mode 100644 index 7dd29f212..000000000 --- a/tomviz/modules/ModulePlot.h +++ /dev/null @@ -1,78 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModulePlot_h -#define tomvizModulePlot_h - -#include "Module.h" - -class vtkCallbackCommand; -class vtkChartXY; -class vtkPlot; -class vtkPVContextView; -class vtkTable; -class vtkTrivialProducer; - -class QCheckBox; -class QLineEdit; - - -namespace tomviz { - -class MoleculeSource; -class OperatorResult; - -class ModulePlot : public Module -{ - Q_OBJECT - -public: - ModulePlot(QObject* parent = nullptr); - ~ModulePlot() override; - - QString label() const override { return "Plot"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* data, vtkSMViewProxy* vtkView) override; - bool initialize(MoleculeSource* data, vtkSMViewProxy* vtkView) override; - bool initialize(OperatorResult* result, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - void addToPanel(QWidget*) override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - QString exportDataTypeString() override { return ""; } - vtkDataObject* dataToExport() override; - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - -private slots: - void onXLogScaleChanged(bool); - void onYLogScaleChanged(bool); - void onXLabelChanged(const QString& label); - void onYLabelChanged(const QString& label); - -private: - static void onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData); - void addAllPlots(); - void removeAllPlots(); - - Q_DISABLE_COPY(ModulePlot) - bool m_visible; - vtkWeakPointer m_view; - vtkNew m_result_modified_cb; - vtkWeakPointer m_table; - vtkWeakPointer m_chart; - vtkWeakPointer m_producer; - QList> m_plots; - QPointer m_xLogCheckBox; - QPointer m_yLogCheckBox; - QPointer m_xLabelEdit; - QPointer m_yLabelEdit; - -}; -} // namespace tomviz -#endif diff --git a/tomviz/modules/ModulePropertiesPanel.cxx b/tomviz/modules/ModulePropertiesPanel.cxx deleted file mode 100644 index 5861ca58f..000000000 --- a/tomviz/modules/ModulePropertiesPanel.cxx +++ /dev/null @@ -1,116 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModulePropertiesPanel.h" -#include "ui_ModulePropertiesPanel.h" - -#include "ActiveObjects.h" -#include "Module.h" -#include "ModuleManager.h" -#include "Utilities.h" -#include "pqView.h" -#include "vtkSMViewProxy.h" - -namespace tomviz { - -class ModulePropertiesPanel::MPPInternals -{ -public: - Ui::ModulePropertiesPanel Ui; - QPointer ActiveModule; -}; - -ModulePropertiesPanel::ModulePropertiesPanel(QWidget* parentObject) - : Superclass(parentObject), - Internals(new ModulePropertiesPanel::MPPInternals()) -{ - Ui::ModulePropertiesPanel& ui = this->Internals->Ui; - ui.setupUi(this); - - // Show active module in the "Module Properties" panel. - this->connect(&ActiveObjects::instance(), &ActiveObjects::moduleChanged, - this, &ModulePropertiesPanel::setModule); - this->connect(&ActiveObjects::instance(), - QOverload::of(&ActiveObjects::viewChanged), - this, &ModulePropertiesPanel::setView); - - /* Disabled the search box for now, uncomment to enable again. - this->connect(ui.SearchBox, &pqSearchBox::advancedSearchActivated, - this, &ModulePropertiesPanel::updatePanel); - this->connect(ui.SearchBox, &pqSearchBox::textChanged, - this, &ModulePropertiesPanel::updatePanel); - */ - - this->connect(ui.DetachColorMap, &QAbstractButton::clicked, this, - &ModulePropertiesPanel::detachColorMap); -} - -ModulePropertiesPanel::~ModulePropertiesPanel() {} - -void ModulePropertiesPanel::setModule(Module* module) -{ - if (module != this->Internals->ActiveModule) { - if (this->Internals->ActiveModule) { - DataSource* dataSource = this->Internals->ActiveModule->dataSource(); - if (dataSource) { - QObject::disconnect(dataSource, &DataSource::dataChanged, this, - &ModulePropertiesPanel::updatePanel); - } - this->Internals->ActiveModule->prepareToRemoveFromPanel(this); - } - - if (module) { - DataSource* dataSource = module->dataSource(); - if (dataSource) { - QObject::connect(dataSource, &DataSource::dataChanged, this, - &ModulePropertiesPanel::updatePanel); - } - } - } - - this->Internals->ActiveModule = module; - Ui::ModulePropertiesPanel& ui = this->Internals->Ui; - - deleteLayoutContents(ui.PropertiesWidget->layout()); - - ui.DetachColorMapWidget->setVisible(false); - if (module) { - module->addToPanel(ui.PropertiesWidget); - if (module->isColorMapNeeded()) { - ui.DetachColorMapWidget->setVisible(true); - ui.DetachColorMap->setChecked(module->useDetachedColorMap()); - ui.DetachColorMap->setEnabled(!module->isOpacityMapped()); - - this->connect(module, &Module::opacityEnforced, this, - &ModulePropertiesPanel::onEnforcedOpacity); - - this->connect(module, &Module::colorMapChanged, this, [&]() { - ui.DetachColorMap->setChecked( - this->Internals->ActiveModule->useDetachedColorMap()); - }); - } - } - ui.PropertiesWidget->layout()->update(); - this->updatePanel(); -} - -void ModulePropertiesPanel::setView(vtkSMViewProxy* vtkNotUsed(view)) {} - -void ModulePropertiesPanel::updatePanel() {} - -void ModulePropertiesPanel::onEnforcedOpacity(bool val) -{ - auto ui = this->Internals->Ui; - ui.DetachColorMap->setEnabled(!val); -} - -void ModulePropertiesPanel::detachColorMap(bool val) -{ - Module* module = this->Internals->ActiveModule; - if (module) { - module->setUseDetachedColorMap(val); - this->setModule(module); // refreshes the module. - emit module->renderNeeded(); - } -} -} // namespace tomviz diff --git a/tomviz/modules/ModulePropertiesPanel.h b/tomviz/modules/ModulePropertiesPanel.h deleted file mode 100644 index 6b4b386dc..000000000 --- a/tomviz/modules/ModulePropertiesPanel.h +++ /dev/null @@ -1,39 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModulePropertiesPanel_h -#define tomvizModulePropertiesPanel_h - -#include -#include - -class vtkSMViewProxy; - -namespace tomviz { -class Module; - -class ModulePropertiesPanel : public QWidget -{ - Q_OBJECT - typedef QWidget Superclass; - -public: - ModulePropertiesPanel(QWidget* parent = nullptr); - virtual ~ModulePropertiesPanel(); - -private slots: - void setModule(Module*); - void setView(vtkSMViewProxy*); - void updatePanel(); - void detachColorMap(bool); - void onEnforcedOpacity(bool); - -private: - Q_DISABLE_COPY(ModulePropertiesPanel) - - class MPPInternals; - const QScopedPointer Internals; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModulePropertiesPanel.ui b/tomviz/modules/ModulePropertiesPanel.ui deleted file mode 100644 index fe14ce797..000000000 --- a/tomviz/modules/ModulePropertiesPanel.ui +++ /dev/null @@ -1,64 +0,0 @@ - - - ModulePropertiesPanel - - - Form - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - - - - 0 - - - - - <html><head/><body><p>When checked, tomviz will use a separate color map for this module. Otherwise, the default behavior is to use the color map associated with the data. </p></body></html> - - - Separate Color Map - - - - - - - - - DetachColorMap - - - - - - - - - - diff --git a/tomviz/modules/ModuleRuler.cxx b/tomviz/modules/ModuleRuler.cxx deleted file mode 100644 index 28ea8b46e..000000000 --- a/tomviz/modules/ModuleRuler.cxx +++ /dev/null @@ -1,246 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleRuler.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace tomviz { - -ModuleRuler::ModuleRuler(QObject* p) : Module(p) {} - -ModuleRuler::~ModuleRuler() -{ - finalize(); -} - -QIcon ModuleRuler::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqRuler.svg"); -} - -bool ModuleRuler::initialize(DataSource* data, vtkSMViewProxy* view) -{ - if (!Module::initialize(data, view)) { - return false; - } - vtkNew controller; - - auto pxm = data->proxy()->GetSessionProxyManager(); - auto alg = vtkAlgorithm::SafeDownCast(data->producer()); - double bounds[6]; - vtkDataSet::SafeDownCast(alg->GetOutputDataObject(0))->GetBounds(bounds); - double boundsMin[3] = { bounds[0], bounds[2], bounds[4] }; - double boundsMax[3] = { bounds[1], bounds[3], bounds[5] }; - - m_rulerSource.TakeReference( - vtkSMSourceProxy::SafeDownCast(pxm->NewProxy("sources", "Ruler"))); - vtkSMPropertyHelper(m_rulerSource, "Point1").Set(boundsMin, 3); - vtkSMPropertyHelper(m_rulerSource, "Point2").Set(boundsMax, 3); - m_rulerSource->UpdateVTKObjects(); - controller->RegisterPipelineProxy(m_rulerSource); - - m_representation = controller->Show(m_rulerSource, 0, view); - - m_representation->UpdateVTKObjects(); - - updateUnits(); - - connect(data, &DataSource::dataChanged, this, &ModuleRuler::updateUnits); - - return m_representation && m_rulerSource; -} - -bool ModuleRuler::finalize() -{ - vtkNew controller; - controller->UnRegisterProxy(m_representation); - controller->UnRegisterProxy(m_rulerSource); - m_representation = nullptr; - m_rulerSource = nullptr; - return true; -} - -void ModuleRuler::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - QVBoxLayout* layout = new QVBoxLayout; - - m_widget = new pqLinePropertyWidget( - m_rulerSource, m_rulerSource->GetPropertyGroup(0), panel); - layout->addWidget(m_widget); - m_widget->setView( - tomviz::convert(ActiveObjects::instance().activeView())); - m_widget->select(); - m_widget->setWidgetVisible(m_showLine); - layout->addStretch(); - connect(m_widget.data(), &pqPropertyWidget::changeFinished, m_widget.data(), - &pqPropertyWidget::apply); - connect(m_widget.data(), &pqPropertyWidget::changeFinished, this, - &ModuleRuler::endPointsUpdated); - connect(m_widget, &pqInteractivePropertyWidgetAbstract::widgetVisibilityUpdated, - this, &ModuleRuler::updateShowLine); - - m_widget->setWidgetVisible(m_showLine); - - QLabel* label0 = new QLabel("Point 0 data value: "); - QLabel* label1 = new QLabel("Point 1 data value: "); - connect(this, &ModuleRuler::newEndpointData, label0, - [label0, label1](double val0, double val1) { - label0->setText(QString("Point 0 data value: %1").arg(val0)); - label1->setText(QString("Point 1 data value: %1").arg(val1)); - }); - layout->addWidget(label0); - layout->addWidget(label1); - panel->setLayout(layout); -} - -void ModuleRuler::prepareToRemoveFromPanel(QWidget* vtkNotUsed(panel)) -{ - // Disconnect before the panel is removed to avoid m_showLine always being set - // to false when the signal widgetVisibilityUpdated(bool) is emitted during - // the tear down of the pqLinePropertyWidget. - disconnect(m_widget, &pqInteractivePropertyWidgetAbstract::widgetVisibilityUpdated, - this, &ModuleRuler::updateShowLine); -} - -bool ModuleRuler::setVisibility(bool val) -{ - vtkSMPropertyHelper(m_representation, "Visibility").Set(val ? 1 : 0); - m_representation->UpdateVTKObjects(); - if (m_widget) { - // calling setWidgetVisible triggers the signal that updates the value of - // m_showLine. But in this case the user is toggling the whole module so - // we don't want m_showLine to update and we cache it locally and restore - // it after calling setWidgetVisible. - bool oldValue = m_showLine; - m_widget->setWidgetVisible(val && m_showLine); - m_showLine = oldValue; - } - - Module::setVisibility(val); - - return true; -} - -bool ModuleRuler::visibility() const -{ - if (m_representation) { - return vtkSMPropertyHelper(m_representation, "Visibility").GetAsInt() != 0; - } else { - return false; - } -} - -QJsonObject ModuleRuler::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - props["showLine"] = m_showLine; - double p1[3]; - double p2[3]; - vtkSMPropertyHelper(m_rulerSource, "Point1").Get(p1, 3); - vtkSMPropertyHelper(m_rulerSource, "Point2").Get(p2, 3); - QJsonArray point1 = { p1[0], p1[1], p1[2] }; - QJsonArray point2 = { p2[0], p2[1], p2[2] }; - props["point1"] = point1; - props["point2"] = point2; - - json["properties"] = props; - return json; -} - -bool ModuleRuler::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - m_showLine = props["showLine"].toBool(); - auto point1 = props["point1"].toArray(); - auto point2 = props["point2"].toArray(); - double p1[3] = { point1[0].toDouble(), point1[1].toDouble(), - point1[2].toDouble() }; - double p2[3] = { point2[0].toDouble(), point2[1].toDouble(), - point2[2].toDouble() }; - vtkSMPropertyHelper(m_rulerSource, "Point1").Set(p1, 3); - vtkSMPropertyHelper(m_rulerSource, "Point2").Set(p2, 3); - m_rulerSource->UpdateVTKObjects(); - return true; - } - return false; -} - -void ModuleRuler::updateUnits() -{ - DataSource* source = dataSource(); - QString units = source->getUnits(); - vtkRulerSourceRepresentation* rep = - vtkRulerSourceRepresentation::SafeDownCast( - m_representation->GetClientSideObject()); - QString labelFormat = "%-#6.3g %1"; - rep->SetLabelFormat(labelFormat.arg(units).toLatin1().data()); -} - -void ModuleRuler::updateShowLine(bool show) -{ - m_showLine = show; -} - -void ModuleRuler::endPointsUpdated() -{ - double point1[3]; - double point2[3]; - vtkSMPropertyHelper(m_rulerSource, "Point1").Get(point1, 3); - vtkSMPropertyHelper(m_rulerSource, "Point2").Get(point2, 3); - DataSource* source = dataSource(); - auto* algo = - vtkAlgorithm::SafeDownCast(source->proxy()->GetClientSideObject()); - if (!algo) { - return; - } - vtkImageData* img = - vtkImageData::SafeDownCast(algo->GetOutputDataObject(0)); - if (!img) { - return; - } - vtkDataArray* scalars = img->GetPointData()->GetScalars(); - if (!scalars) { - return; - } - vtkIdType p1 = img->FindPoint(point1); - vtkIdType p2 = img->FindPoint(point2); - double v1 = scalars->GetTuple1(p1); - double v2 = scalars->GetTuple1(p2); - emit newEndpointData(v1, v2); - renderNeeded(); -} -} // namespace tomviz diff --git a/tomviz/modules/ModuleRuler.h b/tomviz/modules/ModuleRuler.h deleted file mode 100644 index 194d61ecd..000000000 --- a/tomviz/modules/ModuleRuler.h +++ /dev/null @@ -1,64 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleRuler_h -#define tomvizModuleRuler_h - -#include "Module.h" - -#include - -class vtkSMSourceProxy; -class vtkSMProxy; - -class pqLinePropertyWidget; - -namespace tomviz { - -class ModuleRuler : public Module -{ - Q_OBJECT - -public: - ModuleRuler(QObject* parent = nullptr); - virtual ~ModuleRuler(); - - QString label() const override { return "Ruler"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - void addToPanel(QWidget*) override; - void prepareToRemoveFromPanel(QWidget* panel) override; - bool setVisibility(bool val) override; - bool visibility() const override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - bool isColorMapNeeded() const override { return false; } - - void dataSourceMoved(double, double, double) override {} - void dataSourceRotated(double, double, double) override {} - -protected slots: - void updateUnits(); - void updateShowLine(bool show); - void endPointsUpdated(); - -signals: - void newEndpointData(double val1, double val2); - -protected: - void updateColorMap() override {} - - vtkSmartPointer m_rulerSource; - vtkSmartPointer m_representation; - QPointer m_widget; - -private: - Q_DISABLE_COPY(ModuleRuler) - - bool m_showLine = true; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleScaleCube.cxx b/tomviz/modules/ModuleScaleCube.cxx deleted file mode 100644 index 2e6777f3f..000000000 --- a/tomviz/modules/ModuleScaleCube.cxx +++ /dev/null @@ -1,348 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleScaleCube.h" -#include "ModuleScaleCubeWidget.h" - -#include "DataSource.h" -#include "Utilities.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -ModuleScaleCube::ModuleScaleCube(QObject* parentObject) : Module(parentObject) -{ - // Connect to m_cubeRep's "modified" signal, and emit it as our own - // "onPositionChanged" signal - m_observedPositionId = - pqCoreUtilities::connect(m_cubeRep, vtkCommand::ModifiedEvent, this, - SIGNAL(onPositionChanged())); - - // Connect to our "onPositionChanged" signal and emit it with arguments - connect(this, - (void (ModuleScaleCube::*)())(&ModuleScaleCube::onPositionChanged), - this, [&]() { - double p[3]; - m_cubeRep->GetWorldPosition(p); - onPositionChanged(p[0], p[1], p[2]); - }); - - connect(this, - QOverload::of(&ModuleScaleCube::onPositionChanged), - this, &ModuleScaleCube::updateOffset); - - // Connect to m_cubeRep's "modified" signal, and emit it as our own - // "onSideLengthChanged" signal - m_observedSideLengthId = - pqCoreUtilities::connect(m_cubeRep, vtkCommand::ModifiedEvent, this, - SIGNAL(onSideLengthChanged())); - - // Connect to our "onSideLengthChanged" signal and emit it with arguments - connect(this, - (void (ModuleScaleCube::*)())(&ModuleScaleCube::onSideLengthChanged), - this, [&]() { onSideLengthChanged(m_cubeRep->GetSideLength()); }); -} - -ModuleScaleCube::~ModuleScaleCube() -{ - m_cubeRep->RemoveObserver(m_observedPositionId); - m_cubeRep->RemoveObserver(m_observedSideLengthId); - - finalize(); -} - -QIcon ModuleScaleCube::icon() const -{ - return QIcon(":/icons/pqMeasurementCube.png"); -} - -bool ModuleScaleCube::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - connect(data, &DataSource::dataPropertiesChanged, this, - &ModuleScaleCube::dataPropertiesChanged); - - m_view = vtkPVRenderView::SafeDownCast(vtkView->GetClientSideView()); - m_handleWidget->SetInteractor(m_view->GetInteractor()); - - double bounds[6]; - dataSource()->proxy()->GetDataInformation()->GetBounds(bounds); - double length = std::max(floor((bounds[1] - bounds[0]) * .1), 1.); - m_cubeRep->SetSideLength(length); - m_cubeRep->SetAdaptiveScaling(0); - m_cubeRep->SetLengthUnit(data->getUnits().toStdString().c_str()); - - m_offset[0] = 0.5 * length; - m_offset[1] = 0.5 * length; - m_offset[2] = 0.5 * length; - - const double* displayPosition = dataSource()->displayPosition(); - dataSourceMoved(displayPosition[0], displayPosition[1], displayPosition[2]); - - m_handleWidget->SetRepresentation(m_cubeRep); - m_handleWidget->EnabledOn(); - - return true; -} - -bool ModuleScaleCube::finalize() -{ - return true; -} - -bool ModuleScaleCube::visibility() const -{ - return m_cubeRep->GetHandleVisibility() == 1; -} - -bool ModuleScaleCube::setVisibility(bool choice) -{ - m_cubeRep->SetHandleVisibility(choice ? 1 : 0); - if (!choice || m_annotationVisibility) { - m_cubeRep->SetLabelVisibility(choice ? 1 : 0); - } - - Module::setVisibility(choice); - - return true; -} -QJsonObject ModuleScaleCube::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - props["adaptiveScaling"] = m_cubeRep->GetAdaptiveScaling() == 1; - props["sideLength"] = m_cubeRep->GetSideLength(); - double p[3]; - m_cubeRep->GetWorldPosition(p); - QJsonArray position = { p[0], p[1], p[2] }; - props["position"] = position; - props["annotation"] = m_cubeRep->GetLabelVisibility() == 1; - double c[3]; - m_cubeRep->GetProperty()->GetDiffuseColor(c); - QJsonArray color = { c[0], c[1], c[2] }; - props["color"] = color; - - m_cubeRep->GetLabelText()->GetTextProperty()->GetColor(c); - color = { c[0], c[1], c[2] }; - props["textColor"] = color; - - json["properties"] = props; - - return json; -} - -bool ModuleScaleCube::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - m_cubeRep->SetAdaptiveScaling(props["adaptiveScaling"].toBool() ? 1 : 0); - m_cubeRep->SetSideLength(props["sideLength"].toDouble()); - auto p = props["position"].toArray(); - double pos[3] = { p[0].toDouble(), p[1].toDouble(), p[2].toDouble() }; - m_cubeRep->SetWorldPosition(pos); - m_cubeRep->SetLabelVisibility(props["annotation"].toBool() ? 1 : 0); - auto c = props["color"].toArray(); - double color[3] = { c[0].toDouble(), c[1].toDouble(), c[2].toDouble() }; - m_cubeRep->GetProperty()->SetDiffuseColor(color); - - if (props["textColor"].isArray()) { - // This property was added later on... - c = props["textColor"].toArray(); - double textColor[3] = { c[0].toDouble(), c[1].toDouble(), - c[2].toDouble() }; - m_cubeRep->GetLabelText()->GetTextProperty()->SetColor(textColor); - if (m_controllers) { - QColor qTextColor(static_cast(textColor[0] * 255.0 + 0.5), - static_cast(textColor[1] * 255.0 + 0.5), - static_cast(textColor[2] * 255.0 + 0.5)); - m_controllers->setTextColor(qTextColor); - } - } - - if (m_controllers) { - QColor qColor(static_cast(color[0] * 255.0 + 0.5), - static_cast(color[1] * 255.0 + 0.5), - static_cast(color[2] * 255.0 + 0.5)); - m_controllers->setBoxColor(qColor); - m_controllers->setAdaptiveScaling(props["adaptiveScaling"].toBool()); - } - return true; - } - return false; -} - -void ModuleScaleCube::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - - QVBoxLayout* layout = new QVBoxLayout; - panel->setLayout(layout); - - // Create, update and connect - m_controllers = new ModuleScaleCubeWidget; - layout->addWidget(m_controllers); - - // Set initial parameters - m_controllers->setAdaptiveScaling( - static_cast(m_cubeRep->GetAdaptiveScaling())); - m_controllers->setSideLength(m_cubeRep->GetSideLength()); - m_controllers->setAnnotation( - static_cast(m_cubeRep->GetLabelVisibility())); - m_controllers->setLengthUnit(QString(m_cubeRep->GetLengthUnit())); - double worldPosition[3]; - m_cubeRep->GetWorldPosition(worldPosition); - m_controllers->setPosition(worldPosition[0], worldPosition[1], - worldPosition[2]); - m_controllers->setPositionUnit(QString(m_cubeRep->GetLengthUnit())); - double color[3]; - m_cubeRep->GetProperty()->GetDiffuseColor(color); - m_controllers->setBoxColor(QColor(static_cast(color[0] * 255.0 + 0.5), - static_cast(color[1] * 255.0 + 0.5), - static_cast(color[2] * 255.0 + 0.5))); - - m_cubeRep->GetLabelText()->GetTextProperty()->GetColor(color); - m_controllers->setTextColor(QColor(static_cast(color[0] * 255.0 + 0.5), - static_cast(color[1] * 255.0 + 0.5), - static_cast(color[2] * 255.0 + 0.5))); - - // Connect the widget's signals to this class' slots - connect(m_controllers, &ModuleScaleCubeWidget::adaptiveScalingToggled, this, - &ModuleScaleCube::setAdaptiveScaling); - connect(m_controllers, &ModuleScaleCubeWidget::sideLengthChanged, this, - &ModuleScaleCube::setSideLength); - connect(m_controllers, &ModuleScaleCubeWidget::annotationToggled, this, - &ModuleScaleCube::setAnnotation); - connect(m_controllers, &ModuleScaleCubeWidget::boxColorChanged, this, - &ModuleScaleCube::onBoxColorChanged); - connect(m_controllers, &ModuleScaleCubeWidget::textColorChanged, this, - &ModuleScaleCube::onTextColorChanged); - - // Connect this class' signals to the widget's slots - connect(this, &ModuleScaleCube::onLengthUnitChanged, m_controllers, - &ModuleScaleCubeWidget::setLengthUnit); - connect(this, &ModuleScaleCube::onPositionUnitChanged, m_controllers, - &ModuleScaleCubeWidget::setPositionUnit); - connect(this, QOverload::of(&ModuleScaleCube::onSideLengthChanged), - m_controllers, &ModuleScaleCubeWidget::setSideLength); - connect(this, - QOverload::of( - &ModuleScaleCube::onPositionChanged), - m_controllers, &ModuleScaleCubeWidget::setPosition); -} - -void ModuleScaleCube::setAdaptiveScaling(const bool val) -{ - m_cubeRep->SetAdaptiveScaling(val ? 1 : 0); -} - -void ModuleScaleCube::setSideLength(const double length) -{ - m_cubeRep->SetSideLength(length); - emit renderNeeded(); -} - -void ModuleScaleCube::setAnnotation(const bool val) -{ - m_cubeRep->SetLabelVisibility(val ? 1 : 0); - m_annotationVisibility = val; - emit renderNeeded(); -} - -void ModuleScaleCube::setLengthUnit() -{ - DataSource* data = qobject_cast(sender()); - if (!data) { - return; - } - QString s = data->getUnits(); - m_cubeRep->SetLengthUnit(s.toStdString().c_str()); - emit onLengthUnitChanged(s); -} - -void ModuleScaleCube::setPositionUnit() -{ - DataSource* data = qobject_cast(sender()); - if (!data) { - return; - } - emit onLengthUnitChanged(data->getUnits()); -} - -void ModuleScaleCube::dataPropertiesChanged() -{ - DataSource* data = qobject_cast(sender()); - if (!data) { - return; - } - m_cubeRep->SetLengthUnit(data->getUnits().toStdString().c_str()); - - emit onLengthUnitChanged(data->getUnits()); - emit onPositionUnitChanged(data->getUnits()); -} - -void ModuleScaleCube::dataSourceMoved(double newX, double newY, double newZ) -{ - double position[3]; - position[0] = newX + m_offset[0]; - position[1] = newY + m_offset[1]; - position[2] = newZ + m_offset[2]; - - m_cubeRep->PlaceWidget(position); - m_cubeRep->SetWorldPosition(position); -} - -void ModuleScaleCube::onBoxColorChanged(const QColor& color) -{ - m_cubeRep->GetProperty()->SetDiffuseColor( - color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0); - emit renderNeeded(); -} - -void ModuleScaleCube::onTextColorChanged(const QColor& color) -{ - m_cubeRep->GetLabelText()->GetTextProperty()->SetColor( - color.red() / 255.0, color.green() / 255.0, color.blue() / 255.0); - emit renderNeeded(); -} - -void ModuleScaleCube::updateOffset(double x, double y, double z) -{ - const double* displayPosition = dataSource()->displayPosition(); - m_offset[0] = x - displayPosition[0]; - m_offset[1] = y - displayPosition[1]; - m_offset[2] = z - displayPosition[2]; -} - -} // end of namespace tomviz diff --git a/tomviz/modules/ModuleScaleCube.h b/tomviz/modules/ModuleScaleCube.h deleted file mode 100644 index c5cf8ad02..000000000 --- a/tomviz/modules/ModuleScaleCube.h +++ /dev/null @@ -1,92 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleScaleCube_h -#define tomvizModuleScaleCube_h - -#include "Module.h" - -#include -#include - -class vtkPVRenderView; - -class vtkHandleWidget; -class vtkMeasurementCubeHandleRepresentation3D; - -namespace tomviz { - -class ModuleScaleCubeWidget; - -class ModuleScaleCube : public Module -{ - Q_OBJECT - -public: - ModuleScaleCube(QObject* parent = nullptr); - ~ModuleScaleCube() override; - - QString label() const override { return "Scale Cube"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - bool visibility() const override; - bool setVisibility(bool choice) override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - void addToPanel(QWidget* panel) override; - - void dataSourceMoved(double, double, double) override; - void dataSourceRotated(double, double, double) override {} - -protected slots: - void dataPropertiesChanged(); - -private: - Q_DISABLE_COPY(ModuleScaleCube) - - vtkWeakPointer m_view; - vtkNew m_handleWidget; - vtkNew m_cubeRep; - ModuleScaleCubeWidget* m_controllers = nullptr; - unsigned long m_observedPositionId; - unsigned long m_observedSideLengthId; - bool m_annotationVisibility = true; - double m_offset[3]; - -signals: - /** - * relaying changes from m_cubeRep. - */ - void onPositionChanged(); - void onPositionChanged(const double, const double, const double); - - void onSideLengthChanged(); - void onSideLengthChanged(const double); - - /** - * Relaying changes from the data - */ - void onLengthUnitChanged(const QString); - void onPositionUnitChanged(const QString); - -private slots: - /** - * Actuator methods for m_cubeRep. These slots should be connected to the - * appropriate UI signals. - */ - void setAdaptiveScaling(const bool); - void setSideLength(const double); - void setAnnotation(const bool); - - void setLengthUnit(); - void setPositionUnit(); - void onBoxColorChanged(const QColor& color); - void onTextColorChanged(const QColor& color); - - void updateOffset(double, double, double); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleScaleCubeWidget.cxx b/tomviz/modules/ModuleScaleCubeWidget.cxx deleted file mode 100644 index b214bf569..000000000 --- a/tomviz/modules/ModuleScaleCubeWidget.cxx +++ /dev/null @@ -1,92 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleScaleCubeWidget.h" -#include "ui_ModuleScaleCubeWidget.h" - -#include -#include - -namespace tomviz { - -ModuleScaleCubeWidget::ModuleScaleCubeWidget(QWidget* parent_) - : QWidget(parent_), m_ui(new Ui::ModuleScaleCubeWidget) -{ - m_ui->setupUi(this); - m_ui->leSideLength->setValidator(new QDoubleValidator(this)); - - connect(m_ui->chbAdaptiveScaling, &QCheckBox::toggled, this, - &ModuleScaleCubeWidget::adaptiveScalingToggled); - connect(m_ui->leSideLength, &QLineEdit::editingFinished, this, - [&] { sideLengthChanged(m_ui->leSideLength->text().toDouble()); }); - connect(m_ui->chbAnnotation, &QCheckBox::toggled, this, - &ModuleScaleCubeWidget::annotationToggled); - connect(m_ui->colorChooserButton, &pqColorChooserButton::chosenColorChanged, - this, &ModuleScaleCubeWidget::boxColorChanged); - connect(m_ui->textColorChooserButton, - &pqColorChooserButton::chosenColorChanged, this, - &ModuleScaleCubeWidget::textColorChanged); -} - -ModuleScaleCubeWidget::~ModuleScaleCubeWidget() = default; - -void ModuleScaleCubeWidget::setAdaptiveScaling(const bool choice) -{ - m_ui->chbAdaptiveScaling->setChecked(choice); -} - -void ModuleScaleCubeWidget::setSideLength(const double length) -{ - m_ui->leSideLength->setText(QString::number(length)); -} - -void ModuleScaleCubeWidget::setAnnotation(const bool choice) -{ - m_ui->chbAnnotation->setChecked(choice); -} - -void ModuleScaleCubeWidget::setLengthUnit(const QString unit) -{ - m_ui->tlLengthUnit->setText(unit); -} - -void ModuleScaleCubeWidget::setPosition(const double x, const double y, - const double z) -{ - QString s; - QTextStream(&s) << "(" << QString::number(x, 'f', 4) << ", " - << QString::number(y, 'f', 4) << ", " - << QString::number(z, 'f', 4) << ")"; - m_ui->tlPosition->setText(s); -} - -void ModuleScaleCubeWidget::setPositionUnit(const QString unit) -{ - m_ui->tlPositionUnit->setText(unit); -} - -void ModuleScaleCubeWidget::setBoxColor(const QColor& color) -{ - m_ui->colorChooserButton->setChosenColor(color); -} - -void ModuleScaleCubeWidget::setTextColor(const QColor& color) -{ - m_ui->textColorChooserButton->setChosenColor(color); -} - -void ModuleScaleCubeWidget::onAdaptiveScalingChanged(const bool state) -{ - emit adaptiveScalingToggled(state); -} - -void ModuleScaleCubeWidget::onSideLengthChanged(const double length) -{ - emit sideLengthChanged(length); -} - -void ModuleScaleCubeWidget::onAnnotationChanged(const bool state) -{ - emit annotationToggled(state); -} -} // namespace tomviz diff --git a/tomviz/modules/ModuleScaleCubeWidget.h b/tomviz/modules/ModuleScaleCubeWidget.h deleted file mode 100644 index f1fae79fb..000000000 --- a/tomviz/modules/ModuleScaleCubeWidget.h +++ /dev/null @@ -1,72 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleScaleCubeWidget_h -#define tomvizModuleScaleCubeWidget_h - -#include -#include - -/** - * \brief UI layer of ModuleScaleCube. - * - * Signals are forwarded to the actual actuators in ModuleScaleCube. - * This class is intended to contain only logic related to UI actions. - */ - -namespace Ui { -class ModuleScaleCubeWidget; -} - -namespace tomviz { - -class ModuleScaleCubeWidget : public QWidget -{ - Q_OBJECT - -public: - ModuleScaleCubeWidget(QWidget* parent_ = nullptr); - ~ModuleScaleCubeWidget(); - -signals: - //@{ - /** - * Forwarded signals. - */ - void adaptiveScalingToggled(const bool state); - void sideLengthChanged(const double length); - void annotationToggled(const bool state); - void boxColorChanged(QColor color); - void textColorChanged(QColor color); - //@} - -public slots: - //@{ - /** - * UI update methods. The actual module state is stored in - * ModuleScaleCube, so the UI needs to be updated if the state changes - * or when constructing the UI. - */ - void setAdaptiveScaling(const bool choice); - void setAnnotation(const bool choice); - void setLengthUnit(const QString unit); - void setPositionUnit(const QString unit); - void setSideLength(const double length); - void setPosition(const double x, const double y, const double z); - void setBoxColor(const QColor& color); - void setTextColor(const QColor& color); - //@} - -private: - ModuleScaleCubeWidget(const ModuleScaleCubeWidget&) = delete; - void operator=(const ModuleScaleCubeWidget&) = delete; - - QScopedPointer m_ui; - -private slots: - void onAdaptiveScalingChanged(const bool state); - void onSideLengthChanged(const double length); - void onAnnotationChanged(const bool state); -}; -} // namespace tomviz -#endif diff --git a/tomviz/modules/ModuleScaleCubeWidget.ui b/tomviz/modules/ModuleScaleCubeWidget.ui deleted file mode 100644 index 4eaa58f0e..000000000 --- a/tomviz/modules/ModuleScaleCubeWidget.ui +++ /dev/null @@ -1,171 +0,0 @@ - - - ModuleScaleCubeWidget - - - - 0 - 0 - 818 - 728 - - - - - 0 - 0 - - - - Form - - - - - - Adaptive scaling - - - - - - - - - - - - - - 0 - - - - - Length of side ( - - - - - - - TextLabel - - - - - - - ) - - - - - - - - - - - - 0 - - - - - Position ( - - - - - - - TextLabel - - - - - - - ) - - - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - Qt::LeftToRight - - - TextLabel - - - - - - - - - - - - - - Annotation - - - - - - - Box Color - - - - - - - ... - - - - - - - Text Color - - - - - - - ... - - - - - - - - pqColorChooserButton - QToolButton -
pqColorChooserButton.h
-
-
- - -
diff --git a/tomviz/modules/ModuleSegment.cxx b/tomviz/modules/ModuleSegment.cxx deleted file mode 100644 index a84a9ec43..000000000 --- a/tomviz/modules/ModuleSegment.cxx +++ /dev/null @@ -1,261 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleSegment.h" - -#include "DataSource.h" -#include "Utilities.h" -#include "pqCoreUtilities.h" -#include "pqProxiesWidget.h" -#include "vtkAlgorithm.h" -#include "vtkCommand.h" -#include "vtkNew.h" -#include "vtkSMParaViewPipelineControllerWithRendering.h" -#include "vtkSMPropertyHelper.h" -#include "vtkSMProxy.h" -#include "vtkSMSessionProxyManager.h" -#include "vtkSMSourceProxy.h" -#include "vtkSmartPointer.h" - -#include -#include - -namespace tomviz { - -class ModuleSegment::MSInternal -{ -public: - vtkSmartPointer SegmentationScript; - vtkSmartPointer ProgrammableFilter; - vtkSmartPointer ContourFilter; - vtkSmartPointer ContourRepresentation; - bool IsVisible; -}; - -ModuleSegment::ModuleSegment(QObject* p) : Module(p), d(new MSInternal) {} - -ModuleSegment::~ModuleSegment() -{ - finalize(); -} - -QString ModuleSegment::label() const -{ - return "Segmentation"; -} - -QIcon ModuleSegment::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqCalculator.svg"); -} - -bool ModuleSegment::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - vtkNew controller; - vtkSMSourceProxy* producer = data->proxy(); - vtkSMSessionProxyManager* pxm = producer->GetSessionProxyManager(); - - d->SegmentationScript.TakeReference( - pxm->NewProxy("tomviz_proxies", "PythonProgrammableSegmentation")); - vtkSMPropertyHelper(d->SegmentationScript, "Script") - .Set( - "def run_itk_segmentation(itk_image, itk_image_type):\n" - " # should return the result image and result image type like this:\n" - " # return outImage, outImageType\n" - " # An example segmentation script follows: \n\n" - " # Create a filter (ConfidenceConnectedImageFilter) for the input " - "image type\n" - " itk_filter = " - "itk.ConfidenceConnectedImageFilter[itk_image_type,itk.Image.SS3].New()" - "\n\n" - " # Set input parameters on the filter (these are copied from an " - "example in ITK.\n" - " itk_filter.SetInitialNeighborhoodRadius(3)\n" - " itk_filter.SetMultiplier(3)\n" - " itk_filter.SetNumberOfIterations(25)\n" - " itk_filter.SetReplaceValue(255)\n" - " itk_filter.SetSeed((24,65,37))\n\n" - " # Hand the input image to the filter\n" - " itk_filter.SetInput(itk_image)\n" - " # Run the filter\n" - " itk_filter.Update()\n\n" - " # Return the output and the output type (itk.Image.SS3 is one of " - "the valid output\n" - " # types for this filter and is the one we specified when we created " - "the filter above\n" - " return itk_filter.GetOutput(), itk.Image.SS3\n"); - - vtkSmartPointer proxy; - - proxy.TakeReference(pxm->NewProxy("filters", "ProgrammableFilter")); - d->ProgrammableFilter = vtkSMSourceProxy::SafeDownCast(proxy); - Q_ASSERT(d->ProgrammableFilter); - - pqCoreUtilities::connect(d->SegmentationScript, - vtkCommand::PropertyModifiedEvent, this, - SLOT(onPropertyChanged())); - - controller->PreInitializeProxy(d->ProgrammableFilter); - vtkSMPropertyHelper(d->ProgrammableFilter, "Input").Set(producer); - vtkSMPropertyHelper(d->ProgrammableFilter, "OutputDataSetType") - .Set(/*vtkImageData*/ 6); - vtkSMPropertyHelper(d->ProgrammableFilter, "Script") - .Set("self.GetOutput().ShallowCopy(self.GetInput())\n"); - controller->PostInitializeProxy(d->ProgrammableFilter); - controller->RegisterPipelineProxy(d->ProgrammableFilter); - - proxy.TakeReference(pxm->NewProxy("filters", "Contour")); - d->ContourFilter = vtkSMSourceProxy::SafeDownCast(proxy); - Q_ASSERT(d->ContourFilter); - - controller->PreInitializeProxy(d->ContourFilter); - vtkSMPropertyHelper(d->ContourFilter, "Input").Set(d->ProgrammableFilter); - vtkSMPropertyHelper(d->ContourFilter, "ComputeScalars", - /*quiet*/ true) - .Set(1); - - controller->PostInitializeProxy(d->ContourFilter); - controller->RegisterPipelineProxy(d->ContourFilter); - - vtkAlgorithm* alg = - vtkAlgorithm::SafeDownCast(d->ContourFilter->GetClientSideObject()); - alg->SetInputArrayToProcess(0, 0, 0, 0, "ImageScalars"); - - d->ContourRepresentation = controller->Show(d->ContourFilter, 0, vtkView); - Q_ASSERT(d->ContourRepresentation); - vtkSMPropertyHelper(d->ContourRepresentation, "Representation") - .Set("Surface"); - vtkSMPropertyHelper(d->ContourRepresentation, "Translation") - .Set(data->displayPosition(), 3); - - updateColorMap(); - - d->ProgrammableFilter->UpdateVTKObjects(); - d->ContourFilter->UpdateVTKObjects(); - d->ContourRepresentation->UpdateVTKObjects(); - - return true; -} - -bool ModuleSegment::finalize() -{ - vtkNew controller; - controller->UnRegisterProxy(d->ProgrammableFilter); - controller->UnRegisterProxy(d->ContourRepresentation); - controller->UnRegisterProxy(d->ContourFilter); - d->ProgrammableFilter = nullptr; - d->ContourFilter = nullptr; - d->ContourRepresentation = nullptr; - return true; -} - -bool ModuleSegment::visibility() const -{ - Q_ASSERT(d->ContourRepresentation); - return vtkSMPropertyHelper(d->ContourRepresentation, "Visibility") - .GetAsInt() != 0; -} - -bool ModuleSegment::setVisibility(bool val) -{ - Q_ASSERT(d->ContourRepresentation); - vtkSMPropertyHelper(d->ContourRepresentation, "Visibility").Set(val ? 1 : 0); - d->ContourRepresentation->UpdateVTKObjects(); - - Module::setVisibility(val); - - return true; -} - -void ModuleSegment::addToPanel(QWidget* panel) -{ - Q_ASSERT(d->ProgrammableFilter); - - if (panel->layout()) { - delete panel->layout(); - } - - QHBoxLayout* layout = new QHBoxLayout; - panel->setLayout(layout); - pqProxiesWidget* proxiesWidget = new pqProxiesWidget(panel); - layout->addWidget(proxiesWidget); - - QStringList properties; - properties << "Script"; - proxiesWidget->addProxy(d->SegmentationScript, "Script", properties, true); - - Q_ASSERT(d->ContourFilter); - Q_ASSERT(d->ContourRepresentation); - - QStringList contourProperties; - contourProperties << "ContourValues"; - proxiesWidget->addProxy(d->ContourFilter, "Contour", contourProperties, true); - - QStringList contourRepresentationProperties; - contourRepresentationProperties << "Representation" - << "Opacity" - << "Specular"; - proxiesWidget->addProxy(d->ContourRepresentation, "Appearance", - contourRepresentationProperties, true); - proxiesWidget->updateLayout(); - connect(proxiesWidget, &pqProxiesWidget::changeFinished, this, - &ModuleSegment::renderNeeded); -} - -void ModuleSegment::onPropertyChanged() -{ - std::cout << "Got property changed..." << std::endl; - QString userScript = - vtkSMPropertyHelper(d->SegmentationScript, "Script").GetAsString(); - QString segmentScript = - QString("import vtk\n" - "from tomviz import utils\n" - "import itk\n" - "\n" - "idi = self.GetInput()\n" - "ido = self.GetOutput()\n" - "ido.DeepCopy(idi)\n" - "\n" - "array = utils.get_array(idi)\n" - "itk_image_type = itk.Image.F3\n" - "itk_converter = itk.PyBuffer[itk_image_type]\n" - "itk_image = itk_converter.GetImageFromArray(array)\n" - "\n" - "%1\n" - "\n" - "output_itk_image, output_type = run_itk_segmentation(itk_image, " - "itk_image_type)\n" - "\n" - "output_array = " - "itk.PyBuffer[output_type].GetArrayFromImage(output_itk_image)\n" - "utils.set_array(ido, output_array)\n" - "" - "if array.shape == output_array.shape:\n" - " ido.SetOrigin(idi.GetOrigin())\n" - " ido.SetExtent(idi.GetExtent())\n" - " ido.SetSpacing(idi.GetSpacing())\n") - .arg(userScript); - vtkSMPropertyHelper(d->ProgrammableFilter, "Script") - .Set(segmentScript.toLatin1().data()); - d->ProgrammableFilter->UpdateVTKObjects(); - // TODO -} - -void ModuleSegment::updateColorMap() -{ - Q_ASSERT(d->ContourRepresentation); - vtkSMPropertyHelper(d->ContourRepresentation, "LookupTable").Set(colorMap()); - d->ContourRepresentation->UpdateVTKObjects(); -} - -void ModuleSegment::dataSourceMoved(double newX, double newY, double newZ) -{ - double pos[3] = { newX, newY, newZ }; - vtkSMPropertyHelper(d->ContourRepresentation, "Translation").Set(pos, 3); -} - -} // namespace tomviz diff --git a/tomviz/modules/ModuleSegment.h b/tomviz/modules/ModuleSegment.h deleted file mode 100644 index f68616c5c..000000000 --- a/tomviz/modules/ModuleSegment.h +++ /dev/null @@ -1,63 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleSegment_h -#define tomvizModuleSegment_h - -#include "Module.h" - -#include - -namespace tomviz { - -class ModuleSegment : public Module -{ - Q_OBJECT - -public: - ModuleSegment(QObject* parent = nullptr); - ~ModuleSegment(); - - /// Returns a label for this module. - QString label() const override; - - /// Returns an icon to use for this module. - QIcon icon() const override; - - using Module::initialize; - /// Initialize the module for the data source and view. This is called after a - /// new module is instantiated. Subclasses override this method to setup the - /// visualization pipeline for this module. - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - - /// Finalize the module. Subclasses should override this method to delete and - /// release all proxies (and data) created for this module. - bool finalize() override; - - /// Returns the visibility for the module. - bool visibility() const override; - - /// Set the visibility for this module. Subclasses should override this method - /// show/hide all representations created for this module. - bool setVisibility(bool val) override; - - /// This method is called add the proxies in this module to a - /// pqProxiesWidget instance. Default implementation simply adds the view - /// properties. Subclasses should override to add proxies and relevant - /// properties to the panel. - void addToPanel(QWidget* panel) override; - - void dataSourceMoved(double newX, double newY, double newZ) override; - -private slots: - void onPropertyChanged(); - -private: - void updateColorMap() override; - - class MSInternal; - QScopedPointer d; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleSlice.cxx b/tomviz/modules/ModuleSlice.cxx deleted file mode 100644 index 1a0334a79..000000000 --- a/tomviz/modules/ModuleSlice.cxx +++ /dev/null @@ -1,891 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleSlice.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "DoubleSliderWidget.h" -#include "IntSliderWidget.h" -#include "ScalarsComboBox.h" -#include "Utilities.h" -#include "vtkActiveScalarsProducer.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -ModuleSlice::ModuleSlice(QObject* parentObject) : Module(parentObject) {} - -ModuleSlice::~ModuleSlice() -{ - finalize(); -} - -QIcon ModuleSlice::icon() const -{ - return QIcon(":/icons/orthoslice.svg"); -} - -bool ModuleSlice::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - const bool widgetSetup = setupWidget(vtkView); - - if (widgetSetup) { - m_widget->SetDisplayOffset(data->displayPosition()); - m_widget->SetDisplayOrientation(data->displayOrientation()); - m_widget->On(); - m_widget->InteractionOn(); - onDirectionChanged(m_direction); - onTextureInterpolateChanged(m_interpolate); - pqCoreUtilities::connect(m_widget, vtkCommand::InteractionEvent, this, - SLOT(onPlaneChanged())); - connect(data, &DataSource::dataChanged, this, &ModuleSlice::dataChanged); - connect(data, &DataSource::activeScalarsChanged, this, - &ModuleSlice::onScalarArrayChanged); - connect(data, &DataSource::dataPropertiesChanged, this, - &ModuleSlice::dataPropertiesChanged); - } - - Q_ASSERT(m_widget); - return widgetSetup; -} - -bool ModuleSlice::setupWidget(vtkSMViewProxy* vtkView) -{ - vtkRenderWindowInteractor* rwi = vtkView->GetRenderWindow()->GetInteractor(); - - if (!rwi) { - return false; - } - - m_widget = vtkSmartPointer::New(); - m_widget->SetVoxelValueFn( - [this](const vtkVector3i& ijk, double v) { emit mouseOverVoxel(ijk, v); }); - - // Set the interactor on the widget to be what the current - // render window is using. - m_widget->SetInteractor(rwi); - - // Set the thick slice mode to the current setting - m_widget->SetThickSliceMode(m_thickSliceMode); - - // Setup the color of the border of the widget. - { - double color[3] = { 1, 0, 0 }; - m_widget->GetPlaneProperty()->SetColor(color); - } - - // Turn texture interpolation to be linear. - m_widget->TextureInterpolateOn(); - m_widget->SetResliceInterpolateToLinear(); - - // Construct the transfer function proxy for the widget. - vtkSMProxy* lut = colorMap(); - - // Set the widgets lookup table to be the one that the transfer function - // manager is using. - vtkScalarsToColors* stc = - vtkScalarsToColors::SafeDownCast(lut->GetClientSideObject()); - m_widget->SetLookupTable(stc); - - // Lastly we set up the input connection. - m_producer->SetOutput(dataSource()->producer()->GetOutputDataObject(0)); - m_widget->SetInputConnection(m_producer->GetOutputPort()); - - Q_ASSERT(rwi); - onPlaneChanged(); - return true; -} - -void ModuleSlice::updateColorMap() -{ - Q_ASSERT(m_widget); - - // Construct the transfer function proxy for the widget - vtkSMProxy* lut = colorMap(); - - // set the widgets lookup table to be the one that the transfer function - // manager is using - vtkScalarsToColors* stc = - vtkScalarsToColors::SafeDownCast(lut->GetClientSideObject()); - m_widget->SetLookupTable(stc); -} - -void ModuleSlice::updateSliceWidget() -{ - if (!m_sliceSlider || !imageData()) - return; - - m_sliceSlider->setMinimum(0); - m_sliceSlider->setMaximum(maxSlice()); -} - -bool ModuleSlice::finalize() -{ - if (m_widget != nullptr) { - m_widget->Off(); - } - - return true; -} - -bool ModuleSlice::setVisibility(bool val) -{ - Q_ASSERT(m_widget); - m_widget->SetEnabled(val ? 1 : 0); - Module::setVisibility(val); - updateInteractionState(); - - return true; -} - -bool ModuleSlice::visibility() const -{ - return m_widget->GetEnabled() != 0; -} - -void ModuleSlice::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - - QVBoxLayout* layout = new QVBoxLayout; - QFormLayout* formLayout = new QFormLayout; - - QWidget* container = new QWidget; - container->setLayout(formLayout); - layout->addWidget(container); - formLayout->setContentsMargins(0, 0, 0, 0); - - m_opacityCheckBox = new QCheckBox("Map Opacity"); - formLayout->addRow(m_opacityCheckBox); - - m_mapScalarsCheckBox = new QCheckBox("Color Map Data"); - m_mapScalarsCheckBox->setChecked(areScalarsMapped()); - formLayout->addRow(m_mapScalarsCheckBox); - connect(m_mapScalarsCheckBox, &QCheckBox::toggled, this, - &ModuleSlice::setMapScalars); - - auto line = new QFrame; - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Sunken); - formLayout->addRow(line); - - m_scalarsCombo = new ScalarsComboBox(); - m_scalarsCombo->setOptions(dataSource(), this); - formLayout->addRow("Scalars", m_scalarsCombo); - - m_directionCombo = new QComboBox(); - m_directionCombo->addItem("XY Plane", QVariant(Direction::XY)); - m_directionCombo->addItem("YZ Plane", QVariant(Direction::YZ)); - m_directionCombo->addItem("XZ Plane", QVariant(Direction::XZ)); - m_directionCombo->addItem("Custom", QVariant(Direction::Custom)); - m_directionCombo->setCurrentIndex(static_cast(m_direction)); - formLayout->addRow("Direction", m_directionCombo); - - m_sliceSlider = new IntSliderWidget(true); - m_sliceSlider->setLineEditWidth(50); - m_sliceSlider->setPageStep(1); - m_sliceSlider->setMinimum(0); - if (isOrtho()) { - m_sliceSlider->setMaximum(maxSlice()); - } - - // Sanity check: make sure the slice value is within the bounds - if (m_slice < m_sliceSlider->minimum()) - m_slice = m_sliceSlider->minimum(); - else if (m_slice > m_sliceSlider->maximum()) - m_slice = m_sliceSlider->maximum(); - - m_sliceSlider->setValue(m_slice); - - formLayout->addRow("Slice", m_sliceSlider); - - m_thicknessSpin = new QSpinBox(); - m_thicknessSpin->setMaximum(m_sliceSlider->maximum()); - m_thicknessSpin->setMinimum(1); - m_thicknessSpin->setSingleStep(2); - m_thicknessSpin->setValue(m_sliceThickness); - formLayout->addRow("Slice Thickness", m_thicknessSpin); - - m_sliceCombo = new QComboBox(); - m_sliceCombo->addItem("Minimum", QVariant(Mode::Min)); - m_sliceCombo->addItem("Maximum", QVariant(Mode::Max)); - m_sliceCombo->addItem("Mean", QVariant(Mode::Mean)); - m_sliceCombo->addItem("Summation", QVariant(Mode::Sum)); - m_sliceCombo->setCurrentIndex(static_cast(m_thickSliceMode)); - formLayout->addRow("Aggregation", m_sliceCombo); - - m_opacitySlider = new DoubleSliderWidget(true); - m_opacitySlider->setLineEditWidth(50); - m_opacitySlider->setMinimum(0); - m_opacitySlider->setMaximum(1); - m_opacitySlider->setValue(m_opacity); - formLayout->addRow("Opacity", m_opacitySlider); - - m_interpolateCheckBox = new QCheckBox("Interpolate Texture"); - m_interpolateCheckBox->setChecked(m_interpolate); - formLayout->addRow(m_interpolateCheckBox); - - m_showArrowCheckBox = new QCheckBox("Show Arrow"); - m_showArrowCheckBox->setChecked(showArrow()); - formLayout->addRow(m_showArrowCheckBox); - connect(m_showArrowCheckBox, &QCheckBox::toggled, this, - &ModuleSlice::setShowArrow); - - QLabel* label = new QLabel("Point on Plane"); - layout->addWidget(label); - QHBoxLayout* row = new QHBoxLayout; - const char* labels[] = { "X:", "Y:", "Z:" }; - for (int i = 0; i < 3; ++i) { - label = new QLabel(labels[i]); - row->addWidget(label); - pqLineEdit* inputBox = new pqLineEdit; - inputBox->setEnabled(!isOrtho()); - inputBox->setValidator(new QDoubleValidator(inputBox)); - connect(inputBox, &pqLineEdit::textChangedAndEditingFinished, this, - &ModuleSlice::updatePointOnPlane); - row->addWidget(inputBox); - m_pointInputs[i] = inputBox; - } - layout->addItem(row); - - label = new QLabel("Plane Normal"); - layout->addWidget(label); - row = new QHBoxLayout; - for (int i = 0; i < 3; ++i) { - label = new QLabel(labels[i]); - row->addWidget(label); - pqLineEdit* inputBox = new pqLineEdit; - inputBox->setEnabled(!isOrtho()); - inputBox->setValidator(new QDoubleValidator(inputBox)); - connect(inputBox, &pqLineEdit::textChangedAndEditingFinished, this, - &ModuleSlice::updatePlaneNormal); - row->addWidget(inputBox); - m_normalInputs[i] = inputBox; - } - layout->addItem(row); - - // Update the Qt widget values - onPlaneChanged(); - - auto* normalToViewButton = new QPushButton("Set Normal to View"); - connect(normalToViewButton, &QPushButton::clicked, this, - &ModuleSlice::setNormalToView); - normalToViewButton->setToolTip("Set the plane normal to the view direction"); - layout->addWidget(normalToViewButton); - - layout->addStretch(); - - panel->setLayout(layout); - - connect(m_opacityCheckBox, &QCheckBox::toggled, this, [this](bool val) { - m_mapOpacity = val; - // Ensure the colormap is detached before applying opacity - if (val) { - setUseDetachedColorMap(val); - } - auto func = vtkPVDiscretizableColorTransferFunction::SafeDownCast( - colorMap()->GetClientSideObject()); - func->SetEnableOpacityMapping(val); - emit opacityEnforced(val); - updateColorMap(); - emit renderNeeded(); - }); - - m_opacityCheckBox->setChecked(m_mapOpacity); - - connect(m_scalarsCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this](int idx) { - setActiveScalars(m_scalarsCombo->itemData(idx).toInt()); - onScalarArrayChanged(); - }); - - connect(m_directionCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this](int idx) { - Direction dir = m_directionCombo->itemData(idx).value(); - onDirectionChanged(dir); - }); - - connect(m_interpolateCheckBox, &QCheckBox::toggled, this, - &ModuleSlice::onTextureInterpolateChanged); - - connect(m_sliceSlider, &IntSliderWidget::valueEdited, this, - QOverload::of(&ModuleSlice::onSliceChanged)); - connect(m_sliceSlider, &IntSliderWidget::valueChanged, this, - QOverload::of(&ModuleSlice::onSliceChanged)); - - connect(m_thicknessSpin, QOverload::of(&QSpinBox::valueChanged), - this, &ModuleSlice::onThicknessChanged); - connect(m_sliceCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, &ModuleSlice::onThickSliceModeChanged); - - connect(m_opacitySlider, &DoubleSliderWidget::valueEdited, this, - &ModuleSlice::onOpacityChanged); - connect(m_opacitySlider, &DoubleSliderWidget::valueChanged, this, - &ModuleSlice::onOpacityChanged); -} - -void ModuleSlice::planeBounds(double b[6]) -{ - m_widget->GetPlaneBounds(b); -} - -void ModuleSlice::dataUpdated() -{ - // In case there are new slices, update min and max - updateSliceWidget(); - m_widget->UpdatePlacement(); - emit renderNeeded(); -} - -void ModuleSlice::dataChanged() -{ - // FIXME: Implementing the vtkActiveScalarsProducer as a producer breaks the - // vtk pipeline, requiring manual updates to keep in sync like here. - // It really should be implemented as a filter. - m_producer->SetOutput(dataSource()->dataObject()); - dataPropertiesChanged(); - dataUpdated(); -} - -void ModuleSlice::dataPropertiesChanged() -{ - onDirectionChanged(m_direction); -} - -void ModuleSlice::setMapScalars(bool b) -{ - if (b != areScalarsMapped()) { - m_widget->SetMapScalars(b); - emit renderNeeded(); - } -} - -void ModuleSlice::setShowArrow(bool b) -{ - if (b != showArrow()) { - if (m_showArrowCheckBox) { - m_showArrowCheckBox->setChecked(b); - } - m_widget->SetArrowVisibility(b); - updateInteractionState(); - emit renderNeeded(); - } -} - -bool ModuleSlice::showArrow() const -{ - return m_widget->GetArrowVisibility() != 0; -} - -void ModuleSlice::updateInteractionState() -{ - // We can only update the interaction if the widget is visible - if (visibility()) - m_widget->SetInteraction(showArrow()); -} - -void ModuleSlice::updatePointOnPlane() -{ - double point[3] = { 0, 0, 0 }; - - for (int i = 0; i < 3; ++i) { - const auto input = m_pointInputs[i]; - if (input->text().isEmpty()) - continue; - - point[i] = input->text().toDouble(); - } - m_widget->SetCenter(point); - m_widget->UpdatePlacement(); - emit renderNeeded(); -} - -void ModuleSlice::updatePlaneNormal() -{ - double normal[3] = { 0, 0, 0 }; - - for (int i = 0; i < 3; ++i) { - const auto input = m_normalInputs[i]; - if (input->text().isEmpty()) - continue; - - normal[i] = input->text().toDouble(); - } - m_widget->SetNormal(normal); - m_widget->UpdatePlacement(); - emit renderNeeded(); -} - -QJsonObject ModuleSlice::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - props["showArrow"] = showArrow(); - - // Serialize the plane - double point[3]; - m_widget->GetOrigin(point); - QJsonArray origin = { point[0], point[1], point[2] }; - m_widget->GetPoint1(point); - QJsonArray point1 = { point[0], point[1], point[2] }; - m_widget->GetPoint2(point); - QJsonArray point2 = { point[0], point[1], point[2] }; - - props["origin"] = origin; - props["point1"] = point1; - props["point2"] = point2; - props["mapScalars"] = areScalarsMapped(); - props["mapOpacity"] = m_mapOpacity; - - props["slice"] = m_slice; - props["sliceThickness"] = m_sliceThickness; - props["thickSliceMode"] = m_thickSliceMode; - QVariant qData; - qData.setValue(m_direction); - props["direction"] = qData.toString(); - props["interpolate"] = m_interpolate; - props["opacity"] = m_opacity; - - json["properties"] = props; - return json; -} - -bool ModuleSlice::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - setShowArrow(props["showArrow"].toBool()); - if (m_showArrowCheckBox) { - m_showArrowCheckBox->setChecked(showArrow()); - } - if (props.contains("origin") && props.contains("point1") && - props.contains("point2")) { - auto o = props["origin"].toArray(); - auto p1 = props["point1"].toArray(); - auto p2 = props["point2"].toArray(); - double origin[3] = { o[0].toDouble(), o[1].toDouble(), o[2].toDouble() }; - double point1[3] = { p1[0].toDouble(), p1[1].toDouble(), - p1[2].toDouble() }; - double point2[3] = { p2[0].toDouble(), p2[1].toDouble(), - p2[2].toDouble() }; - m_widget->SetOrigin(origin); - m_widget->SetPoint1(point1); - m_widget->SetPoint2(point2); - } - setMapScalars(props["mapScalars"].toBool()); - if (m_mapScalarsCheckBox) { - m_mapScalarsCheckBox->setChecked(areScalarsMapped()); - } - if (props.contains("mapOpacity")) { - m_mapOpacity = props["mapOpacity"].toBool(); - if (m_opacityCheckBox) { - m_opacityCheckBox->setChecked(m_mapOpacity); - } - } - m_widget->UpdatePlacement(); - if (m_scalarsCombo) { - m_scalarsCombo->setOptions(dataSource(), this); - } - // If deserializing a former OrthogonalSlice, the direction is encoded in - // the property "sliceMode" as an int - if (props.contains("sliceMode")) { - Direction direction = modeToDirection(props["sliceMode"].toInt()); - onDirectionChanged(direction); - } - if (props.contains("sliceThickness")) { - m_sliceThickness = props["sliceThickness"].toInt(); - onThicknessChanged(m_sliceThickness); - } - if (props.contains("thickSliceMode")) { - int mode = props["thickSliceMode"].toInt(); - onThickSliceModeChanged(mode); - } - if (props.contains("direction")) { - Direction direction = stringToDirection(props["direction"].toString()); - onDirectionChanged(direction); - } - if (props.contains("slice")) { - m_slice = props["slice"].toInt(); - onSliceChanged(m_slice); - } - if (props.contains("opacity")) { - m_opacity = props["opacity"].toDouble(); - onOpacityChanged(m_opacity); - if (m_opacitySlider) { - m_opacitySlider->setValue(m_opacity); - } - } - if (props.contains("interpolate")) { - m_interpolate = props["interpolate"].toBool(); - onTextureInterpolateChanged(m_interpolate); - if (m_interpolateCheckBox) { - m_interpolateCheckBox->setChecked(m_interpolate); - } - } - onScalarArrayChanged(); - onPlaneChanged(); - return true; - } - return false; -} - -void ModuleSlice::onPlaneChanged() -{ - // Avoid recursive clobbering of the plane position - if (m_ignoreSignals) { - return; - } - m_ignoreSignals = true; - - double* centerPoint = m_widget->GetCenter(); - double* normalVector = m_widget->GetNormal(); - for (int i = 0; i < 3; ++i) { - if (m_pointInputs[i]) { - QSignalBlocker b(m_pointInputs[i]); - m_pointInputs[i]->setText(QString::number(centerPoint[i])); - } - if (m_normalInputs[i]) { - QSignalBlocker b(m_normalInputs[i]); - m_normalInputs[i]->setText(QString::number(normalVector[i])); - } - } - - // Adjust the slice slider if the slice has changed from dragging the arrow - onSliceChanged(centerPoint); - - // I'm not sure why, but the slice plane moving doesn't cause a re-compute - // of the bounds automatically. Make sure the bounds get re-computed. - emit updateClientSideViewNeeded(); - - m_ignoreSignals = false; -} - -void ModuleSlice::dataSourceMoved(double newX, double newY, double newZ) -{ - double pos[3] = { newX, newY, newZ }; - m_widget->SetDisplayOffset(pos); -} - -void ModuleSlice::dataSourceRotated(double newX, double newY, double newZ) -{ - double orientation[3] = { newX, newY, newZ }; - m_widget->SetDisplayOrientation(orientation); -} - -vtkDataObject* ModuleSlice::dataToExport() -{ - return m_widget->GetResliceOutput(); -} - -bool ModuleSlice::areScalarsMapped() const -{ - return m_widget->GetMapScalars() != 0; -} - -vtkImageData* ModuleSlice::imageData() const -{ - vtkImageData* data = vtkImageData::SafeDownCast( - dataSource()->producer()->GetOutputDataObject(0)); - Q_ASSERT(data); - return data; -} - -void ModuleSlice::onScalarArrayChanged() -{ - // The scalar arrays may have been renamed - if (m_scalarsCombo) { - m_scalarsCombo->setOptions(dataSource(), this); - } - - QString arrayName; - if (activeScalars() == Module::defaultScalarsIdx()) { - arrayName = dataSource()->activeScalars(); - } else { - arrayName = dataSource()->scalarsName(activeScalars()); - } - m_producer->SetActiveScalars(arrayName.toLatin1().data()); - emit renderNeeded(); -} - -void ModuleSlice::onDirectionChanged(Direction direction) -{ - m_direction = direction; - int axis = directionAxis(direction); - - for (int i = 0; i < 3; ++i) { - if (m_pointInputs[i]) { - m_pointInputs[i]->setEnabled(!isOrtho()); - } - if (m_normalInputs[i]) { - m_normalInputs[i]->setEnabled(!isOrtho()); - } - } - if (m_sliceSlider) { - m_sliceSlider->setVisible(isOrtho()); - } - - m_widget->SetPlaneOrientation(axis); - - if (m_directionCombo) { - if (direction != m_directionCombo->currentData().value()) { - for (int i = 0; i < m_directionCombo->count(); ++i) { - Direction data = m_directionCombo->itemData(i).value(); - if (data == direction) { - m_directionCombo->setCurrentIndex(i); - } - } - } - } - - if (!isOrtho()) { - return; - } - - int dims[3]; - imageData()->GetDimensions(dims); - - double normal[3] = { 0, 0, 0 }; - int slice = 0; - - normal[axis] = 1; - slice = dims[axis] / 2; - - m_widget->SetNormal(normal); - if (m_sliceSlider) { - m_sliceSlider->setMinimum(0); - m_sliceSlider->setMaximum(maxSlice()); - } - onSliceChanged(slice); - onPlaneChanged(); - dataUpdated(); - - emit directionChanged(m_direction); -} - -bool ModuleSlice::isOrtho() const -{ - return directionAxis(m_direction) >= 0; -} - -int ModuleSlice::maxSlice() const -{ - if (!isOrtho()) { - return -1; - } - - int axis = directionAxis(m_direction); - int dims[3]; - imageData()->GetDimensions(dims); - return dims[axis] - 1; -} - -void ModuleSlice::onSliceChanged(int slice) -{ - m_slice = slice; - int axis = directionAxis(m_direction); - - if (axis < 0) { - return; - } - - m_widget->SetSliceIndex(slice); - if (m_sliceSlider) { - m_sliceSlider->setValue(slice); - } - onPlaneChanged(); - dataUpdated(); - - emit sliceChanged(slice); -} - -void ModuleSlice::onSliceChanged(double* point) -{ - int axis = directionAxis(m_direction); - if (axis < 0) { - return; - } - - int dims[3]; - imageData()->GetDimensions(dims); - double bounds[6]; - imageData()->GetBounds(bounds); - - // Due to changes from commit 43182619 the point on the slice plane could - // fall outside the bounds of the volume. - // This could yield slice numbers that are negative, or are larger than - // the number of slices. The next two lines ensure this never happens. - point[axis] = std::max(point[axis], bounds[2 * axis]); - point[axis] = std::min(point[axis], bounds[2 * axis + 1]); - - double slice = (dims[axis] - 1) * (point[axis] - bounds[2 * axis]) / - (bounds[2 * axis + 1] - bounds[2 * axis]); - - onSliceChanged(round(slice)); -} - -void ModuleSlice::onTextureInterpolateChanged(bool flag) -{ - m_interpolate = flag; - if (!m_widget) { - return; - } - int val = flag ? 1 : 0; - m_widget->SetTextureInterpolate(val); - m_widget->SetResliceInterpolate(val); - emit renderNeeded(); -} - -void ModuleSlice::onOpacityChanged(double opacity) -{ - m_opacity = opacity; - m_widget->SetOpacity(opacity); - emit renderNeeded(); -} - -void ModuleSlice::onThicknessChanged(int value) -{ - m_sliceThickness = value; - if(m_thicknessSpin) { - m_thicknessSpin->setValue(value); - } - m_widget->SetSliceThickness(value); - emit renderNeeded(); -} - -void ModuleSlice::onThickSliceModeChanged(int index) -{ - m_thickSliceMode = static_cast(index); - if (m_sliceCombo) { - m_sliceCombo->setCurrentIndex(index); - } - m_widget->SetThickSliceMode(index); - emit renderNeeded(); -} - -int ModuleSlice::directionAxis(Direction direction) const -{ - switch (direction) { - case Direction::XY: { - return 2; - } - case Direction::YZ: { - return 0; - } - case Direction::XZ: { - return 1; - } - default: { - return -1; - } - } -} - -ModuleSlice::Direction ModuleSlice::stringToDirection(const QString& name) -{ - if (name == "XY") { - return Direction::XY; - } else if (name == "YZ") { - return Direction::YZ; - } else if (name == "XZ") { - return Direction::XZ; - } else { - return Direction::Custom; - } -} - -ModuleSlice::Direction ModuleSlice::modeToDirection(int sliceMode) -{ - switch (sliceMode) { - case 5: { - return Direction::XY; - } - case 6: { - return Direction::YZ; - } - case 7: { - return Direction::XZ; - } - default: { - return Direction::Custom; - } - } -} - -bool ModuleSlice::updateClippingPlane(vtkPlane* plane, bool newFilter) -{ - - m_widget->GetResliceMapper(plane, newFilter); - emit renderNeeded(); - - return true; -} - -void ModuleSlice::setNormalToView() -{ - // First, make sure we have a custom direction - if (m_direction != Direction::Custom) { - onDirectionChanged(Direction::Custom); - } - - // Now set the normal to match that of the view direction - auto* renderView = - ActiveObjects::instance().activePqRenderView()->getRenderViewProxy(); - - double* position = renderView->GetActiveCamera()->GetPosition(); - double* focalPoint = renderView->GetActiveCamera()->GetFocalPoint(); - - double normal[3]; - - for (int i = 0; i < 3; ++i) { - normal[i] = focalPoint[i] - position[i]; - } - - m_widget->SetNormal(normal); - m_widget->UpdatePlacement(); - onPlaneChanged(); - emit renderNeeded(); -} - -} // namespace tomviz diff --git a/tomviz/modules/ModuleSlice.h b/tomviz/modules/ModuleSlice.h deleted file mode 100644 index 9f6594722..000000000 --- a/tomviz/modules/ModuleSlice.h +++ /dev/null @@ -1,163 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleSlice_h -#define tomvizModuleSlice_h - -#include "Module.h" - -#include -#include - -class QCheckBox; -class QComboBox; -class QSpinBox; -class pqLineEdit; -class vtkActiveScalarsProducer; -class vtkNonOrthoImagePlaneWidget; - -namespace tomviz { - -class ScalarsComboBox; -class DoubleSliderWidget; -class IntSliderWidget; - -class ModuleSlice : public Module -{ - Q_OBJECT - -public: - ModuleSlice(QObject* parent = nullptr); - virtual ~ModuleSlice(); - - QString label() const override { return "Slice"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - bool isColorMapNeeded() const override { return true; } - bool isOpacityMapped() const override { return m_mapOpacity; } - bool areScalarsMapped() const override; - void addToPanel(QWidget* panel) override; - - void planeBounds(double b[6]); - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - - QString exportDataTypeString() override { return "Image"; } - - vtkDataObject* dataToExport() override; - - enum Direction - { - XY = 0, - YZ = 1, - XZ = 2, - Custom = 3 - }; - Q_ENUM(Direction) - - enum Mode - { - Min = 0, - Max = 1, - Mean = 2, - Sum = 3 - }; - Q_ENUM(Mode) - - bool showArrow() const; - - bool updateClippingPlane(vtkPlane* plane, bool newFilter) override; - - bool isOrtho() const; - int slice() const { return m_slice; } - int maxSlice() const; - - void onDirectionChanged(Direction direction); - void onSliceChanged(int slice); - void setShowArrow(bool b); - -signals: - - void sliceChanged(int slice); - - void directionChanged(Direction direction); - -protected: - void updateColorMap() override; - void updateSliceWidget(); - void updateInteractionState(); - static Direction stringToDirection(const QString& name); - static Direction modeToDirection(int sliceMode); - vtkImageData* imageData() const; - -private slots: - void onPlaneChanged(); - - void dataChanged(); - - void dataUpdated(); - - void dataPropertiesChanged(); - - void onScalarArrayChanged(); - - void setMapScalars(bool b); - - void updatePointOnPlane(); - void updatePlaneNormal(); - - void onSliceChanged(double* point); - void onThicknessChanged(int value); - void onThickSliceModeChanged(int index); - int directionAxis(Direction direction) const; - void onOpacityChanged(double opacity); - - void onTextureInterpolateChanged(bool flag); - - void setNormalToView(); - -private: - bool setupWidget(vtkSMViewProxy* view); - - Q_DISABLE_COPY(ModuleSlice) - - vtkSmartPointer m_widget; - bool m_ignoreSignals = false; - - QPointer m_opacityCheckBox; - bool m_mapOpacity = false; - - QPointer m_mapScalarsCheckBox; - QPointer m_directionCombo; - QPointer m_sliceCombo; - QPointer m_sliceSlider; - QPointer m_thicknessSpin; - QPointer m_scalarsCombo; - Direction m_direction = Direction::XY; - int m_slice = 0; - int m_sliceThickness = 1; - Mode m_thickSliceMode = Mode::Mean; - - QPointer m_interpolateCheckBox; - bool m_interpolate = false; - - QPointer m_showArrowCheckBox; - - QPointer m_opacitySlider; - double m_opacity = 1; - - QPointer m_pointInputs[3]; - QPointer m_normalInputs[3]; - - vtkNew m_producer; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleThreshold.cxx b/tomviz/modules/ModuleThreshold.cxx deleted file mode 100644 index 60b7879e4..000000000 --- a/tomviz/modules/ModuleThreshold.cxx +++ /dev/null @@ -1,373 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleThreshold.h" - -#include "DataSource.h" -#include "DoubleSliderWidget.h" -#include "Utilities.h" -#include "pqProxiesWidget.h" -#include "pqSignalAdaptors.h" -#include "pqStringVectorPropertyWidget.h" -#include "pqWidgetRangeDomain.h" - -#include "vtkDataObject.h" -#include "vtkNew.h" -#include "vtkSMPVRepresentationProxy.h" -#include "vtkSMParaViewPipelineControllerWithRendering.h" -#include "vtkSMPropertyHelper.h" -#include "vtkSMSessionProxyManager.h" -#include "vtkSMSourceProxy.h" -#include "vtkSMViewProxy.h" -#include "vtkSmartPointer.h" - -#include -#include -#include -#include - -namespace tomviz { - -ModuleThreshold::ModuleThreshold(QObject* parentObject) : Module(parentObject) -{} - -ModuleThreshold::~ModuleThreshold() -{ - finalize(); -} - -QIcon ModuleThreshold::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqThreshold.svg"); -} - -bool ModuleThreshold::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - auto producer = data->proxy(); - - vtkNew controller; - - vtkSMSessionProxyManager* pxm = producer->GetSessionProxyManager(); - - // Create the contour filter. - vtkSmartPointer proxy; - proxy.TakeReference(pxm->NewProxy("filters", "Threshold")); - - m_thresholdFilter = vtkSMSourceProxy::SafeDownCast(proxy); - Q_ASSERT(m_thresholdFilter); - controller->PreInitializeProxy(m_thresholdFilter); - vtkSMPropertyHelper(m_thresholdFilter, "Input").Set(producer); - controller->PostInitializeProxy(m_thresholdFilter); - controller->RegisterPipelineProxy(m_thresholdFilter); - - vtkSMPropertyHelper(m_thresholdFilter, "ThresholdMethod").Set("Between"); - - // Update min/max to avoid thresholding the full dataset. - vtkSMPropertyHelper lowerProp(m_thresholdFilter, "LowerThreshold"); - vtkSMPropertyHelper upperProp(m_thresholdFilter, "UpperThreshold"); - - double range[2], newRange[2]; - lowerProp.Get(&range[0]); - upperProp.Get(&range[1]); - - double delta = (range[1] - range[0]); - double mid = ((range[0] + range[1]) / 2.0); - newRange[0] = mid - 0.1 * delta; - newRange[1] = mid + 0.1 * delta; - - lowerProp.Set(newRange[0]); - upperProp.Set(newRange[1]); - - m_thresholdFilter->UpdateVTKObjects(); - - // Create the representation for it. - m_thresholdRepresentation = controller->Show(m_thresholdFilter, 0, vtkView); - Q_ASSERT(m_thresholdRepresentation); - vtkSMRepresentationProxy::SetRepresentationType(m_thresholdRepresentation, - "Surface"); - vtkSMPropertyHelper(m_thresholdRepresentation, "Translation") - .Set(data->displayPosition(), 3); - vtkSMPropertyHelper(m_thresholdRepresentation, "Orientation") - .Set(data->displayOrientation(), 3); - updateColorMap(); - m_thresholdRepresentation->UpdateVTKObjects(); - - // Give the proxy a friendly name for the GUI/Python world. - if (auto p = convert(proxy)) { - p->rename(label()); - } - - connect(data, &DataSource::activeScalarsChanged, this, - &ModuleThreshold::onScalarArrayChanged); - onScalarArrayChanged(); - - return true; -} - -void ModuleThreshold::updateColorMap() -{ - Q_ASSERT(m_thresholdRepresentation); - - // by default, use the data source's color/opacity maps. - vtkSMPropertyHelper(m_thresholdRepresentation, "LookupTable").Set(colorMap()); - vtkSMPropertyHelper(m_thresholdRepresentation, "ScalarOpacityFunction") - .Set(opacityMap()); - - m_thresholdRepresentation->UpdateVTKObjects(); -} - -bool ModuleThreshold::finalize() -{ - vtkNew controller; - controller->UnRegisterProxy(m_thresholdRepresentation); - controller->UnRegisterProxy(m_thresholdFilter); - m_thresholdFilter = nullptr; - m_thresholdRepresentation = nullptr; - return true; -} - -bool ModuleThreshold::setVisibility(bool val) -{ - Q_ASSERT(m_thresholdRepresentation); - vtkSMPropertyHelper(m_thresholdRepresentation, "Visibility").Set(val ? 1 : 0); - m_thresholdRepresentation->UpdateVTKObjects(); - - Module::setVisibility(val); - - return true; -} - -bool ModuleThreshold::visibility() const -{ - if (m_thresholdRepresentation) { - return vtkSMPropertyHelper(m_thresholdRepresentation, "Visibility") - .GetAsInt() != 0; - } else { - return false; - } -} - -void ModuleThreshold::addToPanel(QWidget* panel) -{ - Q_ASSERT(m_thresholdFilter); - Q_ASSERT(m_thresholdRepresentation); - - if (panel->layout()) { - delete panel->layout(); - } - - QVBoxLayout* layout = new QVBoxLayout; - - pqStringVectorPropertyWidget* arraySelection = - new pqStringVectorPropertyWidget( - m_thresholdFilter->GetProperty("SelectInputScalars"), - m_thresholdFilter); - layout->addWidget(arraySelection); - - auto lowerProp = m_thresholdFilter->GetProperty("LowerThreshold"); - auto upperProp = m_thresholdFilter->GetProperty("UpperThreshold"); - - auto* lowerSlider = new DoubleSliderWidget(true); - auto* upperSlider = new DoubleSliderWidget(true); - - auto clampLower = [lowerSlider, upperSlider]() { - if (lowerSlider->value() > upperSlider->value()) { - lowerSlider->setValue(upperSlider->value()); - } - }; - - auto clampUpper = [lowerSlider, upperSlider]() { - if (lowerSlider->value() > upperSlider->value()) { - upperSlider->setValue(lowerSlider->value()); - } - }; - - connect(lowerSlider, &DoubleSliderWidget::valueEdited, upperSlider, - clampUpper); - connect(upperSlider, &DoubleSliderWidget::valueEdited, lowerSlider, - clampLower); - - // Only update when the user releases the slider - lowerSlider->setSliderTracking(false); - upperSlider->setSliderTracking(false); - - lowerSlider->setKeyboardTracking(false); - upperSlider->setKeyboardTracking(false); - - lowerSlider->setLineEditWidth(50); - upperSlider->setLineEditWidth(50); - - QFormLayout* thresholdFormLayout = new QFormLayout; - thresholdFormLayout->setHorizontalSpacing(5); - layout->addItem(thresholdFormLayout); - - thresholdFormLayout->addRow("Minimum", lowerSlider); - thresholdFormLayout->addRow("Maximum", upperSlider); - - m_links.addPropertyLink(lowerSlider, "value", SIGNAL(valueEdited(double)), - m_thresholdRepresentation, lowerProp); - m_links.addPropertyLink(upperSlider, "value", SIGNAL(valueEdited(double)), - m_thresholdRepresentation, upperProp); - - // Keep the slider ranges up-to-date with the data - new pqWidgetRangeDomain(lowerSlider, "minimum", "maximum", lowerProp); - new pqWidgetRangeDomain(upperSlider, "minimum", "maximum", upperProp); - - QFormLayout* formLayout = new QFormLayout; - formLayout->setHorizontalSpacing(5); - layout->addItem(formLayout); - - QComboBox* representations = new QComboBox; - representations->addItem("Surface"); - representations->addItem("Wireframe"); - representations->addItem("Points"); - formLayout->addRow("Representation", representations); - - DoubleSliderWidget* opacitySlider = new DoubleSliderWidget(true); - opacitySlider->setLineEditWidth(50); - formLayout->addRow("Opacity", opacitySlider); - - DoubleSliderWidget* specularSlider = new DoubleSliderWidget(true); - specularSlider->setLineEditWidth(50); - formLayout->addRow("Specular", specularSlider); - - QCheckBox* mapScalarsCheckBox = new QCheckBox(); - formLayout->addRow("Color Map Data", mapScalarsCheckBox); - - layout->addStretch(); - panel->setLayout(layout); - - pqSignalAdaptorComboBox* adaptor = - new pqSignalAdaptorComboBox(representations); - - m_links.addPropertyLink( - adaptor, "currentText", SIGNAL(currentTextChanged(QString)), - m_thresholdRepresentation, - m_thresholdRepresentation->GetProperty("Representation")); - - m_links.addPropertyLink(opacitySlider, "value", SIGNAL(valueEdited(double)), - m_thresholdRepresentation, - m_thresholdRepresentation->GetProperty("Opacity"), 0); - m_links.addPropertyLink(specularSlider, "value", SIGNAL(valueEdited(double)), - m_thresholdRepresentation, - m_thresholdRepresentation->GetProperty("Specular"), - 0); - - m_links.addPropertyLink(mapScalarsCheckBox, "checked", SIGNAL(toggled(bool)), - m_thresholdRepresentation, - m_thresholdRepresentation->GetProperty("MapScalars"), - 0); - - connect(arraySelection, &pqPropertyWidget::changeFinished, arraySelection, - &pqPropertyWidget::apply); - connect(arraySelection, &pqPropertyWidget::changeFinished, this, - &Module::renderNeeded); - connect(lowerSlider, &DoubleSliderWidget::valueEdited, this, - &ModuleThreshold::dataUpdated); - connect(upperSlider, &DoubleSliderWidget::valueEdited, this, - &ModuleThreshold::dataUpdated); - connect(representations, &QComboBox::currentTextChanged, this, - &ModuleThreshold::dataUpdated); - connect(opacitySlider, &DoubleSliderWidget::valueEdited, this, - &ModuleThreshold::dataUpdated); - connect(specularSlider, &DoubleSliderWidget::valueEdited, this, - &ModuleThreshold::dataUpdated); - connect(mapScalarsCheckBox, &QCheckBox::toggled, this, - &ModuleThreshold::dataUpdated); -} - -void ModuleThreshold::dataUpdated() -{ - m_links.accept(); - // FIXME: why aren't threshold filter changes being pushed automatically? - m_thresholdFilter->UpdateVTKObjects(); - emit renderNeeded(); -} - -void ModuleThreshold::onScalarArrayChanged() -{ - QString arrayName = dataSource()->activeScalars(); - vtkSMPropertyHelper(m_thresholdRepresentation, "ColorArrayName") - .SetInputArrayToProcess(vtkDataObject::FIELD_ASSOCIATION_POINTS, - arrayName.toLatin1().data()); - m_thresholdRepresentation->UpdateVTKObjects(); - - emit renderNeeded(); -} - -QJsonObject ModuleThreshold::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - auto rep = m_thresholdRepresentation; - vtkSMPropertyHelper scalars(m_thresholdFilter, "SelectInputScalars"); - props["scalarArray"] = scalars.GetAsInt(); - double range[2]; - vtkSMPropertyHelper(m_thresholdFilter, "LowerThreshold").Get(&range[0]); - vtkSMPropertyHelper(m_thresholdFilter, "UpperThreshold").Get(&range[1]); - props["minimum"] = range[0]; - props["maximum"] = range[1]; - vtkSMPropertyHelper representationHelper(rep->GetProperty("Representation")); - props["representation"] = representationHelper.GetAsString(); - vtkSMPropertyHelper specular(rep->GetProperty("Specular")); - props["specular"] = specular.GetAsDouble(); - vtkSMPropertyHelper opacity(rep->GetProperty("Opacity")); - props["opacity"] = opacity.GetAsDouble(); - vtkSMPropertyHelper mapScalars(rep->GetProperty("MapScalars")); - props["mapScalars"] = mapScalars.GetAsInt() == 1; - - json["properties"] = props; - - return json; -} - -bool ModuleThreshold::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - auto rep = m_thresholdRepresentation; - vtkSMPropertyHelper scalars(m_thresholdFilter, "SelectInputScalars"); - scalars.Set(props["scalarArray"].toInt()); - vtkSMPropertyHelper(m_thresholdFilter, "LowerThreshold") - .Set(props["minimum"].toDouble()); - vtkSMPropertyHelper(m_thresholdFilter, "UpperThreshold") - .Set(props["maximum"].toDouble()); - vtkSMPropertyHelper repHelper(rep, "Representation"); - repHelper.Set(props["representation"].toString().toStdString().c_str()); - vtkSMPropertyHelper specular(rep, "Specular"); - specular.Set(props["specular"].toDouble()); - vtkSMPropertyHelper opacity(rep, "Opacity"); - opacity.Set(props["opacity"].toDouble()); - vtkSMPropertyHelper mapScalars(rep, "MapScalars"); - mapScalars.Set(props["mapScalars"].toBool() ? 1 : 0); - m_thresholdFilter->UpdateVTKObjects(); - rep->UpdateVTKObjects(); - return true; - } - return false; -} - -void ModuleThreshold::dataSourceMoved(double newX, double newY, double newZ) -{ - double pos[3] = { newX, newY, newZ }; - vtkSMPropertyHelper(m_thresholdRepresentation, "Translation").Set(pos, 3); - m_thresholdRepresentation->UpdateVTKObjects(); -} - -void ModuleThreshold::dataSourceRotated(double newX, double newY, double newZ) -{ - double orientation[3] = { newX, newY, newZ }; - vtkSMPropertyHelper(m_thresholdRepresentation, "Orientation") - .Set(orientation, 3); - m_thresholdRepresentation->UpdateVTKObjects(); -} - -} // namespace tomviz diff --git a/tomviz/modules/ModuleThreshold.h b/tomviz/modules/ModuleThreshold.h deleted file mode 100644 index a21b12f6f..000000000 --- a/tomviz/modules/ModuleThreshold.h +++ /dev/null @@ -1,56 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleThreshold_h -#define tomvizModuleThreshold_h - -#include "Module.h" - -#include -#include - -class vtkSMSourceProxy; - -namespace tomviz { - -class ModuleThreshold : public Module -{ - Q_OBJECT - -public: - ModuleThreshold(QObject* parent = nullptr); - ~ModuleThreshold() override; - - QString label() const override { return "Threshold"; } - QIcon icon() const override; - using Module::initialize; - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - void addToPanel(QWidget*) override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - bool isColorMapNeeded() const override { return true; } - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - -protected: - void updateColorMap() override; - -private slots: - void dataUpdated(); - - void onScalarArrayChanged(); - -private: - Q_DISABLE_COPY(ModuleThreshold) - - pqPropertyLinks m_links; - vtkWeakPointer m_thresholdFilter; - vtkWeakPointer m_thresholdRepresentation; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ModuleVolume.cxx b/tomviz/modules/ModuleVolume.cxx deleted file mode 100644 index 3a8e09365..000000000 --- a/tomviz/modules/ModuleVolume.cxx +++ /dev/null @@ -1,805 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ModuleVolume.h" -#include "ModuleVolumeWidget.h" - -#include "DataSource.h" -#include "HistogramManager.h" -#include "ScalarsComboBox.h" -#include "VolumeManager.h" -#include "vtkTransferFunctionBoxItem.h" -#include "vtkTriangleBar.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -#include -#include -#include -#include - -#include - -namespace tomviz { - -// Subclass vtkSmartVolumeMapper so we can have a little more customization -class SmartVolumeMapper : public vtkSmartVolumeMapper -{ -public: - SmartVolumeMapper() { SetRequestedRenderModeToGPU(); } - - static SmartVolumeMapper* New(); - - void UseJitteringOn() { GetGPUMapper()->UseJitteringOn(); } - void UseJitteringOff() { GetGPUMapper()->UseJitteringOff(); } - vtkTypeBool GetUseJittering() { return GetGPUMapper()->GetUseJittering(); } - void SetUseJittering(vtkTypeBool b) { GetGPUMapper()->SetUseJittering(b); } -}; - -vtkStandardNewMacro(SmartVolumeMapper) - - ModuleVolume::ModuleVolume(QObject* parentObject) - : Module(parentObject) -{ - // NOTE: Due to a bug in vtkMultiVolume, a gradient opacity function must be - // set or the shader will fail to compile. - // (likely fixed in https://gitlab.kitware.com/vtk/vtk/-/merge_requests/8909) - m_gradientOpacity->AddPoint(0.0, 1.0); - connect(&HistogramManager::instance(), &HistogramManager::histogram2DReady, - this, [=](vtkSmartPointer image, - vtkSmartPointer histogram2D) { - // Force the transfer function 2D to update. - if (image == - vtkImageData::SafeDownCast(dataSource()->dataObject())) { - auto colorMap = vtkColorTransferFunction::SafeDownCast( - this->colorMap()->GetClientSideObject()); - auto opacityMap = vtkPiecewiseFunction::SafeDownCast( - this->opacityMap()->GetClientSideObject()); - vtkTransferFunctionBoxItem::rasterTransferFunction2DBox( - histogram2D, *this->transferFunction2DBox(), - transferFunction2D(), colorMap, opacityMap); - } - // Update the volume mapper. - this->updateColorMap(); - emit this->renderNeeded(); - }); - - // NOTE: Due to a bug in vtkMultiVolume, a gradient opacity function must - // be set or the shader will fail to compile. - // (likely fixed in https://gitlab.kitware.com/vtk/vtk/-/merge_requests/8909) - connect(&VolumeManager::instance(), &VolumeManager::usingMultiVolumeChanged, - this, &ModuleVolume::updateColorMap); -} - -ModuleVolume::~ModuleVolume() -{ - finalize(); -} - -QIcon ModuleVolume::icon() const -{ - return QIcon(":/icons/pqVolumeData.png"); -} - -void ModuleVolume::initializeMapper(DataSource* data) -{ - updateMapperInput(data); - m_volumeMapper->SetScalarModeToUsePointFieldData(); - m_volumeMapper->SelectScalarArray(scalarsIndex()); - m_volume->SetMapper(m_volumeMapper); - m_volumeMapper->UseJitteringOn(); - m_volumeMapper->SetBlendMode(vtkVolumeMapper::COMPOSITE_BLEND); - if (m_view != nullptr) { - m_view->Update(); - } -} - -bool ModuleVolume::initialize(DataSource* data, vtkSMViewProxy* vtkView) -{ - if (!Module::initialize(data, vtkView)) { - return false; - } - - initializeMapper(data); - m_volume->SetProperty(m_volumeProperty); - auto* displayPosition = data->displayPosition(); - m_volume->SetPosition(displayPosition[0], displayPosition[1], - displayPosition[2]); - auto* displayOrientation = data->displayOrientation(); - m_volume->SetOrientation(displayOrientation[0], displayOrientation[1], - displayOrientation[2]); - m_volumeProperty->SetInterpolationType(VTK_LINEAR_INTERPOLATION); - m_volumeProperty->SetAmbient(0.0); - m_volumeProperty->SetDiffuse(1.0); - m_volumeProperty->SetSpecular(1.0); - m_volumeProperty->SetSpecularPower(100.0); - - resetRgbaMappingRanges(); - onRgbaMappingToggled(false); - onComponentNamesModified(); - updateColorMap(); - - m_view = vtkPVRenderView::SafeDownCast(vtkView->GetClientSideView()); - m_view->AddPropToRenderer(m_volume); - m_view->Update(); - - connect(data, &DataSource::dataChanged, this, &ModuleVolume::onDataChanged); - connect(data, &DataSource::activeScalarsChanged, this, - &ModuleVolume::onScalarArrayChanged); - connect(data, &DataSource::componentNamesModified, this, - &ModuleVolume::onComponentNamesModified); - - // Work around mapper bug on the mac, see the following issue for details: - // https://github.com/OpenChemistry/tomviz/issues/1776 - // Should be removed when this is fixed. -#if defined(Q_OS_MAC) - connect(data, &DataSource::dataChanged, this, - [this]() { this->initializeMapper(); }); -#endif - return true; -} - -void ModuleVolume::resetRgbaMappingRanges() -{ - // Combined range - computeRange(dataSource()->scalars(), m_rgbaMappingRangeAll.data()); - - // Individual ranges - m_rgbaMappingRanges.clear(); - - resetComponentNames(); - for (const auto& name : m_componentNames) { - m_rgbaMappingRanges[name] = computeRange(name); - } -} - -void ModuleVolume::resetComponentNames() -{ - m_componentNames = dataSource()->componentNames(); -} - -std::array ModuleVolume::computeRange(const QString& component) const -{ - std::array result; - auto index = m_componentNames.indexOf(component); - dataSource()->scalars()->GetRange(result.data(), index); - return result; -} - -std::array& ModuleVolume::rangeForComponent(const QString& component) -{ - return m_rgbaMappingRanges[component]; -} - -std::vector> ModuleVolume::activeRgbaRanges() -{ - std::vector> ret; - if (rgbaMappingCombineComponents()) { - // We are using one combined range - auto scalars = dataSource()->scalars(); - for (int i = 0; i < scalars->GetNumberOfComponents(); ++i) { - ret.push_back(m_rgbaMappingRangeAll); - } - } else { - for (auto& name : m_componentNames) { - ret.push_back(rangeForComponent(name)); - } - } - - return ret; -} - -void ModuleVolume::onRgbaMappingToggled(bool b) -{ - m_useRgbaMapping = b; - - updateMapperInput(); - updateVectorMode(); - if (useRgbaMapping()) { - updateRgbaMappingDataObject(); - m_volumeProperty->IndependentComponentsOff(); - if (m_view) { - m_view->AddPropToRenderer(m_triangleBar); - } - } else { - m_volumeProperty->IndependentComponentsOn(); - if (m_view) { - m_view->RemovePropFromRenderer(m_triangleBar); - } - } - updatePanel(); - - emit renderNeeded(); -} - -void ModuleVolume::onDataChanged() -{ - if (useRgbaMapping()) { - updateRgbaMappingDataObject(); - } - updatePanel(); -} - -void ModuleVolume::onComponentNamesModified() -{ - auto oldNames = m_componentNames; - resetComponentNames(); - auto newNames = m_componentNames; - - // Rename the map keys - for (int i = 0; i < oldNames.size(); ++i) { - if (newNames[i] != oldNames[i]) { - m_rgbaMappingRanges[newNames[i]] = m_rgbaMappingRanges.take(oldNames[i]); - if (m_rgbaMappingComponent == oldNames[i]) { - m_rgbaMappingComponent = newNames[i]; - } - } - } - - // Set labels on the triangle bar - if (newNames.size() >= 3) { - m_triangleBar->SetLabels(newNames[0].toLatin1().data(), - newNames[1].toLatin1().data(), - newNames[2].toLatin1().data()); - if (m_useRgbaMapping) { - emit renderNeeded(); - } - } - - // Update the panel - updatePanel(); -} - -void ModuleVolume::updateMapperInput(DataSource* data) -{ - if (useRgbaMapping()) { - m_volumeMapper->SetInputDataObject(m_rgbaDataObject); - } else if (data || (data = dataSource())) { - auto* output = data->producer()->GetOutputPort(); - m_volumeMapper->SetInputConnection(output); - } -} - -void ModuleVolume::computeRange(vtkDataArray* array, double range[2]) -{ - range[0] = DBL_MAX; - range[1] = -DBL_MAX; - for (int i = 0; i < array->GetNumberOfComponents(); ++i) { - auto* tmp = array->GetRange(i); - range[0] = std::min(range[0], tmp[0]); - range[1] = std::max(range[1], tmp[1]); - } -} - -static double computeNorm(double* vals, int num) -{ - double result = 0; - for (int i = 0; i < num; ++i) { - result += std::pow(vals[i], 2.0); - } - return std::sqrt(result); -} - -static double rescale(double val, double* oldRange, double* newRange) -{ - return (val - oldRange[0]) * (newRange[1] - newRange[0]) / - (oldRange[1] - oldRange[0]) + - newRange[0]; -} - -void ModuleVolume::updateVectorMode() -{ - int vectorMode = vtkSmartVolumeMapper::DISABLED; - auto* array = dataSource()->scalars(); - if (array->GetNumberOfComponents() > 1 && !useRgbaMapping()) { - vectorMode = vtkSmartVolumeMapper::MAGNITUDE; - } - - m_volumeMapper->SetVectorMode(vectorMode); -} - -bool ModuleVolume::rgbaMappingAllowed() -{ - auto* array = dataSource()->scalars(); - return array->GetNumberOfComponents() == 3; -} - -bool ModuleVolume::useRgbaMapping() -{ - if (!rgbaMappingAllowed()) { - m_useRgbaMapping = false; - } - - return m_useRgbaMapping; -} - -void ModuleVolume::updateRgbaMappingDataObject() -{ - auto* imageData = dataSource()->imageData(); - auto* input = dataSource()->scalars(); - - // FIXME: we should probably do a filter instead of an object. - m_rgbaDataObject->SetDimensions(imageData->GetDimensions()); - m_rgbaDataObject->AllocateScalars(input->GetDataType(), 4); - - auto* output = m_rgbaDataObject->GetPointData()->GetScalars(); - - // Rescale from 0 to 1 for the coloring. - double newRange[2] = { 0.0, 1.0 }; - auto oldRanges = activeRgbaRanges(); - for (int i = 0; i < input->GetNumberOfTuples(); ++i) { - for (int j = 0; j < 3; ++j) { - double oldVal = input->GetComponent(i, j); - double newVal = rescale(oldVal, oldRanges[j].data(), newRange); - output->SetComponent(i, j, newVal); - } - auto* vals = input->GetTuple3(i); - auto norm = computeNorm(vals, 3); - output->SetComponent(i, 3, norm); - } -} - -QString ModuleVolume::rgbaMappingComponent() -{ - if (!m_componentNames.contains(m_rgbaMappingComponent)) { - // Set it to the first component - m_rgbaMappingComponent = m_componentNames[0]; - } - - return m_rgbaMappingComponent; -} - -void ModuleVolume::updateColorMap() -{ - m_volumeProperty->SetScalarOpacity( - vtkPiecewiseFunction::SafeDownCast(opacityMap()->GetClientSideObject())); - m_volumeProperty->SetColor( - vtkColorTransferFunction::SafeDownCast(colorMap()->GetClientSideObject())); - - int propertyMode = vtkVolumeProperty::TF_1D; - const Module::TransferMode mode = getTransferMode(); - switch (mode) { - case (Module::SCALAR): { - // NOTE: Due to a bug in vtkMultiVolume, a gradient opacity function must - // be set or the shader will fail to compile. - // (likely fixed in - // https://gitlab.kitware.com/vtk/vtk/-/merge_requests/8909) - bool usingMultiVolume = - VolumeManager::instance().usingMultiVolume(this->view()); - if (usingMultiVolume) { - m_volumeProperty->SetGradientOpacity(m_gradientOpacity); - } else { - m_volumeProperty->SetGradientOpacity(nullptr); - } - break; - } - case (Module::GRADIENT_1D): - m_volumeProperty->SetGradientOpacity(gradientOpacityMap()); - break; - case (Module::GRADIENT_2D): - if (transferFunction2D() && transferFunction2D()->GetExtent()[1] > 0) { - propertyMode = vtkVolumeProperty::TF_2D; - m_volumeProperty->SetTransferFunction2D(transferFunction2D()); - } else { - vtkSmartPointer image = - vtkImageData::SafeDownCast(dataSource()->dataObject()); - // See if the histogram is done, if it is then update the transfer - // function. - if (auto histogram2D = - HistogramManager::instance().getHistogram2D(image)) { - auto colorMap = vtkColorTransferFunction::SafeDownCast( - this->colorMap()->GetClientSideObject()); - auto opacityMap = vtkPiecewiseFunction::SafeDownCast( - this->opacityMap()->GetClientSideObject()); - vtkTransferFunctionBoxItem::rasterTransferFunction2DBox( - histogram2D, *this->transferFunction2DBox(), transferFunction2D(), - colorMap, opacityMap); - propertyMode = vtkVolumeProperty::TF_2D; - m_volumeProperty->SetTransferFunction2D(transferFunction2D()); - } - // If this histogram is not ready, then it finishing will trigger the - // functor created in the constructor and the volume mapper will be - // updated when the histogram2D is done - } - break; - } - - m_volumeProperty->SetTransferFunctionMode(propertyMode); - - // BUG: volume mappers don't update property when LUT is changed and has an - // older Mtime. Fix for now by forcing the LUT to update. - vtkObject::SafeDownCast(colorMap()->GetClientSideObject())->Modified(); -} - -bool ModuleVolume::finalize() -{ - if (m_view) { - m_view->RemovePropFromRenderer(m_volume); - m_view->RemovePropFromRenderer(m_triangleBar); - } - - return true; -} - -bool ModuleVolume::setVisibility(bool val) -{ - m_volume->SetVisibility(val ? 1 : 0); - m_triangleBar->SetVisibility(val ? 1 : 0); - - Module::setVisibility(val); - - return true; -} - -bool ModuleVolume::visibility() const -{ - return m_volume->GetVisibility() != 0; -} - -QJsonObject ModuleVolume::serialize() const -{ - auto json = Module::serialize(); - auto props = json["properties"].toObject(); - - props["transferMode"] = getTransferMode(); - props["interpolation"] = m_volumeProperty->GetInterpolationType(); - props["blendingMode"] = m_volumeMapper->GetBlendMode(); - props["rayJittering"] = m_volumeMapper->GetUseJittering() == 1; - - QJsonObject lighting; - lighting["enabled"] = m_volumeProperty->GetShade() == 1; - lighting["ambient"] = m_volumeProperty->GetAmbient(); - lighting["diffuse"] = m_volumeProperty->GetDiffuse(); - lighting["specular"] = m_volumeProperty->GetSpecular(); - lighting["specularPower"] = m_volumeProperty->GetSpecularPower(); - props["lighting"] = lighting; - props["solidity"] = solidity(); - - json["properties"] = props; - return json; -} - -bool ModuleVolume::deserialize(const QJsonObject& json) -{ - if (!Module::deserialize(json)) { - return false; - } - if (json["properties"].isObject()) { - auto props = json["properties"].toObject(); - - setTransferMode( - static_cast(props["transferMode"].toInt())); - onInterpolationChanged(props["interpolation"].toInt()); - setBlendingMode(props["blendingMode"].toInt()); - setJittering(props["rayJittering"].toBool()); - setSolidity(props["solidity"].toDouble()); - - if (props["lighting"].isObject()) { - auto lighting = props["lighting"].toObject(); - setLighting(lighting["enabled"].toBool()); - onAmbientChanged(lighting["ambient"].toDouble()); - onDiffuseChanged(lighting["diffuse"].toDouble()); - onSpecularChanged(lighting["specular"].toDouble()); - onSpecularPowerChanged(lighting["specularPower"].toDouble()); - } - - updatePanel(); - onScalarArrayChanged(); - return true; - } - return false; -} - -void ModuleVolume::addToPanel(QWidget* panel) -{ - if (panel->layout()) { - delete panel->layout(); - } - if (!m_controllers) { - m_controllers = new ModuleVolumeWidget; - } - - m_scalarsCombo = new ScalarsComboBox(); - m_scalarsCombo->setOptions(dataSource(), this); - m_controllers->formLayout()->insertRow(0, "Active Scalars", m_scalarsCombo); - - QVBoxLayout* layout = new QVBoxLayout; - panel->setLayout(layout); - - // Create, update and connect - layout->addWidget(m_controllers); - updatePanel(); - - connect(m_controllers, &ModuleVolumeWidget::jitteringToggled, this, - &ModuleVolume::setJittering); - connect(m_controllers, &ModuleVolumeWidget::lightingToggled, this, - &ModuleVolume::setLighting); - connect(m_controllers, &ModuleVolumeWidget::blendingChanged, this, - &ModuleVolume::setBlendingMode); - connect(m_controllers, &ModuleVolumeWidget::interpolationChanged, this, - &ModuleVolume::onInterpolationChanged); - connect(m_controllers, &ModuleVolumeWidget::ambientChanged, this, - &ModuleVolume::onAmbientChanged); - connect(m_controllers, &ModuleVolumeWidget::diffuseChanged, this, - &ModuleVolume::onDiffuseChanged); - connect(m_controllers, &ModuleVolumeWidget::specularChanged, this, - &ModuleVolume::onSpecularChanged); - connect(m_controllers, &ModuleVolumeWidget::specularPowerChanged, this, - &ModuleVolume::onSpecularPowerChanged); - connect(m_controllers, &ModuleVolumeWidget::transferModeChanged, this, - &ModuleVolume::onTransferModeChanged); - connect(m_controllers, &ModuleVolumeWidget::useRgbaMappingToggled, this, - &ModuleVolume::onRgbaMappingToggled); - connect(m_controllers, - &ModuleVolumeWidget::rgbaMappingCombineComponentsToggled, this, - &ModuleVolume::onRgbaMappingCombineComponentsToggled); - connect(m_controllers, &ModuleVolumeWidget::rgbaMappingComponentChanged, this, - &ModuleVolume::onRgbaMappingComponentChanged); - connect(m_controllers, &ModuleVolumeWidget::rgbaMappingMinChanged, this, - &ModuleVolume::onRgbaMappingMinChanged); - connect(m_controllers, &ModuleVolumeWidget::rgbaMappingMaxChanged, this, - &ModuleVolume::onRgbaMappingMaxChanged); - connect(m_scalarsCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this](int idx) { - setActiveScalars(m_scalarsCombo->itemData(idx).toInt()); - onScalarArrayChanged(); - }); - connect(m_controllers, &ModuleVolumeWidget::solidityChanged, this, - &ModuleVolume::setSolidity); - connect(m_controllers, &ModuleVolumeWidget::allowMultiVolumeToggled, this, - &ModuleVolume::onAllowMultiVolumeToggled); -} - -void ModuleVolume::updatePanel() -{ - // If m_controllers is present update the values, if not they will be updated - // when it is created and shown. - if (!m_controllers || !m_volumeMapper || !m_volumeProperty || - !m_scalarsCombo) { - return; - } - auto blocked = QSignalBlocker(m_controllers); - - m_controllers->setJittering( - static_cast(m_volumeMapper->GetUseJittering())); - m_controllers->setLighting(static_cast(m_volumeProperty->GetShade())); - m_controllers->setBlendingMode(m_volumeMapper->GetBlendMode()); - m_controllers->setAmbient(m_volumeProperty->GetAmbient()); - m_controllers->setDiffuse(m_volumeProperty->GetDiffuse()); - m_controllers->setSpecular(m_volumeProperty->GetSpecular()); - m_controllers->setSpecularPower(m_volumeProperty->GetSpecularPower()); - m_controllers->setInterpolationType(m_volumeProperty->GetInterpolationType()); - m_controllers->setSolidity(solidity()); - m_controllers->setAllowMultiVolume( - VolumeManager::instance().allowMultiVolume(this->view())); - m_controllers->setEnableAllowMultiVolume( - VolumeManager::instance().volumeCount(this->view()) >= MULTI_VOLUME_SWITCH); - - m_controllers->setRgbaMappingAllowed(rgbaMappingAllowed()); - m_controllers->setUseRgbaMapping(useRgbaMapping()); - if (useRgbaMapping()) { - auto allComponents = rgbaMappingCombineComponents(); - auto options = m_componentNames; - auto component = rgbaMappingComponent(); - - m_controllers->setRgbaMappingCombineComponents(allComponents); - m_controllers->setRgbaMappingComponentOptions(options); - m_controllers->setRgbaMappingComponent(component); - - std::array minmax, sliderRange; - if (allComponents) { - minmax = m_rgbaMappingRangeAll; - computeRange(dataSource()->scalars(), sliderRange.data()); - } else { - minmax = rangeForComponent(component); - sliderRange = computeRange(component); - } - - m_controllers->setRgbaMappingMin(minmax[0]); - m_controllers->setRgbaMappingMax(minmax[1]); - m_controllers->setRgbaMappingSliderRange(sliderRange.data()); - } - - const auto tfMode = getTransferMode(); - m_controllers->setTransferMode(tfMode); - - m_scalarsCombo->setOptions(dataSource(), this); -} - -void ModuleVolume::onTransferModeChanged(const int mode) -{ - setTransferMode(static_cast(mode)); - updateColorMap(); - - emit transferModeChanged(mode); - emit renderNeeded(); -} - -void ModuleVolume::onRgbaMappingCombineComponentsToggled(const bool b) -{ - m_rgbaMappingCombineComponents = b; - updatePanel(); - - updateRgbaMappingDataObject(); - emit renderNeeded(); -} - -void ModuleVolume::onRgbaMappingComponentChanged(const QString& component) -{ - m_rgbaMappingComponent = component; - updatePanel(); -} - -void ModuleVolume::onRgbaMappingMinChanged(const double value) -{ - if (m_rgbaMappingCombineComponents) { - m_rgbaMappingRangeAll[0] = value; - } else { - rangeForComponent(rgbaMappingComponent())[0] = value; - } - - updateRgbaMappingDataObject(); - emit renderNeeded(); -} - -void ModuleVolume::onRgbaMappingMaxChanged(const double value) -{ - if (m_rgbaMappingCombineComponents) { - m_rgbaMappingRangeAll[1] = value; - } else { - rangeForComponent(rgbaMappingComponent())[1] = value; - } - - updateRgbaMappingDataObject(); - emit renderNeeded(); -} - -void ModuleVolume::onAllowMultiVolumeToggled(const bool value) -{ - VolumeManager::instance().allowMultiVolume(value, this->view()); - emit renderNeeded(); -} - -vtkDataObject* ModuleVolume::dataToExport() -{ - auto trv = dataSource()->producer(); - return trv->GetOutputDataObject(0); -} - -void ModuleVolume::onAmbientChanged(const double value) -{ - m_volumeProperty->SetAmbient(value); - emit renderNeeded(); -} - -void ModuleVolume::onDiffuseChanged(const double value) -{ - m_volumeProperty->SetDiffuse(value); - emit renderNeeded(); -} - -void ModuleVolume::onSpecularChanged(const double value) -{ - m_volumeProperty->SetSpecular(value); - emit renderNeeded(); -} - -void ModuleVolume::onSpecularPowerChanged(const double value) -{ - m_volumeProperty->SetSpecularPower(value); - emit renderNeeded(); -} - -void ModuleVolume::onInterpolationChanged(const int type) -{ - m_volumeProperty->SetInterpolationType(type); - emit renderNeeded(); -} - -void ModuleVolume::dataSourceMoved(double newX, double newY, double newZ) -{ - m_volume->SetPosition(newX, newY, newZ); -} - -void ModuleVolume::dataSourceRotated(double newX, double newY, double newZ) -{ - m_volume->SetOrientation(newX, newY, newZ); -} - -void ModuleVolume::setLighting(const bool val) -{ - m_volumeProperty->SetShade(val ? 1 : 0); - emit renderNeeded(); -} - -void ModuleVolume::setBlendingMode(const int mode) -{ - m_volumeMapper->SetBlendMode(mode); - emit renderNeeded(); -} - -void ModuleVolume::setJittering(const bool val) -{ - m_volumeMapper->SetUseJittering(val ? 1 : 0); - emit renderNeeded(); -} - -void ModuleVolume::onScalarArrayChanged() -{ - // The scalar arrays may have been renamed - if (m_scalarsCombo) { - m_scalarsCombo->setOptions(dataSource(), this); - } - - m_volumeMapper->SelectScalarArray(scalarsIndex()); - auto tp = dataSource()->producer(); - if (tp) { - tp->GetOutputDataObject(0)->Modified(); - } - emit renderNeeded(); -} - -double ModuleVolume::solidity() const -{ - return 1 / m_volumeProperty->GetScalarOpacityUnitDistance(); -} - -void ModuleVolume::setSolidity(const double value) -{ - int numComponents = useRgbaMapping() ? 4 : 1; - for (int i = 0; i < numComponents; ++i) { - m_volumeProperty->SetScalarOpacityUnitDistance(i, 1 / value); - } - emit renderNeeded(); -} - -int ModuleVolume::scalarsIndex() -{ - int index; - if (activeScalars() == Module::defaultScalarsIdx()) { - index = dataSource()->activeScalarsIdx(); - } else { - index = activeScalars(); - } - return index; -} - -bool ModuleVolume::updateClippingPlane(vtkPlane* plane, bool newFilter) -{ - if (m_volumeMapper->GetNumberOfClippingPlanes()) { - m_volumeMapper->RemoveClippingPlane(plane); - } - if (!newFilter) { - m_volumeMapper->AddClippingPlane(plane); - } - - emit renderNeeded(); - - return true; -} - -vtkVolume* ModuleVolume::getVolume() -{ - return m_volume; -} - -} // end of namespace tomviz diff --git a/tomviz/modules/ModuleVolume.h b/tomviz/modules/ModuleVolume.h deleted file mode 100644 index 8ef92ccac..000000000 --- a/tomviz/modules/ModuleVolume.h +++ /dev/null @@ -1,146 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizModuleVolume_h -#define tomvizModuleVolume_h - -#include "Module.h" - -#include -#include - -#include -#include - -#include - -class vtkPVRenderView; - -class vtkImageClip; -class vtkImageData; -class vtkPiecewiseFunction; -class vtkPlane; -class vtkTriangleBar; -class vtkVolumeProperty; -class vtkVolume; - -namespace tomviz { - -class ModuleVolumeWidget; -class ScalarsComboBox; -class SmartVolumeMapper; - -class ModuleVolume : public Module -{ - Q_OBJECT - -public: - ModuleVolume(QObject* parent = nullptr); - virtual ~ModuleVolume(); - - QString label() const override { return "Volume"; } - QIcon icon() const override; - using Module::initialize; - void initializeMapper(DataSource *data=nullptr); - bool initialize(DataSource* dataSource, vtkSMViewProxy* view) override; - bool finalize() override; - bool setVisibility(bool val) override; - bool visibility() const override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - bool isColorMapNeeded() const override { return true; } - void addToPanel(QWidget* panel) override; - void updatePanel(); - - bool rgbaMappingAllowed(); - bool useRgbaMapping(); - void updateMapperInput(DataSource* data = nullptr); - void updateRgbaMappingDataObject(); - void resetRgbaMappingRanges(); - void updateVectorMode(); - - void dataSourceMoved(double newX, double newY, double newZ) override; - void dataSourceRotated(double newX, double newY, double newZ) override; - - bool supportsGradientOpacity() override { return true; } - - QString exportDataTypeString() override { return "Volume"; } - - vtkDataObject* dataToExport() override; - - bool updateClippingPlane(vtkPlane* plane, bool newFilter) override; - - double solidity() const; - - vtkVolume* getVolume(); - -protected: - void updateColorMap() override; - -private: - Q_DISABLE_COPY(ModuleVolume) - - QString rgbaMappingComponent(); - bool rgbaMappingCombineComponents() const - { - return m_rgbaMappingCombineComponents; - } - std::array computeRange(const QString& component) const; - static void computeRange(vtkDataArray* array, double range[2]); - std::array& rangeForComponent(const QString& component); - std::vector> activeRgbaRanges(); - void resetComponentNames(); - - vtkWeakPointer m_view; - vtkNew m_volume; - vtkNew m_volumeMapper; - vtkNew m_volumeProperty; - vtkNew m_gradientOpacity; - vtkNew m_triangleBar; - QPointer m_controllers; - QPointer m_scalarsCombo; - - // Data object used for mapping 3-components to Rgba - vtkNew m_rgbaDataObject; - - bool m_useRgbaMapping = false; - bool m_rgbaMappingCombineComponents = true; - QString m_rgbaMappingComponent; - - std::array m_rgbaMappingRangeAll; - // Ranges used for Rgba data object. QString is the component name. - QMap> m_rgbaMappingRanges; - - // Keep track of the component names for renaming... - QStringList m_componentNames; - -private slots: - /** - * Actuator methods for m_volumeMapper. These slots should be connected to - * the appropriate UI signals. - */ - void setLighting(const bool val); - void setBlendingMode(const int mode); - void onInterpolationChanged(const int type); - void setJittering(const bool val); - void onAmbientChanged(const double value); - void onDiffuseChanged(const double value); - void onSpecularChanged(const double value); - void onSpecularPowerChanged(const double value); - void onTransferModeChanged(const int mode); - void onRgbaMappingToggled(const bool b); - void onRgbaMappingCombineComponentsToggled(const bool b); - void onRgbaMappingComponentChanged(const QString& component); - void onRgbaMappingMinChanged(const double value); - void onRgbaMappingMaxChanged(const double value); - void onScalarArrayChanged(); - void setSolidity(const double value); - int scalarsIndex(); - void onAllowMultiVolumeToggled(const bool value); - - void onDataChanged(); - void onComponentNamesModified(); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/modules/ScalarsComboBox.cxx b/tomviz/modules/ScalarsComboBox.cxx deleted file mode 100644 index f0903c23b..000000000 --- a/tomviz/modules/ScalarsComboBox.cxx +++ /dev/null @@ -1,38 +0,0 @@ -#include "ScalarsComboBox.h" - -#include "DataSource.h" -#include "Module.h" - -namespace tomviz { - -ScalarsComboBox::ScalarsComboBox(QWidget* parent) : QComboBox(parent) -{ -} - -void ScalarsComboBox::setOptions(DataSource* ds, Module* module) -{ - const QSignalBlocker blocker(this); - - clear(); - - if (!ds || !module) { - return; - } - - addItem("Default", Module::defaultScalarsIdx()); - - QStringList scalars = ds->listScalars(); - for (int i = 0; i < scalars.length(); ++i) { - addItem(scalars[i], i); - } - - int currentIndex; - if (module->activeScalars() == Module::defaultScalarsIdx()) { - currentIndex = 0; - } else { - currentIndex = module->activeScalars() + 1; - } - - setCurrentIndex(currentIndex); -} -} diff --git a/tomviz/modules/ScalarsComboBox.h b/tomviz/modules/ScalarsComboBox.h deleted file mode 100644 index e78f55923..000000000 --- a/tomviz/modules/ScalarsComboBox.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef tomvizScalarsPicker_h -#define tomvizScalarsPicker_h - -#include - -namespace tomviz { - -class DataSource; -class Module; - -class ScalarsComboBox : public QComboBox -{ - Q_OBJECT - -public: - ScalarsComboBox(QWidget* parent = nullptr); - void setOptions(DataSource* ds, Module* module); -}; -} - -#endif diff --git a/tomviz/modules/VolumeManager.cxx b/tomviz/modules/VolumeManager.cxx deleted file mode 100644 index e6135022b..000000000 --- a/tomviz/modules/VolumeManager.cxx +++ /dev/null @@ -1,302 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "VolumeManager.h" - -#include "ModuleManager.h" -#include "ModuleVolume.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include - -namespace tomviz { - -class ViewVolumes -{ -public: - vtkNew multiVolume; - vtkNew mapper; - vtkNew auxVolume; - vtkNew auxData; - vtkNew auxProducer; - vtkNew auxProperty; - vtkNew auxOpacity; - vtkNew auxGradientOpacity; - vtkNew auxColors; - QMap volumePorts; - int currentPort = 1; - bool allowMultiVolume = true; - bool usingMultiVolume = false; -}; - -class VolumeManager::Internals -{ -public: - QMap> views; -}; - -VolumeManager::VolumeManager(QObject* parentObject) - : Superclass(parentObject), d(new VolumeManager::Internals()) -{ - connect(&ModuleManager::instance(), &ModuleManager::moduleAdded, this, - &VolumeManager::onModuleAdded); - connect(&ModuleManager::instance(), &ModuleManager::moduleRemoved, this, - &VolumeManager::onModuleRemoved); -} - -VolumeManager::~VolumeManager() = default; - -VolumeManager& VolumeManager::instance() -{ - static VolumeManager theInstance; - return theInstance; -} - -void VolumeManager::onModuleAdded(Module* module) -{ - auto volume = qobject_cast(module); - if (volume) { - connect(volume, &Module::visibilityChanged, this, - &VolumeManager::onVisibilityChanged); - auto view = volume->view(); - - if (!this->d->views.contains(view)) { - QSharedPointer viewVolumes(new ViewVolumes()); - viewVolumes->auxData->SetDimensions(1, 1, 1); - viewVolumes->auxData->AllocateScalars(VTK_FLOAT, 1); - viewVolumes->auxData->GetPointData()->GetScalars()->Fill(1); - viewVolumes->auxProducer->SetOutput(viewVolumes->auxData); - viewVolumes->mapper->SetInputConnection( - 0, viewVolumes->auxProducer->GetOutputPort()); - - viewVolumes->auxOpacity->AddPoint(0.0, 0.0); - viewVolumes->auxGradientOpacity->AddPoint(0.0, 1.0); - - viewVolumes->auxColors->AddRGBPoint(0.0, 0.0, 0.0, 0.0); - - viewVolumes->auxProperty->SetColor(viewVolumes->auxColors); - viewVolumes->auxProperty->SetScalarOpacity(viewVolumes->auxOpacity); - // NOTE: Due to a bug in vtkMultiVolume, a gradient opacity function must - // be set or the shader will fail to compile. - // (likely fixed in - // https://gitlab.kitware.com/vtk/vtk/-/merge_requests/8909) - viewVolumes->auxProperty->SetGradientOpacity( - viewVolumes->auxGradientOpacity); - viewVolumes->auxVolume->SetProperty(viewVolumes->auxProperty); - - viewVolumes->multiVolume->SetVolume(viewVolumes->auxVolume, 0); - viewVolumes->multiVolume->SetMapper(viewVolumes->mapper); - this->d->views.insert(view, viewVolumes); - } - - this->d->views[view]->volumePorts[volume] = - this->d->views[view]->currentPort++; - auto allowMultiVolume = this->d->views[view]->allowMultiVolume; - - if (this->d->views[view]->volumePorts.size() >= MULTI_VOLUME_SWITCH && - allowMultiVolume) { - this->multiVolumeOn(view); - } - - emit volumeCountChanged(view, this->d->views[view]->volumePorts.size()); - } -} - -void VolumeManager::onModuleRemoved(Module* module) -{ - auto volume = qobject_cast(module); - if (volume) { - auto view = volume->view(); - - if (!this->d->views.contains(view)) { - return; - } - - auto& volumePorts = this->d->views[view]->volumePorts; - auto& mapper = this->d->views[view]->mapper; - auto& multiVolume = this->d->views[view]->multiVolume; - - auto it = volumePorts.find(volume); - if (it != volumePorts.end()) { - auto mod = it.key(); - auto port = it.value(); - auto vol = mod->getVolume(); - multiVolume->RemoveVolume(port); - mapper->RemoveInputConnection(port, - vol->GetMapper()->GetInputConnection(0, 0)); - volumePorts.erase(it); - } - - if (this->d->views[view]->volumePorts.size() < MULTI_VOLUME_SWITCH) { - this->multiVolumeOff(view); - } - - emit volumeCountChanged(view, this->d->views[view]->volumePorts.size()); - } -} - -void VolumeManager::multiVolumeOn(vtkSMViewProxy* view) -{ - if (!this->d->views.contains(view)) { - return; - } - - auto& volumePorts = this->d->views[view]->volumePorts; - auto& mapper = this->d->views[view]->mapper; - auto& multiVolume = this->d->views[view]->multiVolume; - auto v = vtkPVRenderView::SafeDownCast(view->GetClientSideView()); - - for (auto it = volumePorts.begin(); it != volumePorts.end(); ++it) { - auto mod = it.key(); - auto port = it.value(); - auto vol = mod->getVolume(); - - if (mod->visibility()) { - mapper->SetInputConnection(port, - vol->GetMapper()->GetInputConnection(0, 0)); - multiVolume->SetVolume(vol, port); - } - v->RemovePropFromRenderer(vol); - } - - v->AddPropToRenderer(multiVolume); - - if (!this->d->views[view]->usingMultiVolume) { - this->d->views[view]->usingMultiVolume = true; - emit usingMultiVolumeChanged(view, true); - } -} - -void VolumeManager::multiVolumeOff(vtkSMViewProxy* view) -{ - if (!this->d->views.contains(view)) { - return; - } - - auto& volumePorts = this->d->views[view]->volumePorts; - auto& mapper = this->d->views[view]->mapper; - auto& multiVolume = this->d->views[view]->multiVolume; - auto v = vtkPVRenderView::SafeDownCast(view->GetClientSideView()); - - for (auto it = volumePorts.begin(); it != volumePorts.end(); ++it) { - auto mod = it.key(); - auto port = it.value(); - auto vol = mod->getVolume(); - - multiVolume->RemoveVolume(port); - mapper->RemoveInputConnection(port, - vol->GetMapper()->GetInputConnection(0, 0)); - - v->AddPropToRenderer(vol); - } - - v->RemovePropFromRenderer(multiVolume); - - if (this->d->views[view]->usingMultiVolume) { - this->d->views[view]->usingMultiVolume = false; - emit usingMultiVolumeChanged(view, false); - } -} - -void VolumeManager::allowMultiVolume(bool allowMultiVolume, - vtkSMViewProxy* view) -{ - if (!this->d->views.contains(view)) { - return; - } - - auto usingMultiVolume = this->d->views[view]->usingMultiVolume; - - if (allowMultiVolume) { - auto nVolumes = this->d->views[view]->volumePorts.size(); - if (!usingMultiVolume && nVolumes >= MULTI_VOLUME_SWITCH) { - this->multiVolumeOn(view); - } - } else { - if (usingMultiVolume) { - this->multiVolumeOff(view); - } - } - - if (this->d->views[view]->allowMultiVolume != allowMultiVolume) { - this->d->views[view]->allowMultiVolume = allowMultiVolume; - emit allowMultiVolumeChanged(view, allowMultiVolume); - } -} - -bool VolumeManager::allowMultiVolume(vtkSMViewProxy* view) const -{ - if (!this->d->views.contains(view)) { - return true; - } - - return this->d->views[view]->usingMultiVolume; -} - -bool VolumeManager::usingMultiVolume(vtkSMViewProxy* view) const -{ - if (!this->d->views.contains(view)) { - return false; - } - - return this->d->views[view]->usingMultiVolume; -} - -int VolumeManager::volumeCount(vtkSMViewProxy* view) const -{ - if (!this->d->views.contains(view)) { - return 0; - } - - return this->d->views[view]->volumePorts.size(); -} - -void VolumeManager::onVisibilityChanged(bool visible) -{ - if (auto module = qobject_cast(sender())) { - auto view = module->view(); - - if (!this->d->views.contains(view)) { - return; - } - - auto& volumePorts = this->d->views[view]->volumePorts; - auto& mapper = this->d->views[view]->mapper; - auto& multiVolume = this->d->views[view]->multiVolume; - auto usingMultiVolume = this->d->views[view]->usingMultiVolume; - - auto it = volumePorts.find(module); - if (it == volumePorts.end()) { - return; - } - - auto port = it.value(); - auto vol = module->getVolume(); - - if (usingMultiVolume) { - if (visible) { - mapper->SetInputConnection(port, - vol->GetMapper()->GetInputConnection(0, 0)); - multiVolume->SetVolume(vol, port); - } else { - multiVolume->RemoveVolume(port); - mapper->RemoveInputConnection( - port, vol->GetMapper()->GetInputConnection(0, 0)); - } - } - } -} - -} // namespace tomviz diff --git a/tomviz/modules/VolumeManager.h b/tomviz/modules/VolumeManager.h deleted file mode 100644 index e9130c37c..000000000 --- a/tomviz/modules/VolumeManager.h +++ /dev/null @@ -1,60 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizVolumeManager_h -#define tomvizVolumeManager_h - -#include - -#include -#include - -class vtkSMViewProxy; - -namespace tomviz { - -const int MULTI_VOLUME_SWITCH = 2; - -class Module; -class ModuleVolume; - -/// Singleton to keep track of the volume modules added to each view, -/// So that a vtkMultiVolume can be used to fix volume overlapping issues. -class VolumeManager : public QObject -{ - Q_OBJECT - - typedef QObject Superclass; - -public: - static VolumeManager& instance(); - void allowMultiVolume(bool allow, vtkSMViewProxy* view); - bool allowMultiVolume(vtkSMViewProxy* view) const; - bool usingMultiVolume(vtkSMViewProxy* view) const; - int volumeCount(vtkSMViewProxy* view) const; - -signals: - void volumeCountChanged(vtkSMViewProxy* view, int count); - void usingMultiVolumeChanged(vtkSMViewProxy* view, bool enabled); - void allowMultiVolumeChanged(vtkSMViewProxy* view, bool allow); - -private slots: - void onModuleAdded(Module* module); - void onModuleRemoved(Module* module); - void onVisibilityChanged(bool visible); - -private: - Q_DISABLE_COPY(VolumeManager) - VolumeManager(QObject* parent = nullptr); - ~VolumeManager(); - - void multiVolumeOn(vtkSMViewProxy* view); - void multiVolumeOff(vtkSMViewProxy* view); - - class Internals; - QScopedPointer d; -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/operators/ArrayWranglerOperator.h b/tomviz/operators/ArrayWranglerOperator.h deleted file mode 100644 index 11adaa850..000000000 --- a/tomviz/operators/ArrayWranglerOperator.h +++ /dev/null @@ -1,48 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizArrayWranglerOperator_h -#define tomvizArrayWranglerOperator_h - -#include "Operator.h" - -namespace tomviz { - -class ArrayWranglerOperator : public Operator -{ - Q_OBJECT - -public: - ArrayWranglerOperator(QObject* parent = nullptr); - - QString label() const override { return "Convert Type"; } - QIcon icon() const override; - Operator* clone() const override; - - bool applyTransform(vtkDataObject* data) override; - - EditOperatorWidget* getEditorContentsWithData( - QWidget* parent, vtkSmartPointer data) override; - bool hasCustomUI() const override { return true; } - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - enum class OutputType - { - UInt8, - UInt16 - }; - - void setOutputType(OutputType t) { m_outputType = t; } - void setComponentToKeep(size_t i) { m_componentToKeep = i; } - -private: - OutputType m_outputType = OutputType::UInt8; - int m_componentToKeep = 0; - - Q_DISABLE_COPY(ArrayWranglerOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/ConvertToFloatOperator.cxx b/tomviz/operators/ConvertToFloatOperator.cxx deleted file mode 100644 index 23c2be903..000000000 --- a/tomviz/operators/ConvertToFloatOperator.cxx +++ /dev/null @@ -1,61 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ConvertToFloatOperator.h" - -#include -#include -#include -#include - -namespace { - -template -void convertToFloat(vtkFloatArray* fArray, int nComps, vtkIdType nTuples, - void* data) -{ - auto d = static_cast(data); - auto a = static_cast(fArray->GetVoidPointer(0)); - for (vtkIdType i = 0; i < nComps * nTuples; ++i) { - a[i] = static_cast(d[i]); - } -} -} // namespace - -namespace tomviz { - -ConvertToFloatOperator::ConvertToFloatOperator(QObject* p) : Operator(p) {} - -QIcon ConvertToFloatOperator::icon() const -{ - return QIcon(); -} - -bool ConvertToFloatOperator::applyTransform(vtkDataObject* data) -{ - auto imageData = vtkImageData::SafeDownCast(data); - // sanity check - if (!imageData) { - return false; - } - auto scalars = imageData->GetPointData()->GetScalars(); - vtkNew floatArray; - floatArray->SetNumberOfComponents(scalars->GetNumberOfComponents()); - floatArray->SetNumberOfTuples(scalars->GetNumberOfTuples()); - floatArray->SetName(scalars->GetName()); - switch (scalars->GetDataType()) { - vtkTemplateMacro(convertToFloat( - floatArray.Get(), scalars->GetNumberOfComponents(), - scalars->GetNumberOfTuples(), scalars->GetVoidPointer(0))); - } - imageData->GetPointData()->RemoveArray(scalars->GetName()); - imageData->GetPointData()->SetScalars(floatArray); - return true; -} - -Operator* ConvertToFloatOperator::clone() const -{ - return new ConvertToFloatOperator(); -} - -} // namespace tomviz diff --git a/tomviz/operators/ConvertToFloatOperator.h b/tomviz/operators/ConvertToFloatOperator.h deleted file mode 100644 index d1ae04d4b..000000000 --- a/tomviz/operators/ConvertToFloatOperator.h +++ /dev/null @@ -1,29 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizConvertToFloatOperator_h -#define tomvizConvertToFloatOperator_h - -#include "Operator.h" - -namespace tomviz { - -class ConvertToFloatOperator : public Operator -{ - Q_OBJECT - -public: - ConvertToFloatOperator(QObject* parent = nullptr); - - QString label() const override { return "Convert to Float"; } - QIcon icon() const override; - Operator* clone() const override; - - bool applyTransform(vtkDataObject* data) override; - -private: - Q_DISABLE_COPY(ConvertToFloatOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/ConvertToVolumeOperator.cxx b/tomviz/operators/ConvertToVolumeOperator.cxx deleted file mode 100644 index 0a30f6b0a..000000000 --- a/tomviz/operators/ConvertToVolumeOperator.cxx +++ /dev/null @@ -1,38 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ConvertToVolumeOperator.h" - -#include -#include -#include - -namespace tomviz -{ - -ConvertToVolumeOperator::ConvertToVolumeOperator(QObject* p, - DataSource::DataSourceType t, - QString label) - : Operator(p), m_type(t), m_label(label) -{ -} - -ConvertToVolumeOperator::~ConvertToVolumeOperator() = default; - -QIcon ConvertToVolumeOperator::icon() const -{ - return QIcon(); -} - -Operator* ConvertToVolumeOperator::clone() const -{ - return new ConvertToVolumeOperator; -} - -bool ConvertToVolumeOperator::applyTransform(vtkDataObject* data) -{ - DataSource::setType(data, m_type); - return true; -} - -} diff --git a/tomviz/operators/ConvertToVolumeOperator.h b/tomviz/operators/ConvertToVolumeOperator.h deleted file mode 100644 index 63b773a29..000000000 --- a/tomviz/operators/ConvertToVolumeOperator.h +++ /dev/null @@ -1,38 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizConvertToVolumeOperator_h -#define tomvizConvertToVolumeOperator_h - -#include "Operator.h" - -namespace tomviz -{ - -class ConvertToVolumeOperator : public Operator -{ - Q_OBJECT - -public: - ConvertToVolumeOperator( - QObject* p = nullptr, - DataSource::DataSourceType t = DataSource::Volume, - QString label = "Mark as Volume"); - ~ConvertToVolumeOperator(); - - QString label() const override { return m_label; } - QIcon icon() const override; - Operator* clone() const override; - -protected: - bool applyTransform(vtkDataObject* data) override; - -private: - Q_DISABLE_COPY(ConvertToVolumeOperator) - DataSource::DataSourceType m_type; - QString m_label; -}; - -} // end tomviz namespace - -#endif diff --git a/tomviz/operators/CropOperator.cxx b/tomviz/operators/CropOperator.cxx deleted file mode 100644 index 4fefceea8..000000000 --- a/tomviz/operators/CropOperator.cxx +++ /dev/null @@ -1,140 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "CropOperator.h" - -#include "EditOperatorWidget.h" -#include "SelectVolumeWidget.h" - -#include -#include -#include - -#include -#include -#include -#include - -#include - -namespace { - -class CropWidget : public tomviz::EditOperatorWidget -{ - Q_OBJECT - -public: - CropWidget(tomviz::CropOperator* source, - vtkSmartPointer imageData, QWidget* p) - : tomviz::EditOperatorWidget(p), m_operator(source) - { - double displayPosition[3] = { 0, 0, 0 }; - double origin[3]; - double spacing[3]; - int extent[6]; - imageData->GetOrigin(origin); - imageData->GetSpacing(spacing); - imageData->GetExtent(extent); - if (source->cropBounds()[0] == std::numeric_limits::min()) { - source->setCropBounds(extent); - } - m_widget = new tomviz::SelectVolumeWidget( - origin, spacing, extent, source->cropBounds(), displayPosition, this); - QHBoxLayout* hboxlayout = new QHBoxLayout; - hboxlayout->addWidget(m_widget); - setLayout(hboxlayout); - } - - void applyChangesToOperator() override - { - int bounds[6]; - m_widget->getExtentOfSelection(bounds); - if (m_operator) { - m_operator->setCropBounds(bounds); - } - } - - void dataSourceMoved(double newX, double newY, double newZ) override - { - m_widget->dataMoved(newX, newY, newZ); - } - -private: - QPointer m_operator; - tomviz::SelectVolumeWidget* m_widget; -}; -} // namespace - -#include "CropOperator.moc" - -namespace tomviz { - -CropOperator::CropOperator(QObject* p) : Operator(p) -{ - // By default include the entire volume - for (int i = 0; i < 6; ++i) { - m_bounds[i] = std::numeric_limits::min(); - } -} - -QIcon CropOperator::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqExtractGrid.svg"); -} - -bool CropOperator::applyTransform(vtkDataObject* data) -{ - vtkNew extractor; - extractor->SetVOI(m_bounds); - extractor->SetInputDataObject(data); - extractor->Update(); - extractor->UpdateWholeExtent(); - data->ShallowCopy(extractor->GetOutputDataObject(0)); - return true; -} - -Operator* CropOperator::clone() const -{ - CropOperator* other = new CropOperator(); - other->setCropBounds(m_bounds); - return other; -} - -QJsonObject CropOperator::serialize() const -{ - auto json = Operator::serialize(); - QJsonArray bounds; - for (int i = 0; i < 6; ++i) { - bounds << m_bounds[i]; - } - json["bounds"] = bounds; - return json; -} - -bool CropOperator::deserialize(const QJsonObject& json) -{ - if (json.contains("bounds")) { - auto bounds = json["bounds"].toArray(); - if (bounds.size() == 6) { - for (int i = 0; i < 6; ++i) { - m_bounds[i] = bounds[i].toInt(); - } - } - } - return true; -} - -void CropOperator::setCropBounds(const int bounds[6]) -{ - for (int i = 0; i < 6; ++i) { - m_bounds[i] = bounds[i]; - } - emit transformModified(); -} - -EditOperatorWidget* CropOperator::getEditorContentsWithData( - QWidget* p, vtkSmartPointer data) -{ - return new CropWidget(this, data, p); -} -} // namespace tomviz diff --git a/tomviz/operators/CropOperator.h b/tomviz/operators/CropOperator.h deleted file mode 100644 index d897befab..000000000 --- a/tomviz/operators/CropOperator.h +++ /dev/null @@ -1,43 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizCropOperator_h -#define tomvizCropOperator_h - -#include "Operator.h" - -namespace tomviz { - -class CropOperator : public Operator -{ - Q_OBJECT - -public: - CropOperator(QObject* parent = nullptr); - - QString label() const override { return "Crop"; } - - QIcon icon() const override; - - Operator* clone() const override; - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - EditOperatorWidget* getEditorContentsWithData( - QWidget* parent, vtkSmartPointer data) override; - bool hasCustomUI() const override { return true; } - - void setCropBounds(const int bounds[6]); - const int* cropBounds() const { return m_bounds; } - -protected: - bool applyTransform(vtkDataObject* data) override; - -private: - int m_bounds[6]; - Q_DISABLE_COPY(CropOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/CustomPythonOperatorWidget.h b/tomviz/operators/CustomPythonOperatorWidget.h deleted file mode 100644 index 9cba16a79..000000000 --- a/tomviz/operators/CustomPythonOperatorWidget.h +++ /dev/null @@ -1,40 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizCustomPythonOperatorWidget_h -#define tomvizCustomPythonOperatorWidget_h - -#include - -namespace tomviz { - -class OperatorPython; - -class CustomPythonOperatorWidget : public QWidget -{ - Q_OBJECT - -public: - CustomPythonOperatorWidget(QWidget* parent); - virtual ~CustomPythonOperatorWidget(); - - virtual void getValues(QMap& map) = 0; - virtual void setValues(const QMap& map) = 0; - - // Keep a copy of the current script (including edits) in case the - // custom python operator needs to use it. - virtual void setScript(const QString& script) { m_script = script; } - - // Subclasses can perform some UI setup when this is called, if needed - virtual void setupUI(OperatorPython*) {} - - // Subclasses can write settings when this is called, if needed. - // This is called when the operator is applied. - virtual void writeSettings() {} - -protected: - QString m_script; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/EditOperatorDialog.cxx b/tomviz/operators/EditOperatorDialog.cxx deleted file mode 100644 index 049c933ed..000000000 --- a/tomviz/operators/EditOperatorDialog.cxx +++ /dev/null @@ -1,375 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "EditOperatorDialog.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorWidget.h" -#include "ModuleManager.h" -#include "Operator.h" -#include "Pipeline.h" -#include "Utilities.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -class EditOperatorDialog::EODInternals -{ -public: - QPointer Op; - EditOperatorWidget* Widget; - bool needsToBeAdded; - DataSource* dataSource; - - void saveGeometry(const QRect& geometry) - { - if (Op.isNull()) { - return; - } - QSettings* settings = pqApplicationCore::instance()->settings(); - QString settingName = - QString("Edit%1OperatorDialogGeometry").arg(Op->label()); - settings->setValue(settingName, QVariant(geometry)); - } - - QVariant loadGeometry() - { - if (Op.isNull()) { - return QVariant(); - } - QSettings* settings = pqApplicationCore::instance()->settings(); - QString settingName = - QString("Edit%1OperatorDialogGeometry").arg(Op->label()); - return settings->value(settingName); - } -}; - -EditOperatorDialog::EditOperatorDialog(Operator* op, DataSource* dataSource, - bool needToAddOperator, QWidget* p) - : Superclass(p), Internals(new EditOperatorDialog::EODInternals()) -{ - Q_ASSERT(op); - this->Internals->Op = op; - this->Internals->dataSource = dataSource; - this->Internals->needsToBeAdded = needToAddOperator; - if (this->Internals->dataSource->pipeline()->isRunning()) { - auto result = QMessageBox::question( - this, "Cancel running operation?", - "Editing or adding an operator that is part of a running pipeline " - "will cancel the current running operation and restart the pipeline " - "Proceed anyway?"); - if (result == QMessageBox::No) { - QMetaObject::invokeMethod(this, "close", Qt::QueuedConnection); - return; - } else { - auto whenCanceled = []() {}; - this->Internals->dataSource->pipeline()->cancel(whenCanceled); - } - } - // Connect to the finished signal on the pipeline to handle the UI after - // pressing apply - connect(this, &EditOperatorDialog::editStarted, - this->Internals->dataSource->pipeline(), &Pipeline::startedEditingOp); - connect(this, &EditOperatorDialog::editEnded, - this->Internals->dataSource->pipeline(), - &Pipeline::finishedEditingOp); - emit editStarted(this->Internals->Op); - // If editing an existing operator, still emit the signal to disable - // menubar buttons to add new operators to the current source - if (!needToAddOperator) { - ActiveObjects::instance().setActiveDataSource(this->Internals->dataSource); - } - - if (needToAddOperator) { - op->setParent(this); - this->Internals->dataSource->addOperator(this->Internals->Op); - } - - QVariant geometry = this->Internals->loadGeometry(); - if (!geometry.isNull()) { - // Only restore size, not position — showEvent will center the dialog - // on the main window's screen. Restoring position can cause the - // dialog to appear on a different monitor than the main window. - this->resize(geometry.toRect().size()); - } - - if (op->hasCustomUI()) { - EditOperatorWidget* opWidget = op->getEditorContents(this); - if (opWidget != nullptr) { - this->setupUI(opWidget); - } - // We need the image data for call the datasource to run the pipeline - else { - auto operators = op->dataSource()->operators(); - auto future = - dataSource->pipeline()->execute(op->dataSource(), nullptr, op); - connect(future, &Pipeline::Future::finished, this, - &EditOperatorDialog::getCopyOfImagePriorToFinished); - } - } else { - this->setupUI(); - } - op->setCustomDialog(this); -} - -EditOperatorDialog::~EditOperatorDialog() {} - -void EditOperatorDialog::showEvent(QShowEvent* event) -{ - Superclass::showEvent(event); - - // Always center on the main window's screen, overriding any restored - // geometry or window manager placement that may put the dialog on a - // different monitor. - auto* mainWin = tomviz::mainWidget(); - if (!mainWin) { - return; - } - - auto* screen = mainWin->screen(); - auto screenGeom = screen ? screen->availableGeometry() - : QRect(0, 0, 1920, 1080); - - auto mainCenter = mainWin->frameGeometry().center(); - auto dlgSize = frameGeometry().size(); - - // Center on the main window - int x = mainCenter.x() - dlgSize.width() / 2; - int y = mainCenter.y() - dlgSize.height() / 2; - - // Clamp to the main window's screen so we never spill onto another monitor - x = qBound(screenGeom.left(), x, - screenGeom.right() - dlgSize.width()); - y = qBound(screenGeom.top(), y, - screenGeom.bottom() - dlgSize.height()); - - move(x, y); - raise(); - activateWindow(); -} - -void EditOperatorDialog::setViewMode(const QString& mode) -{ - if (this->Internals->Widget) { - this->Internals->Widget->setViewMode(mode); - } -} - -Operator* EditOperatorDialog::op() -{ - return this->Internals->Op; -} - -void EditOperatorDialog::applyChanges() -{ - if (this->Internals->Op.isNull()) { - return; - } - - if (this->Internals->Widget) { - // If we are modifying an operator that is already part of a pipeline and - // the pipeline is running it has to cancel the currently running pipeline - // first. Warn the user rather that just canceling potentially long-running - // operations. - if (this->Internals->dataSource->pipeline()->isRunning()) { - auto result = QMessageBox::question( - this, "Cancel running operation?", - "Applying changes to an operator that is part of a running pipeline " - "will cancel the current running operator and restart the pipeline " - "run. Proceed anyway?"); - // FIXME There is still a concurrency issue here if the background thread - // running the operator finishes and the finished event is queued behind - // the question() return event above. If that happens then we will not - // get a canceled() event and the pipeline will stay paused. - if (result == QMessageBox::No) { - return; - } else { - auto op = this->Internals->Op; - auto dataSource = this->Internals->dataSource; - auto whenCanceled = [op, dataSource]() { - // Resume the pipeline and emit transformModified - dataSource->pipeline()->resume(); - emit op->transformModified(); - }; - if (this->Internals->needsToBeAdded) { - this->Internals->needsToBeAdded = false; - } - emit editEnded(this->Internals->Op); - // We do this before causing cancel so the values are in place for when - // whenCanceled cause the pipeline to be re-executed. - this->Internals->Widget->applyChangesToOperator(); - if (dataSource->pipeline()->isRunning()) { - this->Internals->dataSource->pipeline()->cancel(whenCanceled); - } else { - whenCanceled(); - } - } - } else { - this->Internals->Widget->applyChangesToOperator(); - if (this->Internals->needsToBeAdded) { - this->Internals->needsToBeAdded = false; - } - // Emit edit ended, so that the pipeline can decide whether to execute - // if there are no other operators being edited at the moment - emit editEnded(this->Internals->Op); - } - } -} - -void EditOperatorDialog::closeDialog() -{ - this->Internals->saveGeometry(this->geometry()); - ActiveObjects::instance().setActiveDataSource(this->Internals->dataSource); -} - -void EditOperatorDialog::onApply() -{ - applyChanges(); - // Changes are applied, but the dialog is still open. - // Let's fire the editStarted signal. - emit editStarted(this->Internals->Op); -} - -void EditOperatorDialog::onOkay() -{ - applyChanges(); - closeDialog(); -} - -void EditOperatorDialog::onCancel() -{ - if (this->Internals->Op == nullptr) { - return; - } - if (this->Internals->needsToBeAdded) { - // Since for now operators can't be programmatically removed - // (i.e. all removals are assumed to be initialized - // from the gui in PipelineModel), we need to do a workaround and have the - // ModuleManaged emit a signal that is captured by the PipelineModel, - // which eventually will lead to the removal of the operator. - ModuleManager::instance().removeOperator(this->Internals->Op); - } - emit editEnded(this->Internals->Op); - closeDialog(); -} - -void EditOperatorDialog::onHelpRequested() -{ - if (!this->Internals->Op) - return; - - openHelpUrl(this->Internals->Op->helpUrl()); -} - -void EditOperatorDialog::setupUI(EditOperatorWidget* opWidget) -{ - if (this->Internals->Op.isNull()) { - return; - } - - QVBoxLayout* vLayout = new QVBoxLayout(this); - vLayout->setContentsMargins(5, 5, 5, 5); - vLayout->setSpacing(5); - if (this->Internals->Op->hasCustomUI()) { - vLayout->addWidget(opWidget, 1); - this->Internals->Widget = opWidget; - const double* dsPosition = this->Internals->dataSource->displayPosition(); - opWidget->dataSourceMoved(dsPosition[0], dsPosition[1], dsPosition[2]); - QObject::connect(this->Internals->dataSource, - &DataSource::displayPositionChanged, opWidget, - &EditOperatorWidget::dataSourceMoved); - } else { - this->Internals->Widget = nullptr; - } - QDialogButtonBox* dialogButtons = new QDialogButtonBox( - QDialogButtonBox::Apply | QDialogButtonBox::Cancel | QDialogButtonBox::Ok, - Qt::Horizontal, this); - - if (!this->Internals->Op->helpUrl().isEmpty()) { - // Add a help button - dialogButtons->addButton(QDialogButtonBox::Help); - } - - vLayout->addWidget(dialogButtons); - dialogButtons->button(QDialogButtonBox::Ok)->setDefault(false); - - this->setLayout(vLayout); - - connect(dialogButtons, &QDialogButtonBox::accepted, this, - &EditOperatorDialog::accept); - connect(dialogButtons, &QDialogButtonBox::rejected, this, - &EditOperatorDialog::reject); - connect(dialogButtons, &QDialogButtonBox::helpRequested, this, - &EditOperatorDialog::onHelpRequested); - connect(dialogButtons->button(QDialogButtonBox::Apply), &QPushButton::clicked, - this, &EditOperatorDialog::onApply); - - connect(this, &EditOperatorDialog::rejected, this, - &EditOperatorDialog::onCancel); - - connect(this, &EditOperatorDialog::accepted, this, - &EditOperatorDialog::onOkay); -} - -void EditOperatorDialog::getCopyOfImagePriorToFinished() -{ - if (this->Internals->Op.isNull()) { - return; - } - - auto future = qobject_cast(this->sender()); - - auto opWidget = - this->Internals->Op->getEditorContentsWithData(this, future->result()); - this->setupUI(opWidget); - - future->deleteLater(); -} - -void EditOperatorDialog::showDialogForOperator(Operator* op, - const QString& viewMode) -{ - if (!op) { - return; - } - - if (op->hasCustomUI()) { - // See if we already have a dialog open for this operator - auto dialog = op->customDialog(); - if (dialog) { - dialog->setViewMode(viewMode); - dialog->show(); - dialog->raise(); - dialog->activateWindow(); - } else { - // Create a non-modal dialog, delete it once it has been closed. - QString dialogTitle("Edit - "); - dialogTitle.append(op->label()); - dialog = new EditOperatorDialog(op, op->dataSource(), false, - tomviz::mainWidget()); - - dialog->setViewMode(viewMode); - dialog->setAttribute(Qt::WA_DeleteOnClose, true); - dialog->setWindowTitle(dialogTitle); - dialog->show(); - - // Close the dialog if the Operator is destroyed. - connect(op, &QObject::destroyed, dialog, &QDialog::reject); - } - } -} -} // namespace tomviz diff --git a/tomviz/operators/EditOperatorDialog.h b/tomviz/operators/EditOperatorDialog.h deleted file mode 100644 index 08686c71f..000000000 --- a/tomviz/operators/EditOperatorDialog.h +++ /dev/null @@ -1,68 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizEditPythonOperatorDialog_h -#define tomvizEditPythonOperatorDialog_h - -#include -#include -#include - -namespace tomviz { -class Operator; -class DataSource; -class EditOperatorWidget; - -class EditOperatorDialog : public QDialog -{ - Q_OBJECT - typedef QDialog Superclass; - -public: - // Creates an editor dialog for the given operator. If this is creating a - // new operator, then pass in true for needToAddOperator and the first time - // Apply/Ok is pressed it will be added to the DataSource. - EditOperatorDialog(Operator* op, DataSource* dataSource, - bool needToAddOperator, QWidget* parent); - virtual ~EditOperatorDialog(); - - // Used to set the mode of the EditOperatorWidget in the dialog. The mode - // corresponds to dialog options like tabs and varies from operator to - // operator. If the requested mode is not recognized, or the widget does not - // support modes, this function does nothing. - void setViewMode(const QString& mode); - - Operator* op(); - - // If the given operator does not already have a dialog, this function creates - // and shows a new dialog for that operator with the given mode (see comment - // above on setViewMode for details about modes). If the given operator has a - // dialog already, that dialog is set to the requested mode and given focus. - static void showDialogForOperator(Operator* op, const QString& viewMode = ""); - -protected: - void showEvent(QShowEvent* event) override; - -private slots: - void onApply(); - void onCancel(); - void onOkay(); - void onHelpRequested(); - void getCopyOfImagePriorToFinished(); - -signals: - void editStarted(Operator*); - void editEnded(Operator*); - -private: - void setupUI(EditOperatorWidget* opWidget = nullptr); - void applyChanges(); - void closeDialog(); - Q_DISABLE_COPY(EditOperatorDialog) - class EODInternals; - QScopedPointer Internals; - bool m_pipelineWasPaused; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/EditOperatorWidget.h b/tomviz/operators/EditOperatorWidget.h deleted file mode 100644 index 2eaf79e1c..000000000 --- a/tomviz/operators/EditOperatorWidget.h +++ /dev/null @@ -1,48 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizEditOperatorWidget_h -#define tomvizEditOperatorWidget_h - -#include - -namespace tomviz { -// This class is the GUI needed to edit the properties of an operator. The -// operator will return one of these from its getEditorContents and it will -// be shown in a dialog. When Apply or Ok is clicked on the dialog, the -// applyChangesToOperator() slot will be called on the widget. -class EditOperatorWidget : public QWidget -{ - Q_OBJECT - typedef QWidget Superclass; - -public: - EditOperatorWidget(QWidget* parent); - ~EditOperatorWidget(); - - // Called when the user interacts to move the data source while the widget - // is active. By default this emits the signal which can be attached to - // subwidgets' slots, but it can be overridden for custom handling. - virtual void dataSourceMoved(double newX, double newY, double newZ) - { - emit dataMoved(newX, newY, newZ); - } - - // Used to set the mode of the EditOperatorWidget. The mode corresponds to - // options like tabs that change the whole widget's appearance and varies from - // operator to operator. If the requested mode is not recognized, or the - // widget does not support modes, this function does nothing. The default - // implementation of this function does nothing. This method should be - // overridden to add support for modes. - virtual void setViewMode(const QString&) {} - -signals: - void dataMoved(double, double, double); - -public slots: - // Called when the dialog should apply its changes to the operator - virtual void applyChangesToOperator() = 0; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/EditPythonOperatorWidget.ui b/tomviz/operators/EditPythonOperatorWidget.ui deleted file mode 100644 index 348ac78e9..000000000 --- a/tomviz/operators/EditPythonOperatorWidget.ui +++ /dev/null @@ -1,165 +0,0 @@ - - - EditPythonOperatorWidget - - - - 0 - 0 - 1098 - 739 - - - - - 1 - 1 - - - - - 866 - 629 - - - - Form - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 5 - - - 5 - - - 5 - - - 5 - - - 5 - - - - - Name - - - - - - - - - - - - - 1 - - - - Script - - - - - - Python Script - - - - - - - - Monospace - - - - QTextEdit::NoWrap - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Monospace'; font-size:11pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">def transform(dataset):</p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> &quot;&quot;&quot;Define this method for Python operators that</p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> transform the input array&quot;&quot;&quot;</p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> import numpy as np</p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> # Get the current volume as a numpy array.</p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> array = dataset.active_scalars</p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> # This is where you operate on your data, here we square root it.</p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> result = np.sqrt(array)</p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> # This is where the transformed data is set, it will display in tomviz.</p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> dataset.active_scalars = result</p></body></html> - - - def transform(dataset): - """Define this method for Python operators that - transform the input array""" - - import numpy as np - - # Get the current volume as a numpy array. - array = dataset.active_scalars - - # This is where you operate on your data, here we square root it. - result = np.sqrt(array) - - # This is where the transformed data is set, it will display in tomviz. - dataset.active_scalars = result - - - - - - - Save Script - - - - - - - - Set Parameter Values - - - - - - - - - - - - - - diff --git a/tomviz/operators/Operator.cxx b/tomviz/operators/Operator.cxx deleted file mode 100644 index 010ad9bba..000000000 --- a/tomviz/operators/Operator.cxx +++ /dev/null @@ -1,239 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "Operator.h" - -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "ModuleManager.h" -#include "OperatorFactory.h" -#include "OperatorResult.h" -#include "Pipeline.h" - -#include "vtkImageData.h" -#include "vtkSMSourceProxy.h" - -#include -#include -#include - -#include - -namespace tomviz { - -Operator::Operator(QObject* parentObject) : QObject(parentObject) -{ - qRegisterMetaType("TransformResult"); - qRegisterMetaType>(); - - // Whenever we emit transform modified, let's trip the m_modified flag - connect(this, &Operator::transformModified, this, - [this]() { m_modified = true; }); - - // When the transform is completed, let's reset m_modified and m_new flags - connect(this, &Operator::transformingDone, this, [this]() { - if (m_state == OperatorState::Complete) { - m_modified = false; - m_new = false; - } - }); -} - -Operator::~Operator() -{ - setNumberOfResults(0); - emit aboutToBeDestroyed(this); -} - -DataSource* Operator::dataSource() -{ - return qobject_cast(parent()); -} - -TransformResult Operator::transform(vtkDataObject* data) -{ - m_state = OperatorState::Running; - emit transformingStarted(); - setProgressStep(0); - bool result = this->applyTransform(data); - TransformResult transformResult = - result ? TransformResult::Complete : TransformResult::Error; - // If the user requested the operator to be canceled then when it returns - // we should treat is as canceled. - if (isCanceled()) { - transformResult = TransformResult::Canceled; - } else { - m_state = static_cast(transformResult); - } - emit transformingDone(transformResult); - - return transformResult; -} - -void Operator::setNumberOfResults(int n) -{ - int previousSize = m_results.size(); - if (previousSize < n) { - // Resize the list - m_results.reserve(n); - for (int i = previousSize; i < n; ++i) { - auto oa = new OperatorResult(this); - m_results.append(oa); - } - } else { - for (int i = n; i < previousSize; ++i) { - auto result = m_results.takeLast(); - delete result; - } - } -} - -int Operator::numberOfResults() const -{ - return m_results.size(); -} - -bool Operator::setResult(int index, vtkDataObject* object) -{ - if (index < 0 || index >= numberOfResults()) { - return false; - } - - auto result = m_results[index]; - Q_ASSERT(result); - result->setDataObject(object); - m_results[index] = result; - - return true; -} - -bool Operator::setResult(const char* name, vtkDataObject* object) -{ - bool valueSet = false; - QString qname(name); - foreach (auto result, m_results) { - if (result->name() == qname) { - result->setDataObject(object); - valueSet = true; - break; - } - } - - return valueSet; -} - -OperatorResult* Operator::resultAt(int i) const -{ - if (i < 0 || i >= m_results.size()) { - return nullptr; - } - return m_results[i]; -} - -void Operator::setHasChildDataSource(bool value) -{ - m_hasChildDataSource = value; -} - -bool Operator::hasChildDataSource() const -{ - return m_hasChildDataSource; -} - -void Operator::setChildDataSource(DataSource* source) -{ - ModuleManager::instance().addChildDataSource(source); - m_childDataSource = source; -} - -DataSource* Operator::childDataSource() const -{ - return m_childDataSource; -} - -QJsonObject Operator::serialize() const -{ - QJsonObject json; - if (childDataSource()) { - QJsonArray dataSources; - DataSource* ds = childDataSource(); - dataSources.append(ds->serialize()); - json["dataSources"] = dataSources; - } - json["type"] = OperatorFactory::instance().operatorType(this); - json["id"] = QString::asprintf("%p", static_cast(this)); - if (m_breakpoint) { - json["breakpoint"] = true; - } - - return json; -} - -bool Operator::deserialize(const QJsonObject& json) -{ - if (json.contains("breakpoint")) { - m_breakpoint = json["breakpoint"].toBool(); - } - - return true; -} - -EditOperatorDialog* Operator::customDialog() const -{ - return m_customDialog; -} - -void Operator::setCustomDialog(EditOperatorDialog* dialog) -{ - if (m_customDialog) { - std::cerr << "Error: Attempting to set custom dialog on an operator that " - "already has one!"; - return; - } - - m_customDialog = dialog; -} - -void Operator::createNewChildDataSource( - const QString& label, vtkSmartPointer childData, - DataSource::DataSourceType type, DataSource::PersistenceState state) -{ - if (this->childDataSource() == nullptr) { - DataSource* childDS = - new DataSource(vtkImageData::SafeDownCast(childData), type, - this->dataSource()->pipeline(), state); - childDS->setLabel(label); - setChildDataSource(childDS); - setHasChildDataSource(true); - emit Operator::newChildDataSource(childDS); - } - // Reuse the existing "Output" data source. - else { - childDataSource()->setData(childData); - childDataSource()->setLabel(label); - childDataSource()->setForkable(true); - childDataSource()->dataModified(); - setHasChildDataSource(true); - } -} - -void Operator::setBreakpoint(bool enabled) -{ - if (m_breakpoint != enabled) { - m_breakpoint = enabled; - emit breakpointChanged(); - } -} - -void Operator::cancelTransform() -{ - m_state = OperatorState::Canceled; - emit transformCanceled(); -} - -void Operator::completionTransform() -{ - m_state = OperatorState::Complete; - emit transformCompleted(); -} -} // namespace tomviz diff --git a/tomviz/operators/Operator.h b/tomviz/operators/Operator.h deleted file mode 100644 index 883b338e2..000000000 --- a/tomviz/operators/Operator.h +++ /dev/null @@ -1,330 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperator_h -#define tomvizOperator_h - -#include - -#include -#include -#include - -#include -#include -#include -#include - -#include "DataSource.h" - - -class vtkImageData; -class QWidget; - -Q_DECLARE_METATYPE(vtkSmartPointer) - -namespace tomviz { -class EditOperatorWidget; -class OperatorResult; -class EditOperatorDialog; - -enum class OperatorState -{ - Queued, - Running, - Complete, - Canceled, - Error, - Edit -}; - -enum class TransformResult -{ - Complete = static_cast(OperatorState::Complete), - Canceled = static_cast(OperatorState::Canceled), - Error = static_cast(OperatorState::Error) -}; - -class Operator : public QObject -{ - Q_OBJECT - -public: - Operator(QObject* parent = nullptr); - ~Operator() override; - - /// Returns the data source the operator operates on - DataSource* dataSource(); - - /// Returns a label for this operator. - virtual QString label() const = 0; - - /// Returns an icon to use for this operator. - virtual QIcon icon() const = 0; - - TransformResult transform(vtkDataObject* data); - - /// Return a new clone. - virtual Operator* clone() const = 0; - - /// Set the number of results produced by this operator. - virtual void setNumberOfResults(int n); - - /// Get number of output results - virtual int numberOfResults() const; - - /// Set the result at the given index to the object. - virtual bool setResult(int index, vtkDataObject* object); - - /// Set the result with the given name to the object. - virtual bool setResult(const char* name, vtkDataObject* object); - - /// Get output result at index. - virtual OperatorResult* resultAt(int index) const; - - /// Set whether the operator is expected to produce a child DataSource. - virtual void setHasChildDataSource(bool value); - - /// Get whether the operator is expected to produce a child DataSource. - virtual bool hasChildDataSource() const; - - /// Set the child DataSource. Can be nullptr. - virtual void setChildDataSource(DataSource* source); - - /// Get the child DataSource. - virtual DataSource* childDataSource() const; - - /// Save/Restore state. - virtual QJsonObject serialize() const; - virtual bool deserialize(const QJsonObject& json); - - /// There are two versions of this function, this one and - /// getEditorContentsWithData. Subclasses should override this one if their - /// editors do not need the previous state of the data. Subclasses should - /// override the other if they need the data just prior to this Operator for - /// the widget to display correctly. If this data is needed, the default - /// implementation to return nullptr should be left for this function. - virtual EditOperatorWidget* getEditorContents(QWidget* vtkNotUsed(parent)) - { - return nullptr; - } - - /// Should return a widget for editing customizable parameters on this - /// operator or nullptr if there is nothing to edit. The vtkImageData - /// is a copy of the DataSource's image with all Operators prior in the - /// pipeline applied to it. This should be used if the widget needs to - /// display the VTK data, but modifications to it will not affect the - /// DataSource. - virtual EditOperatorWidget* getEditorContentsWithData( - QWidget* parent_, - vtkSmartPointer vtkNotUsed(inputDataForDisplay)) - { - return this->getEditorContents(parent_); - } - - /// Should return true if the Operator has a non-null widget to return from - /// getEditorContents. - virtual bool hasCustomUI() const { return false; } - - /// If this operator has a dialog active, this should return that dialog (the - /// dialog will register itself using setCustomDialog in its constructor). - /// Otherwise this will return nullptr. - EditOperatorDialog* customDialog() const; - - /// Set the custom dialog associated with this operator - void setCustomDialog(EditOperatorDialog*); - - /// If the operator has some custom progress UI, then return that UI from this - /// function. This custom UI must be parented to the given widget and should - /// have its signal/slot connections set up to show the progress of the - /// operator. If there is no need for custom progress UI, then leave this - /// default implementation and a default QProgressBar will be created instead. - virtual QWidget* getCustomProgressWidget(QWidget*) const { return nullptr; } - - /// Returns true if the operation supports canceling midway through the - /// applyTransform function via the cancelTransform slot. Defaults to false, - /// can be set by the setSupportsCancel(bool) method by subclasses. - bool supportsCancelingMidTransform() const { return m_supportsCancel; } - - /// Returns true if the operation supports early completion midway through the - /// applyTransform function via the cancelTransform slot. - /// Defaults to false, can be set by the setSupportsCompletion(bool) - /// method by subclasses. - bool supportsCompletionMidTransform() const { return m_supportsCompletion; } - - /// Return the total number of progress updates (assuming each update - /// increments the progress from 0 to some maximum. If the operator doesn't - /// support incremental progress updates, leave value set to zero - /// that QProgressBar interprets the progress as unknown. - int totalProgressSteps() const { return m_totalProgressSteps; } - - /// Set the total number of progress steps - void setTotalProgressSteps(int steps) - { - m_totalProgressSteps = steps; - emit totalProgressStepsChanged(steps); - } - - /// Returns the current progress step - int progressStep() const { return m_progressStep; } - - /// Set the current progress step - void setProgressStep(int step) - { - m_progressStep = step; - emit progressStepChanged(step); - } - - /// Returns the current progress message - QString progressMessage() const { return m_progressMessage; } - - /// Set the current progress message which will appear in the progress dialog - /// title. - void setProgressMessage(const QString& message) - { - m_progressMessage = message; - emit progressMessageChanged(message); - } - - /// Set the operator state, this is needed for external execution. - void setState(OperatorState state) { m_state = state; } - - /// Get/set whether a breakpoint is set on this operator. - bool hasBreakpoint() const { return m_breakpoint; } - void setBreakpoint(bool enabled); - - /// Get the operator's help url - QString helpUrl() const { return m_helpUrl; } - void setHelpUrl(const QString& s) { m_helpUrl = s; } - -signals: - /// Emit this signal with the operation is updated/modified - /// implying that the data needs to be reprocessed. - void transformModified(); - - /// Emit this signal to indicate that the operator's label changed - /// and the GUI needs to refresh its display of the Operator. - void labelModified(); - - /// Emitted to indicate that the progress step has changed. - void progressStepChanged(int); - - /// Emitted to indicate that the progress message has changed. - void progressMessageChanged(const QString& message); - - /// Emitted when the operator starts transforming the data - void transformingStarted(); - - /// Emitted when the operator is done transforming the data. The parameter is - /// the return value from the transform() function. True for success, false - /// for failure. - void transformingDone(TransformResult result); - - /// Emitted when an result is added. - void resultAdded(OperatorResult* result); - - /// Emitted when the total progress steps has changed. - void totalProgressStepsChanged(int steps); - - /// Emitted when a child data source is create by this operator. - void newChildDataSource(DataSource*); - - // Signal used to request the creation of a new data source. Needed to - // ensure the initialization of the new DataSource is performed on UI thread - void newChildDataSource(const QString&, vtkSmartPointer); - - /// Emitted just prior to this object's destruction. - void aboutToBeDestroyed(Operator* op); - - // Emitted when a data source is move to a new operator. For example when - // a new operator is added. - void dataSourceMoved(DataSource*); - - // Emitted when the operator is moved into the canceled state. Note at this - // point the operator may still be running. This is used to indicate that - // a request to cancel this operator has been issued. - void transformCanceled(); - - // Emitted when the operator is accepted before reaching a completed - // transform. The operator will still be running, so there has to be - // a request to cancel the operator as well as encourage the next - // operator to run. - void transformCompleted(); - - // Emitted when the breakpoint state changes. - void breakpointChanged(); - -public slots: - /// Called when the 'Cancel' button is pressed on the progress dialog. - /// Subclasses overriding this method should call the base implementation - /// to ensure the operator is marked as canceled. - /// TODO: Not sure we need this/want this virtual anymore? - virtual void cancelTransform(); - virtual void completionTransform(); - bool isCanceled() { return m_state == OperatorState::Canceled; } - - /// Distinction between this and isFinished is necessary to prevent cascading - /// errors - bool isCompleted() { return m_state == OperatorState::Complete; } - bool failed() { return m_state == OperatorState::Error; } - bool isFinished() - { - return m_state == OperatorState::Complete || - m_state == OperatorState::Error; - }; - bool isModified() { return m_modified; } - bool isNew() { return m_new; } - bool isEditing() { return m_state == OperatorState::Edit; } - bool isQueued() { return m_state == OperatorState::Queued; } - - OperatorState state() { return m_state; } - void setModified() { m_modified = true; } - void resetState() { m_state = OperatorState::Queued; } - void setEditing() { m_state = OperatorState::Edit; } - void setComplete() { m_state = OperatorState::Complete; } - -protected slots: - // Create a new child datasource and set it on this operator - void createNewChildDataSource( - const QString& label, vtkSmartPointer, - DataSource::DataSourceType type = DataSource::DataSourceType::Volume, - DataSource::PersistenceState state = - DataSource::PersistenceState::Transient); - -protected: - /// Method to transform a dataset in-place. - virtual bool applyTransform(vtkDataObject* data) = 0; - - /// Method to set whether the operator supports canceling midway through the - /// transform method call. If you set this to true, you should also override - /// the cancelTransform slot to listen for the cancel signal and handle it. - void setSupportsCancel(bool b) { m_supportsCancel = b; } - - /// Method to set whether the operator supports early completion midway - /// through the transform method call. - /// If you set this to true, you should also override the - /// completionTransform slot to listen for the done signal and handle - /// it. - void setSupportsCompletion(bool b) { m_supportsCompletion = b; } - -private: - Q_DISABLE_COPY(Operator) - - QList m_results; - bool m_supportsCancel = false; - bool m_supportsCompletion = false; - bool m_hasChildDataSource = false; - bool m_modified = true; - bool m_new = true; - QPointer m_childDataSource; - int m_totalProgressSteps = 0; - int m_progressStep = 0; - QString m_progressMessage; - QString m_helpUrl; - bool m_breakpoint = false; - std::atomic m_state{ OperatorState::Queued }; - QPointer m_customDialog; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/OperatorDialog.cxx b/tomviz/operators/OperatorDialog.cxx deleted file mode 100644 index 2ca851d66..000000000 --- a/tomviz/operators/OperatorDialog.cxx +++ /dev/null @@ -1,37 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorDialog.h" - -#include "OperatorWidget.h" - -#include -#include - -namespace tomviz { - -OperatorDialog::OperatorDialog(QWidget* parentObject) : Superclass(parentObject) -{ - m_ui = new OperatorWidget(this); - QVBoxLayout* layout = new QVBoxLayout(this); - QDialogButtonBox* buttons = new QDialogButtonBox( - QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, this); - connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - this->setLayout(layout); - layout->addWidget(m_ui); - layout->addWidget(buttons); -} - -OperatorDialog::~OperatorDialog() {} - -void OperatorDialog::setJSONDescription(const QString& json) -{ - m_ui->setupUI(json); -} - -QMap OperatorDialog::values() const -{ - return m_ui->values(); -} -} // namespace tomviz diff --git a/tomviz/operators/OperatorDialog.h b/tomviz/operators/OperatorDialog.h deleted file mode 100644 index 2875194b4..000000000 --- a/tomviz/operators/OperatorDialog.h +++ /dev/null @@ -1,37 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorDialog_h -#define tomvizOperatorDialog_h - -#include -#include -#include -#include - -namespace tomviz { - -class OperatorWidget; - -class OperatorDialog : public QDialog -{ - Q_OBJECT - typedef QDialog Superclass; - -public: - OperatorDialog(QWidget* parent = nullptr); - ~OperatorDialog() override; - - /// Set the JSON description of the operator - void setJSONDescription(const QString& json); - - /// Get parameter values - QMap values() const; - -private: - Q_DISABLE_COPY(OperatorDialog) - OperatorWidget* m_ui = nullptr; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/OperatorFactory.cxx b/tomviz/operators/OperatorFactory.cxx deleted file mode 100644 index 84607c121..000000000 --- a/tomviz/operators/OperatorFactory.cxx +++ /dev/null @@ -1,143 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorFactory.h" - -#include "ArrayWranglerOperator.h" -#include "ConvertToFloatOperator.h" -#include "ConvertToVolumeOperator.h" -#include "CropOperator.h" -#include "OperatorPython.h" -#include "ReconstructionOperator.h" -#include "SetTiltAnglesOperator.h" -#include "SnapshotOperator.h" -#include "TranslateAlignOperator.h" -#include "TransposeDataOperator.h" -#include -#include - -namespace tomviz { - -// QList OperatorFactory::pythonOperators; - -OperatorFactory::OperatorFactory() = default; - -OperatorFactory::~OperatorFactory() = default; - -OperatorFactory& OperatorFactory::instance() -{ - static OperatorFactory theInstance; - return theInstance; -} - -QList OperatorFactory::operatorTypes() -{ - QList reply; - reply << "ArrayWrangler" - << "ConvertToFloat" - << "ConvertToVolume" - << "Crop" - << "CxxReconstruction" - << "Python" - << "SetTiltAngles" - << "Snapshot" - << "TranslateAlign" - << "TransposeData"; - return reply; -} - -Operator* OperatorFactory::createConvertToVolumeOperator( - DataSource::DataSourceType t) -{ - if (t == DataSource::Volume) { - return new ConvertToVolumeOperator(nullptr, t, "Mark as Volume"); - } else if (t == DataSource::FIB) { - return new ConvertToVolumeOperator(nullptr, t, "Mark as Focused Ion Beam"); - } - return nullptr; -} - -Operator* OperatorFactory::createOperator(const QString& type, DataSource* ds) -{ - - Operator* op = nullptr; - if (type == "Python") { - op = new OperatorPython(ds); - } else if (type == "ArrayWrangler") { - op = new ArrayWranglerOperator(ds); - } else if (type == "ConvertToFloat") { - op = new ConvertToFloatOperator(ds); - } else if (type == "ConvertToVolume") { - op = new ConvertToVolumeOperator(ds); - } else if (type == "Crop") { - op = new CropOperator(ds); - } else if (type == "CxxReconstruction") { - op = new ReconstructionOperator(ds); - } else if (type == "SetTiltAngles") { - op = new SetTiltAnglesOperator(ds); - } else if (type == "TranslateAlign") { - op = new TranslateAlignOperator(ds); - } else if (type == "TransposeData") { - op = new TransposeDataOperator(ds); - } else if (type == "Snapshot") { - op = new SnapshotOperator(ds); - } - return op; -} - -const char* OperatorFactory::operatorType(const Operator* op) -{ - if (qobject_cast(op)) { - return "Python"; - } - if (qobject_cast(op)) { - return "ConvertToVolume"; - } - if (qobject_cast(op)) { - return "ArrayWrangler"; - } - if (qobject_cast(op)) { - return "ConvertToFloat"; - } - if (qobject_cast(op)) { - return "Crop"; - } - if (qobject_cast(op)) { - return "CxxReconstruction"; - } - if (qobject_cast(op)) { - return "SetTiltAngles"; - } - if (qobject_cast(op)) { - return "TranslateAlign"; - } - if (qobject_cast(op)) { - return "TransposeData"; - } - if (qobject_cast(op)) { - return "Snapshot"; - } - return nullptr; -} - -void OperatorFactory::registerPythonOperator( - const QString& label, const QString& source, bool requiresTiltSeries, - bool requiresVolume, bool requiresFib, const QString& json) -{ - PythonOperatorInfo info; - info.label = label; - info.source = source; - info.requiresTiltSeries = requiresTiltSeries; - info.requiresVolume = requiresVolume; - info.requiresFib = requiresFib; - info.json = json; - - m_pythonOperators.append(info); -} - -const QList& OperatorFactory::registeredPythonOperators() -{ - return m_pythonOperators; -} - -} // namespace tomviz diff --git a/tomviz/operators/OperatorFactory.h b/tomviz/operators/OperatorFactory.h deleted file mode 100644 index 1efb35dd6..000000000 --- a/tomviz/operators/OperatorFactory.h +++ /dev/null @@ -1,60 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorFactory_h -#define tomvizOperatorFactory_h - -#include - -#include "DataSource.h" - -namespace tomviz { -class Operator; - -struct PythonOperatorInfo -{ - QString label; - QString source; - bool requiresTiltSeries; - bool requiresVolume; - bool requiresFib; - QString json; -}; - -class OperatorFactory -{ - typedef QObject Superclass; - -public: - static OperatorFactory& instance(); - - /// Returns a list of module types - QList operatorTypes(); - - Operator* createConvertToVolumeOperator( - DataSource::DataSourceType t = DataSource::Volume); - - /// Creates an operator of the given type - Operator* createOperator(const QString& type, DataSource* ds); - - /// Returns the type for an operator instance. - const char* operatorType(const Operator* module); - - /// Register a Python operator - void registerPythonOperator(const QString& label, const QString& source, - bool requiresTiltSeries, bool requiresVolume, - bool requiresFib, const QString& json); - - /// Returns the list of register Python operators - const QList& registeredPythonOperators(); - -private: - OperatorFactory(); - ~OperatorFactory(); - Q_DISABLE_COPY(OperatorFactory) - - QList m_pythonOperators; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/OperatorPropertiesPanel.cxx b/tomviz/operators/OperatorPropertiesPanel.cxx deleted file mode 100644 index cce65948e..000000000 --- a/tomviz/operators/OperatorPropertiesPanel.cxx +++ /dev/null @@ -1,151 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorPropertiesPanel.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "Operator.h" -#include "OperatorWidget.h" -#include "Pipeline.h" -#include "Utilities.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -OperatorPropertiesPanel::OperatorPropertiesPanel(QWidget* p) : QWidget(p) -{ - // Show active module in the "Operator Properties" panel. - connect(&ActiveObjects::instance(), &ActiveObjects::operatorActivated, this, - [this](Operator* op) { setOperator(op); }); - - // Set up a very simple layout with a description label widget. - m_layout = new QVBoxLayout; - setLayout(m_layout); -} - -OperatorPropertiesPanel::~OperatorPropertiesPanel() = default; - -void OperatorPropertiesPanel::setOperator(Operator* op) -{ - if (m_activeOperator) { - disconnect(m_activeOperator, &Operator::labelModified, nullptr, nullptr); - } - deleteLayoutContents(m_layout); - m_operatorWidget = nullptr; - if (op) { - // See if we are dealing with a Python operator - OperatorPython* pythonOperator = qobject_cast(op); - if (pythonOperator) { - setOperator(pythonOperator); - } else { - auto description = new QLabel(op->label()); - layout()->addWidget(description); - connect(op, &Operator::labelModified, this, [this, description]() { - description->setText(m_activeOperator->label()); - }); - } - - m_layout->addStretch(); - } - - m_activeOperator = op; -} - -void OperatorPropertiesPanel::setOperator(OperatorPython* op) -{ - auto buttonLayout = new QHBoxLayout; - auto viewCodeButton = new QPushButton("View Code", this); - - connect(viewCodeButton, &QAbstractButton::clicked, this, - &OperatorPropertiesPanel::viewCodePressed); - buttonLayout->addWidget(viewCodeButton); - - m_operatorWidget = new OperatorWidget(this); - m_operatorWidget->setupUI(op); - - // Check if we have any UI for this operator, there is probably a nicer - // way todo this. - if (!m_operatorWidget->layout() || m_operatorWidget->layout()->count() == 0) { - m_operatorWidget->deleteLater(); - m_operatorWidget = nullptr; - } else { - // For now add to scroll box, our operator widget tend to be a little - // wide! - auto scroll = new QScrollArea(this); - scroll->setWidget(m_operatorWidget); - scroll->setWidgetResizable(true); - - m_layout->addWidget(scroll); - - auto apply = - new QDialogButtonBox(QDialogButtonBox::Apply, Qt::Horizontal, this); - connect(apply, &QDialogButtonBox::clicked, this, - &OperatorPropertiesPanel::apply); - - buttonLayout->addWidget(apply); - } - m_layout->addItem(buttonLayout); -} - -void OperatorPropertiesPanel::apply() -{ - if (m_operatorWidget) { - auto values = m_operatorWidget->values(); - OperatorPython* pythonOperator = - qobject_cast(m_activeOperator); - if (pythonOperator) { - DataSource* dataSource = - qobject_cast(pythonOperator->parent()); - if (dataSource->pipeline()->isRunning()) { - auto result = QMessageBox::question( - this, "Cancel running operation?", - "Applying changes to an operator that is part of a running pipeline " - "will cancel the current running operator and restart the pipeline " - "run. Proceed anyway?"); - // FIXME There is still a concurrency issue here if the background - // thread running the operator finishes and the finished event is queued - // behind the question() return event above. If that happens then we - // will not get a canceled() event and the pipeline will stay paused. - if (result == QMessageBox::No) { - return; - } else { - auto whenCanceled = [pythonOperator, dataSource]() { - // Resume the pipeline and emit transformModified - dataSource->pipeline()->resume(); - emit pythonOperator->transformModified(); - }; - // We pause the pipeline so applyChangesToOperator does cause it to - // execute. - dataSource->pipeline()->pause(); - // We do this before causing cancel so the values are in place for - // when - // whenCanceled cause the pipeline to be re-executed. - pythonOperator->setArguments(values); - if (dataSource->pipeline()->isRunning()) { - dataSource->pipeline()->cancel(whenCanceled); - } else { - whenCanceled(); - } - } - } else { - pythonOperator->setArguments(values); - } - } - } -} - -void OperatorPropertiesPanel::viewCodePressed() -{ - EditOperatorDialog::showDialogForOperator(m_activeOperator, - QStringLiteral("viewCode")); -} -} // namespace tomviz diff --git a/tomviz/operators/OperatorPropertiesPanel.h b/tomviz/operators/OperatorPropertiesPanel.h deleted file mode 100644 index 282f983dd..000000000 --- a/tomviz/operators/OperatorPropertiesPanel.h +++ /dev/null @@ -1,42 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorPropertiesPanel_h -#define tomvizOperatorPropertiesPanel_h - -#include - -#include - -class QLabel; -class QVBoxLayout; - -namespace tomviz { -class Operator; -class OperatorPython; -class OperatorWidget; - -class OperatorPropertiesPanel : public QWidget -{ - Q_OBJECT - -public: - OperatorPropertiesPanel(QWidget* parent = nullptr); - virtual ~OperatorPropertiesPanel(); - -private slots: - void setOperator(Operator*); - void setOperator(OperatorPython*); - void apply(); - void viewCodePressed(); - -private: - Q_DISABLE_COPY(OperatorPropertiesPanel) - - QPointer m_activeOperator = nullptr; - QVBoxLayout* m_layout = nullptr; - OperatorWidget* m_operatorWidget = nullptr; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/OperatorProxy.cxx b/tomviz/operators/OperatorProxy.cxx deleted file mode 100644 index 0f332ad10..000000000 --- a/tomviz/operators/OperatorProxy.cxx +++ /dev/null @@ -1,76 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorProxy.h" - -#include "core/PythonFactory.h" - -#include "OperatorPython.h" - -#include - -namespace tomviz { - -OperatorProxy::OperatorProxy(void* o) : OperatorProxyBase(o) -{ - m_op = static_cast(o); -} - -bool OperatorProxy::canceled() -{ - return m_op->isCanceled(); -} - -bool OperatorProxy::completed() -{ - return m_op->isCompleted(); -} - -void OperatorProxy::setTotalProgressSteps(int progress) -{ - m_op->setTotalProgressSteps(progress); -} - -int OperatorProxy::totalProgressSteps() -{ - return m_op->totalProgressSteps(); -} - -void OperatorProxy::setProgressStep(int progress) -{ - m_op->setProgressStep(progress); -} - -int OperatorProxy::progressStep() -{ - return m_op->progressStep(); -} - -void OperatorProxy::setProgressMessage(const std::string& message) -{ - QString msg = QString::fromStdString(message); - m_op->setProgressMessage(msg); -} - -std::string OperatorProxy::progressMessage() -{ - return m_op->progressMessage().toStdString(); -} - -void OperatorProxy::setProgressData(vtkImageData* imageData) -{ - TemporarilyReleaseGil releaseMe; - emit m_op->childDataSourceUpdated(imageData); -} - -OperatorProxyBase* OperatorProxyFactory::create(void* o) -{ - return new OperatorProxy(o); -} - -void OperatorProxyFactory::registerWithFactory() -{ - PythonFactory::instance().setOperatorProxyFactory(new OperatorProxyFactory); -} - -} // namespace tomviz diff --git a/tomviz/operators/OperatorProxy.h b/tomviz/operators/OperatorProxy.h deleted file mode 100644 index 0950c8ca4..000000000 --- a/tomviz/operators/OperatorProxy.h +++ /dev/null @@ -1,50 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorProxy_h -#define tomvizOperatorProxy_h - -#include "core/OperatorProxyBase.h" - -namespace tomviz { - -class OperatorPython; - -/** Pure virtual base class providing a proxy to the operator class. */ -class OperatorProxy : public OperatorProxyBase -{ -public: - OperatorProxy(void* o); - - bool canceled() override; - - bool completed() override; - - void setTotalProgressSteps(int progress) override; - - int totalProgressSteps() override; - - void setProgressStep(int progress) override; - - int progressStep() override; - - void setProgressMessage(const std::string& message) override; - - std::string progressMessage() override; - - void setProgressData(vtkImageData* object) override; - -private: - OperatorPython* m_op = nullptr; -}; - -class OperatorProxyFactory : public OperatorProxyBaseFactory -{ -public: - OperatorProxyBase* create(void* o) override; - static void registerWithFactory(); -}; - -} // namespace tomviz - -#endif \ No newline at end of file diff --git a/tomviz/operators/OperatorPython.cxx b/tomviz/operators/OperatorPython.cxx deleted file mode 100644 index 239474087..000000000 --- a/tomviz/operators/OperatorPython.cxx +++ /dev/null @@ -1,910 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorPython.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "ActiveObjects.h" -#include "CustomPythonOperatorWidget.h" -#include "DataSource.h" -#include "EditOperatorWidget.h" -#include "ModuleManager.h" -#include "OperatorResult.h" -#include "OperatorWidget.h" -#include "Pipeline.h" -#include "PythonUtilities.h" -#include "Utilities.h" -#include "pqPythonSyntaxHighlighter.h" - -#include "vtkDataObject.h" -#include "vtkImageData.h" -#include "vtkNew.h" -#include "vtkPointData.h" -#include "vtkSMParaViewPipelineController.h" -#include "vtkSMProxy.h" -#include "vtkSMProxyManager.h" -#include "vtkSMSessionProxyManager.h" -#include "vtkSMSourceProxy.h" -#include "vtkTrivialProducer.h" - -#include "ui_EditPythonOperatorWidget.h" - -namespace { - -class EditPythonOperatorWidget : public tomviz::EditOperatorWidget -{ - Q_OBJECT - -public: - EditPythonOperatorWidget( - QWidget* p, tomviz::OperatorPython* o, - tomviz::CustomPythonOperatorWidget* customWidget = nullptr) - : tomviz::EditOperatorWidget(p), m_op(o), m_ui(), - m_customWidget(customWidget), m_opWidget(nullptr) - { - m_ui.setupUi(this); - m_ui.name->setText(o->label()); - // Ensure the tab widget expands to fill available vertical space. - m_ui.verticalLayout->setStretch(1, 1); - auto* highlighter = - new pqPythonSyntaxHighlighter(m_ui.script, *m_ui.script); - highlighter->ConnectHighligter(); - if (!o->script().isEmpty()) { - m_ui.script->setPlainText(o->script()); - } - if (customWidget) { - QVBoxLayout* layout = new QVBoxLayout(); - m_customWidget->setupUI(m_op); - m_customWidget->setValues(m_op->arguments()); - layout->addWidget(m_customWidget, 1); - m_ui.argumentsWidget->setLayout(layout); - } else { - QVBoxLayout* layout = new QVBoxLayout(); - m_opWidget = new tomviz::OperatorWidget(this); - m_opWidget->setupUI(m_op); - layout->addWidget(m_opWidget); - layout->addStretch(); - m_ui.argumentsWidget->setLayout(layout); - } - - onScriptModified(); - setupConnections(); - } - - void setupConnections() - { - connect(m_ui.script, &QTextEdit::textChanged, this, - &EditPythonOperatorWidget::onScriptModified); - connect(m_ui.saveScript, &QPushButton::clicked, this, - &EditPythonOperatorWidget::saveScript); - } - - void setViewMode(const QString& mode) override - { - if (mode == QStringLiteral("viewCode")) { - m_ui.tabWidget->setCurrentWidget(m_ui.scriptTab); - } - } - - void applyChangesToOperator() override - { - if (m_op) { - m_op->setLabel(m_ui.name->text()); - m_op->setScript(m_ui.script->toPlainText()); - if (m_customWidget) { - QMap args; - m_customWidget->getValues(args); - m_op->setArguments(args); - m_customWidget->writeSettings(); - } else if (m_opWidget) { - QMap args = m_opWidget->values(); - m_op->setArguments(args); - } - } - } - - void onScriptModified() - { - // Update any objects that need an up-to-date script... - if (m_customWidget) { - m_customWidget->setScript(m_ui.script->toPlainText()); - } - } - - void saveScript() - { - QString name = QFileDialog::getSaveFileName( - this, "Save Script", tomviz::userDataPath(), "Python scripts (*.py)"); - if (name.isEmpty()) { - return; - } - - QFile file(name, this); - if (file.exists()) { - file.remove(); - } - - if (!file.open(QIODevice::WriteOnly)) { - auto msg = QString("Failed to open file: %1").arg(name); - QMessageBox::critical(this, "Failed to save script", msg); - qDebug() << msg; - return; - } - - if (file.write(m_ui.script->toPlainText().toLatin1()) == -1) { - auto msg = QString("Failed to write to file: %1").arg(name); - QMessageBox::critical(this, "Failed to save script", msg); - qDebug() << msg; - file.close(); - return; - } - - file.close(); - - // If there is a JSON description, and the file does not exist, - // write that out too. - if (m_op->JSONDescription().isEmpty()) { - return; - } - - // Use the same name with a ".json" extension. - QFileInfo info(name); - auto descriptionFilename = info.path() + "/" + info.baseName() + ".json"; - QFile descriptionFile(descriptionFilename, this); - if (descriptionFile.exists()) { - // Check if the user wants to overwrite it. - auto title = QString("Overwrite description file?"); - auto msg = - QString("%1 exists.\n\nOverwrite it?").arg(descriptionFilename); - auto response = QMessageBox::question(this, title, msg); - if (response == QMessageBox::No) { - // Do not overwrite - return; - } - } - - if (!descriptionFile.open(QIODevice::WriteOnly)) { - auto msg = QString("Failed to open %1").arg(descriptionFilename); - QMessageBox::critical(this, "Failed to save description", msg); - qDebug() << msg; - return; - } - - if (descriptionFile.write(m_op->JSONDescription().toLatin1()) == -1) { - auto msg = - QString("Failed to write to file: %1").arg(descriptionFilename); - QMessageBox::critical(this, "Failed to save description", msg); - qDebug() << msg; - }; - - descriptionFile.close(); - } - -private: - QPointer m_op; - Ui::EditPythonOperatorWidget m_ui; - tomviz::CustomPythonOperatorWidget* m_customWidget; - tomviz::OperatorWidget* m_opWidget; -}; - -QMap> - CustomWidgetMap; -} // namespace - -namespace tomviz { - -void OperatorPython::registerCustomWidget(const QString& key, bool needsData, - CustomWidgetFunction func) -{ - CustomWidgetMap.insert(key, - QPair(needsData, func)); -} - -class OperatorPython::OPInternals -{ -public: - Python::Module OperatorModule; - Python::Module TransformModule; - Python::Function TransformMethod; - Python::Module InternalModule; - Python::Function FindTransformFunction; - Python::Function IsCancelableFunction; - Python::Function IsCompletableFunction; - Python::Function DeleteModuleFunction; - Python::Function TransformMethodWrapper; -}; - -OperatorPython::OperatorPython(DataSource* parentObject) - : Operator(parentObject), d(new OperatorPython::OPInternals()), - m_label("Python Operator") -{ - Python::initialize(); - - { - Python python; - d->OperatorModule = python.import("tomviz.utils"); - if (!d->OperatorModule.isValid()) { - qCritical() << "Failed to import tomviz.utils module."; - } - - d->InternalModule = python.import("tomviz._internal"); - if (!d->InternalModule.isValid()) { - qCritical() << "Failed to import tomviz._internal module."; - } - - d->IsCancelableFunction = d->InternalModule.findFunction("is_cancelable"); - if (!d->IsCancelableFunction.isValid()) { - qCritical() << "Unable to locate is_cancelable."; - } - - d->IsCompletableFunction = d->InternalModule.findFunction("is_completable"); - if (!d->IsCompletableFunction.isValid()) { - qCritical() << "Unable to locate is_completable."; - } - - d->FindTransformFunction = - d->InternalModule.findFunction("find_transform_function"); - if (!d->FindTransformFunction.isValid()) { - qCritical() << "Unable to locate find_transform_function."; - } - - d->DeleteModuleFunction = d->InternalModule.findFunction("delete_module"); - if (!d->DeleteModuleFunction.isValid()) { - qCritical() << "Unable to locate delete_module."; - } - - d->TransformMethodWrapper = d->InternalModule.findFunction("transform_method_wrapper"); - if (!d->TransformMethodWrapper.isValid()) { - qCritical() << "Unable to locate transform_method_wrapper."; - } - } - - auto connectionType = Qt::BlockingQueuedConnection; - if (dataSource() != nullptr && dataSource()->pipeline() != nullptr && - dataSource()->pipeline()->executionMode() != - Pipeline::ExecutionMode::Threaded) { - connectionType = Qt::DirectConnection; - } - // Needed so the worker thread can update data in the UI thread. - connect(this, &OperatorPython::childDataSourceUpdated, this, - QOverload>::of( - &OperatorPython::updateChildDataSource), - connectionType); - - // This connection is needed so we can create new child data sources in the UI - // thread from a pipeline worker threads. - connect( - this, - QOverload>::of( - &OperatorPython::newChildDataSource), - this, - [this](const QString& label, vtkSmartPointer data) { - createNewChildDataSource(label, data); - }, - connectionType); - connect(this, &OperatorPython::newOperatorResult, this, - &OperatorPython::setOperatorResult); -} - -OperatorPython::~OperatorPython() {} - -void OperatorPython::setLabel(const QString& txt) -{ - m_label = txt; - emit labelModified(); -} - -QIcon OperatorPython::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqProgrammableFilter.svg"); -} - -void OperatorPython::setJSONDescription(const QString& str) -{ - if (m_jsonDescription == str) { - return; - } - - m_jsonDescription = str; - - auto document = QJsonDocument::fromJson(m_jsonDescription.toLatin1()); - if (!document.isObject()) { - qCritical() << "Failed to parse operator JSON"; - qCritical() << m_jsonDescription; - return; - } - - QJsonObject root = document.object(); - - // Get the label for the operator - QJsonValueRef labelNode = root["label"]; - if (!labelNode.isUndefined() && !labelNode.isNull()) { - setLabel(labelNode.toString()); - } - - QJsonValueRef widgetNode = root["widget"]; - if (!widgetNode.isUndefined() && !widgetNode.isNull()) { - m_customWidgetID = widgetNode.toString(); - } - - m_resultNames.clear(); - - // Get the number of results - QJsonValueRef resultsNode = root["results"]; - if (!resultsNode.isUndefined() && !resultsNode.isNull()) { - QJsonArray resultsArray = resultsNode.toArray(); - QJsonObject::size_type numResults = resultsArray.size(); - setNumberOfResults(numResults); - - for (QJsonObject::size_type i = 0; i < numResults; ++i) { - OperatorResult* oa = resultAt(i); - if (!oa) { - Q_ASSERT(oa != nullptr); - } - QJsonObject resultNode = resultsArray[i].toObject(); - QJsonValueRef nameValue = resultNode["name"]; - if (!nameValue.isUndefined() && !nameValue.isNull()) { - oa->setName(nameValue.toString()); - m_resultNames.append(nameValue.toString()); - } - QJsonValueRef labelValue = resultNode["label"]; - if (!labelValue.isUndefined() && !labelValue.isNull()) { - oa->setLabel(labelValue.toString()); - } - } - } - - // Get the number of parameters - QJsonValueRef parametersNode = root["parameters"]; - if (!parametersNode.isUndefined() && !parametersNode.isNull()) { - QJsonArray parametersArray = parametersNode.toArray(); - QJsonObject::size_type numParameters = parametersArray.size(); - setNumberOfParameters(numParameters); - } - - // Get child dataset information - QJsonValueRef childDatasetNode = root["children"]; - if (!childDatasetNode.isUndefined() && !childDatasetNode.isNull()) { - QJsonArray childDatasetArray = childDatasetNode.toArray(); - QJsonObject::size_type size = childDatasetArray.size(); - if (size != 1) { - qCritical() << "Only one child dataset is supported for now. Found" - << size << " but only the first will be used"; - } - if (size > 0) { - setHasChildDataSource(true); - QJsonObject dataSourceNode = childDatasetArray[0].toObject(); - QJsonValueRef nameValue = dataSourceNode["name"]; - QJsonValueRef labelValue = dataSourceNode["label"]; - if (!nameValue.isUndefined() && !nameValue.isNull() && - !labelValue.isUndefined() && !labelValue.isNull()) { - m_childDataSourceName = nameValue.toString(); - m_childDataSourceLabel = labelValue.toString(); - } else if (nameValue.isNull()) { - qCritical() << "No name given for child DataSet"; - } else if (labelValue.isNull()) { - qCritical() << "No label given for child DataSet"; - } - } - } - - setHelpFromJson(root); -} - -const QString& OperatorPython::JSONDescription() const -{ - return m_jsonDescription; -} - -void OperatorPython::setScript(const QString& str) -{ - if (m_script != str) { - m_script = str; - - Python::Object result; - Python::Object resultComplete; - { - Python python; - QString moduleName = QString("tomviz_%1").arg(label()); - d->TransformModule = python.import(this->m_script, label(), moduleName); - if (!d->TransformModule.isValid()) { - qCritical("Failed to create module."); - return; - } - - // Delete the module from sys.module so we don't reuse it - Python::Tuple delArgs(1); - Python::Object name(moduleName); - delArgs.set(0, name); - auto delResult = d->DeleteModuleFunction.call(delArgs); - if (!delResult.isValid()) { - qCritical("An error occurred deleting module."); - return; - } - - // Create capsule to hold the pointer to the operator in the python world - Python::Tuple findArgs(2); - Python::Capsule op(this); - - findArgs.set(0, d->TransformModule); - findArgs.set(1, op); - - d->TransformMethod = d->FindTransformFunction.call(findArgs); - if (!d->TransformMethod.isValid()) { - qCritical("Script doesn't have any 'transform' function."); - return; - } - - Python::Tuple isArgs(1); - isArgs.set(0, d->TransformModule); - - result = d->IsCancelableFunction.call(isArgs); - if (!result.isValid()) { - qCritical("Error calling is_cancelable."); - return; - } - - resultComplete = d->IsCompletableFunction.call(isArgs); - if (!resultComplete.isValid()) { - qCritical("Error calling is_completable."); - return; - } - } - - setSupportsCancel(result.toBool()); - setSupportsCompletion(resultComplete.toBool()); - - emit transformModified(); - } -} - -void OperatorPython::createChildDataSource() -{ - // Create child datasets in advance. Keep a map from DataSource to name - // so that we can match Python script return dictionary values containing - // child data after the script finishes. - if (hasChildDataSource() && !childDataSource()) { - // Create uninitialized data set as a placeholder for the data - vtkSmartPointer childData = - vtkSmartPointer::New(); - childData->ShallowCopy( - vtkImageData::SafeDownCast(dataSource()->dataObject())); - emit newChildDataSource(m_childDataSourceLabel, childData); - } -} - -bool OperatorPython::updateChildDataSource(Python::Dict outputDict) -{ - if (hasChildDataSource()) { - Python::Object pyDataObject; - pyDataObject = outputDict[m_childDataSourceName]; - if (!pyDataObject.isValid()) { - qCritical() << "No child dataset named " << m_childDataSourceName - << "defined in dictionary returned from Python script.\n"; - return false; - } else { - vtkObjectBase* vtkobject = Python::VTK::convertToDataObject(pyDataObject); - vtkDataObject* dataObject = vtkDataObject::SafeDownCast(vtkobject); - if (dataObject) { - TemporarilyReleaseGil releaseMe; - emit childDataSourceUpdated(dataObject); - } - } - } - - return true; -} - -bool OperatorPython::updateChildDataSource( - QMap> output) -{ - if (hasChildDataSource()) { - for (QMap>::const_iterator iter = - output.begin(); - iter != output.end(); ++iter) { - - if (iter.key() != m_childDataSourceName) { - qCritical() << "No child dataset named " << m_childDataSourceName - << "defined in dictionary returned from Python script.\n"; - return false; - } - - emit childDataSourceUpdated(iter.value()); - } - } - - return true; -} - -bool OperatorPython::applyTransform(vtkDataObject* data) -{ - if (m_script.isEmpty()) { - return false; - } - if (!d->OperatorModule.isValid() || !d->TransformMethod.isValid() || - !d->TransformMethodWrapper.isValid()) { - return false; - } - - Q_ASSERT(data); - - createChildDataSource(); - - Python::Object result; - { - Python python; - - Python::Tuple args(3); - - // Serialize the operator, so that the transform wrapper can - // decide whether to execute this one internally or externally - auto operatorSerialized = Python::Object(QString::fromUtf8(QJsonDocument(serialize()).toJson())); - - args.set(0, d->TransformMethod); - args.set(1, operatorSerialized); - - // Get the name of the function - auto transformMethod = d->TransformMethod.getAttr("__name__").toString(); - if (transformMethod == "transform_scalars") { - // Use the arguments for transform_scalars() - Python::Object pydata = Python::VTK::GetObjectFromPointer(data); - args.set(2, pydata); - } else if (transformMethod == "transform") { - // Use the arguments for transform() - Python::Object pydata = Python::createDataset(data, *dataSource()); - args.set(2, pydata); - } else { - qDebug() << "Unknown TransformMethod name: " << transformMethod; - return false; - } - - Python::Dict kwargs; - foreach (QString key, m_arguments.keys()) { - auto value = m_arguments[key]; - if (value.canConvert()) { - // Handle special case for data sources... - auto* ds = value.value(); - if (transformMethod == "transform_scalars") { - auto pydata = Python::VTK::GetObjectFromPointer(ds->imageData()); - kwargs.set(key, pydata); - } else if (transformMethod == "transform") { - auto pydata = Python::createDataset(ds->imageData(), *ds); - kwargs.set(key, pydata); - } else { - qDebug() << "Unknown TransformMethod name: " << transformMethod; - return false; - } - continue; - } - - Variant v = toVariant(value); - kwargs.set(key, v); - } - - result = d->TransformMethodWrapper.call(args, kwargs); - if (!result.isValid()) { - qCritical("Failed to execute the script."); - return false; - } - } - - // Look for additional outputs from the filter returned in a dictionary - int check = 0; - { - Python python; - check = result.isDict(); - } - - bool errorEncountered = false; - if (check) { - Python python; - Python::Dict outputDict = result.toDict(); - - // Support setting child data from the output dictionary - errorEncountered = !updateChildDataSource(outputDict); - - // Results (tables, etc.) - for (int i = 0; i < m_resultNames.size(); ++i) { - Python::Object pyDataObject; - pyDataObject = outputDict[m_resultNames[i]]; - - if (!pyDataObject.isValid()) { - errorEncountered = true; - qCritical() << "No result named" << m_resultNames[i] - << "defined in dictionary returned from Python script.\n"; - continue; - } - vtkObjectBase* vtkobject = - Python::VTK::GetPointerFromObject(pyDataObject, "vtkDataObject"); - vtkDataObject* dataObject = vtkDataObject::SafeDownCast(vtkobject); - if (dataObject) { - // Emit signal so we switch back to UI thread - emit newOperatorResult(m_resultNames[i], dataObject); - } else { - qCritical() << "Result named '" << m_resultNames[i] - << "' is not a vtkDataObject"; - continue; - } - } - - if (errorEncountered) { - - qCritical() << "Dictionary return from Python script is:\n" - << outputDict.toString(); - } - } - - return !errorEncountered; -} - -Operator* OperatorPython::clone() const -{ - OperatorPython* newClone = - new OperatorPython(qobject_cast(parent())); - newClone->setLabel(label()); - newClone->setScript(script()); - newClone->setJSONDescription(JSONDescription()); - return newClone; -} - -QJsonObject OperatorPython::serialize() const -{ - auto json = Operator::serialize(); - json["description"] = JSONDescription(); - json["label"] = label(); - json["script"] = script(); - if (!m_arguments.isEmpty()) { - auto arguments = m_arguments; - - for (auto& value : arguments) { - if (value.canConvert()) { - // Write out the id - auto* ds = value.value(); - value.setValue(ds->id()); - } - } - - json["arguments"] = QJsonObject::fromVariantMap(arguments); - // If we have no description we still need to save the types of - // the arguments - if (JSONDescription().isEmpty() && !m_typeInfo.isEmpty()) { - QJsonObject typeObj; - for (auto itr = m_typeInfo.begin(); itr != m_typeInfo.end(); ++itr) { - typeObj[itr.key()] = itr.value(); - } - json["argumentTypeInformation"] = typeObj; - } - } - - if (!helpUrl().isEmpty()) { - auto helpObj = QJsonObject(); - helpObj["url"] = helpUrl(); - json["help"] = helpObj; - } - - return json; -} - -namespace { -QJsonObject findJsonObject(const QJsonArray& array, const QString& name) -{ - for (int i = 0; i < array.size(); ++i) { - auto object = array[i].toObject(); - if (object["name"].toString() == name) { - return object; - } - } - return QJsonObject(); -} - -QVariant castJsonArg(const QJsonValue& arg, const QString& type) -{ - if (arg.isArray()) { - auto arr = arg.toArray(); - QVariantList arrayList; - if (type == "int" || type == "enumeration") { - for (int i = 0; i < arr.size(); ++i) { - arrayList << arr[i].toInt(); - } - } else if (type == "double") { - for (int i = 0; i < arr.size(); ++i) { - arrayList << arr[i].toDouble(); - } - } else if (type == "select_scalars") { - for (int i = 0; i < arr.size(); ++i) { - arrayList << arr[i].toString(); - } - } - return arrayList; - } else if (arg.isDouble()) { - QVariant variant; - if (type == "int" || type == "enumeration") { - variant = arg.toInt(); - } else if (type == "double") { - variant = arg.toDouble(); - } - return variant; - } else if (arg.isBool()) { - return arg.toBool(); - } else if (arg.isString()) { - return arg.toString(); - } - return QVariant(); -} -} // namespace - -bool OperatorPython::deserialize(const QJsonObject& json) -{ - setJSONDescription(json["description"].toString()); - setLabel(json["label"].toString()); - setScript(json["script"].toString()); - m_arguments.clear(); - // We use the JSON description to ensure things have the correct type. - if (json.contains("arguments")) { - if (!m_jsonDescription.isEmpty()) { - auto document = QJsonDocument::fromJson(m_jsonDescription.toLatin1()); - if (!document.isObject()) { - qCritical() << "Failed to parse operator JSON"; - qCritical() << m_jsonDescription; - return false; - } - auto args = json["arguments"].toObject(); - auto params = document.object()["parameters"].toArray(); - foreach (const QString& key, args.keys()) { - auto param = findJsonObject(params, key); - if (param.isEmpty()) { - continue; - } - - auto value = castJsonArg(args[key], param["type"].toString()); - if (value.toString().startsWith("0x")) { - // Need to convert a data source id to a data source - // The ModuleManager will be responsible for making sure the - // right data source is loaded before deserializing this - // operator. - auto id = value.toString(); - auto* ds = ModuleManager::instance().dataSourceForStateId(id); - if (!ds) { - qDebug() << "Error: no data source found for state id: " << id; - } else { - value.setValue(ds); - } - } - - m_arguments[key] = value; - } - } else if (json.contains("argumentTypeInformation")) { - auto args = json["arguments"].toObject(); - auto typeInfo = json["argumentTypeInformation"].toObject(); - foreach (const QString& key, args.keys()) { - if (!typeInfo.contains(key)) { - qCritical() << "Deserializing operator " << m_label - << " found argument " << key << " with unknown type."; - return false; - } - auto type = typeInfo[key].toString(); - m_arguments[key] = castJsonArg(args[key], type); - } - } - } - - setHelpFromJson(json); - return true; -} - -EditOperatorWidget* OperatorPython::getEditorContents(QWidget* p) -{ - CustomPythonOperatorWidget* widget = nullptr; - if (m_customWidgetID != "" && CustomWidgetMap.contains(m_customWidgetID)) { - if (!CustomWidgetMap[m_customWidgetID].first) { - vtkSmartPointer nullPtr; - widget = CustomWidgetMap[m_customWidgetID].second(p, this, nullPtr); - } else { - // return nullptr so the caller knows to get the input data and - // use the getEditorContentsWithData method - return nullptr; - } - } - return new EditPythonOperatorWidget(p, this, widget); -} - -EditOperatorWidget* OperatorPython::getEditorContentsWithData( - QWidget* p, vtkSmartPointer displayImage) -{ - // Should only be called if there is a custom widget that needs input data - Q_ASSERT(m_customWidgetID != ""); - Q_ASSERT(CustomWidgetMap[m_customWidgetID].first); - auto widget = CustomWidgetMap[m_customWidgetID].second(p, this, displayImage); - return new EditPythonOperatorWidget(p, this, widget); -} - -void OperatorPython::updateChildDataSource(vtkSmartPointer data) -{ - // Check to see if a child data source has already been created. If not, - // create it here. - auto dataSource = childDataSource(); - Q_ASSERT(dataSource); - - if (!dataSource->volumeModuleAutoAdded()) { - // Automatically add a volume so that users can see live updates - // This also fixes some strange issue where the application will - // crash after this function if no visualization module was ever - // created for this child data source, before the new data was - // copied over. - ModuleManager::instance().createAndAddModule("Volume", dataSource, - ActiveObjects::instance().activeView()); - dataSource->setVolumeModuleAutoAdded(true); - } - - // Now deep copy the new data to the child source data if needed - dataSource->copyData(data); - emit dataSource->dataChanged(); - emit dataSource->dataPropertiesChanged(); - ActiveObjects::instance().renderAllViews(); -} - -void OperatorPython::setOperatorResult(const QString& name, - vtkSmartPointer result) -{ - bool resultWasSet = setResult(name.toLatin1().data(), result); - if (!resultWasSet) { - qCritical() << "Could not set result '" << name << "'"; - } -} - -void OperatorPython::setArguments(QMap args) -{ - if (args != m_arguments) { - m_arguments = args; - emit transformModified(); - } -} - -QMap OperatorPython::arguments() const -{ - return m_arguments; -} - -void OperatorPython::setTypeInfo(const QMap& typeInfo) -{ - m_typeInfo = typeInfo; -} - -const QMap& OperatorPython::typeInfo() const -{ - return m_typeInfo; -} - -void OperatorPython::setHelpFromJson(const QJsonObject& json) -{ - // Clear before trying to read - setHelpUrl(""); - auto helpNode = json["help"]; - if (!helpNode.isUndefined() && !helpNode.isNull()) { - // Need to keep a reference to the object or it gets deleted - auto obj = helpNode.toObject(); - auto helpNodeUrl = obj["url"]; - if (!helpNodeUrl.isUndefined() && !helpNodeUrl.isNull()) { - setHelpUrl(helpNodeUrl.toString()); - } - } -} - -void OperatorPython::setChildDataSource(DataSource* source) -{ - if (source != nullptr) { - source->setLabel(m_childDataSourceLabel); - } - Operator::setChildDataSource(source); -} - -} // namespace tomviz -#include "OperatorPython.moc" diff --git a/tomviz/operators/OperatorPython.h b/tomviz/operators/OperatorPython.h deleted file mode 100644 index 8d8d11261..000000000 --- a/tomviz/operators/OperatorPython.h +++ /dev/null @@ -1,119 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorPython_h -#define tomvizOperatorPython_h - -#include "Operator.h" -#include "PythonUtilities.h" -#include -#include -#include -#include -#include - -namespace tomviz { - -class CustomPythonOperatorWidget; - -class OperatorPython : public Operator -{ - Q_OBJECT - -public: - // The parent must be a DataSource - OperatorPython(DataSource* parent); - ~OperatorPython() override; - - QString label() const override { return m_label; } - void setLabel(const QString& txt); - - /// Returns an icon to use for this operator. - QIcon icon() const override; - - /// Return a new clone. - Operator* clone() const override; - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - void setJSONDescription(const QString& str); - const QString& JSONDescription() const; - - void setScript(const QString& str); - const QString& script() const { return m_script; } - - EditOperatorWidget* getEditorContents(QWidget* parent) override; - EditOperatorWidget* getEditorContentsWithData( - QWidget* parent, - vtkSmartPointer inputDataForDisplay) override; - bool hasCustomUI() const override { return true; } - - /// Set the arguments to pass to the transform_scalars function - void setArguments(QMap args); - - /// Returns the argument that will be passed to transform_scalars - QMap arguments() const; - - /// Not really "public" but needs to called when running pipeline externally. - /// Needed to create the data source upfront for live updates. - void createChildDataSource(); - - /// Not really "public" but needs to called when running pipeline externally. - bool updateChildDataSource(Python::Dict output); - bool updateChildDataSource( - QMap> output); - - typedef CustomPythonOperatorWidget* (*CustomWidgetFunction)( - QWidget*, Operator*, vtkSmartPointer); - - static void registerCustomWidget(const QString& key, bool needsData, - CustomWidgetFunction func); - - void setTypeInfo(const QMap& typeInfo); - const QMap& typeInfo() const; - - int numberOfParameters() const { return m_numberOfParameters; } - - void setChildDataSource(DataSource* source) override; - -signals: - void newOperatorResult(const QString&, vtkSmartPointer); - /// Signal uses to request that the child data source be updated with - /// a new vtkDataObject. - void childDataSourceUpdated(vtkSmartPointer); - -protected: - bool applyTransform(vtkDataObject* data) override; - -private slots: - void updateChildDataSource(vtkSmartPointer); - void setOperatorResult(const QString& name, - vtkSmartPointer result); - -private: - Q_DISABLE_COPY(OperatorPython) - - void setNumberOfParameters(int n) { m_numberOfParameters = n; } - void setHelpFromJson(const QJsonObject& json); - class OPInternals; - const QScopedPointer d; - QString m_label; - QString m_jsonDescription; - QString m_script; - - // This is for operators without a JSON description but with arguments. - // Serialization needs to know the type of the arguments. - QMap m_typeInfo; - - QString m_customWidgetID; - - QList m_resultNames; - QString m_childDataSourceName = "output"; - QString m_childDataSourceLabel = "Output"; - - QMap m_arguments; - int m_numberOfParameters = 0; -}; -} // namespace tomviz -#endif diff --git a/tomviz/operators/OperatorResult.cxx b/tomviz/operators/OperatorResult.cxx deleted file mode 100644 index 2efe5aae3..000000000 --- a/tomviz/operators/OperatorResult.cxx +++ /dev/null @@ -1,138 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorResult.h" - -#include "ActiveObjects.h" -#include "ModuleFactory.h" -#include "ModuleManager.h" -#include "ModuleMolecule.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -OperatorResult::OperatorResult(QObject* parent) - : Superclass(parent), m_name(tr("Unnamed")) -{} - -OperatorResult::~OperatorResult() -{ - finalize(); -} - -void OperatorResult::setName(QString name) -{ - m_name = name; -} - -QString OperatorResult::name() const -{ - return m_name; -} - -void OperatorResult::setLabel(QString label) -{ - m_label = label; -} - -QString OperatorResult::label() const -{ - return m_label; -} - -void OperatorResult::setDescription(QString desc) -{ - m_description = desc; -} - -QString OperatorResult::description() const -{ - return m_description; -} - -bool OperatorResult::finalize() -{ - deleteProxy(); - return true; -} - -vtkDataObject* OperatorResult::dataObject() -{ - vtkDataObject* object = nullptr; - if (m_producerProxy) { - vtkObjectBase* clientSideObject = m_producerProxy->GetClientSideObject(); - vtkTrivialProducer* producer = - vtkTrivialProducer::SafeDownCast(clientSideObject); - object = producer->GetOutputDataObject(0); - } - - return object; -} - -void OperatorResult::setDataObject(vtkDataObject* object) -{ - vtkDataObject* previousObject = dataObject(); - - if (object == previousObject) { - // Nothing to do - return; - } - - if (object == nullptr) { - deleteProxy(); - return; - } - - createProxyIfNeeded(); - - // Set the output in the producer - vtkObjectBase* clientSideObject = m_producerProxy->GetClientSideObject(); - vtkTrivialProducer* producer = - vtkTrivialProducer::SafeDownCast(clientSideObject); - producer->SetOutput(object); -} - -vtkSMSourceProxy* OperatorResult::producerProxy() -{ - createProxyIfNeeded(); - return m_producerProxy; -} - -void OperatorResult::createProxyIfNeeded() -{ - if (!m_producerProxy) { - vtkSMProxyManager* proxyManager = vtkSMProxyManager::GetProxyManager(); - vtkSMSessionProxyManager* sessionProxyManager = - proxyManager->GetActiveSessionProxyManager(); - - vtkSmartPointer producerProxy; - producerProxy.TakeReference( - sessionProxyManager->NewProxy("sources", "TrivialProducer")); - m_producerProxy = vtkSMSourceProxy::SafeDownCast(producerProxy); - m_producerProxy->UpdateVTKObjects(); - - vtkNew controller; - controller->PreInitializeProxy(m_producerProxy); - controller->PostInitializeProxy(m_producerProxy); - controller->RegisterPipelineProxy(m_producerProxy); - } -} - -void OperatorResult::deleteProxy() -{ - if (m_producerProxy) { - vtkNew controller; - controller->UnRegisterPipelineProxy(m_producerProxy); - m_producerProxy = nullptr; - } -} - -} // namespace tomviz diff --git a/tomviz/operators/OperatorResult.h b/tomviz/operators/OperatorResult.h deleted file mode 100644 index eef642758..000000000 --- a/tomviz/operators/OperatorResult.h +++ /dev/null @@ -1,64 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorResult_h -#define tomvizOperatorResult_h - -#include -#include - -#include - -class vtkDataObject; -class vtkSMSourceProxy; - -namespace tomviz { - -// Output result from an operator. Such results may include label maps or -// tables. This class wraps a single vtkDataObject produced by an operator. -class OperatorResult : public QObject -{ - Q_OBJECT - typedef QObject Superclass; - -public: - OperatorResult(QObject* parent = nullptr); - virtual ~OperatorResult() override; - - /// Set name of object. - void setName(QString name); - QString name() const; - - /// Set label of object. - void setLabel(QString label); - QString label() const; - - /// Set description of object. - void setDescription(QString desc); - QString description() const; - - /// Clean up object, releasing the data object and the proxy created - /// for it. - virtual bool finalize(); - - /// Get and set the data object this result wraps. - virtual vtkDataObject* dataObject(); - virtual void setDataObject(vtkDataObject* dataObject); - - virtual vtkSMSourceProxy* producerProxy(); - -private: - Q_DISABLE_COPY(OperatorResult) - - vtkWeakPointer m_producerProxy; - QString m_name; - QString m_label; - QString m_description = "Operator Result"; - - void createProxyIfNeeded(); - void deleteProxy(); -}; - -} // namespace tomviz - -#endif diff --git a/tomviz/operators/OperatorResultPropertiesPanel.cxx b/tomviz/operators/OperatorResultPropertiesPanel.cxx deleted file mode 100644 index c879b1114..000000000 --- a/tomviz/operators/OperatorResultPropertiesPanel.cxx +++ /dev/null @@ -1,53 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorResultPropertiesPanel.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "EditOperatorDialog.h" -#include "MoleculeProperties.h" -#include "OperatorResult.h" -#include "Pipeline.h" -#include "Utilities.h" - -#include "vtkMolecule.h" - -#include -#include - -namespace tomviz { - -OperatorResultPropertiesPanel::OperatorResultPropertiesPanel(QWidget* p) - : QWidget(p) -{ - // Show active module in the "OperatorResult Properties" panel. - connect(&ActiveObjects::instance(), &ActiveObjects::resultChanged, this, - &OperatorResultPropertiesPanel::setOperatorResult); - - // Set up a very simple layout with a description label widget. - m_layout = new QVBoxLayout; - setLayout(m_layout); -} - -OperatorResultPropertiesPanel::~OperatorResultPropertiesPanel() = default; - -void OperatorResultPropertiesPanel::setOperatorResult(OperatorResult* result) -{ - if (result != m_activeOperatorResult) { - deleteLayoutContents(m_layout); - if (result) { - m_layout->addWidget(new QLabel(result->label())); - - if (vtkMolecule::SafeDownCast(result->dataObject())) { - auto molecule = vtkMolecule::SafeDownCast(result->dataObject()); - m_layout->addWidget(new MoleculeProperties(molecule)); - } - } - m_layout->addStretch(); - } - - m_activeOperatorResult = result; -} - -} // namespace tomviz diff --git a/tomviz/operators/OperatorResultPropertiesPanel.h b/tomviz/operators/OperatorResultPropertiesPanel.h deleted file mode 100644 index 627ac57b5..000000000 --- a/tomviz/operators/OperatorResultPropertiesPanel.h +++ /dev/null @@ -1,38 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorResultPropertiesPanel_h -#define tomvizOperatorResultPropertiesPanel_h - -#include - -#include - -class QLabel; -class QTableWidget; -class QVBoxLayout; -class vtkMolecule; - -namespace tomviz { -class OperatorResult; - -class OperatorResultPropertiesPanel : public QWidget -{ - Q_OBJECT - -public: - OperatorResultPropertiesPanel(QWidget* parent = nullptr); - virtual ~OperatorResultPropertiesPanel(); - -private slots: - void setOperatorResult(OperatorResult*); - -private: - Q_DISABLE_COPY(OperatorResultPropertiesPanel) - - QPointer m_activeOperatorResult = nullptr; - QVBoxLayout* m_layout = nullptr; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/OperatorWidget.cxx b/tomviz/operators/OperatorWidget.cxx deleted file mode 100644 index 877b9222a..000000000 --- a/tomviz/operators/OperatorWidget.cxx +++ /dev/null @@ -1,68 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "OperatorWidget.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "DoubleSpinBox.h" -#include "InterfaceBuilder.h" -#include "SpinBox.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -OperatorWidget::OperatorWidget(QWidget* parentObject) : Superclass(parentObject) -{} - -OperatorWidget::~OperatorWidget() {} - -void OperatorWidget::setupUI(OperatorPython* op) -{ - this->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Minimum); - QString json = op->JSONDescription(); - if (!json.isNull()) { - DataSource* dataSource = nullptr; - if (op->childDataSource()) - dataSource = op->childDataSource(); - else - dataSource = qobject_cast(op->parent()); - - if (!dataSource) - dataSource = ActiveObjects::instance().activeDataSource(); - - InterfaceBuilder* ib = new InterfaceBuilder(this, dataSource); - ib->setJSONDescription(json); - ib->setParameterValues(op->arguments()); - buildInterface(ib); - } -} - -void OperatorWidget::setupUI(const QString& json) -{ - InterfaceBuilder* ib = - new InterfaceBuilder(this, ActiveObjects::instance().activeDataSource()); - ib->setJSONDescription(json); - buildInterface(ib); -} - -void OperatorWidget::buildInterface(InterfaceBuilder* builder) -{ - QLayout* layout = builder->buildInterface(); - this->setLayout(layout); -} - -QMap OperatorWidget::values() const -{ - return InterfaceBuilder::parameterValues(this); -} -} // namespace tomviz diff --git a/tomviz/operators/OperatorWidget.h b/tomviz/operators/OperatorWidget.h deleted file mode 100644 index 352dab256..000000000 --- a/tomviz/operators/OperatorWidget.h +++ /dev/null @@ -1,40 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizOperatorWidget_h -#define tomvizOperatorWidget_h - -#include - -#include -#include -#include -#include - -namespace tomviz { - -class InterfaceBuilder; - -class OperatorWidget : public QWidget -{ - Q_OBJECT - typedef QWidget Superclass; - -public: - OperatorWidget(QWidget* parent = nullptr); - ~OperatorWidget() override; - - void setupUI(const QString& json); - void setupUI(OperatorPython* op); - - /// Get parameter values - QMap values() const; - -private: - Q_DISABLE_COPY(OperatorWidget) - OperatorPython* m_operator = nullptr; - void buildInterface(InterfaceBuilder* builder); -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/ReconstructionOperator.cxx b/tomviz/operators/ReconstructionOperator.cxx deleted file mode 100644 index 84b740dab..000000000 --- a/tomviz/operators/ReconstructionOperator.cxx +++ /dev/null @@ -1,150 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "ReconstructionOperator.h" - -#include "DataSource.h" -#include "Pipeline.h" -#include "ReconstructionWidget.h" -#include "TomographyReconstruction.h" -#include "TomographyTiltSeries.h" - -#include "pqSMProxy.h" -#include "vtkDataArray.h" -#include "vtkImageData.h" -#include "vtkNew.h" -#include "vtkPointData.h" -#include "vtkSMProxyManager.h" -#include "vtkSMSessionProxyManager.h" -#include "vtkSMSourceProxy.h" -#include "vtkTrivialProducer.h" - -#include -#include - -namespace tomviz { -ReconstructionOperator::ReconstructionOperator(DataSource* source, QObject* p) - : Operator(p), m_dataSource(source) -{ - qRegisterMetaType>(); - auto t = source->producer(); - auto imageData = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - int dataExtent[6]; - imageData->GetExtent(dataExtent); - for (int i = 0; i < 6; ++i) { - m_extent[i] = dataExtent[i]; - } - setSupportsCancel(true); - setTotalProgressSteps(m_extent[1] - m_extent[0] + 1); - setHasChildDataSource(true); - connect( - this, - static_cast)>( - &Operator::newChildDataSource), - this, - [this](const QString& label, vtkSmartPointer childData) { - this->createNewChildDataSource(label, childData, DataSource::Volume, - DataSource::PersistenceState::Transient); - }); -} - -QIcon ReconstructionOperator::icon() const -{ - return QIcon(":/pqWidgets/Icons/pqExtractGrid.svg"); -} - -Operator* ReconstructionOperator::clone() const -{ - return new ReconstructionOperator(m_dataSource); -} - -QWidget* ReconstructionOperator::getCustomProgressWidget(QWidget* p) const -{ - DataSource* source = m_dataSource; - if (source && source->pipeline()) { - // Use the transformed data source for the reconstruction widget - source = source->pipeline()->transformedDataSource(); - } - - ReconstructionWidget* widget = new ReconstructionWidget(source, p); - QObject::connect(this, &Operator::progressStepChanged, widget, - &ReconstructionWidget::updateProgress); - QObject::connect(this, &ReconstructionOperator::intermediateResults, widget, - &ReconstructionWidget::updateIntermediateResults); - return widget; -} - -bool ReconstructionOperator::applyTransform(vtkDataObject* dataObject) -{ - vtkSmartPointer imageData = - vtkImageData::SafeDownCast(dataObject); - if (!imageData) { - return false; - } - int dataExtent[6]; - imageData->GetExtent(dataExtent); - for (int i = 0; i < 6; ++i) { - if (dataExtent[i] != m_extent[i]) { - // Extent changing shouldn't matter, but update so that correct - // number of steps can be reported. - m_extent[i] = dataExtent[i]; - } - } - setTotalProgressSteps(m_extent[1] - m_extent[0] + 1); - - int numXSlices = dataExtent[1] - dataExtent[0] + 1; - int numYSlices = dataExtent[3] - dataExtent[2] + 1; - int numZSlices = dataExtent[5] - dataExtent[4] + 1; - std::vector sinogramPtr(numYSlices * numZSlices); - std::vector reconstructionPtr(numYSlices * numYSlices); - QVector tiltAngles; - - vtkFieldData* fd = dataObject->GetFieldData(); - vtkDataArray* tiltAnglesVTKArray = fd->GetArray("tilt_angles"); - if (tiltAnglesVTKArray) { - tiltAngles.resize(tiltAnglesVTKArray->GetNumberOfTuples()); - for (int i = 0; i < tiltAngles.size(); ++i) { - tiltAngles[i] = tiltAnglesVTKArray->GetTuple1(i); - } - } - - if (tiltAngles.size() < numZSlices) { - qDebug() << "Incorrect number of tilt angles. There are" - << tiltAngles.size() << "and there should be" << numZSlices - << ".\n"; - return false; - } - - vtkNew reconstructionImage; - int extent2[6] = { dataExtent[0], m_extent[1], dataExtent[2], - dataExtent[3], dataExtent[2], dataExtent[3] }; - reconstructionImage->SetExtent(extent2); - reconstructionImage->AllocateScalars(VTK_FLOAT, 1); - vtkDataArray* darray = reconstructionImage->GetPointData()->GetScalars(); - darray->SetName("scalars"); - - // TODO: talk to Dave Lonie about how to do this in new data array API - float* reconstruction = (float*)darray->GetVoidPointer(0); - for (int i = 0; i < numXSlices && !isCanceled(); ++i) { - QCoreApplication::processEvents(); - TomographyTiltSeries::getSinogram(imageData, i, &sinogramPtr[0]); - TomographyReconstruction::unweightedBackProjection2( - &sinogramPtr[0], tiltAngles.data(), &reconstructionPtr[0], numZSlices, - numYSlices); - for (int j = 0; j < numYSlices; ++j) { - for (int k = 0; k < numYSlices; ++k) { - reconstruction[j * (numYSlices * numXSlices) + k * numXSlices + i] = - reconstructionPtr[k * numYSlices + j]; - } - } - emit intermediateResults(reconstructionPtr); - setProgressStep(i); - } - if (isCanceled()) { - return false; - } - emit newChildDataSource("Reconstruction", reconstructionImage); - return true; -} -} // namespace tomviz diff --git a/tomviz/operators/ReconstructionOperator.h b/tomviz/operators/ReconstructionOperator.h deleted file mode 100644 index 23612e780..000000000 --- a/tomviz/operators/ReconstructionOperator.h +++ /dev/null @@ -1,43 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizReconstructionOperator_h -#define tomvizReconstructionOperator_h - -#include "Operator.h" - -namespace tomviz { -class DataSource; - -class ReconstructionOperator : public Operator -{ - Q_OBJECT - -public: - ReconstructionOperator(DataSource* source, QObject* parent = nullptr); - - QString label() const override { return "Reconstruction"; } - - QIcon icon() const override; - - Operator* clone() const override; - - QWidget* getCustomProgressWidget(QWidget*) const override; - -protected: - bool applyTransform(vtkDataObject* data) override; - -signals: - /// Emitted after each slice is reconstructed, use to display intermediate - /// results the first vector contains the sinogram reconstructed, the second - /// contains the slice of the resulting image. - void intermediateResults(std::vector resultSlice); - -private: - DataSource* m_dataSource; - int m_extent[6]; - Q_DISABLE_COPY(ReconstructionOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/SetTiltAnglesOperator.cxx b/tomviz/operators/SetTiltAnglesOperator.cxx deleted file mode 100644 index b8304b273..000000000 --- a/tomviz/operators/SetTiltAnglesOperator.cxx +++ /dev/null @@ -1,526 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "SetTiltAnglesOperator.h" - -#include "DataSource.h" -#include "EditOperatorWidget.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -class SetTiltAnglesWidget : public tomviz::EditOperatorWidget -{ - Q_OBJECT -public: - SetTiltAnglesWidget(tomviz::SetTiltAnglesOperator* op, - vtkSmartPointer dataObject, QWidget* p) - : EditOperatorWidget(p), Op(op) - { - QMap tiltAngles = this->Op->tiltAngles(); - QHBoxLayout* baseLayout = new QHBoxLayout; - this->setLayout(baseLayout); - this->tabWidget = new QTabWidget; - baseLayout->addWidget(this->tabWidget); - - QWidget* setAutomaticPanel = new QWidget; - - QGridLayout* layout = new QGridLayout; - - QString descriptionString = - "A tomographic \"tilt series\" is a set of projection images taken while " - "rotating (\"tilting\") the specimen. Setting the correct angles is " - "needed for accurate reconstruction. Set a linearly spaced range of " - "angles by specifying the start and end tilt index and start and end " - "angles. The tilt angles can also be set in the \"Data Properties\" " - "panel or from Python."; - - vtkImageData* image = vtkImageData::SafeDownCast(dataObject); - Q_ASSERT(image); - - int extent[6]; - image->GetExtent(extent); - int totalSlices = extent[5] - extent[4] + 1; - this->previousTiltAngles.resize(totalSlices); - if (totalSlices < 60) { - angleIncrement = 3.0; - } else if (totalSlices < 80) { - angleIncrement = 2.0; - } else if (totalSlices < 120) { - angleIncrement = 1.5; - } else { - angleIncrement = 1.0; - } - - double startAngleValue = -(totalSlices - 1) * angleIncrement / 2.0; - double endAngleValue = startAngleValue + (totalSlices - 1) * angleIncrement; - - if (tiltAngles.contains(0) && tiltAngles.contains(totalSlices - 1)) { - startAngleValue = tiltAngles[0]; - endAngleValue = tiltAngles[totalSlices - 1]; - } - - QLabel* descriptionLabel = new QLabel(descriptionString); - descriptionLabel->setMinimumHeight(120); - descriptionLabel->setSizePolicy(QSizePolicy::MinimumExpanding, - QSizePolicy::MinimumExpanding); - descriptionLabel->setWordWrap(true); - layout->addWidget(descriptionLabel, 0, 0, 1, 4, Qt::AlignCenter); - layout->addWidget(new QLabel("Start Image #: "), 1, 0, 1, 1, - Qt::AlignCenter); - this->startTilt = new QSpinBox; - startTilt->setRange(0, totalSlices - 1); - startTilt->setValue(0); - layout->addWidget(startTilt, 1, 1, 1, 1, Qt::AlignCenter); - layout->addWidget(new QLabel("End Image #: "), 2, 0, 1, 1, Qt::AlignCenter); - this->endTilt = new QSpinBox; - endTilt->setRange(0, totalSlices - 1); - endTilt->setValue(totalSlices - 1); - layout->addWidget(endTilt, 2, 1, 1, 1, Qt::AlignCenter); - layout->addWidget(new QLabel("Set Start Angle: "), 1, 2, 1, 1, - Qt::AlignCenter); - this->startAngle = new QDoubleSpinBox; - startAngle->setRange(-360.0, 360.0); - startAngle->setValue(startAngleValue); - layout->addWidget(startAngle, 1, 3, 1, 1, Qt::AlignCenter); - layout->addWidget(new QLabel("Set End Angle: "), 2, 2, 1, 1, - Qt::AlignCenter); - this->endAngle = new QDoubleSpinBox; - endAngle->setRange(-360.0, 360.0); - endAngle->setValue(endAngleValue); - layout->addWidget(endAngle, 2, 3, 1, 1, Qt::AlignCenter); - - layout->addWidget(new QLabel("Angle Increment: "), 3, 2, 1, 1, - Qt::AlignCenter); - - QString s = QString::number(angleIncrement, 'f', 2); - this->angleIncrementLabel = new QLabel(s); - connect(startTilt, QOverload::of(&QSpinBox::valueChanged), this, - &SetTiltAnglesWidget::updateAngleIncrement); - connect(endTilt, QOverload::of(&QSpinBox::valueChanged), this, - &SetTiltAnglesWidget::updateAngleIncrement); - connect(startAngle, QOverload::of(&QDoubleSpinBox::valueChanged), - this, &SetTiltAnglesWidget::updateAngleIncrement); - connect(endAngle, QOverload::of(&QDoubleSpinBox::valueChanged), - this, &SetTiltAnglesWidget::updateAngleIncrement); - layout->addWidget(angleIncrementLabel, 3, 3, 1, 1, Qt::AlignCenter); - - auto outerLayout = new QVBoxLayout; - outerLayout->addLayout(layout); - outerLayout->addStretch(); - - setAutomaticPanel->setLayout(outerLayout); - - QWidget* setFromTablePanel = new QWidget; - QVBoxLayout* tablePanelLayout = new QVBoxLayout; - this->tableWidget = new QTableWidget; - this->tableWidget->setRowCount(totalSlices); - this->tableWidget->setColumnCount(1); - this->tableWidget->setHorizontalHeaderLabels({"Tilt Angle"}); - tablePanelLayout->addWidget(this->tableWidget); - - // Widget to hold tilt angle import button - QWidget* buttonWidget = new QWidget; - QHBoxLayout* buttonLayout = new QHBoxLayout; - buttonWidget->setLayout(buttonLayout); - tablePanelLayout->addWidget(buttonWidget); - - // Add button to load a text file with tilt series values - QPushButton* loadFromFileButton = new QPushButton; - loadFromFileButton->setText("Load From Text File"); - buttonLayout->addWidget(loadFromFileButton); - buttonLayout->insertStretch(-1); - connect(loadFromFileButton, &QPushButton::clicked, this, - &SetTiltAnglesWidget::loadFromFile); - - vtkFieldData* fd = dataObject->GetFieldData(); - vtkDataArray* tiltArray = nullptr; - if (fd) { - tiltArray = fd->GetArray("tilt_angles"); - } - for (vtkIdType i = 0; i < totalSlices; ++i) { - QTableWidgetItem* item = new QTableWidgetItem; - double angle; - // Make sure to set the previous angles - if (tiltArray && i < tiltArray->GetNumberOfTuples()) { - angle = tiltArray->GetTuple1(i); - this->previousTiltAngles[i] = angle; - } else { - angle = 0; - this->previousTiltAngles[i] = 0; - } - // deliberate non-else-if. We want to override with the previous value - // from the operator, but we need the previousTiltAngles array set - // correctly, so the above condition has to be separate. - if (tiltAngles.contains(i)) { - angle = tiltAngles[i]; - } - item->setData(Qt::DisplayRole, QString::number(angle)); - this->tableWidget->setItem(i, 0, item); - } - tableWidget->installEventFilter(this); - - setFromTablePanel->setLayout(tablePanelLayout); - - this->tabWidget->addTab(setAutomaticPanel, "Set by Range"); - this->tabWidget->addTab(setFromTablePanel, "Set Individually"); - - baseLayout->setSizeConstraint(QLayout::SetMinimumSize); - } - - void applyChangesToOperator() override - { - if (this->tabWidget->currentIndex() == 0) { - QMap tiltAngles = this->Op->tiltAngles(); - int start = startTilt->value(); - int end = endTilt->value(); - if (end == start) { - tiltAngles[start] = startAngle->value(); - } else { - double delta = - (endAngle->value() - startAngle->value()) / (end - start); - double baseAngle = startAngle->value(); - if (end < start) { - int temp = start; - start = end; - end = temp; - baseAngle = endAngle->value(); - } - for (int i = 0; start + i <= end; ++i) { - tiltAngles.insert(start + i, baseAngle + delta * i); - this->tableWidget->item(i, 0)->setData( - Qt::DisplayRole, QString::number(tiltAngles[start + i])); - } - } - this->Op->setTiltAngles(tiltAngles); - } else { - QMap tiltAngles; - for (vtkIdType i = 0; i < this->tableWidget->rowCount(); ++i) { - QTableWidgetItem* item = this->tableWidget->item(i, angleColumn()); - tiltAngles[i] = item->data(Qt::DisplayRole).toDouble(); - } - this->Op->setTiltAngles(tiltAngles); - } - } - - bool eventFilter(QObject* obj, QEvent* event) override - { - QKeyEvent* ke = dynamic_cast(event); - if (ke && obj == this->tableWidget) { - if (ke->matches(QKeySequence::Paste) && ke->type() == QEvent::KeyPress) { - QClipboard* clipboard = QGuiApplication::clipboard(); - const QMimeData* mimeData = clipboard->mimeData(); - if (mimeData->hasText()) { - // some spreadsheet programs include an trailing newline when copying - // a range of cells which then gets split into an empty string entry. - // Strip this trailing newline. - QString text = mimeData->text().trimmed(); - QStringList rows = text.split("\n"); - QStringList angles; - for (const QString& row : rows) { - angles << row.split("\t")[0]; - } - auto ranges = this->tableWidget->selectedRanges(); - // check if the table in the clipboard is of numbers - for (const QString& angle : angles) { - bool ok; - angle.toDouble(&ok); - if (!ok) { - QMessageBox::warning( - this, "Error", - QString("Error: pasted tilt angle %1 is not a number") - .arg(angle)); - return true; - } - } - // If separate blocks of rows selected, cancel the paste - // since we don't know where to put angles - if (ranges.size() != 1) { - QMessageBox::warning( - this, "Error", - "Pasting is not supported with non-continuous selections"); - return true; - } - // If multiple rows selected and it is not equal to - // the number of angles pasted, cancel the paste - if (ranges[0].rowCount() > 1 && - ranges[0].rowCount() != angles.size()) { - QMessageBox::warning(this, "Error", - QString("Cells selected (%1) does not match " - "number of angles to paste (%2). \n" - "Please select one cell to mark the " - "start location for pasting or select " - "the same number of cells that will " - "be pasted into.") - .arg(ranges[0].rowCount()) - .arg(angles.size())); - return true; - } - int startRow = ranges[0].topRow(); - for (int i = 0; i < angles.size(); ++i) { - auto item = this->tableWidget->item(i + startRow, angleColumn()); - if (item) { - item->setData(Qt::DisplayRole, angles[i]); - } - } - } - return true; - } - } - return QWidget::eventFilter(obj, event); - } - -public slots: - void updateAngleIncrement() - { - angleIncrement = (endAngle->value() - startAngle->value()) / - (endTilt->value() - startTilt->value()); - QString s; - if (std::isfinite(angleIncrement)) { - s = QString::number(angleIncrement, 'f', 2); - } else if (endAngle->value() == startAngle->value()) { - s = QString::number(0, 'f', 2); - } else { - s = "Invalid inputs!"; - } - this->angleIncrementLabel->setText(s); - } - - void loadFromFile() - { - QStringList filters; - filters << "Any (*)" - << "Text (*.txt)" - << "CSV (*.csv)"; - - QFileDialog dialog; - dialog.setFileMode(QFileDialog::ExistingFile); - dialog.setNameFilters(filters); - dialog.setObjectName("SetTiltAnglesOperator-loadFromFile"); - dialog.setAcceptMode(QFileDialog::AcceptOpen); - - if (dialog.exec() == QDialog::Accepted) { - QString content; - QFile tiltAnglesFile(dialog.selectedFiles()[0]); - if (tiltAnglesFile.open(QIODevice::ReadOnly)) { - content = tiltAnglesFile.readAll(); - } else { - qCritical() - << QString("Unable to read '%1'.").arg(dialog.selectedFiles()[0]); - return; - } - - // Parse lines, trimming and skipping empty lines - QStringList rawLines = content.split("\n"); - QList parsedLines; - for (const QString& rawLine : rawLines) { - QString line = rawLine.trimmed(); - if (line.isEmpty()) { - continue; - } - QStringList tokens = line.split(QRegularExpression("\\s+")); - parsedLines.append(tokens); - } - - if (parsedLines.isEmpty()) { - return; - } - - // Detect two-column format: every line has exactly 2 tokens, - // first parses as int, second parses as double - bool twoColumn = true; - for (const QStringList& tokens : parsedLines) { - if (tokens.size() != 2) { - twoColumn = false; - break; - } - bool ok1, ok2; - tokens[0].toInt(&ok1); - tokens[1].toDouble(&ok2); - if (!ok1 || !ok2) { - twoColumn = false; - break; - } - } - - int maxRows = std::min(static_cast(parsedLines.size()), - this->tableWidget->rowCount()); - - if (twoColumn) { - m_hasScanIDs = true; - this->tableWidget->setColumnCount(2); - this->tableWidget->setHorizontalHeaderLabels( - {"Scan ID", "Tilt Angle"}); - this->tableWidget->horizontalHeader()->setStretchLastSection(true); - - for (int i = 0; i < maxRows; ++i) { - // Scan ID column (read-only) - QTableWidgetItem* scanItem = new QTableWidgetItem; - scanItem->setData(Qt::DisplayRole, parsedLines[i][0]); - scanItem->setFlags(scanItem->flags() & ~Qt::ItemIsEditable); - this->tableWidget->setItem(i, 0, scanItem); - - // Tilt angle column (editable) - QTableWidgetItem* angleItem = new QTableWidgetItem; - angleItem->setData(Qt::DisplayRole, parsedLines[i][1]); - this->tableWidget->setItem(i, 1, angleItem); - } - } else { - m_hasScanIDs = false; - this->tableWidget->setColumnCount(1); - this->tableWidget->setHorizontalHeaderLabels({"Tilt Angle"}); - - for (int i = 0; i < maxRows; ++i) { - QTableWidgetItem* item = this->tableWidget->item(i, 0); - item->setData(Qt::DisplayRole, parsedLines[i][0]); - } - } - } - } - -private: - QSpinBox* startTilt; - QSpinBox* endTilt; - QDoubleSpinBox* startAngle; - QDoubleSpinBox* endAngle; - QTableWidget* tableWidget; - QTabWidget* tabWidget; - QLabel* angleIncrementLabel; - double angleIncrement = 1.0; - bool m_hasScanIDs = false; - - int angleColumn() const { return m_hasScanIDs ? 1 : 0; } - - QPointer Op; - QVector previousTiltAngles; -}; - -} // namespace - -namespace tomviz { - -SetTiltAnglesOperator::SetTiltAnglesOperator(QObject* p) : Operator(p) {} - -QIcon SetTiltAnglesOperator::icon() const -{ - return QIcon(); -} - -Operator* SetTiltAnglesOperator::clone() const -{ - SetTiltAnglesOperator* op = new SetTiltAnglesOperator; - op->setTiltAngles(m_tiltAngles); - return op; -} - -QJsonObject SetTiltAnglesOperator::serialize() const -{ - auto json = Operator::serialize(); - - // Note that this is always a dense array of angles, storing it as such. - QJsonArray angleArray; - for (auto itr = m_tiltAngles.begin(); itr != m_tiltAngles.end(); ++itr) { - angleArray << itr.value(); - } - - json["angles"] = angleArray; - return json; -} - -bool SetTiltAnglesOperator::deserialize(const QJsonObject& json) -{ - if (json.contains("angles") && json["angles"].isArray()) { - auto angleArray = json["angles"].toArray(); - m_tiltAngles.clear(); - for (int i = 0; i < angleArray.size(); ++i) { - m_tiltAngles.insert(i, angleArray[i].toDouble()); - } - } - - return true; -} - -EditOperatorWidget* SetTiltAnglesOperator::getEditorContentsWithData( - QWidget* p, vtkSmartPointer dataObject) -{ - return new SetTiltAnglesWidget(this, dataObject, p); -} - -void SetTiltAnglesOperator::setTiltAngles(const QMap& newAngles) -{ - m_tiltAngles = newAngles; - emit transformModified(); -} - -bool SetTiltAnglesOperator::applyTransform(vtkDataObject* dataObject) -{ - vtkImageData* image = vtkImageData::SafeDownCast(dataObject); - if (!image) { - return false; - } - int extent[6]; - image->GetExtent(extent); - int totalSlices = extent[5] - extent[4] + 1; - vtkFieldData* fd = dataObject->GetFieldData(); - // Make sure the data is marked as a tilt series - vtkTypeInt8Array* dataType = - vtkTypeInt8Array::SafeDownCast(fd->GetArray("tomviz_data_source_type")); - if (!dataType) { - vtkNew array; - array->SetNumberOfTuples(1); - array->SetName("tomviz_data_source_type"); - fd->AddArray(array); - dataType = array; - } - // It should already be this value... - dataType->SetTuple1(0, DataSource::TiltSeries); - // Set the tilt angles - vtkDataArray* dataTiltAngles = fd->GetArray("tilt_angles"); - if (!dataTiltAngles) { - vtkNew angles; - angles->SetNumberOfTuples(totalSlices); - angles->FillComponent(0, 0.0); - angles->SetName("tilt_angles"); - fd->AddArray(angles); - dataTiltAngles = angles; - } else if (dataTiltAngles->GetNumberOfTuples() < totalSlices) { - dataTiltAngles->SetNumberOfTuples(totalSlices); - } - for (auto itr = m_tiltAngles.begin(); itr != m_tiltAngles.end(); ++itr) { - dataTiltAngles->SetTuple(itr.key(), &itr.value()); - } - return true; -} -} // namespace tomviz - -#include "SetTiltAnglesOperator.moc" diff --git a/tomviz/operators/SetTiltAnglesOperator.h b/tomviz/operators/SetTiltAnglesOperator.h deleted file mode 100644 index 56d1b0d33..000000000 --- a/tomviz/operators/SetTiltAnglesOperator.h +++ /dev/null @@ -1,42 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizSetTiltAnglesOperator_h -#define tomvizSetTiltAnglesOperator_h - -#include "Operator.h" - -#include - -namespace tomviz { - -class SetTiltAnglesOperator : public Operator -{ - Q_OBJECT - -public: - SetTiltAnglesOperator(QObject* parent = nullptr); - - QString label() const override { return "Set Tilt Angles"; } - QIcon icon() const override; - Operator* clone() const override; - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - EditOperatorWidget* getEditorContentsWithData( - QWidget* parent, vtkSmartPointer data) override; - bool hasCustomUI() const override { return true; } - - void setTiltAngles(const QMap& newAngles); - const QMap& tiltAngles() const { return m_tiltAngles; } - -protected: - bool applyTransform(vtkDataObject* data) override; - - QMap m_tiltAngles; - -private: - Q_DISABLE_COPY(SetTiltAnglesOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/SnapshotOperator.cxx b/tomviz/operators/SnapshotOperator.cxx deleted file mode 100644 index 2204a60af..000000000 --- a/tomviz/operators/SnapshotOperator.cxx +++ /dev/null @@ -1,95 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "SnapshotOperator.h" - -#include "DataSource.h" - -#include "pqSMProxy.h" -#include "vtkDataArray.h" -#include "vtkImageData.h" -#include "vtkNew.h" -#include "vtkPointData.h" -#include "vtkSMProxyManager.h" -#include "vtkSMSessionProxyManager.h" -#include "vtkSMSourceProxy.h" -#include "vtkTrivialProducer.h" - -#include - -namespace tomviz { - -SnapshotOperator::SnapshotOperator(DataSource* source, QObject* p) - : Operator(p), m_dataSource(source) -{ - setSupportsCancel(false); - setHasChildDataSource(true); - connect( - this, - static_cast)>( - &Operator::newChildDataSource), - this, - [this](const QString& label, vtkSmartPointer childData) { - this->createNewChildDataSource(label, childData, DataSource::Volume, - DataSource::PersistenceState::Modified); - }); -} - -QIcon SnapshotOperator::icon() const -{ - return QIcon(":/icons/pqLock.png"); -} - -Operator* SnapshotOperator::clone() const -{ - return new SnapshotOperator(m_dataSource); -} - -QJsonObject SnapshotOperator::serialize() const -{ - auto json = Operator::serialize(); - - if (hasChildDataSource() && childDataSource() != nullptr && - childDataSource()->persistenceState() == - DataSource::PersistenceState::Saved) { - json["update"] = false; - } - - return json; -} - -bool SnapshotOperator::deserialize(const QJsonObject& json) -{ - if (json.contains("update")) { - m_updateCache = json["update"].toBool(false); - } - - return true; -} - -QWidget* SnapshotOperator::getCustomProgressWidget(QWidget*) const -{ - return nullptr; -} - -bool SnapshotOperator::applyTransform(vtkDataObject* dataObject) -{ - if (!m_updateCache) { - // We already ran once, now mark as successful and leave child data alone. - return true; - } - - m_updateCache = false; - vtkImageData* imageData = vtkImageData::SafeDownCast(dataObject); - if (!imageData) { - return false; - } - - vtkNew cacheImage; - cacheImage->DeepCopy(imageData); - - emit newChildDataSource("Snapshot", cacheImage.Get()); - return true; -} -} // namespace tomviz diff --git a/tomviz/operators/SnapshotOperator.h b/tomviz/operators/SnapshotOperator.h deleted file mode 100644 index c95903a1f..000000000 --- a/tomviz/operators/SnapshotOperator.h +++ /dev/null @@ -1,40 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizSnapshotOperator_h -#define tomvizSnapshotOperator_h - -#include "Operator.h" - -namespace tomviz { -class DataSource; - -class SnapshotOperator : public Operator -{ - Q_OBJECT - -public: - SnapshotOperator(DataSource* source, QObject* parent = nullptr); - - QString label() const override { return "Snapshot"; } - - QIcon icon() const override; - - Operator* clone() const override; - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - QWidget* getCustomProgressWidget(QWidget*) const override; - -protected: - bool applyTransform(vtkDataObject* data) override; - -private: - DataSource* m_dataSource; - bool m_updateCache = true; // Update the first time, then freeze. - Q_DISABLE_COPY(SnapshotOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/TranslateAlignOperator.cxx b/tomviz/operators/TranslateAlignOperator.cxx deleted file mode 100644 index 9bcb4754a..000000000 --- a/tomviz/operators/TranslateAlignOperator.cxx +++ /dev/null @@ -1,215 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "TranslateAlignOperator.h" - -#include "AlignWidget.h" -#include "DataSource.h" -#include "OperatorResult.h" - -#include "vtkImageData.h" -#include "vtkIntArray.h" -#include "vtkNew.h" -#include "vtkPointData.h" -#include "vtkTable.h" - -#include - -namespace { - -// We are assuming an image that begins at 0, 0, 0. -vtkIdType imageIndex(const vtkVector3i& incs, const vtkVector3i& pos) -{ - return pos[0] * incs[0] + pos[1] * incs[1] + pos[2] * incs[2]; -} - -template -void applyImageOffsets(T* in, T* out, vtkImageData* image, - const QVector& offsets) -{ - // We know that the input and output images are the same size, with the - // supplied offsets applied to each slice. Copy the pixels, applying offsets. - int* extents = image->GetExtent(); - vtkVector3i extent(extents[1] - extents[0] + 1, extents[3] - extents[2] + 1, - extents[5] - extents[4] + 1); - vtkVector3i incs(1, 1 * extent[0], 1 * extent[0] * extent[1]); - - // Zero out our output array, we should do this more intelligently in future. - T* ptr = out; - for (int i = 0; i < extent[0] * extent[1] * extent[2]; ++i) { - *ptr++ = 0; - } - - // We need to go slice by slice, applying the pixel offsets to the new image. - for (int i = 0; i < extent[2]; ++i) { - vtkVector2i offset = offsets[i]; - int idx = imageIndex(incs, vtkVector3i(0, 0, i)); - T* inPtr = in + idx; - T* outPtr = out + idx; - for (int y = 0; y < extent[1]; ++y) { - if (y + offset[1] >= extent[1]) { - break; - } else if (y + offset[1] < 0) { - inPtr += incs[1]; - outPtr += incs[1]; - continue; - } - for (int x = 0; x < extent[0]; ++x) { - if (x + offset[0] >= extent[0]) { - inPtr += offset[0]; - outPtr += offset[0]; - break; - } else if (x + offset[0] < 0) { - ++inPtr; - ++outPtr; - continue; - } - *(outPtr + offset[0] + incs[1] * offset[1]) = *inPtr; - ++inPtr; - ++outPtr; - } - } - } -} -} // namespace - -namespace tomviz { -TranslateAlignOperator::TranslateAlignOperator(DataSource* ds, QObject* p) - : Operator(p), dataSource(ds) -{ - initializeResults(); -} - -QIcon TranslateAlignOperator::icon() const -{ - return QIcon(""); -} - -void TranslateAlignOperator::initializeResults() -{ - setNumberOfResults(1); - auto res = resultAt(0); - res->setName("alignments"); - res->setLabel("Alignments"); - vtkNew table; - setResult(0, table); -} - -bool TranslateAlignOperator::applyTransform(vtkDataObject* data) -{ - vtkNew outImage; - vtkImageData* inImage = vtkImageData::SafeDownCast(data); - assert(inImage); - outImage->DeepCopy(data); - - auto numArrays = inImage->GetPointData()->GetNumberOfArrays(); - - for (int i = 0; i < numArrays; ++i) { - std::string arrayName = inImage->GetPointData()->GetArrayName(i); - switch (inImage->GetScalarType()) { - vtkTemplateMacro( - applyImageOffsets( - reinterpret_cast( - inImage->GetPointData()->GetScalars(arrayName.c_str())->GetVoidPointer(0)), - reinterpret_cast( - outImage->GetPointData()->GetScalars(arrayName.c_str())->GetVoidPointer(0)), - inImage, offsets)); - } - } - - offsetsToResult(); - data->ShallowCopy(outImage); - return true; -} - -Operator* TranslateAlignOperator::clone() const -{ - TranslateAlignOperator* op = new TranslateAlignOperator(this->dataSource); - op->setAlignOffsets(this->offsets); - return op; -} - -void TranslateAlignOperator::offsetsToResult() -{ - vtkNew arrX; - arrX->SetName("X Offset"); - vtkNew arrY; - arrY->SetName("Y Offset"); - - vtkNew table; - table->AddColumn(arrX); - table->AddColumn(arrY); - table->SetNumberOfRows(offsets.size()); - - for (int i = 0; i < offsets.size(); ++i) { - table->SetValue(i, 0, offsets[i][0]); - table->SetValue(i, 1, offsets[i][1]); - } - setResult(0, table); -} - -QJsonObject TranslateAlignOperator::serialize() const -{ - auto json = Operator::serialize(); - - QJsonArray offsetArray; - foreach (auto offset, this->offsets) { - offsetArray << offset[0] << offset[1]; - } - json["offsets"] = offsetArray; - - if (m_draftOffsets.size() > 0) { - QJsonArray draftOffsetArray; - foreach (auto offset, this->m_draftOffsets) { - draftOffsetArray << offset[0] << offset[1]; - } - json["draftOffsets"] = draftOffsetArray; - } - - return json; -} - -bool TranslateAlignOperator::deserialize(const QJsonObject& json) -{ - if (json.contains("offsets") && json["offsets"].isArray()) { - auto offsetArray = json["offsets"].toArray(); - this->offsets.resize(offsetArray.size() / 2); - for (int i = 0; i < offsetArray.size() / 2; ++i) { - this->offsets[i][0] = offsetArray[2 * i].toInt(); - this->offsets[i][1] = offsetArray[2 * i + 1].toInt(); - } - } - - if (json.contains("draftOffsets") && json["draftOffsets"].isArray()) { - auto draftOffsetArray = json["draftOffsets"].toArray(); - this->m_draftOffsets.resize(draftOffsetArray.size() / 2); - for (int i = 0; i < draftOffsetArray.size() / 2; ++i) { - this->m_draftOffsets[i][0] = draftOffsetArray[2 * i].toInt(); - this->m_draftOffsets[i][1] = draftOffsetArray[2 * i + 1].toInt(); - } - } - - return true; -} - -EditOperatorWidget* TranslateAlignOperator::getEditorContentsWithData( - QWidget* p, vtkSmartPointer data) -{ - return new AlignWidget(this, data, p); -} - -void TranslateAlignOperator::setAlignOffsets( - const QVector& newOffsets) -{ - this->offsets.resize(newOffsets.size()); - std::copy(newOffsets.begin(), newOffsets.end(), this->offsets.begin()); - emit this->transformModified(); -} - -void TranslateAlignOperator::setDraftAlignOffsets( - const QVector& newOffsets) -{ - this->m_draftOffsets.resize(newOffsets.size()); - std::copy(newOffsets.begin(), newOffsets.end(), this->m_draftOffsets.begin()); -} -} // namespace tomviz diff --git a/tomviz/operators/TranslateAlignOperator.h b/tomviz/operators/TranslateAlignOperator.h deleted file mode 100644 index 06f7d6a5c..000000000 --- a/tomviz/operators/TranslateAlignOperator.h +++ /dev/null @@ -1,59 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizTranslateAlignOperator_h -#define tomvizTranslateAlignOperator_h - -#include "Operator.h" - -#include "vtkVector.h" - -#include -#include - -namespace tomviz { - -class DataSource; - -class TranslateAlignOperator : public Operator -{ - Q_OBJECT - -public: - TranslateAlignOperator(DataSource* dataSource, QObject* parent = nullptr); - - QString label() const override { return "Translation Align"; } - QIcon icon() const override; - Operator* clone() const override; - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - EditOperatorWidget* getEditorContentsWithData( - QWidget* parent, vtkSmartPointer data) override; - - void setAlignOffsets(const QVector& offsets); - void setDraftAlignOffsets(const QVector& offsets); - const QVector& getAlignOffsets() const { return offsets; } - const QVector& getDraftAlignOffsets() const - { - return m_draftOffsets; - } - - DataSource* getDataSource() const { return this->dataSource; } - - bool hasCustomUI() const override { return true; } - -protected: - bool applyTransform(vtkDataObject* data) override; - void offsetsToResult(); - void initializeResults(); - -private: - QVector offsets; - QVector m_draftOffsets; - const QPointer dataSource; -}; -} // namespace tomviz - -#endif diff --git a/tomviz/operators/TransposeDataOperator.cxx b/tomviz/operators/TransposeDataOperator.cxx deleted file mode 100644 index 29f939ba9..000000000 --- a/tomviz/operators/TransposeDataOperator.cxx +++ /dev/null @@ -1,198 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "TransposeDataOperator.h" - -#include "EditOperatorWidget.h" - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -namespace { - -class TransposeDataWidget : public tomviz::EditOperatorWidget -{ - Q_OBJECT - -public: - TransposeDataWidget(tomviz::TransposeDataOperator* source, - vtkSmartPointer imageData, QWidget* p) - : tomviz::EditOperatorWidget(p), m_operator(source), - m_transposeTypesCombo(nullptr) - { - Q_UNUSED(imageData) - - // Set up UI... - auto* transposeLabel = new QLabel("Transpose to:", this); - transposeLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - m_transposeTypesCombo = new QComboBox(this); - using TransposeType = tomviz::TransposeDataOperator::TransposeType; - // This will ensure the combo box indexing matches that of the enum... - m_transposeTypesCombo->insertItem(static_cast(TransposeType::C), - "C Ordering"); - m_transposeTypesCombo->insertItem(static_cast(TransposeType::Fortran), - "Fortran Ordering"); - - auto* vBoxLayout = new QVBoxLayout(this); - - auto* convertHBoxLayout = new QHBoxLayout; - convertHBoxLayout->addWidget(transposeLabel); - convertHBoxLayout->addWidget(m_transposeTypesCombo); - vBoxLayout->addLayout(convertHBoxLayout); - - setLayout(vBoxLayout); - } - - void applyChangesToOperator() override - { - // The combo box and enum indices should match - using TransposeType = tomviz::TransposeDataOperator::TransposeType; - m_operator->setTransposeType( - static_cast(m_transposeTypesCombo->currentIndex())); - } - -private: - QPointer m_operator; - QComboBox* m_transposeTypesCombo; -}; -} // namespace - -#include "TransposeDataOperator.moc" - -namespace { - -template -void ReorderArrayC(T* in, T* out, int dim[3]) -{ - for (int i = 0; i < dim[0]; ++i) { - for (int j = 0; j < dim[1]; ++j) { - for (int k = 0; k < dim[2]; ++k) { - out[static_cast(i * dim[1] + j) * dim[2] + k] = - in[static_cast(k * dim[1] + j) * dim[0] + i]; - } - } - } -} - -template -void ReorderArrayF(T* in, T* out, int dim[3]) -{ - for (int i = 0; i < dim[0]; ++i) { - for (int j = 0; j < dim[1]; ++j) { - for (int k = 0; k < dim[2]; ++k) { - out[static_cast(k * dim[1] + j) * dim[0] + i] = - in[static_cast(i * dim[1] + j) * dim[2] + k]; - } - } - } -} - -} - -namespace tomviz { - -TransposeDataOperator::TransposeDataOperator(QObject* p) : Operator(p) -{ -} - -QIcon TransposeDataOperator::icon() const -{ - return QIcon(); -} - -bool TransposeDataOperator::applyTransform(vtkDataObject* data) -{ - auto imageData = vtkImageData::SafeDownCast(data); - // sanity check - if (!imageData) { - qDebug() << "Error in" << __FUNCTION__ << ": imageData is nullptr!"; - return false; - } - - int dim[3] = { 0, 0, 0 }; - imageData->GetDimensions(dim); - - // We must allocate a new array, and copy the reordered array into it. - auto scalars = imageData->GetPointData()->GetScalars(); - auto dataPtr = scalars->GetVoidPointer(0); - - vtkNew reorderedImageData; - reorderedImageData->SetDimensions(dim); - reorderedImageData->AllocateScalars(scalars->GetDataType(), - scalars->GetNumberOfComponents()); - - auto outputArray = reorderedImageData->GetPointData()->GetScalars(); - outputArray->SetName(scalars->GetName()); - - auto outPtr = outputArray->GetVoidPointer(0); - - switch (m_transposeType) { - case TransposeType::C: - switch (scalars->GetDataType()) { - vtkTemplateMacro(ReorderArrayC( - reinterpret_cast(dataPtr), reinterpret_cast(outPtr), - dim)); - default: - qDebug() << "TransposeType: Unknown data type"; - } - break; - case TransposeType::Fortran: - switch (scalars->GetDataType()) { - vtkTemplateMacro(ReorderArrayF( - reinterpret_cast(dataPtr), reinterpret_cast(outPtr), - dim)); - default: - qDebug() << "TransposeType: Unknown data type"; - } - break; - default: - qDebug() << "Error in" << __FUNCTION__ << ": unknown transpose type!"; - return false; - } - - imageData->GetPointData()->RemoveArray(scalars->GetName()); - imageData->GetPointData()->SetScalars(outputArray); - - return true; -} - -QJsonObject TransposeDataOperator::serialize() const -{ - auto json = Operator::serialize(); - QJsonValue transposeType(static_cast(m_transposeType)); - json["transposeType"] = transposeType; - return json; -} - -bool TransposeDataOperator::deserialize(const QJsonObject& json) -{ - if (json.contains("transposeType")) - m_transposeType = static_cast(json["transposeType"].toInt()); - - return true; -} - -Operator* TransposeDataOperator::clone() const -{ - auto* other = new TransposeDataOperator(); - other->setTransposeType(m_transposeType); - return other; -} - -EditOperatorWidget* TransposeDataOperator::getEditorContentsWithData( - QWidget* p, vtkSmartPointer data) -{ - return new TransposeDataWidget(this, data, p); -} - -} // namespace tomviz diff --git a/tomviz/operators/TransposeDataOperator.h b/tomviz/operators/TransposeDataOperator.h deleted file mode 100644 index 1e6944ffe..000000000 --- a/tomviz/operators/TransposeDataOperator.h +++ /dev/null @@ -1,46 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#ifndef tomvizTransposeDataOperator_h -#define tomvizTransposeDataOperator_h - -#include "Operator.h" - -namespace tomviz { - -class TransposeDataOperator : public Operator -{ - Q_OBJECT - -public: - TransposeDataOperator(QObject* parent = nullptr); - - QString label() const override { return "Transpose Data"; } - QIcon icon() const override; - Operator* clone() const override; - - bool applyTransform(vtkDataObject* data) override; - - EditOperatorWidget* getEditorContentsWithData( - QWidget* parent, vtkSmartPointer data) override; - bool hasCustomUI() const override { return true; } - - QJsonObject serialize() const override; - bool deserialize(const QJsonObject& json) override; - - enum class TransposeType - { - C, - Fortran - }; - - void setTransposeType(TransposeType t) { m_transposeType = t; } - -private: - TransposeType m_transposeType = TransposeType::C; - - Q_DISABLE_COPY(TransposeDataOperator) -}; -} // namespace tomviz - -#endif diff --git a/tomviz/pipeline/CustomNodeWidgetRegistry.cxx b/tomviz/pipeline/CustomNodeWidgetRegistry.cxx new file mode 100644 index 000000000..cf226c238 --- /dev/null +++ b/tomviz/pipeline/CustomNodeWidgetRegistry.cxx @@ -0,0 +1,38 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "CustomNodeWidgetRegistry.h" + +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +QHash& registry() +{ + static QHash r; + return r; +} + +} // namespace + +void registerCustomNodeWidget(const QString& id, + const CustomWidgetInfo& info) +{ + registry().insert(id, info); +} + +const CustomWidgetInfo* findCustomNodeWidget(const QString& id) +{ + auto& r = registry(); + auto it = r.constFind(id); + if (it == r.constEnd()) { + return nullptr; + } + return &it.value(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/CustomNodeWidgetRegistry.h b/tomviz/pipeline/CustomNodeWidgetRegistry.h new file mode 100644 index 000000000..22a84c73b --- /dev/null +++ b/tomviz/pipeline/CustomNodeWidgetRegistry.h @@ -0,0 +1,73 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineCustomNodeWidgetRegistry_h +#define tomvizPipelineCustomNodeWidgetRegistry_h + +#include "PortData.h" + +#include +#include + +#include + +class QWidget; + +namespace tomviz { +namespace pipeline { + +class CustomPythonNodeWidget; + +/// Registration info for a custom widget that replaces the +/// auto-generated parameter UI on a Python node (source or transform). +/// Schema-v1 (LegacyPythonTransform) and schema-v2 (PythonTransform / +/// PythonSource) nodes share this registry and look up widgets by the +/// JSON description's ``widget`` field. +struct CustomWidgetInfo +{ + /// True if widget creation should wait until the host node's input + /// ports carry data (typical for transform widgets that render a + /// preview over an input volume). Always set false for source-shape + /// widgets — there are no inputs to wait for. Independent of whether + /// the widget actually consumes the inputs at construction time: a + /// transform widget that ignores ``inputs`` registers with + /// needsData=false. + bool needsData = false; + + /// Factory: builds the widget from the host node's current input + /// data. Widgets that don't consume inputs ignore the @a inputs + /// parameter; sources always receive an empty map. + std::function& inputs, QWidget* parent)> + create; +}; + +/// Register a custom widget factory under @a id. Calling again with +/// the same id overwrites the previous registration. +void registerCustomNodeWidget(const QString& id, + const CustomWidgetInfo& info); + +/// Convenience overload: register a widget whose constructor matches +/// the standard ``T(const QMap& inputs, QWidget* +/// parent)`` signature. Most existing widgets use this — the explicit +/// CustomWidgetInfo overload is the escape hatch for unusual cases. +template +void registerCustomNodeWidget(const QString& id, bool needsData = false) +{ + registerCustomNodeWidget( + id, { needsData, + [](const QMap& inputs, QWidget* parent) + -> CustomPythonNodeWidget* { + return new T(inputs, parent); + } }); +} + +/// Look up a previously-registered widget. Returns nullptr if no +/// widget is registered under @a id. The returned pointer is owned by +/// the registry and remains valid for the program's lifetime. +const CustomWidgetInfo* findCustomNodeWidget(const QString& id); + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/CustomPythonNodeWidget.cxx b/tomviz/pipeline/CustomPythonNodeWidget.cxx new file mode 100644 index 000000000..ba8b01bfb --- /dev/null +++ b/tomviz/pipeline/CustomPythonNodeWidget.cxx @@ -0,0 +1,14 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "CustomPythonNodeWidget.h" + +namespace tomviz { +namespace pipeline { + +CustomPythonNodeWidget::CustomPythonNodeWidget(QWidget* p) : QWidget(p) {} + +CustomPythonNodeWidget::~CustomPythonNodeWidget() = default; + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/CustomPythonNodeWidget.h b/tomviz/pipeline/CustomPythonNodeWidget.h new file mode 100644 index 000000000..050284609 --- /dev/null +++ b/tomviz/pipeline/CustomPythonNodeWidget.h @@ -0,0 +1,45 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineCustomPythonNodeWidget_h +#define tomvizPipelineCustomPythonNodeWidget_h + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +/// Base class for custom parameter widgets that replace the +/// auto-generated parameter UI for specific Python nodes (sources or +/// transforms). Concrete subclasses are registered with +/// :func:`registerCustomNodeWidget` keyed on the JSON description's +/// ``widget`` field. +class CustomPythonNodeWidget : public QWidget +{ + Q_OBJECT + +public: + CustomPythonNodeWidget(QWidget* parent = nullptr); + ~CustomPythonNodeWidget() override; + + virtual void getValues(QMap& map) = 0; + virtual void setValues(const QMap& map) = 0; + + /// Keep a copy of the current script (including edits) in case the + /// custom widget needs to use it (e.g. for running test Python code). + virtual void setScript(const QString& script) { m_script = script; } + + /// Called when the operator is applied (e.g. to persist settings). + virtual void writeSettings() {} + +protected: + QString m_script; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/DefaultExecutor.cxx b/tomviz/pipeline/DefaultExecutor.cxx new file mode 100644 index 000000000..cbbcc9ab3 --- /dev/null +++ b/tomviz/pipeline/DefaultExecutor.cxx @@ -0,0 +1,173 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "DefaultExecutor.h" + +#include "InputPort.h" +#include "InternalNodeExecutor.h" +#include "Link.h" +#include "Node.h" +#include "NodeExecutor.h" +#include "OutputPort.h" +#include "PassthroughOutputPort.h" +#include "Pipeline.h" +#include "PortData.h" + +#include + +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +/// Walk through any chain of PassthroughOutputPorts to reach the +/// original data-owning OutputPort. SinkGroupNode's passthroughs carry +/// no data of their own — the executor's in-flight map is keyed by the +/// upstream producer, so consumers behind a passthrough need the +/// resolved source to find their handle. +OutputPort* resolveSourceOutput(OutputPort* output) +{ + while (auto* pt = qobject_cast(output)) { + output = pt->source(); + if (!output) { + return nullptr; + } + } + return output; +} + +} // namespace + +DefaultExecutor::DefaultExecutor(QObject* parent) + : PipelineExecutor(parent) +{} + +void DefaultExecutor::execute(const QList& nodes, Pipeline* pipeline) +{ + m_running = true; + m_cancelRequested = false; + + // Keeps published transient outputs alive across the plan window so + // consumers reading via inputPort->data() (or sinks stashing copies) + // see live data. Populated lazily: an entry is created the first time + // an in-plan consumer reads a given source. The map drops at + // end-of-plan, so transient outputs whose handles aren't retained by + // anyone evict automatically. Outputs without any in-plan consumer + // are never taken from — their m_strong stays on the port, so the + // data survives until a future plan delivers it to a consumer (e.g. + // an executeUpstreamOf(target) run where target itself reads later). + QHash> inflight; + + bool breakpointHit = false; + + for (auto* node : nodes) { + if (m_cancelRequested) { + m_running = false; + emit canceled(); + emit executionComplete(false); + return; + } + + if (node->hasBreakpoint()) { + // Skip just this node — siblings that don't depend on it can + // still run. Downstream consumers see anyInputStale() == true + // and skip themselves on the next iterations. + emit pipeline->breakpointReached(node); + breakpointHit = true; + continue; + } + + // No "skip Current" filter here: the plan handed to the executor is + // already trimmed by Pipeline::executionOrder, which deliberately + // re-includes Current nodes whose required outputs were evicted + // (transient data dropped after the last run). Filtering at runtime + // would silently drop those re-runs and feed downstream consumers + // empty payloads — most visibly in SinkGroupNode chains, where the + // passthrough output's hasData() reflects an upstream that the + // executor just skipped. + + // Skip nodes with invalid (type-incompatible) input links + if (node->hasInvalidInputLinks()) { + emit nodeExecutionStarted(node); + emit nodeExecutionFinished(node, false); + continue; + } + + // Skip nodes whose inputs are stale due to upstream failure/cancellation + if (node->anyInputStale()) { + continue; + } + + // Deliver an upstream-payload handle to each input port before the + // node runs. The handle is copied from the in-flight map (lazily + // taken if the producer wasn't itself in the plan, e.g. a + // Current+populated persistent source whose data hasn't been + // taken yet this plan). Sinks copy this handle into their own + // member; transforms read through it and discard. + for (auto* input : node->inputPorts()) { + auto* link = input->link(); + if (!link || !link->from()) { + continue; + } + auto* source = resolveSourceOutput(link->from()); + if (!source) { + continue; + } + auto it = inflight.constFind(source); + if (it == inflight.constEnd()) { + if (auto h = source->take()) { + it = inflight.insert(source, h); + } + } + if (it != inflight.constEnd()) { + input->setHandle(it.value()); + } + } + + auto* nx = node->nodeExecutor(); + if (!nx) { + nx = &InternalNodeExecutor::instance(); + } + + emit nodeExecutionStarted(node); + bool success = nx->execute(node); + emit nodeExecutionFinished(node, success); + + // Drop the delivered handles — the consumer kept its own copy if + // it wanted retention. Leaving them on the input port would pin + // upstream data alive for the lifetime of the InputPort and + // defeat transient eviction. + for (auto* input : node->inputPorts()) { + input->clearHandle(); + } + + if (!success) { + // Mark downstream nodes stale so they are skipped. + node->markStale(); + continue; + } + + // No eager take here. Publications sit on the producer's output + // port via m_strong until an in-plan consumer's input-delivery + // loop above does the lazy take. Outputs with no in-plan consumer + // simply stay pinned on the port for the next plan to find. + } + + m_running = false; + emit executionComplete(!breakpointHit); +} + +void DefaultExecutor::cancel() +{ + m_cancelRequested = true; +} + +bool DefaultExecutor::isRunning() const +{ + return m_running; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/DefaultExecutor.h b/tomviz/pipeline/DefaultExecutor.h new file mode 100644 index 000000000..97c9d3d15 --- /dev/null +++ b/tomviz/pipeline/DefaultExecutor.h @@ -0,0 +1,34 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineDefaultExecutor_h +#define tomvizPipelineDefaultExecutor_h + +#include "PipelineExecutor.h" + +#include + +namespace tomviz { +namespace pipeline { + +class DefaultExecutor : public PipelineExecutor +{ + Q_OBJECT + +public: + DefaultExecutor(QObject* parent = nullptr); + ~DefaultExecutor() override = default; + + void execute(const QList& nodes, Pipeline* pipeline) override; + void cancel() override; + bool isRunning() const override; + +private: + std::atomic m_cancelRequested{ false }; + bool m_running = false; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/DeferredLinkInfo.h b/tomviz/pipeline/DeferredLinkInfo.h new file mode 100644 index 000000000..f25713f73 --- /dev/null +++ b/tomviz/pipeline/DeferredLinkInfo.h @@ -0,0 +1,67 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineDeferredLinkInfo_h +#define tomvizPipelineDeferredLinkInfo_h + +#include "InputPort.h" +#include "Node.h" +#include "NodeState.h" +#include "OutputPort.h" + +#include +#include + +namespace tomviz { +namespace pipeline { + +/// Captures everything needed to undo an eagerly-performed transform +/// insertion if the user cancels the edit dialog. +/// +/// The insertion is applied to the pipeline immediately so the preview shows +/// the transform in its final, inserted position rather than branching off +/// the upstream port. If the user cancels, the new node is removed (which +/// drops its own links) and this struct is used to put the pipeline back +/// exactly as it was: the original links are recreated and the node/port +/// states captured before the insertion are restored, so cancel is a true +/// no-op and nothing is left spuriously stale. +/// +/// For source-shaped insertions (no links to break) this is left empty; the +/// dialog then just removes the node on cancel. +struct DeferredLinkInfo +{ + struct LinkEndpoints + { + QPointer from; + QPointer to; + }; + + struct NodeStateSnapshot + { + QPointer node; + NodeState state; + }; + + struct PortStaleSnapshot + { + QPointer port; + bool stale; + }; + + /// Original links to recreate on cancel. + QList linksToRestore; + + /// Node states captured before the insertion, restored on cancel. + QList nodeStates; + + /// Output-port stale flags captured before the insertion, restored on + /// cancel. + QList portStaleStates; + + bool isEmpty() const { return linksToRestore.isEmpty(); } +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/EditNodeWidget.cxx b/tomviz/pipeline/EditNodeWidget.cxx new file mode 100644 index 000000000..6773dba46 --- /dev/null +++ b/tomviz/pipeline/EditNodeWidget.cxx @@ -0,0 +1,14 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "EditNodeWidget.h" + +namespace tomviz { +namespace pipeline { + +EditNodeWidget::EditNodeWidget(QWidget* parent) : QWidget(parent) {} + +EditNodeWidget::~EditNodeWidget() = default; + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/EditNodeWidget.h b/tomviz/pipeline/EditNodeWidget.h new file mode 100644 index 000000000..e9697201b --- /dev/null +++ b/tomviz/pipeline/EditNodeWidget.h @@ -0,0 +1,51 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineEditNodeWidget_h +#define tomvizPipelineEditNodeWidget_h + +#include + +namespace tomviz { +namespace pipeline { + +/// Base class for custom node editing widgets. +/// +/// Subclasses implement applyChangesToOperator() to commit the current UI +/// state to the node's parameters. The wrapper (NodePropertiesPanel +/// or NodeEditDialog) owns the Apply/OK/Cancel buttons and calls this +/// slot when the user clicks them. +class EditNodeWidget : public QWidget +{ + Q_OBJECT + +public: + EditNodeWidget(QWidget* parent = nullptr); + ~EditNodeWidget() override; + + /// Whether an Apply (or OK) on this widget would do useful work. + /// Wrappers gate the Apply/OK button enabled state on this. Default + /// is true; subclasses that render in a degraded state (e.g. a + /// GatedEditorWidget showing the not-ready warning) override to + /// return false until they have a real editor to commit. + virtual bool canApply() const { return true; } + + /// Documentation URL for the node being edited, or empty if none. + /// Relative paths are resolved against the Tomviz docs base URL by + /// openHelpUrl(). Wrappers show a Help button when non-empty. + virtual QString helpUrl() const { return QString(); } + +signals: + /// Emitted when the return value of canApply() may have changed. + /// Wrappers listen to this to refresh their Apply/OK enablement. + void canApplyChanged(); + +public slots: + /// Apply the current widget state to the node's parameters. + virtual void applyChangesToOperator() = 0; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/ExecutionFuture.cxx b/tomviz/pipeline/ExecutionFuture.cxx new file mode 100644 index 000000000..e75d2f8ba --- /dev/null +++ b/tomviz/pipeline/ExecutionFuture.cxx @@ -0,0 +1,59 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ExecutionFuture.h" + +namespace tomviz { +namespace pipeline { + +ExecutionFuture::ExecutionFuture(QObject* parent) : QObject(parent) {} + +bool ExecutionFuture::isFinished() const +{ + return m_finished; +} + +bool ExecutionFuture::wasCanceled() const +{ + return m_canceled; +} + +bool ExecutionFuture::succeeded() const +{ + return m_success; +} + +void ExecutionFuture::deleteWhenFinished() +{ + if (m_finished) { + deleteLater(); + } else { + connect(this, &ExecutionFuture::finished, this, + &ExecutionFuture::deleteLater); + } +} + +void ExecutionFuture::setFinished(bool success) +{ + if (m_finished) { + return; + } + m_finished = true; + m_success = success; + emit finished(); +} + +void ExecutionFuture::setCanceled() +{ + if (m_finished) { + return; + } + m_canceled = true; + m_finished = true; + m_success = false; + emit canceled(); + emit finished(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/ExecutionFuture.h b/tomviz/pipeline/ExecutionFuture.h new file mode 100644 index 000000000..84ea15050 --- /dev/null +++ b/tomviz/pipeline/ExecutionFuture.h @@ -0,0 +1,46 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineExecutionFuture_h +#define tomvizPipelineExecutionFuture_h + +#include + +namespace tomviz { +namespace pipeline { + +class Pipeline; + +class ExecutionFuture : public QObject +{ + Q_OBJECT + +public: + ExecutionFuture(QObject* parent = nullptr); + ~ExecutionFuture() override = default; + + bool isFinished() const; + bool wasCanceled() const; + bool succeeded() const; + + void deleteWhenFinished(); + +signals: + void finished(); + void canceled(); + +private: + friend class Pipeline; + + void setFinished(bool success); + void setCanceled(); + + bool m_finished = false; + bool m_canceled = false; + bool m_success = false; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/ExternalNodeExecutor.cxx b/tomviz/pipeline/ExternalNodeExecutor.cxx new file mode 100644 index 000000000..5341d7cc8 --- /dev/null +++ b/tomviz/pipeline/ExternalNodeExecutor.cxx @@ -0,0 +1,582 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ExternalNodeExecutor.h" + +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "NodeFactory.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "PortData.h" +#include "PortDataMetadata.h" +#include "ProgressReader.h" +#include "ThreadUtils.h" +#include "SourceNode.h" + +#include "Tvh5Format.h" +#include "data/VolumeData.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +constexpr int kShimSourceId = 1; +constexpr int kShimTargetId = 2; + +bool useSocketProgress() +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MAC) + return false; +#else + return true; +#endif +} + +} // namespace + +ExternalNodeExecutor::ExternalNodeExecutor(QObject* parent) + : NodeExecutor(parent) +{ +} + +ExternalNodeExecutor::ExternalNodeExecutor(const QString& envPath, + QObject* parent) + : NodeExecutor(parent), m_envPath(envPath) +{ +} + +ExternalNodeExecutor::~ExternalNodeExecutor() = default; + +QString ExternalNodeExecutor::typeString() +{ + return QStringLiteral("external"); +} + +QString ExternalNodeExecutor::type() const +{ + return typeString(); +} + +QString ExternalNodeExecutor::envPath() const +{ + return m_envPath; +} + +void ExternalNodeExecutor::setEnvPath(const QString& path) +{ + m_envPath = path; +} + +QJsonObject ExternalNodeExecutor::serialize() const +{ + QJsonObject json; + json[QStringLiteral("envPath")] = m_envPath; + return json; +} + +bool ExternalNodeExecutor::deserialize(const QJsonObject& json) +{ + m_envPath = json.value(QStringLiteral("envPath")).toString(); + return true; +} + +QString ExternalNodeExecutor::findCliExecutable() const +{ + if (m_envPath.isEmpty()) { + return QString(); + } + QDir envDir(m_envPath); +#if defined(Q_OS_WIN) + QFileInfo info(envDir.filePath(QStringLiteral("Scripts/tomviz-pipeline.exe"))); +#else + QFileInfo info(envDir.filePath(QStringLiteral("bin/tomviz-pipeline"))); +#endif + if (!info.exists() || !info.isExecutable()) { + return QString(); + } + return info.absoluteFilePath(); +} + +QString ExternalNodeExecutor::writeShimTvh5(Node* target, + const QTemporaryDir& dir, + int& targetNodeId) const +{ + if (!target) { + return QString(); + } + + // Synthetic SourceNode mirroring the target's connected inputs + + // a fresh clone of the target wired to it. + Pipeline shim; + + auto* shimSource = new SourceNode(); + shim.addNode(shimSource); + shim.setNodeId(shimSource, kShimSourceId); + shimSource->setLabel(QStringLiteral("ExternalShimSource")); + + // Override the input's active scalar with the target's previous + // output's active so apply_to_each_array's merge target preserves + // the user's selection across re-runs. + QString preferredActive; + for (auto* outPort : target->outputPorts()) { + if (!outPort->hasData() || !isVolumeType(outPort->data().type())) { + continue; + } + try { + auto prevVol = outPort->data().value(); + if (prevVol && prevVol->imageData()) { + if (auto* scalars = + prevVol->imageData()->GetPointData()->GetScalars()) { + if (auto* name = scalars->GetName()) { + preferredActive = QString::fromUtf8(name); + break; + } + } + } + } catch (const std::bad_any_cast&) { + } + } + + for (auto* input : target->inputPorts()) { + if (!input->link() || !input->hasData()) { + qWarning() << "ExternalNodeExecutor: input port" << input->name() + << "has no data; cannot run externally."; + return QString(); + } + PortData payload = input->data(); + // Deep copy on override — upstream data must stay untouched. + if (!preferredActive.isEmpty() && isVolumeType(payload.type())) { + try { + auto inputVol = payload.value(); + if (inputVol && inputVol->imageData() && + inputVol->imageData()->GetPointData()->HasArray( + preferredActive.toUtf8().constData())) { + vtkNew copy; + copy->DeepCopy(inputVol->imageData()); + copy->GetPointData()->SetActiveScalars( + preferredActive.toUtf8().constData()); + auto overriddenVol = std::make_shared(copy.Get()); + payload = PortData(std::any(overriddenVol), payload.type()); + } + } catch (const std::bad_any_cast&) { + } + } + shimSource->addOutput(input->name(), payload.type()); + shimSource->setOutputData(input->name(), payload); + } + + QString typeName = NodeFactory::typeName(target); + if (typeName.isEmpty()) { + qWarning() << "ExternalNodeExecutor: target node has no registered type; " + "cannot externalize."; + return QString(); + } + Node* targetClone = NodeFactory::create(typeName); + if (!targetClone) { + qWarning() << "ExternalNodeExecutor: NodeFactory could not create" + << typeName; + return QString(); + } + // Strip the "executor" block so the subprocess doesn't recurse into + // another ExternalNodeExecutor. + QJsonObject cloneJson = target->serialize(); + cloneJson.remove(QStringLiteral("executor")); + targetClone->deserialize(cloneJson); + shim.addNode(targetClone); + shim.setNodeId(targetClone, kShimTargetId); + targetNodeId = kShimTargetId; + + for (auto* input : target->inputPorts()) { + auto* srcPort = shimSource->outputPort(input->name()); + auto* dstPort = targetClone->inputPort(input->name()); + if (!srcPort || !dstPort) { + qWarning() << "ExternalNodeExecutor: missing port pairing for" + << input->name(); + return QString(); + } + shim.createLink(srcPort, dstPort); + } + + QString shimPath = QDir(dir.path()).filePath(QStringLiteral("shim.tvh5")); + if (!Tvh5Format::write(shimPath.toStdString(), &shim)) { + qWarning() << "ExternalNodeExecutor: failed to write shim tvh5 at" + << shimPath; + return QString(); + } + return shimPath; +} + +QMap ExternalNodeExecutor::decodeTvh5Outputs( + Node* target, int targetNodeId, const QString& tvh5Path) const +{ + QMap outputs; + if (!QFileInfo::exists(tvh5Path)) { + return outputs; + } + + // Transient pipeline with just the target's clone, pinned at the + // same id used in the tvh5 so populatePayloadData matches it. + Pipeline transient; + QString typeName = NodeFactory::typeName(target); + Node* clone = NodeFactory::create(typeName); + if (!clone) { + return outputs; + } + QJsonObject cloneJson = target->serialize(); + cloneJson.remove(QStringLiteral("executor")); + clone->deserialize(cloneJson); + // Drop pending per-port metadata stashed by deserialize — the file + // payload is the source of truth here, not the target's state + // snapshot from serialize(). + for (auto* port : clone->outputPorts()) { + port->clearPendingData(); + } + transient.addNode(clone); + transient.setNodeId(clone, targetNodeId); + + QJsonObject state = Tvh5Format::readState(tvh5Path.toStdString()); + if (state.isEmpty()) { + return outputs; + } + QJsonObject pipelineJson = + state.value(QStringLiteral("pipeline")).toObject(); + Tvh5Format::populatePayloadData(&transient, pipelineJson, + tvh5Path.toStdString()); + + for (auto* port : clone->outputPorts()) { + if (port->hasData()) { + outputs.insert(port->name(), port->data()); + } + } + return outputs; +} + +bool ExternalNodeExecutor::populateOutputs(Node* target, int targetNodeId, + const QString& outputPath) const +{ + QMap outputs = + decodeTvh5Outputs(target, targetNodeId, outputPath); + if (outputs.isEmpty()) { + qWarning() << "ExternalNodeExecutor: no output ports were populated from" + << outputPath; + return false; + } + // Must happen before the data reaches the ports: setting port data + // drives the sinks, so a label map that arrives without its + // segmentation colormap is rendered through the upstream volume's + // colormap - miscolored, and with a GPU lookup table VTK has to + // clamp. TransformNode::execute does the same for internally-executed + // nodes; keep the two paths in step. + inheritOutputMetadata(target, target->collectInputs(), outputs); + + // Route through Node::applyOutputs so volume payloads reuse the + // existing VolumeData instance instead of replacing it. That preserves + // the color map across re-executions — without this, every external + // re-run would swap in a fresh VolumeData carrying a default + // ColorMap and the user's choice would be lost. + target->applyOutputs(outputs); + return true; +} + +void ExternalNodeExecutor::handleIntermediate(Node* target, int targetNodeId, + const QString& tvh5Path) const +{ + // Same decode path as populateOutputs but routed through + // setIntermediateOutputs to preserve VolumeOutputPort identity. + QMap updates = + decodeTvh5Outputs(target, targetNodeId, tvh5Path); + if (!updates.isEmpty()) { + // Intermediates are rendered as they arrive, so they need the + // metadata step first too (see populateOutputs). + inheritOutputMetadata(target, target->collectInputs(), updates); + target->setIntermediateOutputs(updates); + } +} + +bool ExternalNodeExecutor::execute(Node* node) +{ + if (!node) { + return false; + } + + // Defensive: the shim builder needs the factory warmed up, and + // pipelines that haven't been saved/loaded yet wouldn't have it. + NodeFactory::registerBuiltins(); + + // Mirror TransformNode::execute's state transitions so the UI sees + // consistent Running/Failed/Canceled/Idle regardless of executor. + node->resetExecutionFlags(); + node->resetProgress(); + node->setExecState(NodeExecState::Running); + m_outputsFinalized.store(false); + + QString cli = findCliExecutable(); + if (cli.isEmpty()) { + QString msg = m_envPath.isEmpty() + ? QStringLiteral("No external Python environment selected. Choose an " + "environment containing tomviz-pipeline in the " + "Execution tab.") + : QStringLiteral("tomviz-pipeline was not found in '%1'. Select a " + "Python environment containing tomviz-pipeline in " + "the Execution tab.").arg(m_envPath); + qWarning() << "ExternalNodeExecutor:" << msg; + node->setProgressMessage(msg); + node->setExecState(NodeExecState::Failed); + return false; + } + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + qWarning() << "ExternalNodeExecutor: failed to create temp dir."; + node->setExecState(NodeExecState::Failed); + return false; + } + + int targetNodeId = -1; + QString shimPath = writeShimTvh5(node, tmpDir, targetNodeId); + if (shimPath.isEmpty()) { + node->setExecState(NodeExecState::Failed); + return false; + } + + QString outDir = QDir(tmpDir.path()).filePath(QStringLiteral("out")); + QDir().mkpath(outDir); + QString outputStatePath = + QDir(outDir).filePath(QStringLiteral("output_state.tvh5")); + + // Unix socket on Linux, polled file dir on macOS/Windows. + QString progressPath; + ProgressReader* reader = nullptr; + if (useSocketProgress()) { + progressPath = QDir(tmpDir.path()).filePath(QStringLiteral("progress.sock")); + reader = new LocalSocketProgressReader(progressPath); + } else { + progressPath = QDir(tmpDir.path()).filePath(QStringLiteral("progress")); + QDir().mkpath(progressPath); + reader = new FilesProgressReader(progressPath); + } + QScopedPointer readerHolder(reader); + m_reader = reader; + + // Forward subprocess progress messages onto the target node's + // progress signals. The id filter skips the shim source's events. + QObject::connect(reader, &ProgressReader::nodeProgressMaximum, node, + [node, targetNodeId](int id, int v) { + if (id == targetNodeId) { + node->setTotalProgressSteps(v); + } + }); + QObject::connect(reader, &ProgressReader::nodeProgressStep, node, + [node, targetNodeId](int id, int v) { + if (id == targetNodeId) { + node->setProgressStep(v); + } + }); + QObject::connect(reader, &ProgressReader::nodeProgressMessage, node, + [node, targetNodeId](int id, const QString& msg) { + if (id == targetNodeId) { + node->setProgressMessage(msg); + } + }); + QObject::connect(reader, &ProgressReader::nodeError, node, + [targetNodeId](int id, const QString& err) { + if (id == targetNodeId) { + qWarning() << "ExternalNodeExecutor: node" << id + << "reported error:" << err; + } + }); + // Live preview: subprocess writes a small tvh5 alongside the + // progress channel and sends its basename here. Resolve relative + // to the temp working dir and apply via setIntermediateOutputs. + QString workingDir = tmpDir.path(); + QObject::connect( + reader, &ProgressReader::nodeProgressData, node, + [this, node, targetNodeId, workingDir](int id, const QString& filename) { + if (id != targetNodeId || filename.isEmpty()) { + return; + } + // Drop tail-end intermediates that get delivered after we've + // installed the final result. Qt's queued-connection events + // already in the receiver's queue still fire after disconnect(). + if (m_outputsFinalized.load()) { + return; + } + QString tvh5Path = QDir(workingDir).filePath(filename); + handleIntermediate(node, targetNodeId, tvh5Path); + }); + + reader->start(); + + // Created on the calling thread so its socket notifiers are driven + // by our nested QEventLoop below. + QProcess process; + m_process = &process; + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.remove(QStringLiteral("TOMVIZ_APPLICATION")); + env.remove(QStringLiteral("PYTHONHOME")); + env.remove(QStringLiteral("PYTHONPATH")); + env.insert(QStringLiteral("PYTHONUNBUFFERED"), QStringLiteral("ON")); + process.setProcessEnvironment(env); + + QStringList args; + args << QStringLiteral("-s") << shimPath + << QStringLiteral("-o") << outDir + << QStringLiteral("--output-format") << QStringLiteral("state") + << QStringLiteral("-p") + << (useSocketProgress() ? QStringLiteral("socket") + : QStringLiteral("files")) + << QStringLiteral("-u") << progressPath; + + QEventLoop loop; + bool finishedCleanly = false; + int exitCode = -1; + QProcess::ExitStatus exitStatus = QProcess::NormalExit; + QObject::connect( + &process, + QOverload::of(&QProcess::finished), &loop, + [&](int code, QProcess::ExitStatus status) { + exitCode = code; + exitStatus = status; + finishedCleanly = true; + loop.quit(); + }); + QObject::connect(&process, &QProcess::errorOccurred, &loop, + [&, node](QProcess::ProcessError err) { + // "Crashed" after a cancel-driven kill() is expected. + if (!node->isCanceled()) { + qWarning() + << "ExternalNodeExecutor: QProcess error" << err + << process.errorString(); + } + loop.quit(); + }); + + // Forward the child's stdout/stderr to the messages box. + QObject::connect(&process, &QProcess::readyReadStandardOutput, &process, + [&]() { + auto out = QString::fromUtf8(process.readAllStandardOutput()); + if (!out.isEmpty()) { + qDebug().noquote() << out.trimmed(); + } + }); + QObject::connect(&process, &QProcess::readyReadStandardError, &process, + [&]() { + auto err = QString::fromUtf8(process.readAllStandardError()); + if (!err.isEmpty()) { + qDebug().noquote() << err.trimmed(); + } + }); + + process.start(cli, args); + if (!process.waitForStarted(-1)) { + qWarning() << "ExternalNodeExecutor: failed to start" << cli + << process.errorString(); + reader->stop(); + m_process.clear(); + m_reader.clear(); + node->setExecState(NodeExecState::Failed); + return false; + } + + loop.exec(); + + // Drain output between the last readyReadStandard* and finished + // signals so a fast-failing subprocess's traceback isn't lost. + auto leftoverOut = QString::fromUtf8(process.readAllStandardOutput()); + if (!leftoverOut.isEmpty()) { + qDebug().noquote() << leftoverOut.trimmed(); + } + auto leftoverErr = QString::fromUtf8(process.readAllStandardError()); + if (!leftoverErr.isEmpty()) { + qDebug().noquote() << leftoverErr.trimmed(); + } + + reader->stop(); + // Stop new intermediates from queuing. Already-queued ones are + // gated by m_outputsFinalized in the lambda below. + QObject::disconnect(reader, nullptr, node, nullptr); + m_process.clear(); + m_reader.clear(); + + bool failed = !finishedCleanly || exitStatus != QProcess::NormalExit || + exitCode != 0; + + if (node->isCanceled()) { + node->setExecState(NodeExecState::Canceled); + return false; + } + + if (failed) { + qWarning() << "ExternalNodeExecutor: subprocess failed (exit=" << exitCode + << ", status=" << exitStatus << ")."; + node->setExecState(NodeExecState::Failed); + return false; + } + + // Apply outputs on the node's thread (port mutation safety, parity + // with setIntermediateData). The flag is set inside the lambda — on + // the node's thread, with the worker blocked — so already-queued + // intermediate lambdas observe it before they run. + bool populated = false; + auto applyOutputs = [this, node, targetNodeId, &outputStatePath, + &populated]() { + populated = populateOutputs(node, targetNodeId, outputStatePath); + m_outputsFinalized.store(true); + }; + runOnThread(node, applyOutputs); + if (!populated) { + node->setExecState(NodeExecState::Failed); + return false; + } + // Mirror TransformNode::execute. Flipping to Current also lets the + // next markStale() cascade downstream — markStale early-returns + // if already Stale. + node->markCurrent(); + node->setExecState(NodeExecState::Idle); + return true; +} + +void ExternalNodeExecutor::cancel(Node* /*node*/) +{ + sendControlSignal(QStringLiteral("cancel")); +} + +void ExternalNodeExecutor::complete(Node* /*node*/) +{ + sendControlSignal(QStringLiteral("complete")); +} + +void ExternalNodeExecutor::sendControlSignal(const QString& signal) +{ + // Soft signal — operator polls and observes at next checkpoint. + // No kill (matches in-process semantics). Posted to the reader's + // own thread because socket/file ops belong there. + auto* r = m_reader.data(); + if (!r) { + return; + } + QMetaObject::invokeMethod( + r, [r, signal]() { r->sendSignal(signal); }, Qt::QueuedConnection); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/ExternalNodeExecutor.h b/tomviz/pipeline/ExternalNodeExecutor.h new file mode 100644 index 000000000..ee7692d4d --- /dev/null +++ b/tomviz/pipeline/ExternalNodeExecutor.h @@ -0,0 +1,108 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineExternalNodeExecutor_h +#define tomvizPipelineExternalNodeExecutor_h + +#include "NodeExecutor.h" +#include "PortData.h" + +#include + +#include +#include +#include +#include + +class QProcess; +class QTemporaryDir; + +namespace tomviz { +namespace pipeline { + +class Node; +class ProgressReader; + +/// NodeExecutor that runs the node in a separate Python interpreter via +/// the `tomviz-pipeline` CLI. The transport is a one-node "shim" +/// pipeline written as `.tvh5` (so input port payloads are bundled); +/// the CLI is asked to write its result as another `.tvh5` whose +/// dataRefs we read back into the parent-side node's output ports. +/// +/// Per-call subprocess spawn — no persistent worker. Progress +/// (`progress.maximum`/`step`/`message`) is forwarded onto the +/// parent-side Node's existing progress signals, so UI plumbing does +/// not need to know external execution is happening. +class ExternalNodeExecutor : public NodeExecutor +{ + Q_OBJECT + +public: + explicit ExternalNodeExecutor(QObject* parent = nullptr); + explicit ExternalNodeExecutor(const QString& envPath, + QObject* parent = nullptr); + ~ExternalNodeExecutor() override; + + bool execute(Node* node) override; + void cancel(Node* node) override; + void complete(Node* node) override; + + QString type() const override; + QJsonObject serialize() const override; + bool deserialize(const QJsonObject& json) override; + + QString envPath() const; + void setEnvPath(const QString& path); + + /// Stable type string written into the node's serialized "executor" + /// block. Exposed so the NodeExecutorFactory registration can use + /// the same constant. + static QString typeString(); + +private: + /// Build the shim pipeline (single SourceNode + a clone of @a target + /// wired to it) and write it to `/shim.tvh5`. Returns the + /// absolute path on success, an empty string on failure. + QString writeShimTvh5(Node* target, const QTemporaryDir& dir, + int& targetNodeId) const; + + /// Read `output_state.tvh5` produced by the subprocess and copy each + /// output port payload onto the matching port of @a target. + bool populateOutputs(Node* target, int targetNodeId, + const QString& outputPath) const; + + /// Apply a live-preview tvh5 from the subprocess as an intermediate + /// update on the parent-side @a target. Routes through + /// Node::setIntermediateOutputs so volume ports keep their existing + /// vtkImageData identity. + void handleIntermediate(Node* target, int targetNodeId, + const QString& tvh5Path) const; + + /// Decode the per-port payloads from a tvh5 file written by the + /// subprocess. Shared by populateOutputs (final result) and + /// handleIntermediate (live preview). + QMap decodeTvh5Outputs(Node* target, int targetNodeId, + const QString& tvh5Path) const; + + /// Locate the `tomviz-pipeline` script next to the configured + /// interpreter. Returns an empty string if missing. + QString findCliExecutable() const; + + /// Forward a control message ("cancel" / "complete") to the + /// subprocess via the progress channel. Posted across threads to + /// the reader's owning thread. + void sendControlSignal(const QString& signal); + + QString m_envPath; + QPointer m_process; + QPointer m_reader; + /// Set after populateOutputs runs. The intermediate-data lambda + /// checks this and drops late-firing progress.data events that + /// would otherwise overwrite the final result. + std::atomic m_outputsFinalized{ false }; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/GatedEditorWidget.cxx b/tomviz/pipeline/GatedEditorWidget.cxx new file mode 100644 index 000000000..30f923265 --- /dev/null +++ b/tomviz/pipeline/GatedEditorWidget.cxx @@ -0,0 +1,135 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "GatedEditorWidget.h" + +#include "InputPort.h" +#include "InputsNotReadyWidget.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "Pipeline.h" + +#include + +namespace tomviz { +namespace pipeline { + +GatedEditorWidget::GatedEditorWidget(Node* node, Pipeline* pipeline, + EditorFactory factory, QWidget* parent) + : EditNodeWidget(parent), m_node(node), m_pipeline(pipeline), + m_factory(std::move(factory)) +{ + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + + if (inputsReady()) { + buildInner(); + } else { + m_notReadyWidget = new InputsNotReadyWidget(this); + connect(m_notReadyWidget, &InputsNotReadyWidget::runRequested, + this, &GatedEditorWidget::onRunRequested); + m_notReadyWidget->setRunEnabled(!m_pipeline->isExecuting()); + m_layout->addWidget(m_notReadyWidget, 1); + + connect(m_pipeline, &Pipeline::executionStarted, this, [this]() { + if (m_notReadyWidget) { + m_notReadyWidget->setRunEnabled(false); + } + }); + connect(m_pipeline, &Pipeline::executionFinished, + this, &GatedEditorWidget::onExecutionFinished); + } +} + +bool GatedEditorWidget::canApply() const +{ + return m_inner != nullptr; +} + +void GatedEditorWidget::applyChangesToOperator() +{ + if (m_inner) { + m_inner->applyChangesToOperator(); + } +} + +bool GatedEditorWidget::inputsReady() const +{ + if (!m_node) { + return false; + } + for (auto* input : m_node->inputPorts()) { + if (!input->link() || input->isStale() || !input->hasData()) { + return false; + } + if (input->link()->from()->dataLocation() != DataLocation::InMemory) { + return false; + } + } + return true; +} + +void GatedEditorWidget::onRunRequested() +{ + if (!m_node) { + return; + } + m_pipeline->executeUpstreamOf(m_node); +} + +void GatedEditorWidget::onExecutionFinished() +{ + if (!m_notReadyWidget || !m_node) { + return; + } + + // Pin freshest data into memory so inputsReady() flips true and the + // inner editor can read via data() without I/O. + for (auto* input : m_node->inputPorts()) { + auto* link = input->link(); + if (!link || !link->from()) { + continue; + } + if (auto handle = link->from()->materialize()) { + m_inputPins.append(handle); + } + } + + if (!inputsReady()) { + m_notReadyWidget->setRunEnabled(true); + return; + } + + // Build the inner editor first. The factory can legitimately return null if + // the upstream data isn't actually usable yet (e.g. this finished signal was + // delivered re-entrantly mid-execution); in that case stay in the not-ready + // state rather than tearing down the widget and showing a blank editor. + if (!buildInner()) { + m_notReadyWidget->setRunEnabled(true); + return; + } + + m_layout->removeWidget(m_notReadyWidget); + m_notReadyWidget->deleteLater(); + m_notReadyWidget = nullptr; +} + +bool GatedEditorWidget::buildInner() +{ + // The factory closure captures the target node by raw pointer, so never + // invoke it once the node is gone - guard here so buildInner() is safe + // regardless of which caller reaches it. + if (!m_node) { + return false; + } + m_inner = m_factory(this); + if (m_inner) { + m_layout->addWidget(m_inner, 1); + } + emit canApplyChanged(); + return m_inner != nullptr; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/GatedEditorWidget.h b/tomviz/pipeline/GatedEditorWidget.h new file mode 100644 index 000000000..68b30fc6b --- /dev/null +++ b/tomviz/pipeline/GatedEditorWidget.h @@ -0,0 +1,69 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineGatedEditorWidget_h +#define tomvizPipelineGatedEditorWidget_h + +#include "EditNodeWidget.h" +#include "PortData.h" + +#include +#include + +#include +#include + +class QVBoxLayout; + +namespace tomviz { +namespace pipeline { + +class InputsNotReadyWidget; +class Node; +class Pipeline; + +/// Reusable wrapper for node editors that need all input data to be +/// linked, current, and in memory before they can render. Shows an +/// InputsNotReadyWidget while inputs are missing; on the user's "Run +/// Pipeline" click runs executeUpstreamOf(target) and, on completion, +/// materializes the input ports and builds the inner editor. +/// +/// Nodes wrap their existing editor construction in a small factory +/// closure that's only invoked once readiness is satisfied — so the +/// closure can assume the upstream data is materialized. +class GatedEditorWidget : public EditNodeWidget +{ + Q_OBJECT + +public: + using EditorFactory = std::function; + + GatedEditorWidget(Node* node, Pipeline* pipeline, + EditorFactory factory, QWidget* parent = nullptr); + ~GatedEditorWidget() override = default; + + bool canApply() const override; + void applyChangesToOperator() override; + +private: + void onRunRequested(); + void onExecutionFinished(); + bool inputsReady() const; + bool buildInner(); + + // QPointer so a node destroyed while this editor is still open (e.g. the + // pipeline rebuilds nodes on execution finish) auto-nulls here instead of + // leaving a dangling pointer for the executionFinished slot to dereference. + QPointer m_node; + Pipeline* m_pipeline; + EditorFactory m_factory; + QVBoxLayout* m_layout = nullptr; + InputsNotReadyWidget* m_notReadyWidget = nullptr; + EditNodeWidget* m_inner = nullptr; + QList> m_inputPins; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/InputPort.cxx b/tomviz/pipeline/InputPort.cxx new file mode 100644 index 000000000..076b60c42 --- /dev/null +++ b/tomviz/pipeline/InputPort.cxx @@ -0,0 +1,86 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "InputPort.h" + +#include "Link.h" +#include "OutputPort.h" + +namespace tomviz { +namespace pipeline { + +InputPort::InputPort(const QString& name, PortTypes acceptedTypes, + QObject* parent) + : Port(name, parent), m_acceptedTypes(acceptedTypes) +{} + +PortTypes InputPort::acceptedTypes() const +{ + return m_acceptedTypes; +} + +void InputPort::setAcceptedTypes(PortTypes types) +{ + m_acceptedTypes = types; +} + +bool InputPort::canConnectTo(const OutputPort* output) const +{ + if (!output) { + return false; + } + return isPortTypeCompatible(output->type(), m_acceptedTypes); +} + +Link* InputPort::link() const +{ + return m_link; +} + +PortData InputPort::data() const +{ + if (m_link) { + return m_link->from()->data(); + } + return PortData(); +} + +bool InputPort::hasData() const +{ + if (m_link) { + return m_link->from()->hasData(); + } + return false; +} + +bool InputPort::isStale() const +{ + if (m_link) { + return m_link->from()->isStale(); + } + return false; +} + +const std::shared_ptr& InputPort::handle() const +{ + return m_handle; +} + +void InputPort::setHandle(std::shared_ptr handle) +{ + m_handle = std::move(handle); +} + +void InputPort::clearHandle() +{ + m_handle.reset(); +} + +void InputPort::setLink(Link* link) +{ + m_link = link; + emit connectionChanged(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/InputPort.h b/tomviz/pipeline/InputPort.h new file mode 100644 index 000000000..cd2bbda8d --- /dev/null +++ b/tomviz/pipeline/InputPort.h @@ -0,0 +1,59 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineInputPort_h +#define tomvizPipelineInputPort_h + +#include "Port.h" +#include "PortData.h" +#include "PortType.h" + +#include + +namespace tomviz { +namespace pipeline { + +class Link; +class OutputPort; + +class InputPort : public Port +{ + Q_OBJECT + +public: + InputPort(const QString& name, PortTypes acceptedTypes, + QObject* parent = nullptr); + ~InputPort() override = default; + + PortTypes acceptedTypes() const; + void setAcceptedTypes(PortTypes types); + bool canConnectTo(const OutputPort* output) const; + Link* link() const; + PortData data() const; + bool hasData() const; + bool isStale() const; + + /// Delivery channel for the upstream payload during executor-driven + /// runs. The executor calls setHandle() before invoking the consumer + /// node and clearHandle() after. Sinks that need their input alive + /// past end-of-plan call handle() to grab the shared_ptr and stash + /// it in a member. Read paths (data()/hasData()) deliberately ignore + /// this — they observe via the upstream output port so UI callers + /// outside of execute() see the same picture. + const std::shared_ptr& handle() const; + void setHandle(std::shared_ptr handle); + void clearHandle(); + +private: + friend class Link; + void setLink(Link* link); + + PortTypes m_acceptedTypes; + Link* m_link = nullptr; + std::shared_ptr m_handle; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/InputsNotReadyWidget.cxx b/tomviz/pipeline/InputsNotReadyWidget.cxx new file mode 100644 index 000000000..dfa739127 --- /dev/null +++ b/tomviz/pipeline/InputsNotReadyWidget.cxx @@ -0,0 +1,60 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "InputsNotReadyWidget.h" + +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +InputsNotReadyWidget::InputsNotReadyWidget(QWidget* parent) : QWidget(parent) +{ + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + + auto* frame = new QFrame(this); + frame->setObjectName("inputsNotReadyFrame"); + frame->setStyleSheet( + "QFrame#inputsNotReadyFrame { " + "background: #fef3c7; border: 1px solid #fcd34d; " + "border-radius: 4px; }" + "QFrame#inputsNotReadyFrame QLabel { color: #b45309; }"); + + auto* layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(8); + + auto* message = new QLabel(frame); + message->setWordWrap(true); + message->setAlignment(Qt::AlignCenter); + message->setText( + tr("This node's properties widget requires connected, current " + "input data.\n\nPlease ensure all input ports are connected and " + "upstream nodes have been executed.")); + layout->addWidget(message); + + auto* buttonRow = new QHBoxLayout; + buttonRow->addStretch(); + m_runButton = new QPushButton(tr("Run Pipeline to Generate Inputs"), frame); + connect(m_runButton, &QPushButton::clicked, + this, &InputsNotReadyWidget::runRequested); + buttonRow->addWidget(m_runButton); + buttonRow->addStretch(); + layout->addLayout(buttonRow); + + outer->addWidget(frame); + outer->addStretch(); +} + +void InputsNotReadyWidget::setRunEnabled(bool enabled) +{ + m_runButton->setEnabled(enabled); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/InputsNotReadyWidget.h b/tomviz/pipeline/InputsNotReadyWidget.h new file mode 100644 index 000000000..b1604a664 --- /dev/null +++ b/tomviz/pipeline/InputsNotReadyWidget.h @@ -0,0 +1,38 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineInputsNotReadyWidget_h +#define tomvizPipelineInputsNotReadyWidget_h + +#include + +class QPushButton; + +namespace tomviz { +namespace pipeline { + +/// Placeholder shown in the properties panel / edit dialog when a node +/// has a custom widget that requires live input data, but the inputs +/// aren't yet linked + current. Carries an amber explanatory label and +/// a "Run Pipeline to Generate Inputs" button. +class InputsNotReadyWidget : public QWidget +{ + Q_OBJECT + +public: + explicit InputsNotReadyWidget(QWidget* parent = nullptr); + ~InputsNotReadyWidget() override = default; + + void setRunEnabled(bool enabled); + +signals: + void runRequested(); + +private: + QPushButton* m_runButton = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/InternalNodeExecutor.cxx b/tomviz/pipeline/InternalNodeExecutor.cxx new file mode 100644 index 000000000..18ca41c3d --- /dev/null +++ b/tomviz/pipeline/InternalNodeExecutor.cxx @@ -0,0 +1,33 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "InternalNodeExecutor.h" + +#include "Node.h" + +namespace tomviz { +namespace pipeline { + +InternalNodeExecutor::InternalNodeExecutor() : NodeExecutor(nullptr) {} + +InternalNodeExecutor& InternalNodeExecutor::instance() +{ + static InternalNodeExecutor s_instance; + return s_instance; +} + +bool InternalNodeExecutor::execute(Node* node) +{ + if (!node) { + return false; + } + return node->execute(); +} + +QString InternalNodeExecutor::type() const +{ + return QString(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/InternalNodeExecutor.h b/tomviz/pipeline/InternalNodeExecutor.h new file mode 100644 index 000000000..d1c507480 --- /dev/null +++ b/tomviz/pipeline/InternalNodeExecutor.h @@ -0,0 +1,37 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineInternalNodeExecutor_h +#define tomvizPipelineInternalNodeExecutor_h + +#include "NodeExecutor.h" + +namespace tomviz { +namespace pipeline { + +/// Default NodeExecutor: runs the node in-process by calling +/// node->execute() directly. Stateless — exposed as a process-wide +/// singleton via instance(), which the pipeline executors use as the +/// fallback when a node has no per-node executor set. +class InternalNodeExecutor : public NodeExecutor +{ + Q_OBJECT + +public: + static InternalNodeExecutor& instance(); + + bool execute(Node* node) override; + + /// Returns an empty string — the internal executor is the implicit + /// default and is never written into the node's serialized form. + QString type() const override; + +private: + InternalNodeExecutor(); + ~InternalNodeExecutor() override = default; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/LegacyStateLoader.cxx b/tomviz/pipeline/LegacyStateLoader.cxx new file mode 100644 index 000000000..768aa54fb --- /dev/null +++ b/tomviz/pipeline/LegacyStateLoader.cxx @@ -0,0 +1,1232 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "LegacyStateLoader.h" + +#include "ActiveObjects.h" +#include "EmdFormat.h" +#include "LoadDataReaction.h" + +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/Node.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SinkNode.h" +#include "pipeline/SourceNode.h" +#include "pipeline/TransformNode.h" +#include "pipeline/data/VolumeData.h" + +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/sinks/MoleculeSink.h" +#include "pipeline/sinks/OutlineSink.h" +#include "pipeline/sinks/PlotSink.h" +#include "pipeline/sinks/RulerSink.h" +#include "pipeline/sinks/ScaleCubeSink.h" +#include "pipeline/sinks/SegmentSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ThresholdSink.h" +#include "pipeline/sinks/VolumeSink.h" + +#include "pipeline/transforms/ArrayWranglerTransform.h" +#include "pipeline/transforms/ConvertToFloatTransform.h" +#include "pipeline/transforms/ConvertToVolumeTransform.h" +#include "pipeline/transforms/CropTransform.h" +#include "pipeline/transforms/LegacyPythonTransform.h" +#include "pipeline/transforms/ReconstructionTransform.h" +#include "pipeline/transforms/SetTiltAnglesTransform.h" +#include "pipeline/transforms/SnapshotTransform.h" +#include "pipeline/transforms/TranslateAlignTransform.h" +#include "pipeline/transforms/TransposeDataTransform.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +// Unwrap the legacy module JSON — which nests per-module state under a +// "properties" sub-object and carries colormap + detached-flag keys at +// the module's top level — into the flat shape the new sinks expose via +// LegacyModuleSink::serialize() / deserialize(). +// +// Legacy: +// { "type", "id", "viewId", +// "activeScalars", "useDetachedColorMap", +// "colorOpacityMap"?, "gradientOpacityMap"?, +// "properties": { "visibility": bool, ...per-module keys... } } +// +// Native (post-translation): +// { "label"?, "visible"?, "activeScalars"?, +// "useDetachedColorMap"?, "colorOpacityMap"?, "gradientOpacityMap"?, +// ...per-module keys... } +// +// "visibility" is renamed to "visible"; the other keys already match +// (the new sinks were designed around this shape). +// Translate a legacy module JSON into the shape the corresponding sink +// expects. The sinks' native serialize()/deserialize() now use the same +// keys as legacy for per-module state, so this is just structural +// flattening plus two small value conversions: +// +// - Legacy stored per-module state nested under "properties": {}; the +// new sinks read a flat top-level. Copy properties out. +// - Legacy called the visibility flag "visibility"; the new sinks use +// "visible" (flattened alongside base-class state like label). +// - Legacy stored SliceSink's "direction" as a string ("XY"/"YZ"/...); +// the new sink reads it as an int matching the Direction enum. +QJsonObject legacyModuleToSinkJson(const QJsonObject& module) +{ + QJsonObject flat; + for (const auto& key : { QStringLiteral("label"), + QStringLiteral("activeScalars"), + QStringLiteral("useDetachedColorMap"), + QStringLiteral("colorOpacityMap"), + QStringLiteral("gradientOpacityMap") }) { + if (module.contains(key)) { + flat[key] = module.value(key); + } + } + auto props = module.value("properties").toObject(); + for (auto it = props.constBegin(); it != props.constEnd(); ++it) { + if (it.key() == QStringLiteral("visibility")) { + flat["visible"] = it.value(); + } else { + flat[it.key()] = it.value(); + } + } + + if (flat.value("direction").isString()) { + static const QMap directionToInt = { + { QStringLiteral("XY"), 0 }, + { QStringLiteral("YZ"), 1 }, + { QStringLiteral("XZ"), 2 }, + { QStringLiteral("Custom"), 3 }, + }; + auto name = flat.value("direction").toString(); + if (directionToInt.contains(name)) { + flat["direction"] = directionToInt.value(name); + } + } + + return flat; +} + +// ParaView proxy-XML helpers. These mirror the ones in legacy +// ModuleManager.cxx (createXmlProperty / createXmlLayout) so the XML we +// hand to pqApplicationCore::loadState() matches the structure +// ParaView's state loader expects. +void xmlPropertyHeader(pugi::xml_node& n, const char* name, int id) +{ + n.set_name("Property"); + n.append_attribute("name").set_value(name); + QString idStr = QString::number(id) + "." + name; + n.append_attribute("id").set_value(idStr.toStdString().c_str()); +} + +template +void xmlScalarProperty(pugi::xml_node& n, const char* name, int id, T value) +{ + xmlPropertyHeader(n, name, id); + n.append_attribute("number_of_elements").set_value(1); + auto element = n.append_child("Element"); + element.append_attribute("index").set_value(0); + element.append_attribute("value").set_value(value); +} + +void xmlArrayProperty(pugi::xml_node& n, const char* name, int id, + const QJsonArray& arr) +{ + xmlPropertyHeader(n, name, id); + n.append_attribute("number_of_elements").set_value(arr.size()); + for (int i = 0; i < arr.size(); ++i) { + auto element = n.append_child("Element"); + element.append_attribute("index").set_value(i); + element.append_attribute("value").set_value(arr[i].toDouble(-1)); + } +} + +void xmlLayoutItems(pugi::xml_node& n, const QJsonArray& items) +{ + n.set_name("Layout"); + n.append_attribute("number_of_elements").set_value(items.size()); + for (int i = 0; i < items.size(); ++i) { + auto obj = items[i].toObject(); + auto item = n.append_child("Item"); + item.append_attribute("direction").set_value(obj["direction"].toInt()); + item.append_attribute("fraction").set_value(obj["fraction"].toDouble()); + item.append_attribute("view").set_value(obj["viewId"].toInt()); + } +} + +// Pick the single view JSON to restore from a legacy state's "views" +// array. Prefers the one marked "active": true, falls back to the +// first entry. Returns an empty object if the array is missing/empty. +QJsonObject selectViewJson(const QJsonObject& state) +{ + auto views = state.value("views").toArray(); + for (const auto& v : views) { + auto obj = v.toObject(); + if (obj.value("active").toBool()) { + return obj; + } + } + if (!views.isEmpty()) { + return views.first().toObject(); + } + return {}; +} + +// Resolve a DataSource reader filename relative to the state file directory. +QString resolveReaderFileName(const QJsonObject& reader, const QDir& stateDir) +{ + auto fileNames = reader.value("fileNames").toArray(); + if (fileNames.isEmpty()) { + return {}; + } + QString first = fileNames.at(0).toString(); + QFileInfo info(first); + if (info.isAbsolute()) { + return first; + } + return stateDir.absoluteFilePath(first); +} + +} // namespace + +void LegacyStateLoader::restoreViewsLayoutsAndPalette( + const QJsonObject& state, Pipeline* pipeline, + QMap* viewIdMap) +{ + LoadContext ctx; + ctx.pipeline = pipeline; + if (state.contains("paletteColor")) { + applyPaletteColor(state.value("paletteColor").toArray()); + } + restoreViewsAndLayouts(state, ctx); + scheduleViewStatesApply(state, ctx); + if (viewIdMap) { + *viewIdMap = ctx.viewIdMap; + } +} + +bool LegacyStateLoader::load(const QJsonObject& state, const QDir& stateDir, + bool executePipeline) +{ + LoadContext ctx; + ctx.pipeline = ActiveObjects::instance().pipeline(); + ctx.stateDir = stateDir; + if (!ctx.pipeline) { + qWarning() << "LegacyStateLoader: no active pipeline to load into"; + return false; + } + + ctx.pipeline->clear(); + + if (state.contains("paletteColor")) { + applyPaletteColor(state.value("paletteColor").toArray()); + } + // Restore all views + the layout that hosts them via ParaView's + // loadState. Populates ctx.viewIdMap so buildModule() can bind each + // sink to the specific view its legacy module referenced. On failure + // or if the state has no views[], we fall through to resolveView() + // and use the app's current active view for every sink. + restoreViewsAndLayouts(state, ctx); + ctx.view = resolveView(ctx); + scheduleViewStatesApply(state, ctx); + + if (!state.value("dataSources").isArray()) { + qWarning() << "LegacyStateLoader: state has no dataSources array"; + return false; + } + + for (const auto& dsVal : state.value("dataSources").toArray()) { + auto ds = dsVal.toObject(); + auto* source = buildSource(ds, ctx); + if (!source) { + continue; + } + ctx.pipeline->addNode(source); + applyDataSourceMetadata(source, ds); + walkDataSource(source, ds, ctx); + } + + // Warn the user about skipped operators/modules, if any. + if (!ctx.skippedOperators.isEmpty() || !ctx.skippedModules.isEmpty()) { + QStringList parts; + if (!ctx.skippedOperators.isEmpty()) { + parts << QObject::tr("Operators: %1") + .arg(ctx.skippedOperators.join(", ")); + } + if (!ctx.skippedModules.isEmpty()) { + parts << QObject::tr("Modules: %1") + .arg(ctx.skippedModules.join(", ")); + } + qWarning().noquote() << "LegacyStateLoader: skipped unrecognized types —" + << parts.join("; "); + } + + auto* pip = ctx.pipeline; + if (executePipeline) { + // Must clear any leftover paused state from a prior no-execute + // load, otherwise Pipeline::execute() early-returns. + pip->setPaused(false); + // Defer execution so the event loop can process pending signals + // first, matching LoadDataReaction::sourceNodeAdded. + QTimer::singleShot(0, pip, [pip]() { pip->execute(); }); + } else { + // User declined auto-execute. Pause the pipeline so parameter + // changes or other interactions don't trigger execution until the + // user explicitly unpauses (PipelineControlsWidget has a toggle). + // Fire executionFinished once anyway so our deferred state-apply + // handlers get to run (sink deserialize, view state, etc.). No + // transforms run — their output ports stay empty until the user + // triggers execution manually. + pip->setPaused(true); + QMetaObject::invokeMethod(pip, &Pipeline::executionFinished, + Qt::QueuedConnection); + } + + return true; +} + +bool LegacyStateLoader::loadFromH5(const QString& filename, + bool executePipeline) +{ + using h5::H5ReadWrite; + H5ReadWrite reader(filename.toStdString(), H5ReadWrite::OpenMode::ReadOnly); + auto stateVec = reader.readData("tomviz_state"); + QString stateStr = std::string(stateVec.begin(), stateVec.end()).c_str(); + auto doc = QJsonDocument::fromJson(stateStr.toUtf8()); + if (!doc.isObject()) { + qWarning() << "LegacyStateLoader: failed to parse tomviz_state in" + << filename; + return false; + } + + LoadContext ctx; + ctx.pipeline = ActiveObjects::instance().pipeline(); + ctx.stateDir = QFileInfo(filename).dir(); + ctx.h5 = &reader; + if (!ctx.pipeline) { + qWarning() << "LegacyStateLoader: no active pipeline to load into"; + return false; + } + + ctx.pipeline->clear(); + + auto state = doc.object(); + if (state.contains("paletteColor")) { + applyPaletteColor(state.value("paletteColor").toArray()); + } + restoreViewsAndLayouts(state, ctx); + ctx.view = resolveView(ctx); + scheduleViewStatesApply(state, ctx); + + if (!state.value("dataSources").isArray()) { + qWarning() << "LegacyStateLoader: state has no dataSources array"; + return false; + } + + // Identify which DataSource id the /data/tomography soft-link points + // at. Legacy writers link the active DataSource there to avoid storing + // the data twice. + for (const auto& dsVal : state.value("dataSources").toArray()) { + auto ds = dsVal.toObject(); + if (ds.value("active").toBool()) { + ctx.activeDataSourceId = ds.value("id").toString(); + break; + } + } + + for (const auto& dsVal : state.value("dataSources").toArray()) { + auto ds = dsVal.toObject(); + auto* source = buildSource(ds, ctx); + if (!source) { + continue; + } + ctx.pipeline->addNode(source); + applyDataSourceMetadata(source, ds); + walkDataSource(source, ds, ctx); + } + + auto* pip = ctx.pipeline; + if (executePipeline) { + // Must clear any leftover paused state from a prior no-execute + // load, otherwise Pipeline::execute() early-returns. + pip->setPaused(false); + QTimer::singleShot(0, pip, [pip]() { pip->execute(); }); + } else { + // User declined auto-execute. Pause the pipeline so parameter + // changes or other interactions don't trigger execution until the + // user explicitly unpauses (PipelineControlsWidget has a toggle). + // Fire executionFinished once anyway so our deferred state-apply + // handlers get to run (sink deserialize, view state, etc.). + pip->setPaused(true); + QMetaObject::invokeMethod(pip, &Pipeline::executionFinished, + Qt::QueuedConnection); + } + return true; +} + +SourceNode* LegacyStateLoader::buildSource(const QJsonObject& dsJson, + LoadContext& ctx) +{ + // .tvh5 path: read voxel data straight out of the HDF5 container, skipping + // any on-disk reader lookup. + if (ctx.h5) { + auto id = dsJson.value("id").toString(); + if (id.isEmpty()) { + qWarning() << "LegacyStateLoader: dataSource has no id (tvh5)"; + return nullptr; + } + std::string path = "/tomviz_datasources/" + id.toStdString(); + vtkNew image; + QVariantMap options = { { "askForSubsample", false } }; + if (!EmdFormat::readNode(*ctx.h5, path, image, options)) { + qWarning() << "LegacyStateLoader: failed to read tvh5 node" << path.c_str(); + return nullptr; + } + auto* source = new SourceNode(); + QString label = dsJson.value("label").toString(); + if (label.isEmpty()) { + label = QStringLiteral("Loaded Data"); + } + source->setLabel(label); + + vtkSmartPointer img = image.GetPointer(); + const bool isTiltSeries = VolumeData::hasTiltAngles(img); + const PortType declaredType = + isTiltSeries ? PortType::TiltSeries : PortType::ImageData; + + source->addOutput("volume", declaredType); + auto vol = std::make_shared(img); + vol->setLabel(label); + source->setOutputData("volume", PortData(vol, declaredType)); + return source; + } + + // .tvsm path: delegate to LoadDataReaction so we get the correct reader + // for the file extension. We disable default modules / auto-add to + // pipeline because the loader controls node and link construction. + auto reader = dsJson.value("reader").toObject(); + QString fileName = resolveReaderFileName(reader, ctx.stateDir); + if (fileName.isEmpty()) { + qWarning() << "LegacyStateLoader: dataSource has no reader.fileNames"; + return nullptr; + } + + QJsonObject opts; + opts["defaultModules"] = false; + opts["addToPipeline"] = false; + opts["addToRecent"] = false; + + // Forward reader-specific options (e.g. subsampleSettings) through. + if (reader.contains("subsampleSettings")) { + opts["subsampleSettings"] = reader.value("subsampleSettings"); + } + if (reader.contains("name")) { + QJsonObject readerBlock = reader; + opts["reader"] = readerBlock; + } + + auto* source = LoadDataReaction::loadData(fileName, opts); + if (!source) { + qWarning() << "LegacyStateLoader: failed to load source file" << fileName; + return nullptr; + } + if (dsJson.contains("label")) { + source->setLabel(dsJson.value("label").toString()); + } + return source; +} + +void LegacyStateLoader::applyDataSourceMetadata(Node* node, + const QJsonObject& dsJson) +{ + if (!node || node->outputPorts().isEmpty()) { + return; + } + auto* outPort = node->outputPorts().first(); + if (!outPort || !outPort->hasData()) { + return; + } + auto portData = outPort->data(); + if (!portData.isValid() || !isVolumeType(portData.type())) { + return; + } + auto vol = portData.value(); + if (!vol) { + return; + } + + // The subset of legacy DataSource keys that map onto VolumeData + // (label, units, spacing, origin, orientation, colorOpacityMap, + // gradientOpacityMap) already uses the native VolumeData shape, so we + // pass the whole DataSource JSON through — VolumeData::deserialize + // ignores keys it doesn't own (operators, modules, reader, ...). + vol->deserialize(dsJson); + // TODO: activeScalars by name (legacy stored the scalar array name) + // and colorMap2DBox. +} + +void LegacyStateLoader::walkDataSource(Node* upstream, + const QJsonObject& ds, + LoadContext& ctx) +{ + if (!upstream || upstream->outputPorts().isEmpty()) { + return; + } + + // Walk the operator chain first. Each operator becomes a TransformNode + // linked to the previous node's primary output port. + Node* current = upstream; + auto operators = ds.value("operators").toArray(); + for (const auto& opVal : operators) { + auto op = opVal.toObject(); + auto* transform = buildOperator(op, ctx); + if (!transform) { + continue; + } + ctx.pipeline->addNode(transform); + if (!current->outputPorts().isEmpty() && + !transform->inputPorts().isEmpty()) { + ctx.pipeline->createLink(current->outputPorts().first(), + transform->inputPorts().first()); + } + + // Legacy child DataSource — its operators/modules attach to the + // transform's primary output port, not to a new SourceNode. + auto childArray = op.value("dataSources").toArray(); + for (const auto& childVal : childArray) { + auto child = childVal.toObject(); + walkDataSource(transform, child, ctx); + + // DataSource-level state (colormap, spacing, units, ...) on a + // child applies to the VolumeData produced by the parent + // transform, which doesn't exist until the pipeline executes. + // Defer to executionFinished. MainWindow's handler on the same + // signal initializes colormaps via copyColorMapFrom(upstream) + // and is connected first, so it runs first; our handler runs + // after and overwrites the copied colormap with the saved one. + // We then re-run updateColorMap on sinks so they pick up the + // restored control points before the next paint. + // + // We cannot use Qt::SingleShotConnection here: in no-execute + // mode the first executionFinished is our synthetic fire, the + // transform's output port has no data yet, and a single-shot + // slot would detach before the user actually runs the pipeline. + // Self-disconnect only once the transform has produced data. + auto* pipeline = ctx.pipeline; + auto conn = std::make_shared(); + *conn = QObject::connect( + pipeline, &Pipeline::executionFinished, pipeline, + [pipeline, transform, child, conn]() { + if (transform->outputPorts().isEmpty() || + !transform->outputPorts().first()->hasData()) { + return; // no execute has happened yet; wait + } + applyDataSourceMetadata(transform, child); + for (auto* n : pipeline->nodes()) { + auto* sink = dynamic_cast(n); + if (sink && sink->isColorMapNeeded()) { + sink->updateColorMap(); + } + } + QObject::disconnect(*conn); + }); + } + + current = transform; + } + + // Attach modules as sinks to the last node in the chain. + auto modules = ds.value("modules").toArray(); + if (modules.isEmpty() || current->outputPorts().isEmpty()) { + return; + } + + auto* sourcePort = current->outputPorts().first(); + + // All modules from the same DataSource share one SinkGroupNode so + // intermediate data is not duplicated — matches the default sink-setup + // pattern in LoadDataReaction::sourceNodeAdded. + auto* group = new SinkGroupNode(); + group->addPassthrough("volume", PortType::ImageData); + ctx.pipeline->addNode(group); + ctx.pipeline->createLink(sourcePort, group->inputPorts().first()); + auto* groupOut = group->outputPorts().first(); + + for (const auto& modVal : modules) { + buildModule(modVal.toObject(), groupOut, group, ctx); + } +} + +void LegacyStateLoader::loadTemplateOperators(const QJsonArray& operators, + Pipeline* pipeline) +{ + if (!pipeline) { + return; + } + LoadContext ctx; + ctx.pipeline = pipeline; + TransformNode* prev = nullptr; + for (const auto& opVal : operators) { + auto op = opVal.toObject(); + auto* transform = buildOperator(op, ctx); + if (!transform) { + continue; + } + pipeline->addNode(transform); + if (prev && !prev->outputPorts().isEmpty() && + !transform->inputPorts().isEmpty()) { + pipeline->createLink(prev->outputPorts().first(), + transform->inputPorts().first()); + } + // Parameters come from the template, so the node isn't "New" in + // the sense the link-completion auto-edit-dialog cares about. + transform->setStateNoCascade(NodeState::Stale); + prev = transform; + } +} + +TransformNode* LegacyStateLoader::buildOperator(const QJsonObject& op, + LoadContext& ctx) +{ + const QString type = op.value("type").toString(); + + TransformNode* transform = nullptr; + if (type == QStringLiteral("Python")) { + transform = new LegacyPythonTransform(); + } else if (type == QStringLiteral("ArrayWrangler")) { + transform = new ArrayWranglerTransform(); + } else if (type == QStringLiteral("ConvertToFloat")) { + transform = new ConvertToFloatTransform(); + } else if (type == QStringLiteral("ConvertToVolume")) { + transform = new ConvertToVolumeTransform(); + } else if (type == QStringLiteral("Crop")) { + transform = new CropTransform(); + } else if (type == QStringLiteral("CxxReconstruction")) { + transform = new ReconstructionTransform(); + } else if (type == QStringLiteral("SetTiltAngles")) { + transform = new SetTiltAnglesTransform(); + } else if (type == QStringLiteral("TranslateAlign")) { + transform = new TranslateAlignTransform(); + } else if (type == QStringLiteral("TransposeData")) { + transform = new TransposeDataTransform(); + } else if (type == QStringLiteral("Snapshot")) { + transform = new SnapshotTransform(); + } else { + if (!type.isEmpty()) { + ctx.skippedOperators.append(type); + } + return nullptr; + } + + if (transform) { + transform->deserialize(op); + } + return transform; +} + +LegacyModuleSink* LegacyStateLoader::buildModule(const QJsonObject& module, + OutputPort* sourcePort, + SinkGroupNode* group, + LoadContext& ctx) +{ + const QString type = module.value("type").toString(); + + LegacyModuleSink* sink = nullptr; + if (type == QStringLiteral("Outline")) { + sink = new OutlineSink(); + } else if (type == QStringLiteral("Volume")) { + sink = new VolumeSink(); + } else if (type == QStringLiteral("Contour")) { + sink = new ContourSink(); + } else if (type == QStringLiteral("Slice") || + type == QStringLiteral("OrthogonalSlice")) { + // OrthogonalSlice is handled by the same sink; ortho-specific behavior + // (if any) is expected to be encoded in the module's "properties". + sink = new SliceSink(); + } else if (type == QStringLiteral("Clip")) { + sink = new ClipSink(); + } else if (type == QStringLiteral("Threshold")) { + sink = new ThresholdSink(); + } else if (type == QStringLiteral("Segment")) { + sink = new SegmentSink(); + } else if (type == QStringLiteral("Molecule")) { + sink = new MoleculeSink(); + } else if (type == QStringLiteral("Plot")) { + sink = new PlotSink(); + } else if (type == QStringLiteral("Ruler")) { + sink = new RulerSink(); + } else if (type == QStringLiteral("ScaleCube")) { + sink = new ScaleCubeSink(); + } else { + if (!type.isEmpty()) { + ctx.skippedModules.append(type); + } + return nullptr; + } + + // Prefer the view that matches the module's saved viewId; fall back + // to the app's current active view when the id isn't in the map + // (which happens when restoreViewsAndLayouts wasn't invoked or the + // module targets a view type we don't display). + vtkSMViewProxy* targetView = ctx.view; + if (module.contains("viewId")) { + int vid = module.value("viewId").toInt(); + if (auto* mapped = ctx.viewIdMap.value(vid, nullptr)) { + targetView = mapped; + } + } + if (targetView) { + sink->initialize(targetView); + } + + ctx.pipeline->addNode(sink); + if (sourcePort && !sink->inputPorts().isEmpty()) { + ctx.pipeline->createLink(sourcePort, sink->inputPorts().first()); + } + + // Defer deserialization of sink state until the pipeline has produced + // data for the first time. If we apply it now, calls like + // setVisibility(true) can flip the VTK actor visibility on before the + // upstream filter has any input (OutlineSink::consume is what calls + // SetInputData on the filter), producing "Input port 0 ... has 0 + // connections" warnings on each paint until consume runs. + // + // Extra wrinkle: when the user loads a state file with "don't auto- + // execute" (or the sink's upstream is a transform that didn't run), + // consume never happens and applying visible=true here is similarly + // premature. So we split the apply: + // - All non-visibility state gets applied as soon as this handler + // fires (on the first executionFinished), even if the sink's + // state isn't Current — properties like detached colormap, + // activeScalars, blendingMode, etc. are safe before consume. + // - The saved visibility is only applied once the sink actually + // reaches NodeState::Current (i.e. consume ran successfully), + // which may be this first firing (normal execute) or some later + // firing (user manually runs the pipeline). + auto* pip = ctx.pipeline; + QJsonObject sinkJson = legacyModuleToSinkJson(module); + QObject::connect( + pip, &Pipeline::executionFinished, sink, + [sink, sinkJson, pip]() { + const bool targetVis = sinkJson.value("visible").toBool(true); + const bool sinkIsCurrent = (sink->state() == NodeState::Current); + if (sinkIsCurrent || !targetVis) { + sink->deserialize(sinkJson); + return; + } + // Apply everything except visible=true; hook the visibility + // flip to the next successful execution. + QJsonObject later = sinkJson; + later["visible"] = false; + sink->deserialize(later); + auto conn = std::make_shared(); + *conn = QObject::connect( + pip, &Pipeline::executionFinished, sink, + [sink, targetVis, conn]() { + if (sink->state() == NodeState::Current) { + sink->setVisibility(targetVis); + QObject::disconnect(*conn); + } + }); + }, + Qt::SingleShotConnection); + + Q_UNUSED(group); + return sink; +} + +vtkSMViewProxy* LegacyStateLoader::resolveView(LoadContext& ctx) +{ + Q_UNUSED(ctx); + ActiveObjects::instance().createRenderViewIfNeeded(); + auto* view = ActiveObjects::instance().activeView(); + if (!view || QString(view->GetXMLName()) != QStringLiteral("RenderView")) { + ActiveObjects::instance().setActiveViewToFirstRenderView(); + view = ActiveObjects::instance().activeView(); + } + return view; +} + +void LegacyStateLoader::applyPaletteColor(const QJsonArray& color) +{ + if (color.size() != 3) { + return; + } + auto* pxm = ActiveObjects::instance().proxyManager(); + if (!pxm) { + return; + } + auto* palette = pxm->GetProxy("settings", "ColorPalette"); + if (!palette) { + return; + } + double rgb[3] = { color.at(0).toDouble(), color.at(1).toDouble(), + color.at(2).toDouble() }; + vtkSMPropertyHelper(palette, "BackgroundColor").Set(rgb, 3); + palette->UpdateVTKObjects(); +} + +void LegacyStateLoader::applyViewState(vtkSMViewProxy* view, + const QJsonObject& viewJson, + bool cameraToo) +{ + if (!view || viewJson.isEmpty()) { + return; + } + + // Interaction + projection modes. Apply before camera so the view's + // constraints (e.g. 2D locks CameraParallelProjection) don't override + // the camera state we set next. + if (viewJson.contains("interactionMode")) { + auto mode = viewJson.value("interactionMode").toString(); + int modeInt = 0; // "3D" + if (mode == QStringLiteral("2D")) { + modeInt = 1; + } else if (mode == QStringLiteral("selection")) { + modeInt = 2; + } + vtkSMPropertyHelper(view, "InteractionMode").Set(modeInt); + } + if (viewJson.contains("isOrthographic")) { + vtkSMPropertyHelper(view, "CameraParallelProjection") + .Set(viewJson.value("isOrthographic").toBool() ? 1 : 0); + } + + // Background. Legacy serialized a single solid color as a 1-element + // array-of-arrays (or 2 elements for Gradient mode). For a first pass + // we just restore the primary Background; gradient mode is rare. + if (viewJson.contains("backgroundColor")) { + auto outer = viewJson.value("backgroundColor").toArray(); + if (outer.size() >= 1 && outer.at(0).isArray()) { + auto bg = outer.at(0).toArray(); + if (bg.size() == 3) { + double rgb[3] = { bg.at(0).toDouble(), bg.at(1).toDouble(), + bg.at(2).toDouble() }; + vtkSMPropertyHelper(view, "Background").Set(rgb, 3); + } + } + } + if (viewJson.contains("useColorPaletteForBackground") && + view->GetProperty("UseColorPaletteForBackground")) { + vtkSMPropertyHelper(view, "UseColorPaletteForBackground") + .Set(viewJson.value("useColorPaletteForBackground").toInt()); + } + + // Axes. + if (viewJson.contains("centerAxesVisible")) { + vtkSMPropertyHelper(view, "CenterAxesVisibility") + .Set(viewJson.value("centerAxesVisible").toBool() ? 1 : 0); + } + if (viewJson.contains("orientationAxesVisible")) { + vtkSMPropertyHelper(view, "OrientationAxesVisibility") + .Set(viewJson.value("orientationAxesVisible").toBool() ? 1 : 0); + } + if (view->GetProperty("AxesGrid") && + viewJson.contains("axesGridVisibility")) { + vtkSMPropertyHelper axesGridProp(view, "AxesGrid"); + vtkSMProxy* axesGrid = axesGridProp.GetAsProxy(); + if (!axesGrid) { + auto* pxm = view->GetSessionProxyManager(); + axesGrid = pxm->NewProxy("annotations", "GridAxes3DActor"); + axesGridProp.Set(axesGrid); + axesGrid->Delete(); + } + vtkSMPropertyHelper(axesGrid, "Visibility") + .Set(viewJson.value("axesGridVisibility").toBool() ? 1 : 0); + axesGrid->UpdateVTKObjects(); + } + + if (viewJson.contains("centerOfRotation")) { + auto arr = viewJson.value("centerOfRotation").toArray(); + if (arr.size() == 3) { + double c[3] = { arr.at(0).toDouble(), arr.at(1).toDouble(), + arr.at(2).toDouble() }; + vtkSMPropertyHelper(view, "CenterOfRotation").Set(c, 3); + } + } + + // Camera. Applied last so view-mode constraints set above have + // already been committed. Skipped when cameraToo is false — used by + // the two-phase restore where non-camera state (background / axes / + // interaction) lands immediately and the camera is deferred until + // after a real execute so it isn't overwritten by + // LegacyModuleSink::resetCameraIfFirstSink. + if (cameraToo) { + auto camera = viewJson.value("camera").toObject(); + auto setVec3 = [&](const char* key, const char* prop) { + if (!camera.contains(key)) { + return; + } + auto arr = camera.value(key).toArray(); + if (arr.size() == 3) { + double v[3] = { arr.at(0).toDouble(), arr.at(1).toDouble(), + arr.at(2).toDouble() }; + vtkSMPropertyHelper(view, prop).Set(v, 3); + } + }; + setVec3("position", "CameraPosition"); + setVec3("focalPoint", "CameraFocalPoint"); + setVec3("viewUp", "CameraViewUp"); + if (camera.contains("viewAngle")) { + vtkSMPropertyHelper(view, "CameraViewAngle") + .Set(camera.value("viewAngle").toDouble()); + } + if (camera.contains("eyeAngle") && view->GetProperty("EyeAngle")) { + vtkSMPropertyHelper(view, "EyeAngle") + .Set(camera.value("eyeAngle").toDouble()); + } + if (camera.contains("parallelScale")) { + vtkSMPropertyHelper(view, "CameraParallelScale") + .Set(camera.value("parallelScale").toDouble()); + } + } + + view->UpdateVTKObjects(); + if (auto* renderView = vtkSMRenderViewProxy::SafeDownCast(view)) { + renderView->StillRender(); + } +} + +void LegacyStateLoader::scheduleViewStateApply(const QJsonObject& state, + vtkSMViewProxy* view, + Pipeline* pipeline) +{ + QJsonObject viewJson = selectViewJson(state); + if (viewJson.isEmpty() || !view || !pipeline) { + return; + } + // Weak pointer: the view may be destroyed before this fires (see the + // note in scheduleViewStatesApply); applyViewState() no-ops on null. + vtkWeakPointer weakView(view); + QObject::connect( + pipeline, &Pipeline::executionFinished, pipeline, + [weakView, viewJson]() { applyViewState(weakView, viewJson); }, + Qt::SingleShotConnection); +} + +void LegacyStateLoader::scheduleViewStatesApply(const QJsonObject& state, + LoadContext& ctx) +{ + auto* pipeline = ctx.pipeline; + if (!pipeline) { + return; + } + + // Phase 1 (now): apply non-camera view state (background / axes / + // interaction mode / orthographic / centerOfRotation) immediately so + // the scene looks right as soon as the state file loads, even if the + // user declined the auto-execute prompt. + // + // Phase 2 (deferred): apply camera state after the first real + // executionFinished. LegacyModuleSink::resetCameraIfFirstSink queues + // a ResetCamera on the sink's main-thread on first consume; that + // queued call runs before the cross-thread executionFinished slot + // fires in auto-execute mode (so our saved camera wins), but in + // no-execute mode the synthetic executionFinished we emit fires + // before any sink consumes — so single-shot-apply-now would be + // overwritten by the reset the next time the user actually runs the + // pipeline. Using a self-disconnecting connection guarded by "any + // node has reached NodeState::Current" handles both cases: it skips + // the synthetic fire in no-execute mode and lands on the first real + // execute, then detaches so the user's subsequent camera edits + // survive re-executions. + auto schedule = [pipeline](vtkSMViewProxy* view, + const QJsonObject& viewJson) { + if (!view) { + return; + } + applyViewState(view, viewJson, /*cameraToo=*/false); + + // Capture the view as a weak pointer. This connection is bound to + // the long-lived `pipeline` (ResetReaction only calls + // Pipeline::clear(), so the Pipeline survives a reset / re-load), + // but the view proxy is destroyed by pqDeleteReaction::deleteAll() + // in the next restoreViewsAndLayouts(). If a prior load left this + // connection pending — its pipeline never reached "any sink + // Current", so it never self-disconnected — a later execute would + // otherwise fire applyViewState() on a freed view and crash. + vtkWeakPointer weakView(view); + auto conn = std::make_shared(); + *conn = QObject::connect( + pipeline, &Pipeline::executionFinished, pipeline, + [weakView, viewJson, pipeline, conn]() { + if (!weakView) { + // View was torn down by a reset / subsequent load. Drop the + // stale connection so it stops firing. + QObject::disconnect(*conn); + return; + } + // A SinkNode only reaches NodeState::Current after consume() + // succeeded — so "any sink Current" reliably discriminates + // the real execute from the synthetic executionFinished we + // emit in no-execute mode. The source node is Current as + // soon as LoadDataReaction::loadData returns, so checking + // all nodes would false-positive on the synthetic fire. + bool anySinkCurrent = false; + for (auto* node : pipeline->nodes()) { + if (dynamic_cast(node) && + node->state() == NodeState::Current) { + anySinkCurrent = true; + break; + } + } + if (!anySinkCurrent) { + return; // no sink has consumed yet; wait for a real execute + } + // Defer the camera apply by one event-loop tick. Two reasons: + // - LegacyModuleSink::resetCameraIfFirstSink queues its + // ResetCamera via QMetaObject::invokeMethod from the worker + // thread; most of those run before this signal reaches us, + // but a late one (e.g. from a sink that consumes near the + // end of the run) can land after the signal cascade and + // overwrite a saved camera we just restored. + // - ParaView sometimes does a deferred clipping-range / + // first-render adjust on a freshly-created view; letting + // that settle before we push our camera prevents it from + // clipping our saved position. + QTimer::singleShot(0, pipeline, [weakView, viewJson, conn]() { + applyViewState(weakView, viewJson, /*cameraToo=*/true); + QObject::disconnect(*conn); + }); + }); + }; + + if (ctx.viewIdMap.isEmpty()) { + QJsonObject viewJson = selectViewJson(state); + if (!viewJson.isEmpty()) { + schedule(ctx.view, viewJson); + } + return; + } + + auto views = state.value("views").toArray(); + const auto idMap = ctx.viewIdMap; + for (const auto& v : views) { + auto view = v.toObject(); + int id = view.value("id").toInt(); + auto it = idMap.find(id); + if (it == idMap.end()) { + continue; + } + schedule(it.value(), view); + } +} + +bool LegacyStateLoader::restoreViewsAndLayouts(const QJsonObject& state, + LoadContext& ctx) +{ + auto views = state.value("views").toArray(); + if (views.isEmpty()) { + return false; + } + auto layouts = state.value("layouts").toArray(); + + // Build ParaView proxy-state XML. This mirrors legacy + // ModuleManager::deserialize. The shapes must match what ParaView's + // state loader expects: a root with + // summaries plus one + // per view/layout with nested + // / children. + pugi::xml_document document; + auto root = document.append_child("ParaView"); + auto pvState = root.append_child("ServerManagerState"); + pvState.append_attribute("version").set_value("5.5.0"); + auto viewCollection = pvState.append_child("ProxyCollection"); + viewCollection.append_attribute("name").set_value("views"); + auto layoutCollection = pvState.append_child("ProxyCollection"); + layoutCollection.append_attribute("name").set_value("layouts"); + + int numViews = 0; + int numLayouts = 0; + for (const auto& v : views) { + auto view = v.toObject(); + int viewId = view.value("id").toInt(); + auto xmlName = view.value("xmlName").toString("RenderView"); + + auto proxyNode = pvState.append_child("Proxy"); + proxyNode.append_attribute("group").set_value("views"); + proxyNode.append_attribute("type").set_value(xmlName.toStdString().c_str()); + proxyNode.append_attribute("id").set_value(viewId); + proxyNode.append_attribute("servers").set_value( + view.value("servers").toInt()); + + if (view.contains("centerOfRotation")) { + auto p = proxyNode.append_child("Property"); + xmlArrayProperty(p, "CenterOfRotation", viewId, + view.value("centerOfRotation").toArray()); + } + auto camera = view.value("camera").toObject(); + if (camera.contains("focalPoint")) { + auto p = proxyNode.append_child("Property"); + xmlArrayProperty(p, "CameraFocalPoint", viewId, + camera.value("focalPoint").toArray()); + } + if (view.contains("useColorPaletteForBackground")) { + auto p = proxyNode.append_child("Property"); + xmlScalarProperty(p, "UseColorPaletteForBackground", viewId, + view.value("useColorPaletteForBackground").toInt()); + } + if (view.contains("backgroundColor")) { + auto backgroundColor = view.value("backgroundColor").toArray(); + if (!view.contains("useColorPaletteForBackground")) { + // Older state files: turn palette off when an explicit + // background was stored. + auto p = proxyNode.append_child("Property"); + xmlScalarProperty(p, "UseColorPaletteForBackground", viewId, 0); + } + if (!backgroundColor.isEmpty() && backgroundColor.at(0).isArray()) { + auto p = proxyNode.append_child("Property"); + xmlArrayProperty(p, "Background", viewId, + backgroundColor.at(0).toArray()); + } + if (backgroundColor.size() > 1 && backgroundColor.at(1).isArray()) { + auto p = proxyNode.append_child("Property"); + xmlArrayProperty(p, "Background2", viewId, + backgroundColor.at(1).toArray()); + auto g = proxyNode.append_child("Property"); + xmlScalarProperty(g, "UseGradientBackground", viewId, 1); + } + } + if (view.contains("isOrthographic")) { + auto p = proxyNode.append_child("Property"); + xmlScalarProperty(p, "CameraParallelProjection", viewId, + view.value("isOrthographic").toBool() ? 1 : 0); + } + if (view.contains("interactionMode")) { + auto mode = view.value("interactionMode").toString(); + int m = 0; + if (mode == QStringLiteral("2D")) { + m = 1; + } else if (mode == QStringLiteral("selection")) { + m = 2; + } + auto p = proxyNode.append_child("Property"); + xmlScalarProperty(p, "InteractionMode", viewId, m); + } + + auto summary = viewCollection.append_child("Item"); + summary.append_attribute("id").set_value(viewId); + summary.append_attribute("name").set_value( + QString("View%1").arg(++numViews).toStdString().c_str()); + } + + for (const auto& l : layouts) { + auto layout = l.toObject(); + int layoutId = layout.value("id").toInt(); + auto proxyNode = pvState.append_child("Proxy"); + proxyNode.append_attribute("group").set_value("misc"); + proxyNode.append_attribute("type").set_value("ViewLayout"); + proxyNode.append_attribute("id").set_value(layoutId); + proxyNode.append_attribute("servers").set_value( + layout.value("servers").toInt()); + + auto items = layout.value("items").toArray(); + for (const auto& row : items) { + auto layoutNode = proxyNode.append_child("Layout"); + xmlLayoutItems(layoutNode, row.toArray()); + } + + auto summary = layoutCollection.append_child("Item"); + summary.append_attribute("id").set_value(layoutId); + summary.append_attribute("name").set_value( + QString("Layout%1").arg(++numLayouts).toStdString().c_str()); + } + + std::ostringstream stream; + document.first_child().print(stream); + + vtkNew parser; + if (!parser->Parse(stream.str().c_str())) { + qWarning() << "LegacyStateLoader: failed to parse synthesized view " + "state XML"; + return false; + } + + // Clear the existing views/layouts so loadState can recreate them + // with the legacy global IDs. pqDeleteReaction::deleteAll handles + // views, layouts, and any other proxies the UI knows about. + pqDeleteReaction::deleteAll(); + + // Capture the ProxyLocator that loadState uses to resolve the + // just-created proxies. The signal fires synchronously during + // loadState; auto-disconnect via SingleShotConnection so we don't + // leak the connection across subsequent state loads. + auto* appCore = pqApplicationCore::instance(); + auto& idMap = ctx.viewIdMap; + QObject::connect( + appCore, &pqApplicationCore::stateLoaded, appCore, + [&idMap, &views](vtkPVXMLElement*, vtkSMProxyLocator* locator) { + if (!locator) { + return; + } + for (const auto& v : views) { + auto view = v.toObject(); + int viewId = view.value("id").toInt(); + if (auto* proxy = vtkSMViewProxy::SafeDownCast( + locator->LocateProxy(viewId))) { + idMap.insert(viewId, proxy); + } + } + }, + Qt::SingleShotConnection); + + auto* server = pqActiveObjects::instance().activeServer(); + appCore->loadState(parser->GetRootElement(), server); + + // Make the "active" view (if any) the app's active view too. + for (const auto& v : views) { + auto view = v.toObject(); + if (view.value("active").toBool()) { + if (auto* proxy = idMap.value(view.value("id").toInt(), nullptr)) { + ActiveObjects::instance().setActiveView(proxy); + } + break; + } + } + return !idMap.isEmpty(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/LegacyStateLoader.h b/tomviz/pipeline/LegacyStateLoader.h new file mode 100644 index 000000000..5e1c78d73 --- /dev/null +++ b/tomviz/pipeline/LegacyStateLoader.h @@ -0,0 +1,181 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineLegacyStateLoader_h +#define tomvizPipelineLegacyStateLoader_h + +#include +#include +#include +#include +#include +#include + +class vtkImageData; +class vtkSMViewProxy; + +namespace h5 { +class H5ReadWrite; +} + +namespace tomviz { +namespace pipeline { + +class Node; +class OutputPort; +class Pipeline; +class SourceNode; +class TransformNode; +class SinkGroupNode; +class LegacyModuleSink; + +/// Load a legacy Tomviz state file (.tvsm JSON or .tvh5 HDF5) into the +/// current new-pipeline graph. Legacy state produced by older Tomviz +/// versions still rides on the same JSON schema; the loader walks that +/// schema and creates the equivalent SourceNode / TransformNode / SinkNode +/// tree. +/// +/// This is a one-way compatibility layer — it is not a saver. Saving and +/// the new state-file format come in later stages. +class LegacyStateLoader +{ +public: + /// Load from a parsed .tvsm JSON object. @a stateDir is used to resolve + /// relative reader file paths stored in each DataSource's "reader" + /// section. When @a executePipeline is false, the pipeline graph is + /// built and the view/sink state is applied but operator execution is + /// skipped — the user will run the pipeline manually later. + /// Returns false on any unrecoverable error (individual unrecognized + /// operators/modules are skipped, not fatal). + static bool load(const QJsonObject& state, const QDir& stateDir, + bool executePipeline = true); + + /// Load from a .tvh5 file on disk. Opens the HDF5 container, extracts + /// the /tomviz_state JSON, then walks the same structure as load() but + /// with data arrays coming from the HDF5 groups rather than from + /// separate files on disk. + static bool loadFromH5(const QString& filename, + bool executePipeline = true); + + /// Load a v1 template: walk @a operators (an "operators" array from a + /// legacy .tvsm file), build each as a TransformNode, and chain them + /// linearly via createLink on the active pipeline. Any nested child + /// dataSources / modules on the operators are intentionally ignored — + /// templates are transform-only fragments. + static void loadTemplateOperators(const QJsonArray& operators, + Pipeline* pipeline); + + /// Restore the top-level `views`, `layouts`, and `paletteColor` + /// sections of @a state into the running ParaView session. Shared + /// between the legacy loader (which calls the private helpers + /// directly) and the new-format loader in SaveLoadStateReaction. + /// If @a pipeline is non-null, camera state is applied on + /// Pipeline::executionFinished so it lands after any + /// resetCameraIfFirstSink adjustments. + /// @a viewIdMap, if non-null, is populated with the legacy + /// view-id → restored-proxy mapping so the caller can bind sinks + /// back to their saved views. + static void restoreViewsLayoutsAndPalette( + const QJsonObject& state, Pipeline* pipeline, + QMap* viewIdMap = nullptr); + +private: + struct LoadContext + { + Pipeline* pipeline = nullptr; + vtkSMViewProxy* view = nullptr; + QDir stateDir; + h5::H5ReadWrite* h5 = nullptr; // non-null for .tvh5 loads + QString activeDataSourceId; // /data/tomography source (tvh5) + QStringList skippedOperators; + QStringList skippedModules; + /// Legacy view id → restored view proxy, built after + /// pqApplicationCore::loadState() recreates the views. + QMap viewIdMap; + }; + + /// Build a SourceNode from a top-level DataSource JSON entry. For .tvsm + /// this resolves reader.fileNames relative to stateDir; for .tvh5 it + /// reads voxel data from the HDF5 group keyed by the DataSource id. + static SourceNode* buildSource(const QJsonObject& dsJson, + LoadContext& ctx); + + /// Walk the pipeline rooted at @a ds (a DataSource JSON object) and + /// attach operators/modules to @a upstream's primary output port. + /// + /// The ``ds`` object may be: + /// - a top-level DataSource (has a "reader" section), or + /// - a child DataSource embedded inside an operator's "dataSources" + /// array (no reader; inherits data from the producing transform). + static void walkDataSource(Node* upstream, const QJsonObject& ds, + LoadContext& ctx); + + /// Create a TransformNode for the given operator JSON entry. Returns + /// null and appends to ctx.skippedOperators for unknown types. + static TransformNode* buildOperator(const QJsonObject& opJson, + LoadContext& ctx); + + /// Create and configure a LegacyModuleSink for the given module JSON + /// entry. Returns null and appends to ctx.skippedModules for unknown + /// types. The sink is added to ``pipeline`` and linked to @a sourcePort + /// via a SinkGroupNode (one SinkGroupNode per upstream port). + static LegacyModuleSink* buildModule(const QJsonObject& moduleJson, + OutputPort* sourcePort, + SinkGroupNode* group, + LoadContext& ctx); + + /// Apply DataSource-level metadata (spacing, origin, units, colormap + /// points...) to the VolumeData on @a node's primary output port. + /// No-op if the port has no data yet. + static void applyDataSourceMetadata(Node* node, + const QJsonObject& dsJson); + + /// Pick the active view for sinks to render into. Used as a fallback + /// when a module's viewId isn't present in ctx.viewIdMap. + static vtkSMViewProxy* resolveView(LoadContext& ctx); + + /// Recreate ParaView views + layout from @a state ("views", "layouts") + /// via pqApplicationCore::loadState(), preserving legacy global IDs, + /// and populate ctx.viewIdMap from the ProxyLocator so later + /// buildModule() calls can look up the right view per module. + /// Returns false if no views[] is present or XML construction/parse + /// fails — caller should fall back to the app's current active view. + static bool restoreViewsAndLayouts(const QJsonObject& state, + LoadContext& ctx); + + /// Apply a legacy top-level paletteColor (array of 3 doubles) to the + /// ColorPalette settings proxy's BackgroundColor. Views that set + /// UseColorPaletteForBackground=1 will then pick up this color. + static void applyPaletteColor(const QJsonArray& color); + + /// Apply a legacy view JSON (camera, axes visibility, background, + /// interaction/orthographic modes, ...) to @a view. Multi-view state + /// is not restored — the caller selects a single representative + /// view (the one marked "active", or the first, falling back to the + /// application's current active view). When @a cameraToo is false + /// the camera position / focal point / viewUp / angles / parallel + /// scale are skipped — used when state needs to land before any + /// sink's first consume has had a chance to auto-reset the camera. + static void applyViewState(vtkSMViewProxy* view, + const QJsonObject& viewJson, + bool cameraToo = true); + + /// Schedule applyViewState() to fire once on Pipeline::executionFinished + /// so the restored camera lands after LegacyModuleSink::resetCameraIfFirstSink + /// runs on the first consume. Single-view path. + static void scheduleViewStateApply(const QJsonObject& state, + vtkSMViewProxy* view, + Pipeline* pipeline); + + /// Multi-view variant: schedule applyViewState() for every view in + /// state["views"] that resolves through ctx.viewIdMap. Falls back to + /// scheduleViewStateApply() (single-view, active view) when the id + /// map is empty. + static void scheduleViewStatesApply(const QJsonObject& state, + LoadContext& ctx); +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/Link.cxx b/tomviz/pipeline/Link.cxx new file mode 100644 index 000000000..9f511cbfe --- /dev/null +++ b/tomviz/pipeline/Link.cxx @@ -0,0 +1,67 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "Link.h" + +#include "InputPort.h" +#include "OutputPort.h" +#include "PortType.h" + +namespace tomviz { +namespace pipeline { + +Link::Link(OutputPort* from, InputPort* to, QObject* parent) + : QObject(parent), m_from(from), m_to(to) +{ + if (m_from) { + m_from->addLink(this); + } + if (m_to) { + m_to->setLink(this); + } + // Compute initial validity + m_valid = isConnected() && m_to->canConnectTo(m_from); +} + +Link::~Link() +{ + emit aboutToBeRemoved(); + if (m_from) { + m_from->removeLink(this); + } + if (m_to) { + m_to->setLink(nullptr); + } +} + +OutputPort* Link::from() const +{ + return m_from; +} + +InputPort* Link::to() const +{ + return m_to; +} + +bool Link::isValid() const +{ + return m_valid; +} + +bool Link::isConnected() const +{ + return m_from && m_to; +} + +void Link::recheck() +{ + bool valid = isConnected() && m_to->canConnectTo(m_from); + if (m_valid != valid) { + m_valid = valid; + emit validityChanged(valid); + } +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/Link.h b/tomviz/pipeline/Link.h new file mode 100644 index 000000000..2fdd57c6f --- /dev/null +++ b/tomviz/pipeline/Link.h @@ -0,0 +1,52 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineLink_h +#define tomvizPipelineLink_h + +#include + +namespace tomviz { +namespace pipeline { + +class InputPort; +class OutputPort; + +class Link : public QObject +{ + Q_OBJECT + +public: + Link(OutputPort* from, InputPort* to, QObject* parent = nullptr); + ~Link() override; + + OutputPort* from() const; + InputPort* to() const; + + /// True if both endpoints are non-null AND the output's effective type is + /// compatible with the input's accepted types. Invalid links are kept in + /// the graph but block execution; they become valid again when upstream + /// type changes restore compatibility. + bool isValid() const; + + /// True if both endpoints are non-null (ignoring type compatibility). + bool isConnected() const; + + /// Re-evaluate validity based on current effective types. + /// Emits validityChanged() if the result changed. + void recheck(); + +signals: + void aboutToBeRemoved(); + void validityChanged(bool valid); + +private: + OutputPort* m_from; + InputPort* m_to; + bool m_valid = true; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/LinkPropertiesWidget.cxx b/tomviz/pipeline/LinkPropertiesWidget.cxx new file mode 100644 index 000000000..1720e36f4 --- /dev/null +++ b/tomviz/pipeline/LinkPropertiesWidget.cxx @@ -0,0 +1,84 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "LinkPropertiesWidget.h" + +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "PortType.h" + +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +// "NodeLabel[portName]" for one endpoint, guarding against null pieces. +static QString endpointText(Node* node, const QString& portName) +{ + QString nodeLabel = node ? node->label() : QObject::tr("(none)"); + if (portName.isEmpty()) { + return nodeLabel; + } + return QStringLiteral("%1[%2]").arg(nodeLabel, portName); +} + +LinkPropertiesWidget::LinkPropertiesWidget(Link* link, QWidget* parent) + : QWidget(parent), m_link(link) +{ + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(10); + + auto* form = new QFormLayout; + form->setLabelAlignment(Qt::AlignLeft | Qt::AlignVCenter); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + form->setHorizontalSpacing(8); + form->setVerticalSpacing(6); + layout->addLayout(form); + + OutputPort* from = m_link ? m_link->from() : nullptr; + InputPort* to = m_link ? m_link->to() : nullptr; + + // Values are right-aligned so they line up on the right edge, opposite the + // left-aligned labels. + auto makeValue = [this](const QString& text) { + auto* l = new QLabel(text, this); + l->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + return l; + }; + + form->addRow(tr("From:"), + makeValue(endpointText(from ? from->node() : nullptr, + from ? from->name() : QString()))); + form->addRow(tr("To:"), + makeValue(endpointText(to ? to->node() : nullptr, + to ? to->name() : QString()))); + + if (from) { + form->addRow(tr("Type:"), makeValue(portTypeToString(from->type()))); + } + + if (m_link && !m_link->isValid()) { + auto* status = makeValue(tr("Invalid (incompatible types)")); + QFont f = status->font(); + f.setBold(true); + status->setFont(f); + form->addRow(tr("Status:"), status); + } + + auto* deleteButton = new QPushButton(tr("Delete Link"), this); + connect(deleteButton, &QPushButton::clicked, this, + [this]() { emit deleteRequested(m_link); }); + layout->addWidget(deleteButton); + + layout->addStretch(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/LinkPropertiesWidget.h b/tomviz/pipeline/LinkPropertiesWidget.h new file mode 100644 index 000000000..05f3d6d1a --- /dev/null +++ b/tomviz/pipeline/LinkPropertiesWidget.h @@ -0,0 +1,36 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineLinkPropertiesWidget_h +#define tomvizPipelineLinkPropertiesWidget_h + +#include + +namespace tomviz { +namespace pipeline { + +class Link; + +/// Minimal properties panel for a selected Link: shows the source and +/// destination endpoints and offers a button to delete the link. +class LinkPropertiesWidget : public QWidget +{ + Q_OBJECT + +public: + explicit LinkPropertiesWidget(Link* link, QWidget* parent = nullptr); + ~LinkPropertiesWidget() override = default; + +signals: + /// Emitted when the user clicks the delete button. The owner is + /// responsible for actually removing the link from the pipeline. + void deleteRequested(Link* link); + +private: + Link* m_link = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/Node.cxx b/tomviz/pipeline/Node.cxx new file mode 100644 index 000000000..d4cecd96e --- /dev/null +++ b/tomviz/pipeline/Node.cxx @@ -0,0 +1,680 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "Node.h" + +#include "InputPort.h" +#include "Link.h" +#include "NodeExecutor.h" +#include "NodeExecutorFactory.h" +#include "OutputPort.h" +#include "ThreadUtils.h" +#include "data/VolumeData.h" + +#include + +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +QJsonArray portTypesToJson(PortTypes types) +{ + QJsonArray arr; + for (PortType t : kAllPortTypes) { + if (types.testFlag(t)) { + arr.append(portTypeToString(t)); + } + } + return arr; +} + +QString nodeStateToString(NodeState state) +{ + switch (state) { + case NodeState::New: + return QStringLiteral("New"); + case NodeState::Stale: + return QStringLiteral("Stale"); + case NodeState::Current: + return QStringLiteral("Current"); + } + return QStringLiteral("New"); +} + +} // namespace + +Node::Node(QObject* parent) : QObject(parent) {} + +QString Node::label() const +{ + return m_label; +} + +void Node::setLabel(const QString& label) +{ + if (m_label != label) { + m_label = label; + emit labelChanged(); + } +} + +NodeState Node::state() const +{ + return m_state; +} + +void Node::markStale() +{ + if (m_state == NodeState::Stale) { + return; + } + m_state = NodeState::Stale; + emit stateChanged(m_state); + + for (auto* output : m_outputPorts) { + output->setStale(true); + for (auto* link : output->links()) { + if (link->to()) { + Node* downstream = link->to()->node(); + if (downstream) { + downstream->markStale(); + } + } + } + } +} + +void Node::markCurrent() +{ + m_state = NodeState::Current; + emit stateChanged(m_state); +} + +void Node::setStateNoCascade(NodeState state) +{ + if (m_state != state) { + m_state = state; + emit stateChanged(m_state); + } +} + +NodeExecState Node::execState() const +{ + return m_execState; +} + +void Node::setExecState(NodeExecState state) +{ + if (m_execState != state) { + m_execState = state; + emit execStateChanged(m_execState); + } +} + +bool Node::isEditing() const +{ + return m_editing; +} + +void Node::setEditing(bool editing) +{ + if (m_editing != editing) { + m_editing = editing; + emit editingChanged(m_editing); + } +} + +bool Node::hasBreakpoint() const +{ + return m_breakpoint; +} + +void Node::setBreakpoint(bool enabled) +{ + if (m_breakpoint != enabled) { + m_breakpoint = enabled; + emit breakpointChanged(); + } +} + +bool Node::isAtBreakpoint() const +{ + return m_breakpoint && m_state != NodeState::Current && allInputsCurrent(); +} + +QList Node::inputPorts() const +{ + return m_inputPorts; +} + +QList Node::outputPorts() const +{ + return m_outputPorts; +} + +InputPort* Node::inputPort(const QString& name) const +{ + for (auto* port : m_inputPorts) { + if (port->name() == name) { + return port; + } + } + return nullptr; +} + +OutputPort* Node::outputPort(const QString& name) const +{ + for (auto* port : m_outputPorts) { + if (port->name() == name) { + return port; + } + } + return nullptr; +} + +QMap Node::collectInputs() const +{ + QMap inputs; + for (auto* port : m_inputPorts) { + inputs[port->name()] = port->data(); + } + return inputs; +} + +bool Node::allInputsCurrent() const +{ + for (auto* input : m_inputPorts) { + if (!input->link()) { + return false; + } + Node* upstream = input->link()->from()->node(); + if (upstream && upstream->state() != NodeState::Current) { + return false; + } + } + return true; +} + +bool Node::anyInputStale() const +{ + for (auto* input : m_inputPorts) { + if (input->isStale()) { + return true; + } + } + return false; +} + +QList Node::upstreamNodes() const +{ + QList result; + for (auto* input : m_inputPorts) { + if (input->link() && input->link()->from()) { + Node* upstream = input->link()->from()->node(); + if (upstream && !result.contains(upstream)) { + result.append(upstream); + } + } + } + return result; +} + +QList Node::downstreamNodes() const +{ + QList result; + for (auto* output : m_outputPorts) { + for (auto* link : output->links()) { + if (link->to()) { + Node* downstream = link->to()->node(); + if (downstream && !result.contains(downstream)) { + result.append(downstream); + } + } + } + } + return result; +} + +void Node::setProperty(const QString& key, const QVariant& value) +{ + m_properties[key] = value; +} + +QVariant Node::property(const QString& key, + const QVariant& defaultValue) const +{ + return m_properties.value(key, defaultValue); +} + +QVariantMap Node::properties() const +{ + return m_properties; +} + +QIcon Node::icon() const +{ + return QIcon(QStringLiteral(":/pipeline/pqInspect.png")); +} + +QIcon Node::actionIcon() const +{ + return QIcon(); +} + +void Node::triggerAction() {} + +bool Node::execute() +{ + return true; +} + +bool Node::hasPropertiesWidget() const +{ + return false; +} + +EditNodeWidget* Node::createPropertiesWidget(Pipeline* /*pipeline*/, + QWidget* /*parent*/) +{ + return nullptr; +} + +void Node::setIntermediateOutputs(const QMap& updates) +{ + for (auto it = updates.constBegin(); it != updates.constEnd(); ++it) { + if (auto* port = outputPort(it.key())) { + port->setIntermediateData(it.value()); + } + } +} + +void Node::applyOutputs(const QMap& outputs) +{ + auto apply = [this, &outputs]() { + for (auto it = outputs.constBegin(); it != outputs.constEnd(); ++it) { + auto* port = outputPort(it.key()); + if (!port) { + continue; + } + + // Reuse existing VolumeData so its color map survives. + if (port->hasData() && isVolumeType(port->data().type()) && + isVolumeType(it.value().type())) { + try { + auto existing = port->data().value(); + auto fresh = it.value().value(); + if (existing && fresh && existing != fresh) { + existing->setImageData( + vtkSmartPointer(fresh->imageData())); + existing->setLabel(fresh->label()); + existing->setUnits(fresh->units()); + port->setData(PortData(std::any(existing), it.value().type())); + continue; + } + } catch (const std::bad_any_cast&) { + } + } + + port->setData(it.value()); + } + }; + + runOnThread(this, apply); +} + +NodeExecutor* Node::nodeExecutor() const +{ + return m_nodeExecutor; +} + +void Node::setNodeExecutor(NodeExecutor* executor) +{ + if (m_nodeExecutor == executor) { + return; + } + // Replace any previously-owned executor. Safe even when executor is + // null — deleteLater on a null QObject is a no-op via the guard. + if (m_nodeExecutor) { + m_nodeExecutor->deleteLater(); + } + m_nodeExecutor = executor; + if (m_nodeExecutor) { + m_nodeExecutor->setParent(this); + } +} + +int Node::totalProgressSteps() const +{ + return m_totalProgressSteps; +} + +void Node::setTotalProgressSteps(int steps) +{ + m_totalProgressSteps = steps; + emit totalProgressStepsChanged(steps); +} + +int Node::progressStep() const +{ + return m_progressStep; +} + +void Node::setProgressStep(int step) +{ + m_progressStep = step; + emit progressStepChanged(step); +} + +QString Node::progressMessage() const +{ + return m_progressMessage; +} + +void Node::setProgressMessage(const QString& message) +{ + m_progressMessage = message; + emit progressMessageChanged(message); +} + +void Node::resetProgress() +{ + m_totalProgressSteps = 0; + m_progressStep = 0; + m_progressMessage.clear(); +} + +bool Node::supportsCancelingMidExecution() const +{ + return m_supportsCancel; +} + +bool Node::supportsCompletionMidExecution() const +{ + return m_supportsCompletion; +} + +bool Node::isCanceled() const +{ + return m_canceled.load(); +} + +bool Node::isCompleted() const +{ + return m_completed.load(); +} + +void Node::cancelExecution() +{ + m_canceled = true; + emit executionCanceled(); + // Notify the per-node executor so it can take any executor-specific + // action (e.g. ExternalNodeExecutor terminates its subprocess). The + // default NodeExecutor::cancel is a no-op — the canceled flag set + // above is what in-process executors rely on. + if (m_nodeExecutor) { + m_nodeExecutor->cancel(this); + } +} + +void Node::completeExecution() +{ + m_completed = true; + emit executionCompleted(); + // Same dispatch as cancelExecution — let the executor forward the + // request to any out-of-process work. + if (m_nodeExecutor) { + m_nodeExecutor->complete(this); + } +} + +void Node::resetExecutionFlags() +{ + m_canceled = false; + m_completed = false; +} + +void Node::setSupportsCancel(bool b) +{ + m_supportsCancel = b; +} + +void Node::setSupportsCompletion(bool b) +{ + m_supportsCompletion = b; +} + +void Node::setTypeInferenceSource(const QString& outputPortName, + const QString& inputPortName) +{ + m_typeInferenceSources[outputPortName] = inputPortName; +} + +void Node::recomputeEffectiveTypes() +{ + for (auto* output : m_outputPorts) { + if (output->declaredType() != PortType::ImageData) { + // Concrete types (TiltSeries, Volume, Table, etc.) are never inferred. + output->setEffectiveType(output->declaredType()); + continue; + } + + // Find the driving input port for this output. + InputPort* driver = nullptr; + auto it = m_typeInferenceSources.constFind(output->name()); + if (it != m_typeInferenceSources.constEnd()) { + // Explicit mapping + driver = inputPort(it.value()); + } else { + // Default: first ImageData input port + for (auto* input : m_inputPorts) { + if (input->acceptedTypes().testFlag(PortType::ImageData)) { + driver = input; + break; + } + } + } + + if (driver && driver->link() && driver->link()->from()) { + // Inherit effective type from the upstream output + output->setEffectiveType(driver->link()->from()->type()); + } else { + // No connection — revert to declared type + output->setEffectiveType(PortType::ImageData); + } + } +} + +bool Node::hasInvalidInputLinks() const +{ + for (auto* input : m_inputPorts) { + if (input->link() && !input->link()->isValid()) { + return true; + } + } + return false; +} + +InputPort* Node::addInputPort(const QString& name, PortTypes acceptedTypes) +{ + auto* port = new InputPort(name, acceptedTypes, this); + m_inputPorts.append(port); + return port; +} + +OutputPort* Node::addOutputPort(const QString& name, PortType type) +{ + auto* port = new OutputPort(name, type, this); + m_outputPorts.append(port); + return port; +} + +void Node::addOutputPort(OutputPort* port) +{ + port->setParent(this); + m_outputPorts.append(port); +} + +QJsonObject Node::serialize() const +{ + QJsonObject json; + json[QStringLiteral("label")] = m_label; + if (m_state != NodeState::New) { + // Defaults to New on load; only persist when different. + json[QStringLiteral("state")] = nodeStateToString(m_state); + } + if (m_breakpoint) { + json[QStringLiteral("breakpoint")] = true; + } + if (!m_properties.isEmpty()) { + json[QStringLiteral("properties")] = + QJsonObject::fromVariantMap(m_properties); + } + if (!m_typeInferenceSources.isEmpty()) { + QJsonObject tis; + for (auto it = m_typeInferenceSources.constBegin(); + it != m_typeInferenceSources.constEnd(); ++it) { + tis[it.key()] = it.value(); + } + json[QStringLiteral("typeInferenceSources")] = tis; + } + // Persist only when a non-default executor is set. The implicit + // InternalNodeExecutor carries no JSON footprint. + if (m_nodeExecutor && !m_nodeExecutor->type().isEmpty()) { + QJsonObject executor = m_nodeExecutor->serialize(); + executor[QStringLiteral("type")] = m_nodeExecutor->type(); + json[QStringLiteral("executor")] = executor; + } + if (!m_outputPorts.isEmpty()) { + QJsonObject outputs; + for (auto* port : m_outputPorts) { + QJsonObject entry; + entry[QStringLiteral("type")] = + portTypeToString(port->declaredType()); + entry[QStringLiteral("persistent")] = port->isPersistent(); + // Persistence medium. Only meaningful when persistent==true. + // Omitted for the default (InMemory) so older readers (and the + // ones that only look at the bool) keep working. + if (port->isPersistent() && + port->persistenceMode() != PersistenceMode::InMemory) { + entry[QStringLiteral("persistenceMode")] = + persistenceModeToString(port->persistenceMode()); + } + auto metadata = port->serialize(); + if (!metadata.isEmpty()) { + entry[QStringLiteral("metadata")] = metadata; + } + outputs[port->name()] = entry; + } + json[QStringLiteral("outputPorts")] = outputs; + } + if (!m_inputPorts.isEmpty()) { + QJsonObject inputs; + for (auto* port : m_inputPorts) { + QJsonObject entry; + entry[QStringLiteral("type")] = + portTypesToJson(port->acceptedTypes()); + inputs[port->name()] = entry; + } + json[QStringLiteral("inputPorts")] = inputs; + } + return json; +} + +bool Node::deserialize(const QJsonObject& json) +{ + if (json.contains(QStringLiteral("label"))) { + setLabel(json.value(QStringLiteral("label")).toString()); + } + if (json.contains(QStringLiteral("state"))) { + auto s = json.value(QStringLiteral("state")).toString(); + if (s == QLatin1String("Stale")) { + markStale(); + } else if (s == QLatin1String("Current")) { + markCurrent(); + } + // "New" is the default; nothing to do. + } + if (json.contains(QStringLiteral("breakpoint"))) { + setBreakpoint(json.value(QStringLiteral("breakpoint")).toBool()); + } + if (json.contains(QStringLiteral("properties"))) { + m_properties = + json.value(QStringLiteral("properties")).toObject().toVariantMap(); + } + if (json.contains(QStringLiteral("typeInferenceSources"))) { + m_typeInferenceSources.clear(); + auto tis = + json.value(QStringLiteral("typeInferenceSources")).toObject(); + for (auto it = tis.constBegin(); it != tis.constEnd(); ++it) { + m_typeInferenceSources[it.key()] = it.value().toString(); + } + } + if (json.contains(QStringLiteral("executor"))) { + // Defensive: ad-hoc paths (e.g. ExternalNodeExecutor cloning a + // node) may hit deserialize without going through PipelineStateIO. + NodeExecutorFactory::registerBuiltins(); + auto executorJson = + json.value(QStringLiteral("executor")).toObject(); + auto type = executorJson.value(QStringLiteral("type")).toString(); + if (!type.isEmpty()) { + auto* executor = NodeExecutorFactory::instance().create(type); + if (executor) { + executor->deserialize(executorJson); + setNodeExecutor(executor); + } else { + qWarning() << "Node::deserialize: unknown executor type" << type; + } + } + } + if (json.contains(QStringLiteral("outputPorts"))) { + auto outputs = json.value(QStringLiteral("outputPorts")).toObject(); + for (auto it = outputs.constBegin(); it != outputs.constEnd(); ++it) { + auto* port = outputPort(it.key()); + if (!port) { + continue; + } + auto entry = it.value().toObject(); + // Restore declaredType so a reader-style source whose effective + // type was promoted at execute time (e.g. ImageData → TiltSeries) + // lands on the saved type *before* links are resolved, letting + // downstream inference pick it up. + if (entry.contains(QStringLiteral("type"))) { + PortType t = + portTypeFromString(entry.value(QStringLiteral("type")).toString()); + if (t != PortType::None && t != port->declaredType()) { + port->setDeclaredType(t); + } + } + if (entry.contains(QStringLiteral("persistent"))) { + port->setPersistent( + entry.value(QStringLiteral("persistent")).toBool()); + // The medium key is omitted when it is the default (InMemory), + // so reset it explicitly: the port may still carry the + // application-wide default (e.g. OnDisk) from construction. + if (port->isPersistent() && + !entry.contains(QStringLiteral("persistenceMode"))) { + port->setPersistenceMode(PersistenceMode::InMemory); + } + } + if (entry.contains(QStringLiteral("persistenceMode"))) { + port->setPersistenceMode(persistenceModeFromString( + entry.value(QStringLiteral("persistenceMode")).toString())); + } + if (entry.contains(QStringLiteral("metadata"))) { + port->deserialize( + entry.value(QStringLiteral("metadata")).toObject()); + } + } + } + // inputPorts: types are intrinsic to the Node subclass, so we do not + // restore acceptedTypes here. Subclasses with dynamic inputs (e.g. + // SinkGroupNode) are expected to create their ports before calling + // Node::deserialize so per-port state can apply. + return true; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/Node.h b/tomviz/pipeline/Node.h new file mode 100644 index 000000000..ae1de362f --- /dev/null +++ b/tomviz/pipeline/Node.h @@ -0,0 +1,275 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNode_h +#define tomvizPipelineNode_h + +#include "NodeExecState.h" +#include "NodeState.h" +#include "PortData.h" +#include "PortType.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class QWidget; + +namespace tomviz { +namespace pipeline { + +class EditNodeWidget; +class InputPort; +class NodeExecutor; +class OutputPort; +class Pipeline; + +class Node : public QObject +{ + Q_OBJECT + +public: + Node(QObject* parent = nullptr); + ~Node() override = default; + + QString label() const; + void setLabel(const QString& label); + + NodeState state() const; + void markStale(); + void markCurrent(); + /// Set the node's state without any side effects on neighbors. + /// markStale() cascades through the DAG; this is the escape hatch + /// for loaders / restorers that already know the full state of the + /// graph and don't want to re-cascade. + void setStateNoCascade(NodeState state); + + NodeExecState execState() const; + + bool isEditing() const; + void setEditing(bool editing); + + bool hasBreakpoint() const; + void setBreakpoint(bool enabled); + + /// True when this node carries a breakpoint that the executor has + /// just stopped at: it has a breakpoint set, all of its inputs are + /// Current (upstream already ran), and the node itself has not been + /// executed (state != Current). Used by the pipeline widget to swap + /// the breakpoint indicator for a resume affordance. + bool isAtBreakpoint() const; + + QList inputPorts() const; + QList outputPorts() const; + InputPort* inputPort(const QString& name) const; + OutputPort* outputPort(const QString& name) const; + + /// Snapshot the current data on every input port, keyed by port name. + /// Used by execute() to feed transform()/produce(), and by + /// createPropertiesWidget() to seed custom widgets that render over + /// live input data. Empty for source-shape nodes. + QMap collectInputs() const; + + bool allInputsCurrent() const; + bool anyInputStale() const; + + QList upstreamNodes() const; + QList downstreamNodes() const; + + /// Arbitrary metadata properties. + void setProperty(const QString& key, const QVariant& value); + QVariant property(const QString& key, + const QVariant& defaultValue = {}) const; + QVariantMap properties() const; + + virtual QIcon icon() const; + + /// Optional action button icon shown on the node card (e.g. visibility + /// toggle). Return a null QIcon to indicate no action button. + virtual QIcon actionIcon() const; + + /// Called when the user clicks the action button. + virtual void triggerAction(); + + virtual bool execute(); + + /// Whether this node provides an editor widget (script + parameters + /// + execution settings) usable from the properties panel and the + /// edit dialog. Defaults to false; subclasses that have user-editable + /// parameters (transforms, schema-v2 sources, ...) override. + virtual bool hasPropertiesWidget() const; + + /// Build the editor widget. The caller (NodeEditDialog or + /// NodePropertiesPanel) takes ownership and provides Apply/OK/Cancel. + /// Returns nullptr when hasPropertiesWidget() is false. + /// + /// @a pipeline is passed in so editors that need to react to pipeline + /// events (e.g. GatedEditorWidget waiting for inputs to materialize, + /// PythonNodeEditorWidget refreshing its Parameters tab) can subscribe + /// directly. Editors that don't need it can ignore the parameter. + virtual EditNodeWidget* createPropertiesWidget(Pipeline* pipeline, + QWidget* parent); + + /// Per-node executor strategy. Null means "use the pipeline-level + /// fallback" (InternalNodeExecutor singleton). Setting a non-null + /// executor reparents it under this node — the node owns its + /// executor's lifetime. + NodeExecutor* nodeExecutor() const; + void setNodeExecutor(NodeExecutor* executor); + + /// Apply a batch of intermediate (live preview) updates to this + /// node's output ports. Each entry maps an output port name to the + /// new payload; missing port names are ignored. Routes through + /// OutputPort::setIntermediateData so subclasses with type-specific + /// merge semantics (e.g. VolumeOutputPort preserving the existing + /// vtkImageData identity for downstream color-map references) take + /// effect. Thread-safe. + void setIntermediateOutputs(const QMap& updates); + + /// Return the total number of progress steps. Zero means indeterminate. + int totalProgressSteps() const; + void setTotalProgressSteps(int steps); + + /// Current progress step (0 to totalProgressSteps). + int progressStep() const; + void setProgressStep(int step); + + /// Optional progress message shown in the progress dialog title. + QString progressMessage() const; + void setProgressMessage(const QString& message); + + /// Reset progress state (steps, step, message) to defaults. + void resetProgress(); + + /// If the node has custom progress UI, return it parented to the given + /// widget. Otherwise return nullptr and a default QProgressBar will be used. + virtual QWidget* getCustomProgressWidget(QWidget*) const { return nullptr; } + + /// Re-show any visualization this node owns (or, for group nodes, that its + /// children own) after an input link is recreated, without re-running the + /// pipeline. The counterpart of the implicit hide that happens when a link + /// is broken. Called when a deferred insertion is cancelled and the original + /// links are restored. Default no-op. + virtual void restorePresentation() {} + + /// Whether the node supports canceling mid-execution via cancelExecution(). + bool supportsCancelingMidExecution() const; + + /// Whether the node supports early completion via completeExecution(). + bool supportsCompletionMidExecution() const; + + /// True if cancellation has been requested (thread-safe). + bool isCanceled() const; + + /// True if early completion has been requested (thread-safe). + bool isCompleted() const; + +public slots: + /// Request cancellation of an in-progress execution. + virtual void cancelExecution(); + + /// Request early completion of an in-progress execution. + virtual void completeExecution(); + + /// Declare that the effective type of an output port follows the effective + /// type of an input port. Only meaningful when the output port is declared + /// as ImageData. If no explicit mapping is set for an ImageData output, + /// it defaults to following the first ImageData input port. + void setTypeInferenceSource(const QString& outputPortName, + const QString& inputPortName); + + /// Recompute effective types for all output ports based on current input + /// connections and inference rules. Emits effectiveTypeChanged on any + /// output whose effective type changed. + void recomputeEffectiveTypes(); + + /// True if any input link is invalid (type-incompatible). + bool hasInvalidInputLinks() const; + + /// Serialize this node's persistent state to JSON (label, breakpoint, + /// properties, typeInferenceSources, output/input port state). + /// Subclasses should call the base and then add their own fields. + virtual QJsonObject serialize() const; + + /// Apply JSON produced by serialize() to this node. Returns false on + /// unrecoverable errors. Subclasses that create dynamic ports should + /// create them before calling up to Node::deserialize so per-port + /// state can be applied. + virtual bool deserialize(const QJsonObject& json); + +signals: + void stateChanged(NodeState state); + void execStateChanged(NodeExecState state); + void editingChanged(bool editing); + void labelChanged(); + void breakpointChanged(); + void progressStepChanged(int step); + void totalProgressStepsChanged(int steps); + void progressMessageChanged(const QString& message); + void executionCanceled(); + void executionCompleted(); + + /// Emitted when the user applies new parameter values via the editor + /// widget. The node has already been marked stale by the caller; + /// connect this to pipeline re-execution. + void parametersApplied(); + +public: + /// Reset the canceled/completed flags. Public so a NodeExecutor can + /// prime them at the start of an execution. + void resetExecutionFlags(); + + /// Update the execution state machine indicator. Public so a + /// NodeExecutor that fully replaces the in-process execute() path + /// (e.g. ExternalNodeExecutor) can drive the same transitions + /// TransformNode::execute does internally. + void setExecState(NodeExecState state); + + /// Apply a map of output port name → PortData to this node's output + /// ports as the final step of execute(). When both the existing and + /// incoming payloads are volume-shaped, reuses the existing VolumeData + /// instance (replaces its vtkImageData / label / units in place) so + /// downstream references like color maps survive a re-run. Marshals + /// to the node's owning thread when called from a worker thread. + /// Public for the same reason as setExecState — out-of-process + /// executors need to drive this path too. + void applyOutputs(const QMap& outputs); + +protected: + void setSupportsCancel(bool b); + void setSupportsCompletion(bool b); + InputPort* addInputPort(const QString& name, PortTypes acceptedTypes); + OutputPort* addOutputPort(const QString& name, PortType type); + void addOutputPort(OutputPort* port); + +private: + QString m_label; + NodeState m_state = NodeState::New; + NodeExecState m_execState = NodeExecState::Idle; + bool m_editing = false; + bool m_breakpoint = false; + QList m_inputPorts; + QList m_outputPorts; + QVariantMap m_properties; + QMap m_typeInferenceSources; + int m_totalProgressSteps = 0; + int m_progressStep = 0; + QString m_progressMessage; + bool m_supportsCancel = false; + bool m_supportsCompletion = false; + std::atomic m_canceled{false}; + std::atomic m_completed{false}; + NodeExecutor* m_nodeExecutor = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeEditDialog.cxx b/tomviz/pipeline/NodeEditDialog.cxx new file mode 100644 index 000000000..028d05ee6 --- /dev/null +++ b/tomviz/pipeline/NodeEditDialog.cxx @@ -0,0 +1,290 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeEditDialog.h" + +#include "EditNodeWidget.h" +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "sinks/LegacyModuleSink.h" + +#include "Utilities.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +NodeEditDialog::NodeEditDialog(Node* node, Pipeline* pipeline, QWidget* parent) + : QDialog(parent), m_node(node), m_pipeline(pipeline), + m_isNewInsertion(false) +{ + init(); +} + +NodeEditDialog::NodeEditDialog(Node* node, Pipeline* pipeline, + const DeferredLinkInfo& deferred, + QWidget* parent) + : QDialog(parent), m_node(node), m_pipeline(pipeline), + m_deferred(deferred), m_isNewInsertion(true) +{ + init(); +} + +NodeEditDialog::~NodeEditDialog() +{ + saveGeometry(); + + // Restore the parametersApplied → execute() auto-wiring that was + // disconnected in init(), unless the node was removed (cancel in insertion + // mode). + if (m_node && m_pipeline && m_pipeline->nodes().contains(m_node)) { + m_node->setEditing(false); + connect(m_node, &Node::parametersApplied, m_pipeline, + [pip = m_pipeline]() { pip->execute(); }); + } +} + +Node* NodeEditDialog::node() const +{ + return m_node; +} + +void NodeEditDialog::init() +{ + // Float above the main window so the dialog does not slip behind it on + // macOS while the user keeps working in the render view. + floatAboveMainWindow(this); + + m_node->setEditing(true); + + // Suppress the auto-execute wiring so the dialog controls execution. + QObject::disconnect(m_node, &Node::parametersApplied, m_pipeline, nullptr); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(5, 5, 5, 5); + layout->setSpacing(5); + + m_buttonBox = new QDialogButtonBox( + QDialogButtonBox::Apply | QDialogButtonBox::Ok | QDialogButtonBox::Cancel, + Qt::Horizontal, this); + + m_buttonBox->button(QDialogButtonBox::Ok)->setDefault(false); + + connect(m_buttonBox, &QDialogButtonBox::accepted, this, + &NodeEditDialog::onOkay); + connect(m_buttonBox, &QDialogButtonBox::rejected, this, + &NodeEditDialog::reject); + connect(m_buttonBox->button(QDialogButtonBox::Apply), &QPushButton::clicked, + this, &NodeEditDialog::onApply); + + m_editWidget = m_node->createPropertiesWidget(m_pipeline, this); + if (m_editWidget) { + layout->addWidget(m_editWidget, 1); + connect(m_editWidget, &EditNodeWidget::canApplyChanged, + this, &NodeEditDialog::refreshButtonEnablement); + + QString helpUrl = m_editWidget->helpUrl(); + if (!helpUrl.isEmpty()) { + auto* helpButton = m_buttonBox->addButton(QDialogButtonBox::Help); + connect(helpButton, &QPushButton::clicked, this, + [helpUrl]() { openHelpUrl(helpUrl); }); + } + } + + layout->addWidget(m_buttonBox); + + restoreGeometry(); + + connect(m_pipeline, &Pipeline::executionStarted, + this, &NodeEditDialog::refreshButtonEnablement); + connect(m_pipeline, &Pipeline::executionFinished, + this, &NodeEditDialog::refreshButtonEnablement); + + // The dialog is modeless, so the node can be deleted while it is open + // (delete from the strip, session clear, group removal). Drop our pointer + // and close so OK/Apply cannot touch the freed node. + connect(m_pipeline, &Pipeline::nodeRemoved, this, [this](Node* removed) { + if (removed == m_node) { + m_node = nullptr; + close(); + } + }); + refreshButtonEnablement(); +} + +void NodeEditDialog::refreshButtonEnablement() +{ + bool canCommit = m_editWidget && m_editWidget->canApply() && + !m_pipeline->isExecuting(); + m_buttonBox->button(QDialogButtonBox::Apply)->setEnabled(canCommit); + m_buttonBox->button(QDialogButtonBox::Ok)->setEnabled(canCommit); +} + +void NodeEditDialog::onApply() +{ + if (!m_node) { + return; + } + + if (m_editWidget) { + m_editWidget->applyChangesToOperator(); + } + + if (m_isNewInsertion && !m_insertionCompleted) { + completeInsertion(); + } + + m_node->markStale(); + m_pipeline->execute(); +} + +void NodeEditDialog::onOkay() +{ + if (!m_node) { + return; + } + + if (m_editWidget) { + m_editWidget->applyChangesToOperator(); + } + + if (m_isNewInsertion && !m_insertionCompleted) { + completeInsertion(); + } + + m_node->markStale(); + m_pipeline->execute(); + accept(); +} + +void NodeEditDialog::reject() +{ + if (m_isNewInsertion && !m_insertionCompleted) { + // The insertion was applied eagerly when the dialog opened. Undo it so + // that cancel is a true no-op. Removing the new node also drops its own + // input/output links (for sources there are none). Clear m_node first so + // the nodeRemoved handler above does not close() and re-enter reject(). + Node* node = m_node; + m_node = nullptr; + m_pipeline->removeNode(node); + + // Recreate the original links that were broken to insert the node. + for (const auto& ep : m_deferred.linksToRestore) { + if (ep.from && ep.to) { + auto* link = m_pipeline->createLink(ep.from, ep.to); + // Breaking the link to insert the node hid any downstream module: a + // direct module sink via onInputDisconnected, or modules behind a + // SinkGroupNode via the group's resetVisualization() fan-out. Re-link + // alone does not re-show them and we deliberately do not re-execute, so + // ask the downstream node to restore its presentation explicitly. The + // VTK objects still hold the last data, so this is presentation-only -- + // no pipeline run and no dependence on upstream PortData (which a + // transient source releases on disconnect). Done only on this cancel + // path so the insertion preview keeps its current behavior (the moved + // module stays hidden until the not-yet-run transform produces data). + if (link && ep.to->node()) { + ep.to->node()->restorePresentation(); + } + } + } + + // createLink() above marks the affected downstream subtree stale. + // Restore the states captured before the insertion so nothing is left + // spuriously stale (which would otherwise force a needless re-run). + for (const auto& ps : m_deferred.portStaleStates) { + if (ps.port) { + ps.port->setStale(ps.stale); + } + } + for (const auto& ns : m_deferred.nodeStates) { + if (ns.node) { + ns.node->setStateNoCascade(ns.state); + } + } + + emit insertionCanceled(); + } + + QDialog::reject(); +} + +void NodeEditDialog::showEvent(QShowEvent* event) +{ + QDialog::showEvent(event); + + auto* mainWin = tomviz::mainWidget(); + if (!mainWin) { + return; + } + + auto* screen = mainWin->screen(); + auto screenGeom = screen ? screen->availableGeometry() + : QRect(0, 0, 1920, 1080); + + auto mainCenter = mainWin->frameGeometry().center(); + auto dlgSize = frameGeometry().size(); + + int x = mainCenter.x() - dlgSize.width() / 2; + int y = mainCenter.y() - dlgSize.height() / 2; + + x = qBound(screenGeom.left(), x, + screenGeom.right() - dlgSize.width()); + y = qBound(screenGeom.top(), y, + screenGeom.bottom() - dlgSize.height()); + + move(x, y); + raise(); + activateWindow(); +} + +void NodeEditDialog::saveGeometry() +{ + if (!m_node) { + return; + } + QSettings* settings = pqApplicationCore::instance()->settings(); + QString key = + QString("Edit%1NodeDialogGeometry").arg(m_node->label()); + settings->setValue(key, QVariant(geometry())); +} + +void NodeEditDialog::restoreGeometry() +{ + if (!m_node) { + return; + } + QSettings* settings = pqApplicationCore::instance()->settings(); + QString key = + QString("Edit%1NodeDialogGeometry").arg(m_node->label()); + QVariant saved = settings->value(key); + if (!saved.isNull()) { + resize(saved.toRect().size()); + } else { + resize(900, 700); + } +} + +void NodeEditDialog::completeInsertion() +{ + // The insertion (node + link rewiring) was performed eagerly when the + // dialog opened, so the pipeline already has its final topology. There is + // nothing left to rewire here -- just mark the insertion committed so the + // cancel path will not try to roll it back. + m_insertionCompleted = true; + m_isNewInsertion = false; + emit insertionCompleted(m_node); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeEditDialog.h b/tomviz/pipeline/NodeEditDialog.h new file mode 100644 index 000000000..9f3845865 --- /dev/null +++ b/tomviz/pipeline/NodeEditDialog.h @@ -0,0 +1,93 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeEditDialog_h +#define tomvizPipelineNodeEditDialog_h + +#include "DeferredLinkInfo.h" + +#include +#include + +class QDialogButtonBox; +class QShowEvent; + +namespace tomviz { +namespace pipeline { + +class EditNodeWidget; +class Node; +class Pipeline; + +/// A dialog for configuring node parameters with Apply/OK/Cancel. +/// +/// Two modes: +/// - **Insertion mode**: The node has already been added and spliced into +/// the pipeline (eagerly, so the preview shows its final position). +/// Apply/OK executes the pipeline and commits. Cancel removes the node +/// and restores the original links and node states captured in +/// DeferredLinkInfo, so it is a true no-op. +/// - **Edit mode**: The node already exists. Apply/OK re-applies +/// parameters and executes. Cancel just closes the dialog. +/// +/// Editor gating (e.g. waiting for input data) is handled inside the +/// EditNodeWidget; this dialog just gates Apply/OK on canApply() and +/// the pipeline's executing state. +class NodeEditDialog : public QDialog +{ + Q_OBJECT + +public: + /// Edit an existing node (edit mode). + NodeEditDialog(Node* node, Pipeline* pipeline, QWidget* parent = nullptr); + + /// Insert a new node (insertion mode). For source-shaped nodes the + /// DeferredLinkInfo can be empty. + NodeEditDialog(Node* node, Pipeline* pipeline, + const DeferredLinkInfo& deferred, + QWidget* parent = nullptr); + + ~NodeEditDialog() override; + + Node* node() const; + + /// Overridden so that every cancel path -- the Cancel button, the Escape + /// key, and the window close button -- funnels through here and rolls back + /// an in-progress insertion. + void reject() override; + +signals: + /// Emitted after Apply/OK completes an insertion. + void insertionCompleted(Node* node); + + /// Emitted after Cancel aborts an insertion. + void insertionCanceled(); + +private slots: + void onApply(); + void onOkay(); + +protected: + void showEvent(QShowEvent* event) override; + +private: + void init(); + void refreshButtonEnablement(); + void saveGeometry(); + void restoreGeometry(); + + void completeInsertion(); + + QPointer m_node; + Pipeline* m_pipeline; + EditNodeWidget* m_editWidget = nullptr; + QDialogButtonBox* m_buttonBox = nullptr; + DeferredLinkInfo m_deferred; + bool m_isNewInsertion; + bool m_insertionCompleted = false; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeExecState.h b/tomviz/pipeline/NodeExecState.h new file mode 100644 index 000000000..4a4c33bf3 --- /dev/null +++ b/tomviz/pipeline/NodeExecState.h @@ -0,0 +1,21 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeExecState_h +#define tomvizPipelineNodeExecState_h + +namespace tomviz { +namespace pipeline { + +enum class NodeExecState +{ + Idle, + Running, + Failed, + Canceled +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeExecutor.cxx b/tomviz/pipeline/NodeExecutor.cxx new file mode 100644 index 000000000..554f0a733 --- /dev/null +++ b/tomviz/pipeline/NodeExecutor.cxx @@ -0,0 +1,36 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeExecutor.h" + +namespace tomviz { +namespace pipeline { + +NodeExecutor::NodeExecutor(QObject* parent) : QObject(parent) {} + +void NodeExecutor::cancel(Node* /*node*/) +{ + // Default no-op. Node::cancelExecution has already set the canceled + // flag and emitted the signal before invoking us; in-process + // executors don't need anything more. +} + +void NodeExecutor::complete(Node* /*node*/) +{ + // Default no-op. Same shape as cancel(): the completed flag is + // already set by Node::completeExecution; in-process executors are + // satisfied with that. +} + +QJsonObject NodeExecutor::serialize() const +{ + return {}; +} + +bool NodeExecutor::deserialize(const QJsonObject&) +{ + return true; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeExecutor.h b/tomviz/pipeline/NodeExecutor.h new file mode 100644 index 000000000..da20931f1 --- /dev/null +++ b/tomviz/pipeline/NodeExecutor.h @@ -0,0 +1,66 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeExecutor_h +#define tomvizPipelineNodeExecutor_h + +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +class Node; + +/// Strategy for running a single Node. Pipeline-level executors +/// (DefaultExecutor, ThreadedExecutor) walk the graph; a NodeExecutor +/// decides where/how a single node actually runs (in-process, +/// subprocess in a foreign Python env, container, ...). +/// +/// Progress and lifecycle are surfaced through the Node's existing +/// signals — implementations should drive setProgressStep / setProgress +/// Message / setTotalProgressSteps on @a node so the UI plumbing does +/// not need to know which executor is in use. +class NodeExecutor : public QObject +{ + Q_OBJECT + +public: + NodeExecutor(QObject* parent = nullptr); + ~NodeExecutor() override = default; + + /// Run @a node. Blocks until done. Returns true on success. + virtual bool execute(Node* node) = 0; + + /// Executor-specific cancellation hook, invoked by + /// Node::cancelExecution after the canceled flag has already been + /// set. The default is a no-op — in-process executors rely solely on + /// the flag, which polling transform code observes at its next + /// checkpoint. Subclasses that drive out-of-process work override + /// this to forward the request across the boundary. + virtual void cancel(Node* node); + + /// Executor-specific early-completion hook, invoked by + /// Node::completeExecution after the completed flag has already + /// been set. Same semantics as cancel(): in-process executors get + /// nothing extra; out-of-process executors override to forward the + /// signal to their child. + virtual void complete(Node* node); + + /// Identifier written to the node's serialized "executor" block. + /// Used by NodeExecutorFactory to round-trip the executor on load. + /// Returning an empty string suppresses serialization (e.g. the + /// internal executor — its absence is the default). + virtual QString type() const = 0; + + /// Subclass-specific configuration round-tripped under the + /// "executor" block alongside `type`. Default is empty. + virtual QJsonObject serialize() const; + virtual bool deserialize(const QJsonObject& json); +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeExecutorFactory.cxx b/tomviz/pipeline/NodeExecutorFactory.cxx new file mode 100644 index 000000000..1b430b7b3 --- /dev/null +++ b/tomviz/pipeline/NodeExecutorFactory.cxx @@ -0,0 +1,46 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeExecutorFactory.h" + +#include "ExternalNodeExecutor.h" +#include "NodeExecutor.h" + +namespace tomviz { +namespace pipeline { + +NodeExecutorFactory& NodeExecutorFactory::instance() +{ + static NodeExecutorFactory s_instance; + return s_instance; +} + +void NodeExecutorFactory::registerType(const QString& type, Creator creator) +{ + m_creators.insert(type, std::move(creator)); +} + +NodeExecutor* NodeExecutorFactory::create(const QString& type) const +{ + auto it = m_creators.constFind(type); + if (it == m_creators.constEnd()) { + return nullptr; + } + return (it.value())(); +} + +void NodeExecutorFactory::registerBuiltins() +{ + static bool done = false; + if (done) { + return; + } + done = true; + + instance().registerType( + ExternalNodeExecutor::typeString(), + []() -> NodeExecutor* { return new ExternalNodeExecutor(); }); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeExecutorFactory.h b/tomviz/pipeline/NodeExecutorFactory.h new file mode 100644 index 000000000..7ba94b44e --- /dev/null +++ b/tomviz/pipeline/NodeExecutorFactory.h @@ -0,0 +1,43 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeExecutorFactory_h +#define tomvizPipelineNodeExecutorFactory_h + +#include +#include + +#include + +namespace tomviz { +namespace pipeline { + +class NodeExecutor; + +/// Maps the `type` field stored in a node's serialized "executor" block +/// back to a NodeExecutor instance. Mirrors the role of NodeFactory for +/// nodes themselves. +class NodeExecutorFactory +{ +public: + using Creator = std::function; + + static NodeExecutorFactory& instance(); + + void registerType(const QString& type, Creator creator); + NodeExecutor* create(const QString& type) const; + + /// Register built-in NodeExecutor implementations (currently the + /// ExternalNodeExecutor). Idempotent — safe to call multiple times. + static void registerBuiltins(); + +private: + NodeExecutorFactory() = default; + + QHash m_creators; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeFactory.cxx b/tomviz/pipeline/NodeFactory.cxx new file mode 100644 index 000000000..4e86780f6 --- /dev/null +++ b/tomviz/pipeline/NodeFactory.cxx @@ -0,0 +1,125 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeFactory.h" + +#include "Node.h" +#include "SinkGroupNode.h" +#include "SourceNode.h" +#include "sinks/ClipSink.h" +#include "sinks/ContourSink.h" +#include "sinks/MoleculeSink.h" +#include "sinks/OutlineSink.h" +#include "sinks/PlotSink.h" +#include "sinks/RulerSink.h" +#include "sinks/ScaleCubeSink.h" +#include "sinks/SegmentSink.h" +#include "sinks/SliceSink.h" +#include "sinks/ThresholdSink.h" +#include "sinks/VolumeSink.h" +#include "sinks/VolumeStatsSink.h" +#include "sources/PythonSource.h" +#include "sources/ReaderSourceNode.h" +#include "sources/SphereSource.h" +#include "transforms/ArrayWranglerTransform.h" +#include "transforms/ConvertToFloatTransform.h" +#include "transforms/ConvertToVolumeTransform.h" +#include "transforms/CropTransform.h" +#include "transforms/LegacyPythonTransform.h" +#include "transforms/PythonTransform.h" +#include "transforms/ReconstructionTransform.h" +#include "transforms/SetTiltAnglesTransform.h" +#include "transforms/SnapshotTransform.h" +#include "transforms/ThresholdTransform.h" +#include "transforms/TransposeDataTransform.h" +#include "transforms/TranslateAlignTransform.h" + +namespace tomviz { +namespace pipeline { + +NodeFactory& NodeFactory::instance() +{ + static NodeFactory inst; + return inst; +} + +Node* NodeFactory::create(const QString& typeName) +{ + auto& inst = instance(); + auto it = inst.m_creators.constFind(typeName); + if (it == inst.m_creators.constEnd()) { + return nullptr; + } + return it.value()(); +} + +QString NodeFactory::typeName(const Node* node) +{ + if (!node) { + return {}; + } + auto& inst = instance(); + auto it = inst.m_typeNames.find(std::type_index(typeid(*node))); + if (it == inst.m_typeNames.end()) { + return {}; + } + return it->second; +} + +void NodeFactory::registerBuiltins() +{ + static bool done = false; + if (done) { + return; + } + done = true; + + // Base SourceNode — used by the legacy loader and several reactions + // (LoadDataReaction, CloneDataReaction, MergeImagesReaction, ...) to + // expose an already-materialized VolumeData that has no file-reading + // counterpart. Ports are reconstructed from the "outputPorts" map in + // SourceNode::deserialize. + registerType(QStringLiteral("source.generic")); + registerType(QStringLiteral("source.reader")); + registerType(QStringLiteral("source.sphere")); + registerType(QStringLiteral("source.python")); + + registerType( + QStringLiteral("transform.arrayWrangler")); + registerType( + QStringLiteral("transform.convertToFloat")); + registerType( + QStringLiteral("transform.convertToVolume")); + registerType(QStringLiteral("transform.crop")); + registerType( + QStringLiteral("transform.legacyPython")); + registerType(QStringLiteral("transform.python")); + registerType( + QStringLiteral("transform.reconstruction")); + registerType( + QStringLiteral("transform.setTiltAngles")); + registerType(QStringLiteral("transform.snapshot")); + registerType(QStringLiteral("transform.threshold")); + registerType( + QStringLiteral("transform.transposeData")); + registerType( + QStringLiteral("transform.translateAlign")); + + registerType(QStringLiteral("sink.clip")); + registerType(QStringLiteral("sink.contour")); + registerType(QStringLiteral("sink.molecule")); + registerType(QStringLiteral("sink.outline")); + registerType(QStringLiteral("sink.plot")); + registerType(QStringLiteral("sink.ruler")); + registerType(QStringLiteral("sink.scaleCube")); + registerType(QStringLiteral("sink.segment")); + registerType(QStringLiteral("sink.slice")); + registerType(QStringLiteral("sink.threshold")); + registerType(QStringLiteral("sink.volume")); + registerType(QStringLiteral("sink.volumeStats")); + + registerType(QStringLiteral("sinkGroup")); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeFactory.h b/tomviz/pipeline/NodeFactory.h new file mode 100644 index 000000000..da6dda7d4 --- /dev/null +++ b/tomviz/pipeline/NodeFactory.h @@ -0,0 +1,60 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeFactory_h +#define tomvizPipelineNodeFactory_h + +#include +#include + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +class Node; + +/// Central registry mapping the state-file "type" discriminator +/// (e.g. "source.reader", "transform.crop", "sink.volume") to Node +/// subclasses. Used by the new-format loader to construct nodes from +/// JSON and by the saver to stamp each node with its type string. +class NodeFactory +{ +public: + /// Register a Node subclass under the given state-file type string. + /// Safe to call at any time before load/save; later registrations + /// under the same name overwrite earlier ones. + template + static void registerType(const QString& typeName) + { + auto& inst = instance(); + inst.m_creators[typeName] = []() -> Node* { return new T(); }; + inst.m_typeNames[std::type_index(typeid(T))] = typeName; + } + + /// Construct a new Node of the registered type, or nullptr if the + /// type is unknown. Caller takes ownership. + static Node* create(const QString& typeName); + + /// Reverse lookup: the type string registered for @a node's concrete + /// class, or an empty string if none has been registered. + static QString typeName(const Node* node); + + /// Populate the factory with all core Tomviz node types. Idempotent. + static void registerBuiltins(); + +private: + NodeFactory() = default; + static NodeFactory& instance(); + + QHash> m_creators; + std::unordered_map m_typeNames; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodePropertiesPanel.cxx b/tomviz/pipeline/NodePropertiesPanel.cxx new file mode 100644 index 000000000..533c61b3c --- /dev/null +++ b/tomviz/pipeline/NodePropertiesPanel.cxx @@ -0,0 +1,97 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodePropertiesPanel.h" + +#include "EditNodeWidget.h" +#include "Node.h" +#include "Pipeline.h" + +#include "Utilities.h" + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +NodePropertiesPanel::NodePropertiesPanel(Node* node, Pipeline* pipeline, + QWidget* parent) + : QWidget(parent), m_node(node), m_pipeline(pipeline) +{ + // Suppress the auto-execute wiring so this panel controls execution. + QObject::disconnect(m_node, &Node::parametersApplied, m_pipeline, nullptr); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + m_editWidget = node->createPropertiesWidget(pipeline, this); + if (!m_editWidget) { + return; + } + + auto* scrollArea = new QScrollArea(this); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidgetResizable(true); + scrollArea->setWidget(m_editWidget); + layout->addWidget(scrollArea, 1); + + auto* buttonBox = new QDialogButtonBox( + QDialogButtonBox::Apply, Qt::Horizontal, this); + m_applyButton = buttonBox->button(QDialogButtonBox::Apply); + connect(m_applyButton, &QPushButton::clicked, + this, &NodePropertiesPanel::apply); + + QString helpUrl = m_editWidget->helpUrl(); + if (!helpUrl.isEmpty()) { + auto* helpButton = buttonBox->addButton(QDialogButtonBox::Help); + connect(helpButton, &QPushButton::clicked, this, + [helpUrl]() { openHelpUrl(helpUrl); }); + } + + layout->addWidget(buttonBox); + + connect(m_editWidget, &EditNodeWidget::canApplyChanged, + this, &NodePropertiesPanel::refreshApplyEnablement); + connect(m_pipeline, &Pipeline::executionStarted, + this, &NodePropertiesPanel::refreshApplyEnablement); + connect(m_pipeline, &Pipeline::executionFinished, + this, &NodePropertiesPanel::refreshApplyEnablement); + refreshApplyEnablement(); +} + +NodePropertiesPanel::~NodePropertiesPanel() +{ + // Restore the auto-wiring if the node is still in the pipeline. + if (m_node && m_pipeline && m_pipeline->nodes().contains(m_node)) { + connect(m_node, &Node::parametersApplied, m_pipeline, + [pip = m_pipeline]() { pip->execute(); }); + } +} + +void NodePropertiesPanel::refreshApplyEnablement() +{ + if (!m_applyButton || !m_editWidget) { + return; + } + m_applyButton->setEnabled(m_editWidget->canApply() && + !m_pipeline->isExecuting()); +} + +void NodePropertiesPanel::apply() +{ + if (m_editWidget) { + m_editWidget->applyChangesToOperator(); + } + if (m_node) { + m_node->markStale(); + } + if (m_pipeline) { + m_pipeline->execute(); + } +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodePropertiesPanel.h b/tomviz/pipeline/NodePropertiesPanel.h new file mode 100644 index 000000000..5af5abc00 --- /dev/null +++ b/tomviz/pipeline/NodePropertiesPanel.h @@ -0,0 +1,51 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodePropertiesPanel_h +#define tomvizPipelineNodePropertiesPanel_h + +#include + +class QPushButton; + +namespace tomviz { +namespace pipeline { + +class EditNodeWidget; +class Node; +class Pipeline; + +/// Panel wrapper for node properties shown in the properties dock. +/// Creates the node's EditNodeWidget, adds an Apply button, and handles +/// applying parameters + pipeline re-execution. Gating of the editor +/// (e.g. waiting for input data) is handled inside the EditNodeWidget +/// itself; this wrapper just toggles the Apply button based on +/// canApply() and the pipeline's executing state. +/// +/// Suppresses the parametersApplied → execute() auto-wiring while alive +/// to avoid double execution / deadlock with ThreadedExecutor. +class NodePropertiesPanel : public QWidget +{ + Q_OBJECT + +public: + NodePropertiesPanel(Node* node, Pipeline* pipeline, + QWidget* parent = nullptr); + ~NodePropertiesPanel() override; + +private slots: + void apply(); + +private: + void refreshApplyEnablement(); + + Node* m_node; + Pipeline* m_pipeline; + EditNodeWidget* m_editWidget = nullptr; + QPushButton* m_applyButton = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodePropertiesWidget.cxx b/tomviz/pipeline/NodePropertiesWidget.cxx new file mode 100644 index 000000000..ffae87f5c --- /dev/null +++ b/tomviz/pipeline/NodePropertiesWidget.cxx @@ -0,0 +1,45 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodePropertiesWidget.h" + +#include "ParameterInterfaceBuilder.h" + +#include + +namespace tomviz { +namespace pipeline { + +NodePropertiesWidget::NodePropertiesWidget( + const QString& jsonDescription, + const QMap& currentValues, + const QList& portScalars, QWidget* parent) + : EditNodeWidget(parent) +{ + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + ParameterInterfaceBuilder builder; + builder.setJSONDescription(jsonDescription); + builder.setParameterValues(currentValues); + builder.setPortScalars(portScalars); + + m_innerWidget = builder.buildWidget(this); + layout->addWidget(m_innerWidget); +} + +QMap NodePropertiesWidget::values() const +{ + if (!m_innerWidget) { + return {}; + } + return ParameterInterfaceBuilder::parameterValues(m_innerWidget); +} + +void NodePropertiesWidget::applyChangesToOperator() +{ + emit applyRequested(values()); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodePropertiesWidget.h b/tomviz/pipeline/NodePropertiesWidget.h new file mode 100644 index 000000000..9a9cbe503 --- /dev/null +++ b/tomviz/pipeline/NodePropertiesWidget.h @@ -0,0 +1,48 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodePropertiesWidget_h +#define tomvizPipelineNodePropertiesWidget_h + +#include "EditNodeWidget.h" +#include "ParameterInterfaceBuilder.h" + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +/// A JSON-driven parameter editing widget built by ParameterInterfaceBuilder. +/// Does not own any buttons — the wrapper (NodePropertiesPanel or +/// NodeEditDialog) provides Apply/OK/Cancel. +class NodePropertiesWidget + : public EditNodeWidget +{ + Q_OBJECT + +public: + NodePropertiesWidget(const QString& jsonDescription, + const QMap& currentValues, + const QList& portScalars = {}, + QWidget* parent = nullptr); + + QMap values() const; + + void applyChangesToOperator() override; + +signals: + /// Emitted by applyChangesToOperator(), carrying the current values. + /// The node connects to this signal to update its parameters. + void applyRequested(const QMap& values); + +private: + QWidget* m_innerWidget = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/operators/CustomPythonOperatorWidget.cxx b/tomviz/pipeline/NodeState.h similarity index 50% rename from tomviz/operators/CustomPythonOperatorWidget.cxx rename to tomviz/pipeline/NodeState.h index 194635d04..f203ce039 100644 --- a/tomviz/operators/CustomPythonOperatorWidget.cxx +++ b/tomviz/pipeline/NodeState.h @@ -1,12 +1,20 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#include "CustomPythonOperatorWidget.h" +#ifndef tomvizPipelineNodeState_h +#define tomvizPipelineNodeState_h namespace tomviz { +namespace pipeline { -CustomPythonOperatorWidget::CustomPythonOperatorWidget(QWidget* p) : QWidget(p) -{} +enum class NodeState +{ + New, + Stale, + Current +}; -CustomPythonOperatorWidget::~CustomPythonOperatorWidget() {} +} // namespace pipeline } // namespace tomviz + +#endif diff --git a/tomviz/pipeline/OutputPort.cxx b/tomviz/pipeline/OutputPort.cxx new file mode 100644 index 000000000..c58572edf --- /dev/null +++ b/tomviz/pipeline/OutputPort.cxx @@ -0,0 +1,500 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "OutputPort.h" + +#include "Link.h" +#include "PortDataDiskCache.h" +#include "ThreadUtils.h" +#include "data/VolumeData.h" + +#include +#include +#include +#include + +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +QString portCacheDir() +{ + if (const char* env = std::getenv("TOMVIZ_PORT_CACHE_DIR"); + env && *env != '\0') { + return QString::fromUtf8(env); + } + return QDir::tempPath(); +} + +} // namespace + +OutputPort::OutputPort(const QString& name, PortType type, QObject* parent) + : Port(name, parent), m_declaredType(type), m_effectiveType(type) +{} + +OutputPort::~OutputPort() +{ + // Tell any in-flight OnDisk deleter (firing on another thread that + // still holds a shared_ptr to our payload) to no-op instead of + // touching half-destroyed state. + m_destroying.store(true); + // Release our own ref first so the deleter (if any) runs with the + // destroying flag already set. + { + std::lock_guard lock(m_diskMutex); + m_strong.reset(); + } + // m_diskFile destruction removes the temp file from disk. +} + +void OutputPort::reconcilePersistence() +{ + const DataLocation before = dataLocation(); + // Hold any ref we're about to drop in a local that destructs *after* + // we release the mutex — the universal deleter takes the same mutex + // (via swapToDisk) and would otherwise deadlock. + std::shared_ptr dropped; + std::unique_ptr droppedFile; + { + std::lock_guard lock(m_diskMutex); + + if (!m_persistent) { + // Transient: the port doesn't keep a ref. Any cache file is + // meaningless (transient = no persistence at all) — drop it. + dropped = std::move(m_strong); + m_strong.reset(); + if (m_onDisk) { + m_onDisk = false; + droppedFile = std::move(m_diskFile); + } + } else if (m_mode == PersistenceMode::InMemory) { + // InMemory persistent: port pins data in memory. + if (!m_strong) { + // Try to recover any live ref from m_weak; otherwise pull + // from disk if we still have a cache. + m_strong = m_weak.lock(); + if (!m_strong && m_onDisk) { + m_strong = reloadFromDisk(); + } + } + // InMemory has no use for the disk cache. + if (m_onDisk) { + m_onDisk = false; + droppedFile = std::move(m_diskFile); + } + } else { + // OnDisk persistent: port doesn't pin in memory — the universal + // deleter swaps to disk when the last consumer drops. If we + // were the only holder, releasing now triggers the swap. + dropped = std::move(m_strong); + m_strong.reset(); + } + } + // Release outside the mutex (the deleter acquires m_diskMutex inside + // swapToDisk) and before the `after` snapshot, so any eviction the + // release triggers is reflected in it. Reconciling can move the + // payload (evict, reload, drop) without a setData(); surface that to + // keep the dataLocationChanged contract. + dropped.reset(); + droppedFile.reset(); + const DataLocation after = dataLocation(); + if (after != before) { + emit dataLocationChanged(after); + } +} + +PortType OutputPort::type() const +{ + return m_effectiveType; +} + +PortType OutputPort::declaredType() const +{ + return m_declaredType; +} + +void OutputPort::setDeclaredType(PortType type) +{ + m_declaredType = type; + setEffectiveType(type); +} + +void OutputPort::setEffectiveType(PortType type) +{ + if (m_effectiveType != type) { + m_effectiveType = type; + emit effectiveTypeChanged(type); + } +} + +bool OutputPort::isPersistent() const +{ + return m_persistent; +} + +void OutputPort::setPersistent(bool persistent) +{ + if (m_persistent == persistent) { + return; + } + m_persistent = persistent; + reconcilePersistence(); + emit persistenceChanged(); +} + +PersistenceMode OutputPort::persistenceMode() const +{ + return m_mode; +} + +void OutputPort::setPersistenceMode(PersistenceMode mode) +{ + if (m_mode == mode) { + return; + } + m_mode = mode; + reconcilePersistence(); + emit persistenceChanged(); +} + +PortData OutputPort::data() const +{ + if (auto sp = m_weak.lock()) { + return *sp; + } + return PortData(); +} + +std::shared_ptr OutputPort::materialize() +{ + if (auto sp = m_weak.lock()) { + return sp; + } + if (m_persistent && m_mode == PersistenceMode::OnDisk) { + std::shared_ptr sp; + bool reloaded = false; + { + // Acquire the disk mutex only for the reload, then release it + // before sp's destruction could fire the OnDisk deleter — the + // deleter takes the same mutex and would otherwise deadlock. + std::lock_guard lock(m_diskMutex); + sp = m_weak.lock(); + if (!sp && m_onDisk) { + sp = reloadFromDisk(); + reloaded = (sp != nullptr); + } + } + if (reloaded) { + emit dataLocationChanged(DataLocation::InMemory); + } + return sp; + } + return nullptr; +} + +void OutputPort::setData(const PortData& data) +{ + { + std::lock_guard lock(m_diskMutex); + // Bumping the generation invalidates the deleter of any earlier + // shared_ptr still in flight — that deleter will no-op instead of + // writing stale data over our new cache. + m_generation.fetch_add(1); + // Any prior on-disk content belongs to a previous generation; it + // will be rewritten next time the new payload evicts. + m_onDisk = false; + + // Always wrap with the universal deleter. It reads the port's + // *current* mode at fire time, so a shared_ptr created while the + // port was (say) InMemory still does the right thing if the port + // is switched to OnDisk before the shared_ptr is dropped. + m_strong = wrapPortData(new PortData(data)); + m_weak = m_strong; + m_stale = false; + } + // If a metadata blob arrived via deserialize() before data was + // populated, apply it now to the freshly-set payload — e.g. user + // edits to a source's colormap / scalar renames that must survive + // a state-file save+load+execute cycle. + if (!m_pendingData.isEmpty()) { + QJsonObject pending = m_pendingData; + m_pendingData = {}; + deserialize(pending); + } + emit dataChanged(); + emit dataLocationChanged(DataLocation::InMemory); +} + +void OutputPort::clearData() +{ + { + std::lock_guard lock(m_diskMutex); + // Invalidate any in-flight deleter: this payload is discarded + // outright, so a deleter firing on the reset below must not evict + // it to disk (resurrecting cleared data) nor take m_diskMutex via + // swapToDisk while we hold it. + m_generation.fetch_add(1); + m_strong.reset(); + m_weak.reset(); + m_onDisk = false; + } + emit dataChanged(); + emit dataLocationChanged(DataLocation::None); +} + +bool OutputPort::hasData() const +{ + return dataLocation() != DataLocation::None; +} + +DataLocation OutputPort::dataLocation() const +{ + if (!m_weak.expired()) { + return DataLocation::InMemory; + } + std::lock_guard lock(m_diskMutex); + if (m_onDisk) { + return DataLocation::OnDisk; + } + return DataLocation::None; +} + +std::shared_ptr OutputPort::take() +{ + if (m_persistent && m_mode == PersistenceMode::InMemory) { + // InMemory: keep our own strong ref alive; hand the executor a + // shared copy. The port retains the data indefinitely. + if (m_strong) { + return m_strong; + } + return m_weak.lock(); + } + if (m_persistent && m_mode == PersistenceMode::OnDisk) { + // OnDisk uses the same handoff semantics as transient — we move + // our strong ref out so that when the last consumer drops it the + // shared_ptr's deleter fires and evicts to disk. If everyone has + // already dropped and the data is on disk, reload it. + std::shared_ptr result; + bool reloaded = false; + { + std::lock_guard lock(m_diskMutex); + if (m_strong) { + result = std::move(m_strong); + m_strong.reset(); + } else if (auto sp = m_weak.lock()) { + result = sp; + } else if (m_onDisk) { + result = reloadFromDisk(); + reloaded = (result != nullptr); + } + } + if (reloaded) { + emit dataLocationChanged(DataLocation::InMemory); + } + return result; + } + // Transient + if (m_strong) { + auto handle = std::move(m_strong); + m_strong.reset(); + return handle; + } + return m_weak.lock(); +} + +std::shared_ptr OutputPort::wrapPortData(PortData* raw) +{ + // Capture: a QPointer so a destroyed port is harmless, and the + // current generation tag so a later setData() invalidates this + // deleter cleanly. The mode itself is NOT captured — it's read at + // fire time, so the deleter responds to the port's current state + // even if persistence was switched between construction and drop. + QPointer guard(this); + int gen = m_generation.load(); + return std::shared_ptr(raw, [guard, gen](PortData* p) { + OutputPort* port = guard.data(); + bool tracked = port && !port->m_destroying.load() && + port->m_generation.load() == gen; + bool swapped = false; + if (tracked && port->m_persistent && + port->m_mode == PersistenceMode::OnDisk) { + // Best-effort: if the swap fails (unsupported payload type, + // disk full, ...) swapToDisk has already logged a warning. + // The data is lost; the planner will see hasData()==false + // and re-run the producer when something downstream needs + // it. + swapped = port->swapToDisk(p); + } + // InMemory persistent: m_strong was the sole holder and is being + // dropped (mode switch or port destruction); just delete. + // Transient: same, no special action. + delete p; + if (tracked && !swapped) { + // The payload is gone for good (transient/unpinned drop, or a + // failed swap). Surface it, or UI residency cues keep showing + // in-memory data that no longer exists. The successful-swap + // transition is emitted by swapToDisk itself. + emit port->dataLocationChanged(DataLocation::None); + } + }); +} + +bool OutputPort::swapToDisk(PortData* p) +{ + if (!p || !p->isValid()) { + return false; + } + bool swapped = false; + { + std::lock_guard lock(m_diskMutex); + // Re-check under lock in case the port started destroying between + // the deleter's outer check and acquiring the mutex. + if (m_destroying.load()) { + return false; + } + if (!m_diskFile) { + // Lazily create the cache file under the configured directory. + QString tmpl = QDir(portCacheDir()) + .filePath(QStringLiteral("tomviz_port_XXXXXX.tvh5")); + m_diskFile = std::make_unique(tmpl); + m_diskFile->setAutoRemove(true); + if (!m_diskFile->open()) { + qWarning() << "OutputPort: failed to open cache file" << tmpl; + m_diskFile.reset(); + return false; + } + // Close the QFile handle so Tvh5Format can open it for writing. + m_diskFile->close(); + } + QString path = m_diskFile->fileName(); + if (!writePortDataToFile(*p, path)) { + qWarning() << "OutputPort: writePortDataToFile failed for" << path; + return false; + } + m_onDisk = true; + swapped = true; + } + if (swapped) { + emit dataLocationChanged(DataLocation::OnDisk); + } + return swapped; +} + +std::shared_ptr OutputPort::reloadFromDisk() +{ + // m_diskMutex must already be held by caller. + if (!m_onDisk || !m_diskFile) { + return nullptr; + } + PortData payload = readPortDataFromFile(m_diskFile->fileName()); + if (!payload.isValid()) { + qWarning() << "OutputPort: reload failed; clearing on-disk flag for" + << m_diskFile->fileName(); + m_onDisk = false; + return nullptr; + } + // No generation bump here: the data hasn't changed, just been + // re-materialized. If another deleter were in flight it would have + // kept m_weak alive (so we wouldn't be in this branch). The new + // shared_ptr captures the current generation; nothing stale. + auto sp = wrapPortData(new PortData(std::move(payload))); + m_weak = sp; + // Do not pin m_strong: the reload happened because someone read the + // data; that someone (executor / consumer) will keep the strong ref + // alive. When they drop it, the deleter writes back to disk. + return sp; +} + +void OutputPort::setIntermediateData(const PortData& data) +{ + // Default: replace the payload outright. Subclasses (e.g. + // VolumeOutputPort) override when they can preserve the existing + // object's identity to keep downstream references (color maps, + // module pipelines) attached. Marshaled to the port's owning + // thread so callers from worker threads are safe. + auto apply = [this, data]() { + setData(data); + emit intermediateDataApplied(); + }; + runOnThread(this, apply); +} + +bool OutputPort::isStale() const +{ + return m_stale; +} + +void OutputPort::setStale(bool stale) +{ + if (m_stale != stale) { + m_stale = stale; + emit staleChanged(stale); + } +} + +QList OutputPort::links() const +{ + return m_links; +} + +void OutputPort::addLink(Link* link) +{ + m_links.append(link); + emit connectionChanged(); +} + +void OutputPort::removeLink(Link* link) +{ + m_links.removeOne(link); + emit connectionChanged(); +} + +bool OutputPort::canAcceptLink(InputPort* /*to*/) const +{ + return true; +} + +QJsonObject OutputPort::serialize() const +{ + auto sp = m_weak.lock(); + if (!sp) { + return {}; + } + // Known round-trippable payloads. Extend as new PortData types + // (Molecule, Table, ...) acquire serialize()/deserialize() support. + // Use std::any_cast's nothrow pointer form so ports carrying + // non-matching payloads (e.g. Molecule, Table) don't throw here. + if (auto* volume = std::any_cast(&sp->data())) { + return (*volume)->serialize(); + } + return {}; +} + +bool OutputPort::deserialize(const QJsonObject& json) +{ + if (json.isEmpty()) { + return true; + } + if (auto sp = m_weak.lock()) { + if (auto* volume = std::any_cast(&sp->data())) { + return (*volume)->deserialize(json); + } + } + // No payload yet — stash so the next setData() can apply this JSON + // on top of the freshly-populated data (e.g. source node execute + // that produces a fresh VolumeData, to which we then reattach the + // user's colormap / scalar renames from the state file). + m_pendingData = json; + return true; +} + +void OutputPort::clearPendingData() +{ + m_pendingData = QJsonObject(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/OutputPort.h b/tomviz/pipeline/OutputPort.h new file mode 100644 index 000000000..e0c2430d7 --- /dev/null +++ b/tomviz/pipeline/OutputPort.h @@ -0,0 +1,257 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineOutputPort_h +#define tomvizPipelineOutputPort_h + +#include "PersistenceMode.h" +#include "Port.h" +#include "PortData.h" +#include "PortType.h" + +#include +#include + +#include +#include +#include + +class QTemporaryFile; + +namespace tomviz { +namespace pipeline { + +class InputPort; +class Link; + +/// Where the port's currently-published payload lives at this moment. +/// Orthogonal to persistence mode: an OnDisk port reports `InMemory` +/// while a consumer is still holding the data, then flips to `OnDisk` +/// once the last consumer drops and the eviction completes. +enum class DataLocation +{ + None, + InMemory, + OnDisk +}; + +class OutputPort : public Port +{ + Q_OBJECT + +public: + OutputPort(const QString& name, PortType type, + QObject* parent = nullptr); + ~OutputPort() override; + + /// Effective type (may differ from declared type due to inference). + /// Most code should use this rather than declaredType(). + PortType type() const; + + /// The type set at construction time, before any inference. + PortType declaredType() const; + + /// Change the declared type (also resets the effective type to match). + void setDeclaredType(PortType type); + + /// Set the effective type. Called by the owning Node during type + /// inference. Only meaningful when declaredType() is ImageData. + void setEffectiveType(PortType type); + + /// Virtual so subclasses whose persistence is intrinsic to the type + /// (e.g. PassthroughOutputPort — a pure forwarder with no data of its + /// own) can pin the answer regardless of the m_persistent flag, so an + /// old state file's `persistent: true` can't reintroduce spurious + /// data-serialization for them. + virtual bool isPersistent() const; + void setPersistent(bool persistent); + + /// The medium that backs the port when isPersistent() is true. + /// Orthogonal to the persistent flag itself — setting the mode does + /// not change whether the port is persistent; setPersistent(false) + /// makes the mode irrelevant. Defaults to InMemory, so legacy + /// setPersistent(true) callers get the established behavior. + PersistenceMode persistenceMode() const; + void setPersistenceMode(PersistenceMode mode); + + /// Peek at the currently-in-memory payload. Does NOT trigger a + /// disk reload for OnDisk ports whose data has been evicted — + /// callers that don't intend to keep the data alive (UI inspection + /// widgets, histogram, properties panel) get an invalid PortData + /// in that case and treat it as missing. Use materialize() when + /// you genuinely need to read an evicted payload. + virtual PortData data() const; + void setData(const PortData& data); + void clearData(); + virtual bool hasData() const; + + /// Materializing read: returns a strong handle to the payload, + /// loading from disk if this is an OnDisk persistent port whose data + /// is currently evicted. Returns nullptr when there is no payload to + /// surface (transient port whose data is already gone). The handle + /// keeps the port's m_weak alive — callers that need the data to + /// stay materialized must hold the returned shared_ptr; dropping it + /// re-evicts an OnDisk port via the universal deleter. For frequent + /// UI reads, prefer data(), which treats on-disk payloads as + /// effectively absent. + virtual std::shared_ptr materialize(); + + /// Where the published payload currently lives. Tracks the actual + /// material state, not the persistence policy — an OnDisk port can + /// report `InMemory` between eviction cycles. Useful for UI cues + /// that should reflect the real underlying state. + virtual DataLocation dataLocation() const; + + /// Transient lifetime hook for the pipeline executor. + /// + /// Each setData() builds a shared_ptr that the port self-pins + /// (publish-handoff window: the data must outlive its own setData() + /// emission so consumers about to read see something). The executor + /// calls take() once it has consumers downstream that depend on this + /// port; for a transient port that moves the strong ref out of the + /// port into the executor's in-flight map, leaving the port with only + /// a weak ref. When the in-flight map drops at end-of-plan, the data + /// evicts unless some consumer (e.g. a sink) has stashed a copy of + /// data() in a member. + /// + /// Persistent ports do NOT release their strong ref: take() returns a + /// shared copy without clearing m_strong, so the port retains data + /// indefinitely. + /// + /// A leaf output port (no outgoing links in the current plan) is never + /// taken from — the port keeps m_strong and the data lives until the + /// port gains a consumer, at which point the next take() unpins it. + std::shared_ptr take(); + + /// Push intermediate data during execution. Thread-safe: can be called + /// from a worker thread. The update is applied on the main thread + /// (blocking the caller until complete). Emits intermediateDataApplied() + /// after the data is applied. Subclasses override to handle specific + /// data types; the default implementation is a no-op. + virtual void setIntermediateData(const PortData& data); + + virtual bool isStale() const; + void setStale(bool stale); + + /// Whether this port accepts a link to the given input port. + /// Default returns true. Subclasses can restrict (e.g. sinks only). + virtual bool canAcceptLink(InputPort* to) const; + + QList links() const; + + /// Serialize the metadata of the port's current payload (VolumeData, + /// Molecule, Table, ...) as JSON. Does NOT include raw voxel / table + /// byte payloads — those are embedded by container-level savers (e.g. + /// Tvh5Format) under their own HDF5 groups. Returns an empty object + /// if the port has no data or the payload type isn't round-trippable. + /// Override in subclasses that carry specialized payloads. + virtual QJsonObject serialize() const; + + /// Apply JSON produced by serialize() onto this port's current + /// payload. No-op if the port has no payload yet (loaders that need + /// to reconstruct the payload from scratch must do so before calling + /// deserialize). Returns false on unrecoverable parse errors. + virtual bool deserialize(const QJsonObject& json); + + /// Drop pending metadata stashed by deserialize() so the next + /// setData() installs the payload as-is. Used by transient clones + /// that need port structure but not replayed data-state metadata. + void clearPendingData(); + +signals: + void dataChanged(); + void staleChanged(bool stale); + + /// Emitted when the persistence policy changes: the persistent flag + /// (setPersistent) or the backing medium (setPersistenceMode). UI + /// showing policy cues (pin badge, menus) refreshes on this; actual + /// payload residency changes are reported separately via + /// dataLocationChanged. + void persistenceChanged(); + void effectiveTypeChanged(PortType newType); + void intermediateDataApplied(); + + /// Emitted when the payload's residency changes (memory ↔ disk ↔ + /// none) without the payload itself necessarily being replaced. + /// Distinct from dataChanged so subscribers that don't care about + /// disk swaps (e.g. save dialogs that want a refresh only on new + /// content) aren't woken on every cache cycle. + void dataLocationChanged(DataLocation location); + + /// Emitted when lightweight metadata (units, label, spacing, origin) + /// changes on the data held by this port, without the image data itself + /// being replaced. Sinks can listen to this to update annotations/UI + /// without a full pipeline re-execution. + void metadataChanged(); + +private: + friend class Link; + void addLink(Link* link); + void removeLink(Link* link); + + /// For OnDisk-mode persistence: when the last shared_ptr to this + /// port's PortData is destroyed, the universal deleter calls this to + /// serialize the payload into m_diskFile and flip m_onDisk to true. + /// Returns true on success. On failure (write error, unserializable + /// payload type, ...) the caller should treat the data as still + /// in-memory and re-pin via the universal deleter rather than + /// deleting it. + /// Internal; not part of the public API. + bool swapToDisk(PortData* data); + /// Try to reload from the cache file, populating m_weak so subsequent + /// data()/take() calls see the data again. Returns the freshly-built + /// shared_ptr (with the universal deleter) or nullptr if the file is + /// missing/corrupt. Called under m_diskMutex. + std::shared_ptr reloadFromDisk(); + /// Construct a shared_ptr wrapping @a data with the + /// universal deleter — captures the port (via QPointer) and the + /// current generation tag. The deleter reads the port's CURRENT + /// persistence mode at fire time and acts accordingly: OnDisk → + /// swapToDisk; otherwise → just delete. This is what makes runtime + /// mode switches behave correctly (a shared_ptr created when the + /// port was InMemory still does the right thing if the port has + /// since become OnDisk). + std::shared_ptr wrapPortData(PortData* data); + /// Adjust m_strong / m_onDisk / m_diskFile to satisfy the invariants + /// implied by the current (m_persistent, m_mode) combination. Called + /// from setPersistent() and setPersistenceMode() when their value + /// actually changes, so the port immediately reflects the new mode + /// instead of waiting for the next setData() or consumer event. + void reconcilePersistence(); + + PortType m_declaredType; + PortType m_effectiveType; + /// Strong ref to the published PortData. Set by setData(); cleared by + /// take() on transient ports; retained on persistent ports. + std::shared_ptr m_strong; + /// Observation handle. Always set alongside m_strong in setData() and + /// outlives the strong-ref handoff so that hasData()/data() can detect + /// whether the data is still materialized anywhere. + std::weak_ptr m_weak; + bool m_persistent = false; + PersistenceMode m_mode = PersistenceMode::InMemory; + bool m_stale = false; + /// On-disk cache state. m_diskFile is created lazily on first eviction + /// and lives for the port's lifetime (auto-removed on destruction). + /// m_onDisk is true iff the cache file currently contains the live + /// payload. m_generation is bumped on every setData() and captured by + /// each fresh deleter so a stale generation's deleter no-ops instead + /// of clobbering newer data. m_destroying short-circuits the deleter + /// during the destructor so we don't try to write to a disappearing + /// port. m_diskMutex guards the on-disk state transitions. + std::unique_ptr m_diskFile; + bool m_onDisk = false; + std::atomic m_generation{ 0 }; + std::atomic m_destroying{ false }; + mutable std::mutex m_diskMutex; + QList m_links; + /// Metadata deserialized before setData ran (typical load path: + /// source node populates data only when it executes). Applied in + /// setData() and then cleared. + QJsonObject m_pendingData; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/ParameterBindingUtils.cxx b/tomviz/pipeline/ParameterBindingUtils.cxx new file mode 100644 index 000000000..ea8569fbe --- /dev/null +++ b/tomviz/pipeline/ParameterBindingUtils.cxx @@ -0,0 +1,172 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ParameterBindingUtils.h" + +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "SinkGroupNode.h" +#include "sinks/SliceSink.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +/// Walk @a port's outgoing links for a sink of type @a SinkT +/// satisfying @a ok, treating a SinkGroupNode as transparent: when a +/// link lands on a group's input, the search descends through the +/// matching passthrough output port and continues. Recurses to handle +/// nested groups. +template +SinkT* findSinkOnOutputPort(OutputPort* port, Pred ok) +{ + if (!port) { + return nullptr; + } + for (auto* link : port->links()) { + auto* dst = link->to(); + if (!dst) { + continue; + } + auto* dstNode = dst->node(); + if (auto* sink = qobject_cast(dstNode)) { + if (ok(sink)) { + return sink; + } + } + if (auto* group = qobject_cast(dstNode)) { + if (auto* deep = + findSinkOnOutputPort(group->outputPort(dst->name()), ok)) { + return deep; + } + } + } + return nullptr; +} + +/// Walk @a node's port topology looking for a sink of type @a SinkT +/// satisfying @a ok. Sibling sinks on each upstream output port are +/// preferred (1st-preference: a sink already attached to the same +/// branch the input feeds from); otherwise sinks downstream of any of +/// @a node's own output ports. Sinks behind a SinkGroupNode are +/// reachable through the group's passthrough. +template +SinkT* findBindableSink(const Node* node, Pred ok) +{ + for (auto* in : node->inputPorts()) { + auto* link = in->link(); + if (!link) { + continue; + } + if (auto* sink = findSinkOnOutputPort(link->from(), ok)) { + return sink; + } + } + for (auto* out : node->outputPorts()) { + if (auto* sink = findSinkOnOutputPort(out, ok)) { + return sink; + } + } + return nullptr; +} + +/// Live link a QSpinBox to a SliceSink::slice property. Cycle is +/// broken by the value-equality guard; QSignalBlocker is belt-and- +/// suspenders for the seed and any signal interleavings. +void wireSliceSinkBinding(QWidget* widget, QSpinBox* spin, SliceSink* sink) +{ + { + QSignalBlocker b(spin); + spin->setValue(sink->slice()); + } + + QPointer sinkPtr(sink); + QObject::connect(sink, &SliceSink::sliceChanged, widget, + [spin, sinkPtr](int value) { + if (!sinkPtr || spin->value() == value) { + return; + } + QSignalBlocker b(spin); + spin->setValue(value); + }); + QObject::connect(spin, qOverload(&QSpinBox::valueChanged), widget, + [sinkPtr](int value) { + if (!sinkPtr || sinkPtr->slice() == value) { + return; + } + sinkPtr->setSlice(value); + }); +} + +} // namespace + +QMap parseParameterBindings( + const QJsonObject& description) +{ + QMap bindings; + for (const auto& v : description.value(QStringLiteral("parameters")).toArray()) { + QJsonObject param = v.toObject(); + QString name = param.value(QStringLiteral("name")).toString(); + if (name.isEmpty()) { + continue; + } + QJsonValue bindVal = param.value(QStringLiteral("bindToSink")); + if (!bindVal.isObject()) { + continue; + } + QJsonObject bindObj = bindVal.toObject(); + ParameterBinding b; + b.sinkType = bindObj.value(QStringLiteral("type")).toString(); + b.sinkProperty = bindObj.value(QStringLiteral("property")).toString(); + if (b.sinkType.isEmpty() || b.sinkProperty.isEmpty()) { + continue; + } + bindings[name] = b; + } + return bindings; +} + +void wireParameterBindings(Node* node, QWidget* widget, + const QMap& bindings) +{ + if (!node || !widget || bindings.isEmpty()) { + return; + } + + for (auto it = bindings.constBegin(); it != bindings.constEnd(); ++it) { + const QString& paramName = it.key(); + const ParameterBinding& binding = it.value(); + + if (binding.sinkType == QStringLiteral("SliceSink") && + binding.sinkProperty == QStringLiteral("slice")) { + auto* sink = findBindableSink( + node, [](SliceSink* s) { return s->isOrtho(); }); + if (!sink) { + continue; + } + // ParameterInterfaceBuilder names each numeric control after its + // parameter, so a recursive findChild reaches the spinbox no + // matter where it lives in the tab/sub-widget tree. + auto* spin = widget->findChild(paramName); + if (!spin) { + continue; + } + wireSliceSinkBinding(widget, spin, sink); + } + // Future sink types: add another dispatch arm here. + } +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/ParameterBindingUtils.h b/tomviz/pipeline/ParameterBindingUtils.h new file mode 100644 index 000000000..087d98a27 --- /dev/null +++ b/tomviz/pipeline/ParameterBindingUtils.h @@ -0,0 +1,45 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineParameterBindingUtils_h +#define tomvizPipelineParameterBindingUtils_h + +#include +#include + +class QJsonObject; +class QWidget; + +namespace tomviz { +namespace pipeline { + +class Node; + +/// Declares a live link between a parameter's editor control and a +/// property on a sink node connected to the same pipeline branch. +/// The binding is resolved at widget-open time and torn down with +/// the widget; nothing about it is persisted. +struct ParameterBinding +{ + QString sinkType; // e.g. "SliceSink" + QString sinkProperty; // e.g. "slice" +}; + +/// Walk an operator JSON description's "parameters" array and return +/// the bindings declared via each parameter's optional "bindToSink" +/// object. Parameters without the hint are simply omitted. +QMap parseParameterBindings( + const QJsonObject& description); + +/// For each binding, find a matching sink reachable through @a node's +/// port topology and wire two-way connections between the named +/// parameter's control under @a widget and the sink's property. +/// Connections are parented to @a widget — Qt auto-disconnects when +/// the widget is destroyed. +void wireParameterBindings(Node* node, QWidget* widget, + const QMap& bindings); + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/InterfaceBuilder.cxx b/tomviz/pipeline/ParameterInterfaceBuilder.cxx similarity index 51% rename from tomviz/InterfaceBuilder.cxx rename to tomviz/pipeline/ParameterInterfaceBuilder.cxx index 87dda0bff..ba4236577 100644 --- a/tomviz/InterfaceBuilder.cxx +++ b/tomviz/pipeline/ParameterInterfaceBuilder.cxx @@ -1,46 +1,43 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#include "InterfaceBuilder.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "DoubleSpinBox.h" -#include "ModuleManager.h" -#include "SpinBox.h" -#include "Utilities.h" +#include "ParameterInterfaceBuilder.h" #include #include #include #include #include +#include #include #include -#include #include +#include +#include #include #include #include #include #include +#include #include #include #include -#include #include +#include +#include #include #include #include +#include -using tomviz::DataSource; +namespace { -Q_DECLARE_METATYPE(DataSource*) +using tomviz::pipeline::PortScalars; -namespace { +// --- Templated JSON helpers --- -// Templated query of QJsonValue type template bool isType(const QJsonValue&) { @@ -59,7 +56,6 @@ bool isType(const QJsonValue& value) return value.isDouble(); } -// Templated accessor of QJsonValue value template T getAs(const QJsonValue&) { @@ -69,40 +65,23 @@ T getAs(const QJsonValue&) template <> int getAs(const QJsonValue& value) { - int iValue = 0; - try { - iValue = value.toInt(); - } catch (...) { - qCritical() << "Could not get int from QJsonValue"; - } - return iValue; + return value.toInt(); } template <> double getAs(const QJsonValue& value) { - double dValue = 0.0; - try { - dValue = value.toDouble(); - } catch (...) { - qCritical() << "Could not get double from QJsonValue"; - } - return dValue; + return value.toDouble(); } template <> QString getAs(const QJsonValue& value) { - QString strValue; - try { - strValue = value.toString(); - } catch (...) { - qCritical() << "Could not get QString from QJsonValue"; - } - return strValue; + return value.toString(); } -// Templated generation of numeric editing widgets. +// --- Numeric widget factory --- + template QWidget* getNumericWidget(T, T, T, int, T) { @@ -113,12 +92,8 @@ template <> QWidget* getNumericWidget(int defaultValue, int rangeMin, int rangeMax, int /*precision*/, int step) { - tomviz::SpinBox* spinBox = new tomviz::SpinBox(); - if (step == -1) { - spinBox->setSingleStep(1); - } else { - spinBox->setSingleStep(step); - } + auto* spinBox = new QSpinBox(); + spinBox->setSingleStep(step == -1 ? 1 : step); spinBox->setMinimum(rangeMin); spinBox->setMaximum(rangeMax); spinBox->setValue(defaultValue); @@ -129,36 +104,34 @@ template <> QWidget* getNumericWidget(double defaultValue, double rangeMin, double rangeMax, int precision, double step) { - tomviz::DoubleSpinBox* spinBox = new tomviz::DoubleSpinBox(); - if (step == -1) { - spinBox->setSingleStep(0.5); - } else { - spinBox->setSingleStep(step); - } - if (precision != -1) { - spinBox->setDecimals(precision); - } else { - spinBox->setDecimals(3); - } + auto* spinBox = new QDoubleSpinBox(); + spinBox->setSingleStep(step == -1.0 ? 0.5 : step); + spinBox->setDecimals(precision != -1 ? precision : 3); spinBox->setMinimum(rangeMin); spinBox->setMaximum(rangeMax); spinBox->setValue(defaultValue); return spinBox; } -template -bool isWidgetType(const QWidget* widget) { +// --- Widget type helpers --- + +template +bool isWidgetType(const QWidget* widget) +{ return qobject_cast(widget) != nullptr; } -bool isWidgetNumeric(const QWidget* widget) { - return isWidgetType(widget) || isWidgetType(widget); +bool isWidgetNumeric(const QWidget* widget) +{ + return isWidgetType(widget) || + isWidgetType(widget); } +// --- Widget changed signal --- + template auto changedSignal() { - // Get a generic symbol a widget has changed if constexpr (std::is_same_v) { return QOverload::of(&QComboBox::currentIndexChanged); } else if constexpr (std::is_same_v) { @@ -172,12 +145,11 @@ auto changedSignal() } } +// --- Widget value extraction --- + template auto widgetValue(const T* w) { - // Get the widget value. - // This will be an int for QSpinBox, double for QDoubleSpinBox, - // string for QComboBox, etc. if constexpr (std::is_same_v) { return w->currentData().toString(); } else if constexpr (std::is_same_v) { @@ -191,19 +163,18 @@ auto widgetValue(const T* w) } } +// --- Widget builders --- + void addBoolWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) { QJsonValueRef nameValue = parameterNode["name"]; QJsonValueRef labelValue = parameterNode["label"]; if (nameValue.isUndefined()) { - QJsonDocument document(parameterNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); return; } - QLabel* label = new QLabel(nameValue.toString()); + auto* label = new QLabel(nameValue.toString()); if (!labelValue.isUndefined()) { label->setText(labelValue.toString()); } @@ -216,7 +187,7 @@ void addBoolWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) defaultValue = defaultNode.toBool(); } } - QCheckBox* checkBox = new QCheckBox(); + auto* checkBox = new QCheckBox(); checkBox->setObjectName(nameValue.toString()); checkBox->setCheckState(defaultValue ? Qt::Checked : Qt::Unchecked); label->setBuddy(checkBox); @@ -224,20 +195,17 @@ void addBoolWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) } template -void addNumericWidget(QGridLayout* layout, int row, QJsonObject& parameterNode, - DataSource* dataSource) +void addNumericWidget(QGridLayout* layout, int row, + QJsonObject& parameterNode) { QJsonValueRef nameValue = parameterNode["name"]; QJsonValueRef labelValue = parameterNode["label"]; if (nameValue.isUndefined()) { - QJsonDocument document(parameterNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); return; } - QLabel* label = new QLabel(nameValue.toString()); + auto* label = new QLabel(nameValue.toString()); if (!labelValue.isUndefined()) { label->setText(labelValue.toString()); } @@ -254,43 +222,38 @@ void addNumericWidget(QGridLayout* layout, int row, QJsonObject& parameterNode, defaultValues.push_back(getAs(defaultArray[i])); } } - } else if (parameterNode.contains("data-default")) { - if (!dataSource) { - return; - } - QJsonValueRef defaultNode = parameterNode["data-default"]; - int extent[6]; - dataSource->getExtent(extent); - if (defaultNode.toString() == "num-voxels-x") { - defaultValues.push_back(extent[1] - extent[0] + 1); - } else if (defaultNode.toString() == "num-voxels-y") { - defaultValues.push_back(extent[3] - extent[2] + 1); - } else if (defaultNode.toString() == "num-voxels-z") { - defaultValues.push_back(extent[5] - extent[4] + 1); - } } - std::vector minValues(defaultValues.size(), std::numeric_limits::lowest()); + if (defaultValues.empty()) { + // No default specified, use a single zero + defaultValues.push_back(T(0)); + } + + std::vector minValues(defaultValues.size(), + std::numeric_limits::lowest()); if (parameterNode.contains("minimum")) { QJsonValueRef minNode = parameterNode["minimum"]; if (isType(minNode)) { minValues[0] = getAs(minNode); } else if (minNode.isArray()) { QJsonArray minArray = minNode.toArray(); - for (QJsonObject::size_type i = 0; i < minArray.size(); ++i) { + for (QJsonObject::size_type i = 0; + i < minArray.size() && i < (int)minValues.size(); ++i) { minValues[i] = getAs(minArray[i]); } } } - std::vector maxValues(defaultValues.size(), std::numeric_limits::max()); + std::vector maxValues(defaultValues.size(), + std::numeric_limits::max()); if (parameterNode.contains("maximum")) { QJsonValueRef maxNode = parameterNode["maximum"]; if (isType(maxNode)) { maxValues[0] = getAs(maxNode); } else if (maxNode.isArray()) { QJsonArray maxArray = maxNode.toArray(); - for (QJsonObject::size_type i = 0; i < maxArray.size(); ++i) { + for (QJsonObject::size_type i = 0; + i < maxArray.size() && i < (int)maxValues.size(); ++i) { maxValues[i] = getAs(maxArray[i]); } } @@ -311,9 +274,9 @@ void addNumericWidget(QGridLayout* layout, int row, QJsonObject& parameterNode, } } - QHBoxLayout* horizontalLayout = new QHBoxLayout(); + auto* horizontalLayout = new QHBoxLayout(); horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* horizontalWidget = new QWidget; + auto* horizontalWidget = new QWidget; horizontalWidget->setLayout(horizontalLayout); label->setBuddy(horizontalWidget); layout->addWidget(horizontalWidget, row, 1, 1, 1); @@ -321,9 +284,6 @@ void addNumericWidget(QGridLayout* layout, int row, QJsonObject& parameterNode, for (size_t i = 0; i < defaultValues.size(); ++i) { QString name = nameValue.toString(); if (defaultValues.size() > 1) { - // Multi-element parameters are named with the pattern 'basename#XXX' - // where 'basename' is the name of the parameter and 'XXX' is the - // element number. name.append("#%1"); name = name.arg(i, 3, 10, QLatin1Char('0')); } @@ -342,19 +302,16 @@ void addEnumerationWidget(QGridLayout* layout, int row, QJsonValueRef labelValue = parameterNode["label"]; if (nameValue.isUndefined()) { - QJsonDocument document(parameterNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); return; } - QLabel* label = new QLabel(nameValue.toString()); + auto* label = new QLabel(nameValue.toString()); if (!labelValue.isUndefined()) { label->setText(labelValue.toString()); } layout->addWidget(label, row, 0, 1, 1); - QComboBox* comboBox = new QComboBox(); + auto* comboBox = new QComboBox(); comboBox->setObjectName(nameValue.toString()); label->setBuddy(comboBox); QJsonValueRef optionsNode = parameterNode["options"]; @@ -366,23 +323,19 @@ void addEnumerationWidget(QGridLayout* layout, int row, QJsonValueRef optionValueNode = optionNode[optionName]; QVariant optionValue; if (isType(optionValueNode)) { - // Convert to an int if possible optionValue = optionValueNode.toInt(); } else { - // Otherwise, let it be whatever type it is... optionValue = optionValueNode.toVariant(); } comboBox->addItem(optionName, optionValue); } } - // Set the default if present QJsonValueRef defaultNode = parameterNode["default"]; if (!defaultNode.isUndefined()) { if (isType(defaultNode)) { comboBox->setCurrentIndex(getAs(defaultNode)); } else if (defaultNode.isString()) { - // Find the data that matches, and set it int defaultIndex = comboBox->findData(getAs(defaultNode)); if (defaultIndex >= 0) { comboBox->setCurrentIndex(defaultIndex); @@ -395,142 +348,122 @@ void addEnumerationWidget(QGridLayout* layout, int row, void addXYZHeaderWidget(QGridLayout* layout, int row, const QJsonValue&) { - QHBoxLayout* horizontalLayout = new QHBoxLayout; + auto* horizontalLayout = new QHBoxLayout; horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* horizontalWidget = new QWidget; + auto* horizontalWidget = new QWidget; horizontalWidget->setLayout(horizontalLayout); layout->addWidget(horizontalWidget, row, 1, 1, 1); - QLabel* xLabel = new QLabel("X"); + auto* xLabel = new QLabel("X"); xLabel->setAlignment(Qt::AlignCenter); horizontalLayout->addWidget(xLabel); - QLabel* yLabel = new QLabel("Y"); + auto* yLabel = new QLabel("Y"); yLabel->setAlignment(Qt::AlignCenter); horizontalLayout->addWidget(yLabel); - QLabel* zLabel = new QLabel("Z"); + auto* zLabel = new QLabel("Z"); zLabel->setAlignment(Qt::AlignCenter); horizontalLayout->addWidget(zLabel); } void addPathWidget(QGridLayout* layout, int row, QJsonObject& pathNode) { - QHBoxLayout* horizontalLayout = new QHBoxLayout; + auto* horizontalLayout = new QHBoxLayout; horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* horizontalWidget = new QWidget; + auto* horizontalWidget = new QWidget; horizontalWidget->setLayout(horizontalLayout); layout->addWidget(horizontalWidget, row, 1, 1, 1); QJsonValueRef typeValue = pathNode["type"]; if (typeValue.isUndefined()) { - QJsonDocument document(pathNode); - qWarning() << QString("Parameter %1 has no type. Skipping.") - .arg(document.toJson().data()); return; } QString type = typeValue.toString(); QJsonValueRef nameValue = pathNode["name"]; if (nameValue.isUndefined()) { - QJsonDocument document(pathNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); return; } QJsonValueRef labelValue = pathNode["label"]; - QLabel* label = new QLabel(nameValue.toString()); + auto* label = new QLabel(nameValue.toString()); label->setBuddy(horizontalWidget); if (!labelValue.isUndefined()) { label->setText(labelValue.toString()); } layout->addWidget(label, row, 0, 1, 1); - QLineEdit* pathField = new QLineEdit(); - // Tag the line edit with the type, so we can distinguish it from other line - // edit uses ( such as in a QSpinBox ) + auto* pathField = new QLineEdit(); pathField->setProperty("type", type); pathField->setObjectName(nameValue.toString()); pathField->setMinimumWidth(500); - // Set default if present QJsonValueRef defaultNode = pathNode["default"]; if (!defaultNode.isUndefined() && defaultNode.isString()) { - auto defaultValue = getAs(defaultNode); - pathField->setText(defaultValue); + pathField->setText(getAs(defaultNode)); } horizontalLayout->addWidget(pathField); auto filter = pathNode["filter"].toString(); - QPushButton* browseButton = new QPushButton("Browse"); + auto* browseButton = new QPushButton("Browse"); horizontalLayout->addWidget(browseButton); - QObject::connect(browseButton, &QPushButton::clicked, [type, pathField, filter]() { - // Determine the directory we should open the file browser at. - QString browseDir; - if (!pathField->text().isEmpty()) { - QFileInfo currentValue = QFileInfo(pathField->text()); - auto dir = currentValue.dir(); - if (dir.exists()) { - browseDir = dir.absolutePath(); + QObject::connect( + browseButton, &QPushButton::clicked, + [type, pathField, filter]() { + QString browseDir; + if (!pathField->text().isEmpty()) { + QFileInfo currentValue(pathField->text()); + auto dir = currentValue.dir(); + if (dir.exists()) { + browseDir = dir.absolutePath(); + } } - } - // Now open the appropriate dialog to browse for a file or directory. - QString path; - if (type == "file") { - path = QFileDialog::getOpenFileName(tomviz::mainWidget(), "Select File", - browseDir, filter); - } else if (type == "save_file") { - path = QFileDialog::getSaveFileName(tomviz::mainWidget(), "Save File Path", - browseDir, filter); - } else { - path = QFileDialog::getExistingDirectory(tomviz::mainWidget(), - "Select Directory", browseDir); - } + QString path; + if (type == "file") { + path = QFileDialog::getOpenFileName( + pathField->window(), "Select File", browseDir, filter); + } else if (type == "save_file") { + path = QFileDialog::getSaveFileName( + pathField->window(), "Save File Path", browseDir, filter); + } else { + path = QFileDialog::getExistingDirectory( + pathField->window(), "Select Directory", browseDir); + } - // If a path was selected update the line edit. - if (!path.isNull()) { - pathField->setText(path); - } - }); + if (!path.isNull()) { + pathField->setText(path); + } + }); } void addStringWidget(QGridLayout* layout, int row, QJsonObject& pathNode) { - QHBoxLayout* horizontalLayout = new QHBoxLayout; + auto* horizontalLayout = new QHBoxLayout; horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* horizontalWidget = new QWidget; + auto* horizontalWidget = new QWidget; horizontalWidget->setLayout(horizontalLayout); layout->addWidget(horizontalWidget, row, 1, 1, 1); QJsonValueRef typeValue = pathNode["type"]; if (typeValue.isUndefined()) { - QJsonDocument document(pathNode); - qWarning() << QString("Parameter %1 has no type. Skipping.") - .arg(document.toJson().data()); return; } QString type = typeValue.toString(); QJsonValueRef nameValue = pathNode["name"]; if (nameValue.isUndefined()) { - QJsonDocument document(pathNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); return; } QJsonValueRef labelValue = pathNode["label"]; - QLabel* label = new QLabel(nameValue.toString()); + auto* label = new QLabel(nameValue.toString()); if (!labelValue.isUndefined()) { label->setText(labelValue.toString()); } layout->addWidget(label, row, 0, 1, 1); - QLineEdit* stringField = new QLineEdit(); - stringField->setProperty("type", type); - // Tag the line edit with the type, so we can distinguish it from other line - // edit uses ( such as in a QSpinBox ) + auto* stringField = new QLineEdit(); stringField->setProperty("type", type); stringField->setObjectName(nameValue.toString()); stringField->setMinimumWidth(500); @@ -539,16 +472,16 @@ void addStringWidget(QGridLayout* layout, int row, QJsonObject& pathNode) QJsonValueRef defaultNode = pathNode["default"]; if (!defaultNode.isUndefined() && defaultNode.isString()) { - auto defaultValue = getAs(defaultNode); - stringField->setText(defaultValue); + stringField->setText(getAs(defaultNode)); } } -void addDatasetWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) +void addSelectScalarsWidget(QGridLayout* layout, int row, + QJsonObject& parameterNode, + const QList& portScalars) { QJsonValueRef nameValue = parameterNode["name"]; QJsonValueRef labelValue = parameterNode["label"]; - auto defaultId = parameterNode.value("default").toString(); if (nameValue.isUndefined()) { QJsonDocument document(parameterNode); @@ -557,161 +490,247 @@ void addDatasetWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) return; } - QLabel* labelWidget = new QLabel(nameValue.toString()); - if (!labelValue.isUndefined()) { - labelWidget->setText(labelValue.toString()); - } - layout->addWidget(labelWidget, row, 0, 1, 1); + QString name = nameValue.toString(); - QComboBox* comboBox = new QComboBox(); - comboBox->setObjectName(nameValue.toString()); - labelWidget->setBuddy(comboBox); - QStringList addedLabels; - auto dataSources = - tomviz::ModuleManager::instance().allDataSourcesDepthFirst(); - auto labels = tomviz::ModuleManager::createUniqueLabels(dataSources); - auto defaultIndex = -1; - for (int i = 0; i < dataSources.size(); ++i) { - auto* dataSource = dataSources[i]; - if (dataSource->id() == defaultId) { - defaultIndex = i; + // Resolve which input port this parameter draws from. JSON's "input" + // field selects by port name; missing/unmatched falls back to the + // first entry (typically the primary volume input). + QString inputName = parameterNode.value("input").toString(); + const PortScalars* resolved = nullptr; + if (!inputName.isEmpty()) { + for (const auto& ps : portScalars) { + if (ps.portName == inputName) { + resolved = &ps; + break; + } } - - auto label = labels[i]; - - QVariant data; - data.setValue(dataSource); - comboBox->addItem(label, data); - addedLabels.append(label); - } - - if (defaultIndex != -1) { - comboBox->setCurrentIndex(defaultIndex); } - - layout->addWidget(comboBox, row, 1, 1, 1); -} - -void addSelectScalarsWidget(QGridLayout* layout, int row, - QJsonObject& parameterNode, - DataSource* dataSource) -{ - QJsonValueRef nameValue = parameterNode["name"]; - QJsonValueRef labelValue = parameterNode["label"]; - - if (nameValue.isUndefined()) { - QJsonDocument document(parameterNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); - return; + if (!resolved && !portScalars.isEmpty()) { + resolved = &portScalars.first(); } + const QStringList availableScalars = + resolved ? resolved->scalarNames : QStringList{}; + const QString activeScalar = + resolved ? resolved->activeScalar : QString{}; - QString name = nameValue.toString(); - - QLabel* label = new QLabel(name); + auto* label = new QLabel(name); if (!labelValue.isUndefined()) { label->setText(labelValue.toString()); } layout->addWidget(label, row, 0, 1, 1); - // Container widget - QWidget* container = new QWidget(); + auto* container = new QWidget(); container->setObjectName(name); container->setProperty("type", "select_scalars"); + // Stash the active scalar name so parameterValues() can return it as + // the parameter value when "Select active" is checked, without needing + // VolumeData access at extraction time. + container->setProperty("activeScalar", activeScalar); + // Fixed vertical policy so the parent grid doesn't squish the combo box + // when total dialog height is tight — keep the row at its sizeHint. + container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); label->setBuddy(container); - QVBoxLayout* vLayout = new QVBoxLayout(); + auto* vLayout = new QVBoxLayout(); vLayout->setContentsMargins(0, 0, 0, 0); container->setLayout(vLayout); - // "Apply to all scalars" checkbox - bool showApplyAll = parameterNode.value("show_apply_all").toBool(true); - QCheckBox* applyAllCheckBox = new QCheckBox("Apply to all scalars"); - applyAllCheckBox->setObjectName(name + "_apply_all"); - applyAllCheckBox->setChecked(showApplyAll); - applyAllCheckBox->setVisible(showApplyAll); - vLayout->addWidget(applyAllCheckBox); - - // Checkable combo box for individual scalar selection - QComboBox* comboBox = new QComboBox(); + bool showShortcuts = parameterNode.value("show_apply_all").toBool(true); + // Wrap the two shortcut checkboxes in a fixed-width widget so the row + // doesn't propagate an "expanding" horizontal policy up and force the + // enclosing column to claim all available dialog width. + auto* shortcutsWidget = new QWidget(); + shortcutsWidget->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + auto* shortcutsRow = new QHBoxLayout(shortcutsWidget); + shortcutsRow->setContentsMargins(0, 0, 0, 0); + + auto* selectAllCheckBox = new QCheckBox("Select all"); + selectAllCheckBox->setObjectName(name + "_select_all"); + selectAllCheckBox->setChecked(showShortcuts); + selectAllCheckBox->setVisible(showShortcuts); + shortcutsRow->addWidget(selectAllCheckBox); + + auto* selectActiveCheckBox = new QCheckBox("Select active"); + selectActiveCheckBox->setObjectName(name + "_select_active"); + selectActiveCheckBox->setChecked(false); + selectActiveCheckBox->setVisible(showShortcuts && !activeScalar.isEmpty()); + shortcutsRow->addWidget(selectActiveCheckBox); + + vLayout->addWidget(shortcutsWidget, 0, Qt::AlignLeft); + + auto* comboBox = new QComboBox(); comboBox->setObjectName(name + "_combo"); - QStandardItemModel* model = new QStandardItemModel(comboBox); + auto* model = new QStandardItemModel(comboBox); comboBox->setModel(model); - comboBox->setEnabled(!showApplyAll); - - if (dataSource) { - QStringList scalars = dataSource->listScalars(); - for (const QString& scalar : scalars) { - QStandardItem* item = new QStandardItem(scalar); - item->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); - item->setData(Qt::Checked, Qt::CheckStateRole); - model->appendRow(item); + comboBox->setEnabled(!selectAllCheckBox->isChecked() && + !selectActiveCheckBox->isChecked()); + // Editable + read-only QLineEdit so we can drive the collapsed display + // text ourselves (the joined list of checked items). QComboBox's default + // display just shows currentIndex's label, which isn't useful here. + comboBox->setEditable(true); + comboBox->lineEdit()->setReadOnly(true); + comboBox->lineEdit()->setFocusPolicy(Qt::NoFocus); + + for (const QString& scalar : availableScalars) { + auto* item = new QStandardItem(scalar); + item->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + item->setData(Qt::Checked, Qt::CheckStateRole); + model->appendRow(item); + } + + // Restore previous selection from "default" if present + QJsonValueRef defaultNode = parameterNode["default"]; + if (!defaultNode.isUndefined() && defaultNode.isArray()) { + QJsonArray defaultArray = defaultNode.toArray(); + QSet selected; + for (const auto& v : defaultArray) { + selected.insert(v.toString()); } - // Restore previous selection from "default" if present - QJsonValueRef defaultNode = parameterNode["default"]; - if (!defaultNode.isUndefined() && defaultNode.isArray()) { - QJsonArray defaultArray = defaultNode.toArray(); - QSet selected; - for (const auto& v : defaultArray) { - selected.insert(v.toString()); + bool allSelected = true; + for (int i = 0; i < model->rowCount(); ++i) { + bool isSelected = selected.contains(model->item(i)->text()); + model->item(i)->setData(isSelected ? Qt::Checked : Qt::Unchecked, + Qt::CheckStateRole); + if (!isSelected) { + allSelected = false; } + } + selectAllCheckBox->setChecked(showShortcuts && allSelected); + comboBox->setEnabled(!selectAllCheckBox->isChecked()); + } + + if (availableScalars.size() <= 1) { + label->setVisible(false); + container->setVisible(false); + } + + vLayout->addWidget(comboBox); - bool allSelected = true; + auto updateDisplayText = + [comboBox, model, selectAllCheckBox, selectActiveCheckBox, + activeScalar]() { + QStringList selected; + if (selectActiveCheckBox->isChecked()) { + if (!activeScalar.isEmpty()) { + selected << activeScalar; + } + } else if (selectAllCheckBox->isChecked()) { for (int i = 0; i < model->rowCount(); ++i) { - bool isSelected = selected.contains(model->item(i)->text()); - model->item(i)->setData(isSelected ? Qt::Checked : Qt::Unchecked, - Qt::CheckStateRole); - if (!isSelected) { - allSelected = false; + selected << model->item(i)->text(); + } + } else { + for (int i = 0; i < model->rowCount(); ++i) { + auto* item = model->item(i); + if (item->checkState() == Qt::Checked) { + selected << item->text(); } } - applyAllCheckBox->setChecked(showApplyAll && allSelected); - comboBox->setEnabled(!applyAllCheckBox->isChecked()); } + auto* le = comboBox->lineEdit(); + le->setText(selected.join(", ")); + // Park the cursor at the start so a too-long string truncates on the + // right (visible head) instead of the left (visible tail). + le->setCursorPosition(0); + }; - // Auto-hide when only one scalar - if (scalars.size() <= 1) { - label->setVisible(false); - container->setVisible(false); - } - } + auto updateComboEnabled = + [comboBox, selectAllCheckBox, selectActiveCheckBox]() { + comboBox->setEnabled(!selectAllCheckBox->isChecked() && + !selectActiveCheckBox->isChecked()); + }; - vLayout->addWidget(comboBox); + // Deferred via a 0-ms timer because QComboBox internally reacts to the + // model's dataChanged signal (after a check-state toggle) and rewrites + // the line edit text to the clicked item's label. Running after the + // event loop spins ensures our joined text wins. + QObject::connect(model, &QStandardItemModel::itemChanged, comboBox, + [updateDisplayText](QStandardItem*) { + QTimer::singleShot(0, updateDisplayText); + }); - // Toggle combo box enabled state based on checkbox - QObject::connect(applyAllCheckBox, &QCheckBox::toggled, - [comboBox](bool checked) { - comboBox->setEnabled(!checked); + // Mutual exclusivity: when either shortcut is checked, the other one + // turns off. QSignalBlocker prevents the other slot from firing back. + QObject::connect(selectAllCheckBox, &QCheckBox::toggled, container, + [selectActiveCheckBox, updateComboEnabled, + updateDisplayText](bool checked) { + if (checked) { + QSignalBlocker blocker(selectActiveCheckBox); + selectActiveCheckBox->setChecked(false); + } + updateComboEnabled(); + updateDisplayText(); + }); + QObject::connect(selectActiveCheckBox, &QCheckBox::toggled, container, + [selectAllCheckBox, updateComboEnabled, + updateDisplayText](bool checked) { + if (checked) { + QSignalBlocker blocker(selectAllCheckBox); + selectAllCheckBox->setChecked(false); + } + updateComboEnabled(); + updateDisplayText(); }); - // Install event filter on combo box viewport to prevent popup from closing - // on item click, while still toggling the checkbox. Only toggle on release - // if a matching press was seen on the viewport — this ignores the orphaned - // release from the click that originally opened the popup. + // Clicking the (read-only) line edit opens the popup, so the field + // behaves like the rest of the combo box rather than a dead text area. + // We open on release, not press: opening on press lets the in-flight + // release land on the already-open popup, where it's interpreted as + // "released outside any item, close" — same effect even if showPopup() + // is deferred, since the user may still be holding the button when the + // timer fires. + class LineEditOpensPopupFilter : public QObject + { + public: + LineEditOpensPopupFilter(QComboBox* combo, QObject* parent) + : QObject(parent), m_combo(combo) {} + bool eventFilter(QObject* /*obj*/, QEvent* event) override + { + if (!m_combo->isEnabled()) { + return false; + } + if (event->type() == QEvent::MouseButtonPress) { + return true; + } + if (event->type() == QEvent::MouseButtonRelease) { + m_combo->showPopup(); + return true; + } + return false; + } + private: + QComboBox* m_combo; + }; + comboBox->lineEdit()->installEventFilter( + new LineEditOpensPopupFilter(comboBox, comboBox)); + + updateDisplayText(); + + // Keep the combo box popup open while the user ticks/un-ticks items. + // Consume the press; on release, toggle the item under the cursor only + // if a matching press was seen on the viewport (ignores the orphaned + // release from the click that originally opened the popup). class ComboEventFilter : public QObject { public: ComboEventFilter(QComboBox* combo, QObject* parent) : QObject(parent), m_combo(combo) {} - bool eventFilter(QObject* obj, QEvent* event) override + bool eventFilter(QObject* /*obj*/, QEvent* event) override { if (event->type() == QEvent::MouseButtonPress) { m_pressedOnViewport = true; - return true; // Consume press to keep popup open + return true; } if (event->type() == QEvent::MouseButtonRelease) { if (!m_pressedOnViewport) { - return true; // No matching press — consume without toggling + return true; } m_pressedOnViewport = false; - // Manually toggle the check state of the item under the cursor auto* view = m_combo->view(); auto index = view->indexAt( static_cast(event)->pos()); if (index.isValid()) { - auto* model = - qobject_cast(m_combo->model()); + auto* model = qobject_cast(m_combo->model()); if (model) { auto* item = model->itemFromIndex(index); if (item && (item->flags() & Qt::ItemIsUserCheckable)) { @@ -721,9 +740,9 @@ void addSelectScalarsWidget(QGridLayout* layout, int row, } } } - return true; // Consume the event to keep popup open + return true; } - return QObject::eventFilter(obj, event); + return false; } private: QComboBox* m_combo; @@ -738,404 +757,126 @@ void addSelectScalarsWidget(QGridLayout* layout, int row, static const QStringList PATH_TYPES = { "file", "save_file", "directory" }; -} // end anonymous namespace - -namespace tomviz { - -InterfaceBuilder::InterfaceBuilder(QObject* parentObject, DataSource* ds) - : QObject(parentObject), m_dataSource(ds) -{} +// --- enable_if / visible_if support --- -void InterfaceBuilder::setJSONDescription(const QString& description) +template +bool compareGeneric(T value, T ref, const QString& comparator) { - setJSONDescription(QJsonDocument::fromJson(description.toLatin1())); + if (comparator == "==") { + return value == ref; + } else if (comparator == "!=") { + return value != ref; + } + return false; } -void InterfaceBuilder::setJSONDescription(const QJsonDocument& description) +template +bool compareNumbers(T value, T ref, const QString& comparator) { - if (!description.isObject()) { - qCritical() << "Failed to parse operator JSON"; - qCritical() << m_json; - m_json = QJsonDocument(); - } else { - m_json = description; + if (comparator == "==") { + return value == ref; + } else if (comparator == "!=") { + return value != ref; + } else if (comparator == ">") { + return value > ref; + } else if (comparator == "<") { + return value < ref; + } else if (comparator == ">=") { + return value >= ref; + } else if (comparator == "<=") { + return value <= ref; } + return false; } -QLayout* InterfaceBuilder::buildParameterInterface(QGridLayout* layout, - QJsonArray& parameters, - const QString& tag) const +template +bool compare(const T* widget, const QVariant& compareValue, + const QString& comparator) { - QJsonObject::size_type numParameters = parameters.size(); - for (QJsonObject::size_type i = 0; i < numParameters; ++i) { - QJsonValueRef parameterNode = parameters[i]; - QJsonObject parameterObject = parameterNode.toObject(); - - QString tagValue = parameterObject["tag"].toString(""); - if (tagValue != tag) { - // The tag doesn't match, skip over this one. - continue; - } - - QJsonValueRef typeValue = parameterObject["type"]; - - if (typeValue.isUndefined()) { - qWarning() << tr("Parameter has no type entry"); - continue; + auto value = widgetValue(widget); + if constexpr (std::is_same_v || + std::is_same_v) { + QString ref = compareValue.toString(); + if (ref.startsWith("'") || ref.startsWith("\"")) { + ref = ref.mid(1, ref.length() - 2); } + return compareGeneric(value, ref, comparator); + } else if constexpr (std::is_same_v) { + return compareGeneric(value, compareValue.toBool(), comparator); + } else if constexpr (std::is_same_v) { + return compareNumbers(value, compareValue.toInt(), comparator); + } else if constexpr (std::is_same_v) { + return compareNumbers(value, compareValue.toDouble(), comparator); + } + return false; +} - QString typeString = typeValue.toString(); +struct EnableCondition +{ + QWidget* refWidget = nullptr; + QString comparator; + QVariant compareValue; +}; - // See if we have a parameter value that we need to set the default - // to. - QJsonValueRef nameValue = parameterObject["name"]; - if (!nameValue.isUndefined()) { - QString parameterName = nameValue.toString(); - if (m_parameterValues.contains(parameterName)) { - QVariant parameterValue = m_parameterValues[parameterName]; - if (parameterValue.canConvert()) { - // Save the id of the pointer so we can store it in a QJsonValue - auto* ds = parameterValue.value(); - parameterValue.setValue(ds->id()); - } +static bool evaluateCondition(const EnableCondition& cond) +{ + auto* w = cond.refWidget; + if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, + cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, + cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, + cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, + cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, + cond.comparator); + } + return false; +} - parameterObject["default"] = QJsonValue::fromVariant(parameterValue); +static bool evaluateCompound( + const QList>& orGroups) +{ + for (auto& andGroup : orGroups) { + bool groupResult = true; + for (auto& cond : andGroup) { + if (!evaluateCondition(cond)) { + groupResult = false; + break; } } - - if (typeString == "bool") { - addBoolWidget(layout, i + 1, parameterObject); - } else if (typeString == "int") { - addNumericWidget(layout, i + 1, parameterObject, m_dataSource); - } else if (typeString == "double") { - addNumericWidget(layout, i + 1, parameterObject, m_dataSource); - } else if (typeString == "enumeration") { - addEnumerationWidget(layout, i + 1, parameterObject); - } else if (typeString == "xyz_header") { - addXYZHeaderWidget(layout, i + 1, parameterObject); - } else if (PATH_TYPES.contains(typeString)) { - addPathWidget(layout, i + 1, parameterObject); - } else if (typeString == "string") { - addStringWidget(layout, i + 1, parameterObject); - } else if (typeString == "dataset") { - addDatasetWidget(layout, i + 1, parameterObject); - } else if (typeString == "select_scalars") { - addSelectScalarsWidget(layout, i + 1, parameterObject, m_dataSource); + if (groupResult) { + return true; } } - - setupEnableAndVisibleStates(layout->parentWidget(), parameters); - - return layout; + return false; } -void InterfaceBuilder::setupEnableAndVisibleStates( - const QObject* parent, - QJsonArray& parameters) const +QWidget* findRootInterfaceWidget(const QWidget* widget) { - setupEnableStates(parent, parameters, true); - setupEnableStates(parent, parameters, false); + auto* parent = widget->parent(); + while (parent && + !parent->property("isRootInterfaceWidget").toBool()) { + parent = parent->parent(); + } + + if (parent && parent->property("isRootInterfaceWidget").toBool()) { + return qobject_cast(parent); + } + return nullptr; } -QLayout* InterfaceBuilder::buildInterface() const +QLabel* findLabelForWidget(const QWidget* widget) { - QWidget* widget = new QWidget; - widget->setProperty("isRootInterfaceWidget", true); - - QVBoxLayout* verticalLayout = new QVBoxLayout; - verticalLayout->addWidget(widget); - verticalLayout->addStretch(); - - QGridLayout* layout = new QGridLayout; - widget->setLayout(layout); - - if (!m_json.isObject()) { - return layout; - } - QJsonObject root = m_json.object(); - - QLabel* descriptionLabel = new QLabel("No description provided in JSON"); - descriptionLabel->setWordWrap(true); - descriptionLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); - QJsonValueRef descriptionValue = root["description"]; - if (!descriptionValue.isUndefined()) { - descriptionLabel->setText(descriptionValue.toString()); - } - verticalLayout->insertWidget(0, descriptionLabel); - - // Get the label for the operator - QString operatorLabel; - QJsonValueRef labelNode = root["label"]; - if (!labelNode.isUndefined()) { - operatorLabel = QString(labelNode.toString()); - } - - // Get the parameters for the operator - QJsonValueRef parametersNode = root["parameters"]; - if (parametersNode.isUndefined()) { - return layout; - } - auto parameters = parametersNode.toArray(); - buildParameterInterface(layout, parameters); - - return verticalLayout; -} - -static bool setWidgetValue(QObject* o, const QVariant& v) -{ - // Returns true if the widget type was found, false otherwise. - - // Handle select_scalars container widget - if (auto w = qobject_cast(o)) { - if (w->property("type").toString() == "select_scalars") { - QStringList selected; - if (v.canConvert()) { - for (const auto& item : v.toList()) { - selected << item.toString(); - } - } else if (v.canConvert()) { - selected = v.toStringList(); - } - - auto* applyAllCB = w->findChild(w->objectName() + "_apply_all"); - auto* combo = w->findChild(w->objectName() + "_combo"); - if (!applyAllCB || !combo) { - return false; - } - - auto* model = qobject_cast(combo->model()); - if (!model) { - return false; - } - - // Check if all items are selected - bool allSelected = true; - for (int i = 0; i < model->rowCount(); ++i) { - if (!selected.contains(model->item(i)->text())) { - allSelected = false; - break; - } - } - - applyAllCB->setChecked(allSelected); - for (int i = 0; i < model->rowCount(); ++i) { - Qt::CheckState state = selected.contains(model->item(i)->text()) - ? Qt::Checked : Qt::Unchecked; - model->item(i)->setData(state, Qt::CheckStateRole); - } - return true; - } - } - - if (auto cb = qobject_cast(o)) { - cb->setChecked(v.toBool()); - } else if (auto sb = qobject_cast(o)) { - sb->setValue(v.toInt()); - } else if (auto dsb = qobject_cast(o)) { - dsb->setValue(v.toDouble()); - } else if (auto combo = qobject_cast(o)) { - // Check if the variant is a data source - if (v.canConvert()) { - // Find the item with this data, and set it - auto* ds = v.value(); - bool found = false; - for (int i = 0; i < combo->count(); ++i) { - if (combo->itemData(i).value() == ds) { - found = true; - combo->setCurrentIndex(i); - break; - } - } - if (!found) { - qDebug() << "Warning: could not find combo box for data source: " - << ds->label() << "(" << ds << ")"; - } - } else { - // Assume it is a string - combo->setCurrentText(v.toString()); - } - } else if (auto le = qobject_cast(o)) { - le->setText(v.toString()); - } else { - return false; - } - - return true; -} - -void InterfaceBuilder::setParameterValues(QMap values) -{ - m_parameterValues = values; -} - -void InterfaceBuilder::updateWidgetValues(const QObject* parent) -{ - static const QStringList skipTypes = { - "QLabel", - }; - - for (auto* child : parent->findChildren()) { - if (skipTypes.contains(child->metaObject()->className())) { - // Skip this type... - continue; - } - - const auto& name = child->objectName(); - if (!m_parameterValues.contains(name)) { - continue; - } - - if (!setWidgetValue(child, m_parameterValues[name])) { - qDebug() << "Failed to set value for child: " << child; - } - } -} - -QVariantMap InterfaceBuilder::parameterValues(const QObject* parent) -{ - QVariantMap map; - - // Handle select_scalars widgets first, and collect their internal widget - // names so we can skip them in the generic loops below. - QSet selectScalarsInternalNames; - QList allWidgets = parent->findChildren(); - for (auto* w : allWidgets) { - if (w->property("type").toString() != "select_scalars") { - continue; - } - QString name = w->objectName(); - auto* applyAllCB = w->findChild(name + "_apply_all"); - auto* combo = w->findChild(name + "_combo"); - if (!applyAllCB || !combo) { - continue; - } - - selectScalarsInternalNames.insert(applyAllCB->objectName()); - selectScalarsInternalNames.insert(combo->objectName()); - - auto* model = qobject_cast(combo->model()); - if (!model) { - continue; - } - - QVariantList selectedScalars; - if (applyAllCB->isChecked()) { - // All scalars selected - for (int i = 0; i < model->rowCount(); ++i) { - selectedScalars << model->item(i)->text(); - } - } else { - // Only checked scalars - for (int i = 0; i < model->rowCount(); ++i) { - if (model->item(i)->checkState() == Qt::Checked) { - selectedScalars << model->item(i)->text(); - } - } - } - map[name] = selectedScalars; - } - - // Iterate over all children, taking the value of the named widgets - // and stuffing them into the map. - QList checkBoxes = parent->findChildren(); - for (int i = 0; i < checkBoxes.size(); ++i) { - if (selectScalarsInternalNames.contains(checkBoxes[i]->objectName())) { - continue; - } - map[checkBoxes[i]->objectName()] = - (checkBoxes[i]->checkState() == Qt::Checked); - } - - QList spinBoxes = parent->findChildren(); - for (int i = 0; i < spinBoxes.size(); ++i) { - map[spinBoxes[i]->objectName()] = spinBoxes[i]->value(); - } - - QList doubleSpinBoxes = - parent->findChildren(); - for (int i = 0; i < doubleSpinBoxes.size(); ++i) { - map[doubleSpinBoxes[i]->objectName()] = doubleSpinBoxes[i]->value(); - } - - QList comboBoxes = parent->findChildren(); - for (int i = 0; i < comboBoxes.size(); ++i) { - if (selectScalarsInternalNames.contains(comboBoxes[i]->objectName())) { - continue; - } - int currentIndex = comboBoxes[i]->currentIndex(); - map[comboBoxes[i]->objectName()] = comboBoxes[i]->itemData(currentIndex); - } - - // Assemble multi-component properties into single properties in the map. - QMap::iterator iter = map.begin(); - while (iter != map.end()) { - QString name = iter.key(); - QVariant value = iter.value(); - int poundIndex = name.indexOf(tr("#")); - if (poundIndex >= 0) { - QString indexString = name.mid(poundIndex + 1); - - // Keep the part of the name to the left of the '#' - name = name.left(poundIndex); - - QList valueList; - QMap::iterator findIter = map.find(name); - if (findIter != map.end()) { - valueList = map[name].toList(); - } - - // The QMap keeps entries sorted by lexicographic order, so we - // can just append to the list and the elements will be inserted - // in the correct order. - valueList.append(value); - map[name] = valueList; - - // Delete the individual component map entry. Doing so increments the - // iterator. - iter = map.erase(iter); - } else { - // Single-element parameter, nothing to do - ++iter; - } - } - - // QLineEdit's ( currently 'file', 'save_file', and 'directory' types ). - QList lineEdits = parent->findChildren(); - for (int i = 0; i < lineEdits.size(); ++i) { - auto lineEdit = lineEdits[i]; - QVariant type = lineEdit->property("type"); - bool canConvertTypeToString = QMetaType::canConvert(type.metaType(), QMetaType(QMetaType::QString)); - if (canConvertTypeToString && PATH_TYPES.contains(type.toString())) { - map[lineEdit->objectName()] = lineEdit->text(); - } else if (canConvertTypeToString && type.toString() == "string") { - map[lineEdit->objectName()] = lineEdit->text(); - } - } - - return map; -} - -QWidget* findRootInterfaceWidget(const QWidget* widget) -{ - auto* parent = widget->parent(); - while (parent and not parent->property("isRootInterfaceWidget").toBool()) { - parent = parent->parent(); - } - - if (parent and parent->property("isRootInterfaceWidget").toBool()) { - return qobject_cast(parent); - } - - return nullptr; -} - -QLabel* findLabelForWidget(const QWidget* widget) -{ - // We use the buddy system to keep track of which label is for - // which widget. - auto* parent = findRootInterfaceWidget(widget); - if (!parent) { - return nullptr; + auto* parent = findRootInterfaceWidget(widget); + if (!parent) { + return nullptr; } for (auto* child : parent->findChildren()) { @@ -1143,168 +884,191 @@ QLabel* findLabelForWidget(const QWidget* widget) return child; } } - return nullptr; } void setWidgetProperty(QWidget* widget, const char* property, QVariant value) { if (isWidgetNumeric(widget) || isWidgetType(widget)) { - // These types actually want the parent widget instead, because there - // is some parent widget holding everything (spinboxes, path button, etc.). widget = widget->parentWidget(); if (!widget) { - // This hopefully should't happen. return; } } - // First, set the property on the widget widget->setProperty(property, value); - // Next, see if we can find the label corresponding to this widget, and - // set the property there as well. auto* label = findLabelForWidget(widget); if (label) { label->setProperty(property, value); } } -template -bool compareGeneric(T value, T ref, const QString& comparator) +static void connectWidgetChanged(QWidget* refWidget, QWidget* target, + std::function func) { - // The generic one, for bools and strings, can only do `==` and `!=`. - if (comparator == "==") { - return value == ref; - } else if (comparator == "!=") { - return value != ref; + if (isWidgetType(refWidget)) { + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); + } else if (isWidgetType(refWidget)) { + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); + } else if (isWidgetType(refWidget)) { + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); + } else if (isWidgetType(refWidget)) { + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); + } else if (isWidgetType(refWidget)) { + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } - - return false; } -template -bool compareNumbers(T value, T ref, const QString& comparator) -{ - // This is for ints and floats. We can do inequality comparisons. - if (comparator == "==") { - return value == ref; - } else if (comparator == "!=") { - return value != ref; - } else if (comparator == ">") { - return value > ref; - } else if (comparator == "<") { - return value < ref; - } else if (comparator == ">=") { - return value >= ref; - } else if (comparator == "<=") { - return value <= ref; - } +} // end anonymous namespace - return false; +namespace tomviz { +namespace pipeline { + +ParameterInterfaceBuilder::ParameterInterfaceBuilder(QObject* parentObject) + : QObject(parentObject) +{} + +void ParameterInterfaceBuilder::setJSONDescription(const QString& json) +{ + setJSONDescription(QJsonDocument::fromJson(json.toLatin1())); } -template -bool compare(const T* widget, const QVariant& compareValue, - const QString& comparator) +void ParameterInterfaceBuilder::setJSONDescription(const QJsonDocument& doc) { - auto value = widgetValue(widget); - if constexpr (std::is_same_v || std::is_same_v) { - QString ref = compareValue.toString(); - if (ref.startsWith("'") || ref.startsWith("\"")) { - // Remove the first and last characters - ref = ref.mid(1, ref.length() - 2); - } - return compareGeneric(value, ref, comparator); - } else if constexpr (std::is_same_v) { - return compareGeneric(value, compareValue.toBool(), comparator); - } else if constexpr (std::is_same_v) { - return compareNumbers(value, compareValue.toInt(), comparator); - } else if constexpr (std::is_same_v) { - return compareNumbers(value, compareValue.toDouble(), comparator); + if (!doc.isObject()) { + qCritical() << "Failed to parse operator JSON"; + m_json = QJsonDocument(); + } else { + m_json = doc; } +} - return false; +void ParameterInterfaceBuilder::setParameterValues( + const QMap& values) +{ + m_parameterValues = values; } -// Represents a single condition clause like "algorithm == 'mlem'" -struct EnableCondition +void ParameterInterfaceBuilder::setPortScalars( + const QList& ports) { - QWidget* refWidget = nullptr; - QString comparator; - QVariant compareValue; -}; + m_portScalars = ports; +} -// Evaluate a single condition by delegating to the typed compare() function -static bool evaluateCondition(const EnableCondition& cond) +QWidget* ParameterInterfaceBuilder::buildWidget(QWidget* parent) const { - auto* w = cond.refWidget; - if (isWidgetType(w)) { - return compare(qobject_cast(w), cond.compareValue, cond.comparator); - } else if (isWidgetType(w)) { - return compare(qobject_cast(w), cond.compareValue, cond.comparator); - } else if (isWidgetType(w)) { - return compare(qobject_cast(w), cond.compareValue, cond.comparator); - } else if (isWidgetType(w)) { - return compare(qobject_cast(w), cond.compareValue, cond.comparator); - } else if (isWidgetType(w)) { - return compare(qobject_cast(w), cond.compareValue, cond.comparator); + auto* widget = new QWidget(parent); + widget->setProperty("isRootInterfaceWidget", true); + + auto* verticalLayout = new QVBoxLayout; + verticalLayout->setContentsMargins(0, 0, 0, 0); + widget->setLayout(verticalLayout); + + auto* gridContainer = new QWidget; + verticalLayout->addWidget(gridContainer); + verticalLayout->addStretch(); + + auto* layout = new QGridLayout; + gridContainer->setLayout(layout); + + if (!m_json.isObject()) { + return widget; } - return false; + QJsonObject root = m_json.object(); + + // Description label + QJsonValueRef descriptionValue = root["description"]; + if (!descriptionValue.isUndefined()) { + auto* descLabel = new QLabel(descriptionValue.toString()); + descLabel->setWordWrap(true); + descLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); + verticalLayout->insertWidget(0, descLabel); + } + + // Parameters + QJsonValueRef parametersNode = root["parameters"]; + if (parametersNode.isUndefined()) { + return widget; + } + auto parameters = parametersNode.toArray(); + + // Use a non-const copy of this for the mutable methods + auto* self = + const_cast(this); + self->buildParameterInterface(layout, parameters); + + return widget; } -// Evaluate a compound expression: list of condition groups joined by "or", -// where each group is a list of conditions joined by "and". -// Result = (g0[0] && g0[1] && ...) || (g1[0] && g1[1] && ...) || ... -static bool evaluateCompound( - const QList>& orGroups) +void ParameterInterfaceBuilder::buildParameterInterface( + QGridLayout* layout, QJsonArray& parameters) const { - for (auto& andGroup : orGroups) { - bool groupResult = true; - for (auto& cond : andGroup) { - if (!evaluateCondition(cond)) { - groupResult = false; - break; + QJsonObject::size_type numParameters = parameters.size(); + for (QJsonObject::size_type i = 0; i < numParameters; ++i) { + QJsonValueRef parameterNode = parameters[i]; + QJsonObject parameterObject = parameterNode.toObject(); + + QJsonValueRef typeValue = parameterObject["type"]; + if (typeValue.isUndefined()) { + qWarning() << "Parameter has no type entry"; + continue; + } + + QString typeString = typeValue.toString(); + + // Override default with stored parameter value + QJsonValueRef nameValue = parameterObject["name"]; + if (!nameValue.isUndefined()) { + QString parameterName = nameValue.toString(); + if (m_parameterValues.contains(parameterName)) { + QVariant parameterValue = m_parameterValues[parameterName]; + parameterObject["default"] = + QJsonValue::fromVariant(parameterValue); } } - if (groupResult) { - return true; + + if (typeString == "bool") { + addBoolWidget(layout, i + 1, parameterObject); + } else if (typeString == "int") { + addNumericWidget(layout, i + 1, parameterObject); + } else if (typeString == "double") { + addNumericWidget(layout, i + 1, parameterObject); + } else if (typeString == "enumeration") { + addEnumerationWidget(layout, i + 1, parameterObject); + } else if (typeString == "xyz_header") { + addXYZHeaderWidget(layout, i + 1, parameterObject); + } else if (PATH_TYPES.contains(typeString)) { + addPathWidget(layout, i + 1, parameterObject); + } else if (typeString == "string") { + addStringWidget(layout, i + 1, parameterObject); + } else if (typeString == "select_scalars") { + addSelectScalarsWidget(layout, i + 1, parameterObject, m_portScalars); } + // Note: "dataset" type is not supported in the pipeline lib + // (it depends on DataSource/ModuleManager). } - return false; + + setupEnableAndVisibleStates(layout->parentWidget(), parameters); } -static void connectWidgetChanged(QWidget* refWidget, QWidget* target, - std::function func) +void ParameterInterfaceBuilder::setupEnableAndVisibleStates( + const QObject* parent, QJsonArray& parameters) const { - if (isWidgetType(refWidget)) { - target->connect(qobject_cast(refWidget), - changedSignal(), target, func); - } else if (isWidgetType(refWidget)) { - target->connect(qobject_cast(refWidget), - changedSignal(), target, func); - } else if (isWidgetType(refWidget)) { - target->connect(qobject_cast(refWidget), - changedSignal(), target, func); - } else if (isWidgetType(refWidget)) { - target->connect(qobject_cast(refWidget), - changedSignal(), target, func); - } else if (isWidgetType(refWidget)) { - target->connect(qobject_cast(refWidget), - changedSignal(), target, func); - } else { - qCritical() << "Unhandled widget type for enable/visible trigger:" - << refWidget->objectName(); - } + setupEnableStates(parent, parameters, true); + setupEnableStates(parent, parameters, false); } -void InterfaceBuilder::setupEnableStates(const QObject* parent, - QJsonArray& parameters, - bool visible) const +void ParameterInterfaceBuilder::setupEnableStates( + const QObject* parent, QJsonArray& parameters, bool visible) const { - static const QStringList validComparators = { - "==", "!=", ">", ">=", "<", "<=" - }; + static const QStringList validComparators = { "==", "!=", ">", + ">=", "<", "<=" }; QJsonObject::size_type numParameters = parameters.size(); for (QJsonObject::size_type i = 0; i < numParameters; ++i) { @@ -1319,34 +1083,26 @@ void InterfaceBuilder::setupEnableStates(const QObject* parent, QString widgetName = parameterObject["name"].toString(""); if (widgetName.isEmpty()) { - qCritical() << text << "parameters must have a name. Ignoring..."; continue; } auto* widget = parent->findChild(widgetName); if (!widget) { - qCritical() << "Failed to find widget with name:" << widgetName; continue; } - // Split on " or " first, then each piece on " and ". - // Precedence: "and" binds tighter than "or". - auto orParts = enableIfValue.simplified().split(" or ", - Qt::KeepEmptyParts, - Qt::CaseInsensitive); + auto orParts = enableIfValue.simplified().split( + " or ", Qt::KeepEmptyParts, Qt::CaseInsensitive); QList> orGroups; bool parseError = false; for (auto& orPart : orParts) { - auto andParts = orPart.simplified().split(" and ", - Qt::KeepEmptyParts, - Qt::CaseInsensitive); + auto andParts = orPart.simplified().split( + " and ", Qt::KeepEmptyParts, Qt::CaseInsensitive); QList andGroup; for (auto& clause : andParts) { auto tokens = clause.simplified().split(" "); if (tokens.size() != 3) { - qCritical() << "Invalid" << text << "clause:" << clause - << "in expression:" << enableIfValue; parseError = true; break; } @@ -1357,15 +1113,11 @@ void InterfaceBuilder::setupEnableStates(const QObject* parent, auto* refWidget = parent->findChild(refWidgetName); if (!refWidget) { - qCritical() << "Invalid widget name" << refWidgetName << "in" - << text << "string:" << enableIfValue; parseError = true; break; } if (!validComparators.contains(comparator)) { - qCritical() << "Invalid comparator" << comparator << "in" - << text << "string:" << enableIfValue; parseError = true; break; } @@ -1389,13 +1141,11 @@ void InterfaceBuilder::setupEnableStates(const QObject* parent, const char* property = visible ? "visible" : "enabled"; - // Build the evaluation callback auto evalFunc = [orGroups, widget, property]() { bool result = evaluateCompound(orGroups); setWidgetProperty(widget, property, result); }; - // Connect every referenced widget's changed signal to re-evaluate QSet connectedWidgets; for (auto& andGroup : orGroups) { for (auto& cond : andGroup) { @@ -1412,4 +1162,125 @@ void InterfaceBuilder::setupEnableStates(const QObject* parent, } } +QMap ParameterInterfaceBuilder::parameterValues( + const QWidget* parent) +{ + QMap map; + + // Handle select_scalars containers first, and record the names of + // their internal widgets so the generic loops below skip them. + QSet selectScalarsInternalNames; + for (auto* w : parent->findChildren()) { + if (w->property("type").toString() != "select_scalars") { + continue; + } + QString name = w->objectName(); + auto* selectAllCB = w->findChild(name + "_select_all"); + auto* selectActiveCB = w->findChild(name + "_select_active"); + auto* combo = w->findChild(name + "_combo"); + if (!selectAllCB || !selectActiveCB || !combo) { + continue; + } + selectScalarsInternalNames.insert(selectAllCB->objectName()); + selectScalarsInternalNames.insert(selectActiveCB->objectName()); + selectScalarsInternalNames.insert(combo->objectName()); + + auto* model = qobject_cast(combo->model()); + if (!model) { + continue; + } + + QVariantList selectedScalars; + if (selectActiveCB->isChecked()) { + QString activeScalar = w->property("activeScalar").toString(); + if (!activeScalar.isEmpty()) { + selectedScalars << activeScalar; + } + } else if (selectAllCB->isChecked()) { + for (int i = 0; i < model->rowCount(); ++i) { + selectedScalars << model->item(i)->text(); + } + } else { + for (int i = 0; i < model->rowCount(); ++i) { + if (model->item(i)->checkState() == Qt::Checked) { + selectedScalars << model->item(i)->text(); + } + } + } + map[name] = selectedScalars; + } + + // Checkboxes + QList checkBoxes = parent->findChildren(); + for (auto* cb : checkBoxes) { + if (selectScalarsInternalNames.contains(cb->objectName())) { + continue; + } + map[cb->objectName()] = (cb->checkState() == Qt::Checked); + } + + // QSpinBox + QList spinBoxes = parent->findChildren(); + for (auto* sb : spinBoxes) { + map[sb->objectName()] = sb->value(); + } + + // QDoubleSpinBox + QList doubleSpinBoxes = + parent->findChildren(); + for (auto* dsb : doubleSpinBoxes) { + map[dsb->objectName()] = dsb->value(); + } + + // QComboBox + QList comboBoxes = parent->findChildren(); + for (auto* combo : comboBoxes) { + if (selectScalarsInternalNames.contains(combo->objectName())) { + continue; + } + int currentIndex = combo->currentIndex(); + map[combo->objectName()] = combo->itemData(currentIndex); + } + + // Assemble multi-component properties (name#000, name#001, ...) + QMap::iterator iter = map.begin(); + while (iter != map.end()) { + QString name = iter.key(); + QVariant value = iter.value(); + int poundIndex = name.indexOf("#"); + if (poundIndex >= 0) { + name = name.left(poundIndex); + + QList valueList; + auto findIter = map.find(name); + if (findIter != map.end()) { + valueList = map[name].toList(); + } + + valueList.append(value); + map[name] = valueList; + + iter = map.erase(iter); + } else { + ++iter; + } + } + + // QLineEdits (file, save_file, directory, string types) + QList lineEdits = parent->findChildren(); + for (auto* lineEdit : lineEdits) { + QVariant type = lineEdit->property("type"); + bool canConvert = QMetaType::canConvert( + type.metaType(), QMetaType(QMetaType::QString)); + if (canConvert && + (PATH_TYPES.contains(type.toString()) || + type.toString() == "string")) { + map[lineEdit->objectName()] = lineEdit->text(); + } + } + + return map; +} + +} // namespace pipeline } // namespace tomviz diff --git a/tomviz/pipeline/ParameterInterfaceBuilder.h b/tomviz/pipeline/ParameterInterfaceBuilder.h new file mode 100644 index 000000000..138c2c2cd --- /dev/null +++ b/tomviz/pipeline/ParameterInterfaceBuilder.h @@ -0,0 +1,78 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineParameterInterfaceBuilder_h +#define tomvizPipelineParameterInterfaceBuilder_h + +#include +#include +#include +#include +#include +#include +#include +#include + +class QGridLayout; + +namespace tomviz { +namespace pipeline { + +/// Scalar metadata for one input port — feeds the "select_scalars" +/// parameter widget. The widget picks the matching entry by JSON's +/// "input" field, falling back to the first entry. +struct PortScalars +{ + QString portName; + QStringList scalarNames; + QString activeScalar; +}; + +/// Builds a Qt widget from a JSON parameter description. +/// This is a standalone version of tomviz::InterfaceBuilder that does not +/// depend on DataSource, ActiveObjects, or ModuleManager. +class ParameterInterfaceBuilder : public QObject +{ + Q_OBJECT + +public: + ParameterInterfaceBuilder(QObject* parent = nullptr); + + /// Set the JSON description (operator JSON with "parameters" array). + void setJSONDescription(const QString& json); + void setJSONDescription(const QJsonDocument& doc); + + /// Set parameter values to override defaults. + void setParameterValues(const QMap& values); + + /// Set per-input-port scalar metadata for "select_scalars" parameters. + /// Each parameter's JSON may include "input": to pick its + /// source; if missing or unmatched, the first entry is used. Order + /// matters: pass the node's input ports in declaration order. + void setPortScalars(const QList& ports); + + /// Build the widget tree. Caller owns the returned widget. + QWidget* buildWidget(QWidget* parent) const; + + /// Extract current parameter values from a widget tree built by this class. + static QMap parameterValues(const QWidget* widget); + +private: + Q_DISABLE_COPY(ParameterInterfaceBuilder) + + void buildParameterInterface(QGridLayout* layout, + QJsonArray& parameters) const; + void setupEnableAndVisibleStates(const QObject* parent, + QJsonArray& parameters) const; + void setupEnableStates(const QObject* parent, QJsonArray& parameters, + bool visible) const; + + QJsonDocument m_json; + QMap m_parameterValues; + QList m_portScalars; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/PassthroughOutputPort.cxx b/tomviz/pipeline/PassthroughOutputPort.cxx new file mode 100644 index 000000000..5fc919eb0 --- /dev/null +++ b/tomviz/pipeline/PassthroughOutputPort.cxx @@ -0,0 +1,102 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PassthroughOutputPort.h" + +#include "InputPort.h" +#include "SinkNode.h" + +namespace tomviz { +namespace pipeline { + +PassthroughOutputPort::PassthroughOutputPort(const QString& name, + PortType type, + QObject* parent) + : OutputPort(name, type, parent) +{} + +void PassthroughOutputPort::setSource(OutputPort* source) +{ + if (m_source == source) { + return; + } + + // Disconnect old source signals. + if (m_source) { + disconnect(m_source, nullptr, this, nullptr); + } + + m_source = source; + + // Forward all relevant signals from the new source. + if (m_source) { + connect(m_source, &OutputPort::dataChanged, + this, &OutputPort::dataChanged); + connect(m_source, &OutputPort::intermediateDataApplied, + this, &OutputPort::intermediateDataApplied); + connect(m_source, &OutputPort::metadataChanged, + this, &OutputPort::metadataChanged); + connect(m_source, &OutputPort::staleChanged, + this, &OutputPort::staleChanged); + connect(m_source, &OutputPort::effectiveTypeChanged, + this, &OutputPort::effectiveTypeChanged); + connect(m_source, &OutputPort::dataLocationChanged, + this, &OutputPort::dataLocationChanged); + } +} + +OutputPort* PassthroughOutputPort::source() const +{ + return m_source; +} + +PortData PassthroughOutputPort::data() const +{ + if (m_source) { + return m_source->data(); + } + return PortData(); +} + +std::shared_ptr PassthroughOutputPort::materialize() +{ + if (m_source) { + return m_source->materialize(); + } + return nullptr; +} + +bool PassthroughOutputPort::hasData() const +{ + if (m_source) { + return m_source->hasData(); + } + return false; +} + +bool PassthroughOutputPort::isStale() const +{ + if (m_source) { + return m_source->isStale(); + } + return true; +} + +DataLocation PassthroughOutputPort::dataLocation() const +{ + if (m_source) { + return m_source->dataLocation(); + } + return DataLocation::None; +} + +bool PassthroughOutputPort::canAcceptLink(InputPort* to) const +{ + if (!to || !to->node()) { + return false; + } + return qobject_cast(to->node()) != nullptr; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/PassthroughOutputPort.h b/tomviz/pipeline/PassthroughOutputPort.h new file mode 100644 index 000000000..d64ef5b10 --- /dev/null +++ b/tomviz/pipeline/PassthroughOutputPort.h @@ -0,0 +1,53 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelinePassthroughOutputPort_h +#define tomvizPipelinePassthroughOutputPort_h + +#include "OutputPort.h" + +namespace tomviz { +namespace pipeline { + +class SinkNode; + +/// An OutputPort proxy that delegates data access and signal forwarding +/// to a source OutputPort. Used by SinkGroupNode so that grouped sinks +/// see the upstream data without any copying. +class PassthroughOutputPort : public OutputPort +{ + Q_OBJECT + +public: + PassthroughOutputPort(const QString& name, PortType type, + QObject* parent = nullptr); + + /// Set (or change) the upstream port this proxy delegates to. + /// Disconnects signals from the previous source and connects the new one. + /// Pass nullptr to disconnect. + void setSource(OutputPort* source); + OutputPort* source() const; + + PortData data() const override; + std::shared_ptr materialize() override; + bool hasData() const override; + bool isStale() const override; + DataLocation dataLocation() const override; + + /// A passthrough owns no data of its own — it forwards from m_source. + /// Always report non-persistent so state-file writers skip it (no + /// duplicated voxel embeds) and stay immune to old state files whose + /// persistent flag would otherwise flip the base m_persistent. + bool isPersistent() const override { return false; } + + /// Only SinkNodes may connect to a passthrough port. + bool canAcceptLink(InputPort* to) const override; + +private: + OutputPort* m_source = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/PersistenceMode.h b/tomviz/pipeline/PersistenceMode.h new file mode 100644 index 000000000..fa43b4bc8 --- /dev/null +++ b/tomviz/pipeline/PersistenceMode.h @@ -0,0 +1,47 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelinePersistenceMode_h +#define tomvizPipelinePersistenceMode_h + +#include + +namespace tomviz { +namespace pipeline { + +/// The medium that backs a persistent output port. +/// +/// Orthogonal to `isPersistent()` itself: a port is either persistent or +/// transient, and — if persistent — picks which medium holds the payload +/// across consumer-release cycles. +enum class PersistenceMode +{ + InMemory, ///< Port keeps its strong ref forever (legacy behavior). + OnDisk ///< Strong ref is released when no consumer holds; the + ///< payload is serialized to a temporary file and lazily + ///< reloaded on the next access. +}; + +inline QString persistenceModeToString(PersistenceMode mode) +{ + switch (mode) { + case PersistenceMode::InMemory: + return QStringLiteral("memory"); + case PersistenceMode::OnDisk: + return QStringLiteral("disk"); + } + return QStringLiteral("memory"); +} + +inline PersistenceMode persistenceModeFromString(const QString& s) +{ + if (s == QLatin1String("disk")) { + return PersistenceMode::OnDisk; + } + return PersistenceMode::InMemory; +} + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/Pipeline.cxx b/tomviz/pipeline/Pipeline.cxx new file mode 100644 index 000000000..60cc8d71e --- /dev/null +++ b/tomviz/pipeline/Pipeline.cxx @@ -0,0 +1,935 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "Pipeline.h" + +#include "DefaultExecutor.h" +#include "ExecutionFuture.h" +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "OutputPort.h" +#include "PipelineExecutor.h" +#include "SinkGroupNode.h" +#include "SinkNode.h" +#include "TransformNode.h" + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +Pipeline::Pipeline(QObject* parent) : QObject(parent) {} + +Pipeline::~Pipeline() +{ + // Delete links before nodes to avoid dangling pointer access in Link::~Link + qDeleteAll(m_links); + m_links.clear(); +} + +void Pipeline::addNode(Node* node) +{ + if (!node || m_nodes.contains(node)) { + return; + } + node->setParent(this); + m_nodes.append(node); + + // Auto re-execute when transform parameters change + if (auto* transform = dynamic_cast(node)) { + connect(transform, &TransformNode::parametersApplied, + this, [this]() { execute(); }); + } + + emit nodeAdded(node); +} + +void Pipeline::removeNode(Node* node) +{ + if (!node || !m_nodes.contains(node)) { + return; + } + + // If this is a group node, remove all member sinks first. + if (auto* group = qobject_cast(node)) { + const auto members = group->sinks(); + for (auto* sink : members) { + removeNode(sink); + } + } + + // Remove all links connected to this node + QList linksToRemove; + for (auto* link : m_links) { + if (link->from()->node() == node || link->to()->node() == node) { + linksToRemove.append(link); + } + } + for (auto* link : linksToRemove) { + removeLink(link); + } + + m_nodes.removeOne(node); + m_nodeIds.remove(node); + emit nodeRemoved(node); + delete node; +} + +QList Pipeline::nodes() const +{ + return m_nodes; +} + +void Pipeline::clear() +{ + // Stop any in-flight execution and wait for the worker to leave the + // current node before deleting anything. Otherwise the worker thread + // can still be executing a node we delete below and emit from / touch + // the freed Node — a SIGSEGV. The close-while-running path + // (MainWindow::closeEvent -> clear()) is the one that hits this. + if (m_executor && m_executor->isRunning()) { + m_executor->cancelAndWait(); + } + + // Drop links first so their removal signals fire while both + // endpoint nodes are still alive. removeNode() itself also scrubs + // any remaining links connected to the node and deletes the node; + // we don't touch `node` afterward. + const auto links = m_links; + for (auto* link : links) { + removeLink(link); + } + const auto nodes = m_nodes; + for (auto* node : nodes) { + removeNode(node); + } +} + +QList Pipeline::roots() const +{ + QList result; + for (auto* node : m_nodes) { + if (node->inputPorts().isEmpty()) { + result.append(node); + } else { + // Check if all inputs are unconnected + bool hasConnection = false; + for (auto* input : node->inputPorts()) { + if (input->link()) { + hasConnection = true; + break; + } + } + if (!hasConnection) { + result.append(node); + } + } + } + return result; +} + +int Pipeline::creationIndex(Node* node) const +{ + return m_nodes.indexOf(node); +} + +int Pipeline::nodeId(Node* node) +{ + if (!node || !m_nodes.contains(node)) { + return -1; + } + auto it = m_nodeIds.find(node); + if (it != m_nodeIds.end()) { + return it.value(); + } + int id = m_nextNodeId++; + m_nodeIds.insert(node, id); + return id; +} + +Node* Pipeline::nodeById(int id) const +{ + for (auto it = m_nodeIds.constBegin(); it != m_nodeIds.constEnd(); ++it) { + if (it.value() == id) { + return it.key(); + } + } + return nullptr; +} + +void Pipeline::setNodeId(Node* node, int id) +{ + if (!node || !m_nodes.contains(node)) { + return; + } + m_nodeIds[node] = id; +} + +int Pipeline::nextNodeId() const +{ + return m_nextNodeId; +} + +void Pipeline::setNextNodeId(int id) +{ + m_nextNodeId = id; +} + +Link* Pipeline::createLink(OutputPort* from, InputPort* to) +{ + if (!from || !to) { + return nullptr; + } + + if (!to->canConnectTo(from)) { + return nullptr; + } + + if (!from->canAcceptLink(to)) { + return nullptr; + } + + if (wouldCreateCycle(from, to)) { + return nullptr; + } + + // Remove existing link on this input port + if (to->link()) { + removeLink(to->link()); + } + + auto* link = new Link(from, to, this); + m_links.append(link); + emit linkCreated(link); + + // Propagate effective types and recheck link validity downstream + propagateEffectiveTypes(to->node()); + + // Mark downstream stale so it re-runs against the new input. Skip if the + // node is still New (never configured/run) so the link-completion handler + // in MainWindow can pop the edit dialog before first execution. + Node* downstream = to->node(); + if (downstream && downstream->state() != NodeState::New) { + downstream->markStale(); + } + + return link; +} + +void Pipeline::removeLink(Link* link) +{ + if (!link || !m_links.contains(link)) { + return; + } + + // Remember the downstream node before deleting the link + Node* downstream = link->to() ? link->to()->node() : nullptr; + + m_links.removeOne(link); + emit linkRemoved(link); + delete link; + + // Propagate effective types from the formerly-downstream node + if (downstream) { + propagateEffectiveTypes(downstream); + } +} + +QList Pipeline::links() const +{ + return m_links; +} + +bool Pipeline::wouldCreateCycle(OutputPort* from, InputPort* to) const +{ + if (!from || !to) { + return false; + } + + Node* sourceNode = from->node(); + Node* targetNode = to->node(); + + if (!sourceNode || !targetNode) { + return false; + } + + if (sourceNode == targetNode) { + return true; + } + + // DFS from targetNode downstream to see if we can reach sourceNode. + // If so, adding sourceNode -> targetNode would create a cycle. + QSet visited; + QStack stack; + stack.push(targetNode); + + while (!stack.isEmpty()) { + Node* current = stack.pop(); + if (current == sourceNode) { + return true; + } + if (visited.contains(current)) { + continue; + } + visited.insert(current); + + for (auto* downstream : current->downstreamNodes()) { + if (!visited.contains(downstream)) { + stack.push(downstream); + } + } + } + + return false; +} + +bool Pipeline::isValid() const +{ + // Check all links are valid + for (auto* link : m_links) { + if (!link->isValid()) { + return false; + } + } + + // Check no cycles (try topological sort) + // Use Kahn's algorithm - if we can sort all nodes, no cycles. + // Count in-degree by unique upstream node, not per link. + QMap inDegree; + for (auto* node : m_nodes) { + inDegree[node] = 0; + } + for (auto* node : m_nodes) { + for (auto* upstream : node->upstreamNodes()) { + if (m_nodes.contains(upstream)) { + inDegree[node]++; + } + } + } + + QQueue queue; + for (auto it = inDegree.constBegin(); it != inDegree.constEnd(); ++it) { + if (it.value() == 0) { + queue.enqueue(it.key()); + } + } + + int count = 0; + while (!queue.isEmpty()) { + Node* node = queue.dequeue(); + count++; + for (auto* downstream : node->downstreamNodes()) { + inDegree[downstream]--; + if (inDegree[downstream] == 0) { + queue.enqueue(downstream); + } + } + } + + return count == m_nodes.size(); +} + +void Pipeline::depthFirstTraversal(std::function visitor, + const QList& startNodes) +{ + QList starts = startNodes.isEmpty() ? roots() : startNodes; + QSet visited; + QStack stack; + + for (auto* node : starts) { + stack.push(node); + } + + while (!stack.isEmpty()) { + Node* current = stack.pop(); + if (visited.contains(current)) { + continue; + } + visited.insert(current); + visitor(current); + + for (auto* downstream : current->downstreamNodes()) { + if (!visited.contains(downstream)) { + stack.push(downstream); + } + } + } +} + +QList Pipeline::topologicalSort(const QList& startNodes, + SortOrder order) +{ + // Determine the subset of nodes to sort + QSet subset; + if (startNodes.isEmpty()) { + for (auto* node : m_nodes) { + subset.insert(node); + } + } else { + // Collect all nodes reachable from startNodes + QStack stack; + for (auto* node : startNodes) { + stack.push(node); + } + while (!stack.isEmpty()) { + Node* current = stack.pop(); + if (subset.contains(current)) { + continue; + } + subset.insert(current); + for (auto* downstream : current->downstreamNodes()) { + if (!subset.contains(downstream)) { + stack.push(downstream); + } + } + } + } + + if (order == SortOrder::DepthFirst) { + // DFS reverse post-order: keeps chains together. + // Tiebreak among siblings/roots by creation order. + QList result; + QSet visited; + + // Build creation-index map for the subset + QMap indexMap; + for (int i = 0; i < m_nodes.size(); ++i) { + if (subset.contains(m_nodes[i])) { + indexMap[m_nodes[i]] = i; + } + } + + auto byCreation = [&indexMap](Node* a, Node* b) { + return indexMap.value(a, INT_MAX) < indexMap.value(b, INT_MAX); + }; + + // Collect roots within the subset, sorted by creation order + QList subsetRoots; + for (auto* node : m_nodes) { + if (!subset.contains(node)) { + continue; + } + bool isRoot = true; + for (auto* input : node->inputPorts()) { + if (input->link() && subset.contains(input->link()->from()->node())) { + isRoot = false; + break; + } + } + if (isRoot) { + subsetRoots.append(node); + } + } + // subsetRoots already in creation order (iterated m_nodes) + + // Iterative DFS with two-pass approach for post-order + struct Frame + { + Node* node; + bool childrenPushed; + }; + QStack dfsStack; + // Push in forward creation order so that later-created roots sit on top + // of the stack, get processed (and prepended) first, and end up after + // earlier-created roots in the final result. + for (int i = 0; i < subsetRoots.size(); ++i) { + dfsStack.push({ subsetRoots[i], false }); + } + + while (!dfsStack.isEmpty()) { + auto& frame = dfsStack.top(); + if (visited.contains(frame.node)) { + dfsStack.pop(); + continue; + } + if (frame.childrenPushed) { + // Post-order: all children processed, emit this node + visited.insert(frame.node); + result.prepend(frame.node); + dfsStack.pop(); + } else { + frame.childrenPushed = true; + // Sort downstream by creation order, then push in forward order so + // that later-created children sit on top, get prepended first, and + // end up after earlier-created siblings in the final result. + auto downstream = frame.node->downstreamNodes(); + std::sort(downstream.begin(), downstream.end(), byCreation); + for (int i = 0; i < downstream.size(); ++i) { + if (subset.contains(downstream[i]) && + !visited.contains(downstream[i])) { + dfsStack.push({ downstream[i], false }); + } + } + } + } + + return result; + } + + // Kahn's algorithm on the subset. + // Count in-degree by unique upstream node (not per link) so that multiple + // links between the same pair of nodes count as a single dependency edge. + QMap inDegree; + for (auto* node : subset) { + inDegree[node] = 0; + } + for (auto* node : subset) { + for (auto* upstream : node->upstreamNodes()) { + if (subset.contains(upstream)) { + inDegree[node]++; + } + } + } + + if (order == SortOrder::Stable) { + // Stable Kahn's: among nodes with in-degree 0, pick the one with the + // smallest creation index (position in m_nodes). + QMap indexMap; + for (int i = 0; i < m_nodes.size(); ++i) { + if (subset.contains(m_nodes[i])) { + indexMap[m_nodes[i]] = i; + } + } + + // Collect initial ready set + QList ready; + for (auto it = inDegree.constBegin(); it != inDegree.constEnd(); ++it) { + if (it.value() == 0) { + ready.append(it.key()); + } + } + + QList result; + while (!ready.isEmpty()) { + // Pick the node with the smallest creation index + int bestIdx = 0; + int bestCreation = indexMap.value(ready[0], INT_MAX); + for (int i = 1; i < ready.size(); ++i) { + int ci = indexMap.value(ready[i], INT_MAX); + if (ci < bestCreation) { + bestCreation = ci; + bestIdx = i; + } + } + Node* node = ready.takeAt(bestIdx); + result.append(node); + + for (auto* downstream : node->downstreamNodes()) { + if (!subset.contains(downstream)) { + continue; + } + inDegree[downstream]--; + if (inDegree[downstream] == 0) { + ready.append(downstream); + } + } + } + + return result; + } + + // Default Kahn's (original behavior) + QQueue queue; + for (auto it = inDegree.constBegin(); it != inDegree.constEnd(); ++it) { + if (it.value() == 0) { + queue.enqueue(it.key()); + } + } + + QList result; + while (!queue.isEmpty()) { + Node* node = queue.dequeue(); + result.append(node); + for (auto* downstream : node->downstreamNodes()) { + if (!subset.contains(downstream)) { + continue; + } + inDegree[downstream]--; + if (inDegree[downstream] == 0) { + queue.enqueue(downstream); + } + } + } + + return result; +} + +void Pipeline::setExecutor(PipelineExecutor* executor) +{ + m_executor = executor; + if (m_executor) { + m_executor->setParent(this); + } +} + +PipelineExecutor* Pipeline::executor() const +{ + return m_executor; +} + +bool Pipeline::isExecuting() const +{ + return m_executor && m_executor->isRunning(); +} + +bool Pipeline::isPaused() const +{ + return m_paused; +} + +void Pipeline::setPaused(bool paused) +{ + if (m_paused == paused) { + return; + } + m_paused = paused; + emit pausedChanged(m_paused); + if (!m_paused) { + // Resume: execute if any node is stale. + for (auto* node : m_nodes) { + if (node->state() == NodeState::Stale || + node->state() == NodeState::New) { + execute(); + break; + } + } + } +} + +void Pipeline::cancelExecution() +{ + if (m_executor && m_executor->isRunning()) { + m_executor->cancel(); + } +} + +ExecutionFuture* Pipeline::execute() +{ + // Compute the plan by treating each leaf (no downstream consumers) + // as a target and merging the per-target execution orders. This + // routes through executionOrder()'s logic, so a Current node whose + // required output has been evicted (transient data dropped after the + // last run) is re-included only when a downstream consumer in the + // plan actually needs that output. Without this, the executor's + // simple "skip Current" filter would let stale-looking-but-evicted + // intermediates feed an empty payload into downstream sinks. + QList order; + QSet inOrder; + for (auto* node : m_nodes) { + if (!node->downstreamNodes().isEmpty()) { + continue; + } + auto sub = executionOrder(node); + for (auto* n : sub) { + if (!inOrder.contains(n)) { + inOrder.insert(n); + order.append(n); + } + } + } + // executionOrder() returns each per-leaf plan topo-sorted internally, + // but merging two such lists may leave the result out of order when + // a node appears in both. Re-sort the union to guarantee global + // topological order. + if (!order.isEmpty()) { + QMap inDegree; + for (auto* n : order) { + inDegree[n] = 0; + } + for (auto* n : order) { + for (auto* up : n->upstreamNodes()) { + if (inOrder.contains(up)) { + inDegree[n]++; + } + } + } + QQueue ready; + for (auto it = inDegree.constBegin(); it != inDegree.constEnd(); ++it) { + if (it.value() == 0) { + ready.enqueue(it.key()); + } + } + QList sorted; + while (!ready.isEmpty()) { + Node* n = ready.dequeue(); + sorted.append(n); + for (auto* d : n->downstreamNodes()) { + if (!inOrder.contains(d)) { + continue; + } + if (--inDegree[d] == 0) { + ready.enqueue(d); + } + } + } + order = sorted; + } + + return runPlan(order); +} + +ExecutionFuture* Pipeline::execute(Node* target) +{ + return runPlan(executionOrder(target)); +} + +void Pipeline::executeWhenIdle() +{ + if (!isExecuting()) { + execute(); + return; + } + if (!m_idleExecuteQueued) { + m_idleExecuteQueued = true; + connect(this, &Pipeline::executionFinished, this, + [this]() { + m_idleExecuteQueued = false; + execute(); + }, + static_cast(Qt::SingleShotConnection)); + } +} + +ExecutionFuture* Pipeline::executeUpstreamOf(Node* target) +{ + return runPlan(upstreamExecutionOrder(target)); +} + +ExecutionFuture* Pipeline::runPlan(QList order) +{ + if (m_paused) { + auto* future = new ExecutionFuture(this); + future->setFinished(false); + return future; + } + + if (!m_executor) { + auto* defaultExec = new DefaultExecutor(this); + setExecutor(defaultExec); + } + + if (order.isEmpty()) { + auto* future = new ExecutionFuture(this); + future->setFinished(true); + // Emit executionFinished even for empty plans so callers waiting on + // the signal (e.g. the properties panel's not-ready warning) refresh + // their state. No executionStarted is paired with it — nothing + // actually ran, so toggling mutation-locked UI on/off would just + // flicker. Consumers must therefore tolerate a Finished without a + // matching prior Started. + emit executionFinished(); + return future; + } + + if (m_executor->isRunning()) { + m_executor->cancel(); + } + + auto* future = new ExecutionFuture(this); + + connect(m_executor, &PipelineExecutor::executionComplete, future, + [this, future](bool success) { + future->setFinished(success); + emit executionFinished(); + }, + static_cast(Qt::AutoConnection | + Qt::SingleShotConnection)); + + m_executor->execute(order, this); + emit executionStarted(); + + return future; +} + +namespace { + +// Topo-sort + dependency walk shared by executionOrder() and +// upstreamExecutionOrder(). The caller seeds @a stack with the nodes +// that should be considered the "leaves" of the plan; this walks each +// node's input ports backward, pulling in any upstream node whose +// feeding output is missing or whose state isn't Current. The result +// is the topo-sorted union, excluding any node in @a exclude. +QList buildExecutionPlan(QStack stack, + const QSet& exclude = {}) +{ + QSet needed; + + while (!stack.isEmpty()) { + Node* current = stack.pop(); + if (needed.contains(current) || exclude.contains(current)) { + continue; + } + needed.insert(current); + + // Step to each upstream producer by walking the specific link feeding + // this input. A Current upstream is re-included only when the output + // we actually depend on has been evicted — other evicted outputs on + // the same node don't matter. + for (auto* input : current->inputPorts()) { + auto* link = input->link(); + if (!link || !link->from()) { + continue; + } + auto* upstreamOutput = link->from(); + auto* upstream = upstreamOutput->node(); + if (!upstream || needed.contains(upstream) || + exclude.contains(upstream)) { + continue; + } + bool upstreamNeedsRerun = + upstream->state() != NodeState::Current || + !upstreamOutput->hasData(); + if (upstreamNeedsRerun) { + stack.push(upstream); + } + } + } + + if (needed.isEmpty()) { + return {}; + } + + // Topological sort of just the needed nodes. + // Count in-degree by unique upstream node, not per link. + QMap inDegree; + for (auto* node : needed) { + inDegree[node] = 0; + } + for (auto* node : needed) { + for (auto* upstream : node->upstreamNodes()) { + if (needed.contains(upstream)) { + inDegree[node]++; + } + } + } + + QQueue queue; + for (auto it = inDegree.constBegin(); it != inDegree.constEnd(); ++it) { + if (it.value() == 0) { + queue.enqueue(it.key()); + } + } + + QList result; + while (!queue.isEmpty()) { + Node* node = queue.dequeue(); + result.append(node); + for (auto* downstream : node->downstreamNodes()) { + if (!needed.contains(downstream)) { + continue; + } + inDegree[downstream]--; + if (inDegree[downstream] == 0) { + queue.enqueue(downstream); + } + } + } + + return result; +} + +} // namespace + +QList Pipeline::executionOrder(Node* target) +{ + if (!target) { + return {}; + } + + // Whether target itself needs to (re-)run. Current targets with all + // outputs still materialized have nothing to do; Current targets + // whose outputs have been evicted (transient data dropped after the + // last execution) must re-run to repopulate. + auto needsRerun = [](Node* node) { + if (node->state() != NodeState::Current) { + return true; + } + for (auto* output : node->outputPorts()) { + if (!output->hasData()) { + return true; + } + } + return false; + }; + + QStack stack; + if (needsRerun(target)) { + stack.push(target); + } + return buildExecutionPlan(std::move(stack)); +} + +QList Pipeline::upstreamExecutionOrder(Node* target) +{ + if (!target) { + return {}; + } + + // Seed with each upstream node feeding a missing/stale input. Same + // per-link gate as executionOrder(): a Current upstream is only + // re-included when the specific output we depend on has been evicted. + // Target itself is excluded from the result even if it would otherwise + // be reached via the walk (it can't be, since we only walk upstream). + QStack stack; + for (auto* input : target->inputPorts()) { + auto* link = input->link(); + if (!link || !link->from()) { + continue; + } + auto* upstreamOutput = link->from(); + auto* upstream = upstreamOutput->node(); + if (!upstream) { + continue; + } + bool upstreamNeedsRerun = + upstream->state() != NodeState::Current || + !upstreamOutput->hasData(); + if (upstreamNeedsRerun) { + stack.push(upstream); + } + } + return buildExecutionPlan(std::move(stack), { target }); +} + +void Pipeline::propagateEffectiveTypes(Node* startNode) +{ + if (!startNode) { + return; + } + + // Forward BFS from startNode through the DAG + QQueue queue; + QSet visited; + queue.enqueue(startNode); + + while (!queue.isEmpty()) { + Node* node = queue.dequeue(); + if (visited.contains(node)) { + continue; + } + visited.insert(node); + + node->recomputeEffectiveTypes(); + + // Recheck validity of all links from this node's outputs + for (auto* output : node->outputPorts()) { + for (auto* link : output->links()) { + link->recheck(); + // Continue propagation to downstream nodes + if (link->to() && link->to()->node()) { + Node* downstream = link->to()->node(); + if (!visited.contains(downstream)) { + queue.enqueue(downstream); + } + } + } + } + } +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/Pipeline.h b/tomviz/pipeline/Pipeline.h new file mode 100644 index 000000000..ace61c65c --- /dev/null +++ b/tomviz/pipeline/Pipeline.h @@ -0,0 +1,143 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelinePipeline_h +#define tomvizPipelinePipeline_h + +#include +#include +#include + +#include + +namespace tomviz { +namespace pipeline { + +class ExecutionFuture; +class InputPort; +class Link; +class Node; +class OutputPort; +class PipelineExecutor; + +enum class SortOrder +{ + Default, // Kahn's algorithm, arbitrary tiebreaking (by pointer address) + Stable, // Kahn's algorithm, creation-order tiebreaking + DepthFirst // DFS reverse post-order (keeps chains together) +}; + +class Pipeline : public QObject +{ + Q_OBJECT + +public: + Pipeline(QObject* parent = nullptr); + ~Pipeline() override; + + // Node management + void addNode(Node* node); + void removeNode(Node* node); + QList nodes() const; + QList roots() const; + + /// Remove every node and link. Used by the file-reset action and by + /// state-file loaders that replace the whole graph. + void clear(); + + /// Return the index at which @a node was added (creation order). + /// Returns -1 if the node is not in this pipeline. + int creationIndex(Node* node) const; + + /// File-scoped integer id for @a node, used by the state-file schema + /// as a stable cross-reference within a single saved session. Assigns + /// a fresh id on first access (monotonic, never reused). Returns -1 + /// for null or unowned nodes. + int nodeId(Node* node); + + /// Reverse lookup: the node with the given id, or nullptr. + Node* nodeById(int id) const; + + /// Explicitly set the id for @a node. Used by the state loader to + /// preserve ids persisted in the file so re-saves round-trip + /// identically. + void setNodeId(Node* node, int id); + + /// Next id the allocator will hand out. Written to the state file so + /// re-saves don't collide with ids for nodes added after load. + int nextNodeId() const; + void setNextNodeId(int id); + + // Link management + Link* createLink(OutputPort* from, InputPort* to); + void removeLink(Link* link); + QList links() const; + + // Validation + bool wouldCreateCycle(OutputPort* from, InputPort* to) const; + bool isValid() const; + + // Traversal + void depthFirstTraversal(std::function visitor, + const QList& startNodes = {}); + QList topologicalSort( + const QList& startNodes = {}, + SortOrder order = SortOrder::Default); + + // Execution + void setExecutor(PipelineExecutor* executor); + PipelineExecutor* executor() const; + bool isExecuting() const; + bool isPaused() const; + void setPaused(bool paused); + ExecutionFuture* execute(); + ExecutionFuture* execute(Node* target); + + /// Schedule execution after the current run finishes, without cancelling + /// it. Used when adding sinks to avoid re-running upstream operators. + void executeWhenIdle(); + + /// Run the smallest subset of the pipeline needed to make every input + /// port of @a target carry current data — without re-running target + /// itself. Used by the properties-panel/edit-dialog "Run Pipeline to + /// Generate Inputs" action when a node's custom widget needs live + /// input data to render. + ExecutionFuture* executeUpstreamOf(Node* target); + + void cancelExecution(); + QList executionOrder(Node* target); + + /// Plan-only counterpart to executeUpstreamOf(): topo-sorted node + /// list that excludes @a target. + QList upstreamExecutionOrder(Node* target); + + /// Recompute effective types starting from @a startNode and propagating + /// downstream. Rechecks link validity for all affected links. + void propagateEffectiveTypes(Node* startNode); + +signals: + void nodeAdded(Node* node); + void nodeRemoved(Node* node); + void linkCreated(Link* link); + void linkRemoved(Link* link); + void executionStarted(); + void executionFinished(); + void pausedChanged(bool paused); + void breakpointReached(Node* node); + +private: + ExecutionFuture* runPlan(QList order); + + QList m_nodes; + QList m_links; + QHash m_nodeIds; + int m_nextNodeId = 1; + PipelineExecutor* m_executor = nullptr; + bool m_paused = false; + bool m_idleExecuteQueued = false; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/PipelineControlsWidget.cxx b/tomviz/pipeline/PipelineControlsWidget.cxx new file mode 100644 index 000000000..950a14ba0 --- /dev/null +++ b/tomviz/pipeline/PipelineControlsWidget.cxx @@ -0,0 +1,257 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PipelineControlsWidget.h" + +#include "Pipeline.h" +#include "PipelineSettings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +PipelineControlsWidget::PipelineControlsWidget(QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 2, 4, 2); + + m_spinnerLabel = new QLabel(this); + m_spinnerLabel->setFixedSize(SpinnerSize, SpinnerSize); + m_spinnerLabel->hide(); + layout->addWidget(m_spinnerLabel); + + m_statusLabel = new QLabel(this); + QPalette labelPal = m_statusLabel->palette(); + labelPal.setColor(QPalette::WindowText, palette().color(QPalette::Dark)); + m_statusLabel->setPalette(labelPal); + layout->addWidget(m_statusLabel); + + layout->addStretch(); + + m_button = new QToolButton(this); + m_button->setAutoRaise(true); + m_button->setIconSize(QSize(20, 20)); + connect(m_button, &QToolButton::clicked, this, + &PipelineControlsWidget::onButtonClicked); + layout->addWidget(m_button); + + auto* separator = new QFrame(this); + separator->setFrameShape(QFrame::VLine); + separator->setFrameShadow(QFrame::Sunken); + layout->addWidget(separator); + + m_dimmingButton = new QToolButton(this); + m_dimmingButton->setAutoRaise(true); + m_dimmingButton->setIconSize(QSize(20, 20)); + m_dimmingButton->setIcon(QIcon(":/icons/filter.svg")); + m_dimmingButton->setToolTip( + tr("Enable focus dimming (dim unrelated pipeline elements on selection)")); + connect(m_dimmingButton, &QToolButton::clicked, this, [this]() { + m_dimmingEnabled = !m_dimmingEnabled; + if (m_dimmingEnabled) { + m_dimmingButton->setIcon(QIcon(":/icons/filter_disabled.svg")); + m_dimmingButton->setToolTip( + tr("Disable focus dimming (show all pipeline elements at full opacity)")); + } else { + m_dimmingButton->setIcon(QIcon(":/icons/filter.svg")); + m_dimmingButton->setToolTip( + tr("Enable focus dimming (dim unrelated pipeline elements on selection)")); + } + emit dimmingToggled(m_dimmingEnabled); + }); + layout->addWidget(m_dimmingButton); + + auto* persistenceSeparator = new QFrame(this); + persistenceSeparator->setFrameShape(QFrame::VLine); + persistenceSeparator->setFrameShadow(QFrame::Sunken); + layout->addWidget(persistenceSeparator); + + setupPersistenceButton(layout); + + m_spinnerTimer = new QTimer(this); + m_spinnerTimer->setInterval(50); + connect(m_spinnerTimer, &QTimer::timeout, this, [this]() { + m_spinnerAngle = (m_spinnerAngle + 30) % 360; + // Repaint the spinner label + QPixmap pix(SpinnerSize, SpinnerSize); + pix.fill(Qt::transparent); + QPainter p(&pix); + p.setRenderHint(QPainter::SmoothPixmapTransform); + QPixmap spinner(QStringLiteral(":/pipeline/spinner.png")); + p.translate(SpinnerSize / 2, SpinnerSize / 2); + p.rotate(m_spinnerAngle); + p.drawPixmap(-SpinnerSize / 2, -SpinnerSize / 2, + SpinnerSize, SpinnerSize, spinner); + p.end(); + m_spinnerLabel->setPixmap(pix); + }); + + updateState(); +} + +void PipelineControlsWidget::setPipeline(Pipeline* pipeline) +{ + if (m_pipeline) { + m_pipeline->disconnect(this); + } + m_pipeline = pipeline; + if (m_pipeline) { + connect(m_pipeline, &Pipeline::executionStarted, this, + &PipelineControlsWidget::updateState); + connect(m_pipeline, &Pipeline::executionFinished, this, + &PipelineControlsWidget::updateState); + connect(m_pipeline, &Pipeline::pausedChanged, this, + &PipelineControlsWidget::updateState); + } + updateState(); +} + +Pipeline* PipelineControlsWidget::pipeline() const +{ + return m_pipeline; +} + +bool PipelineControlsWidget::isDimmingEnabled() const +{ + return m_dimmingEnabled; +} + +void PipelineControlsWidget::updateState() +{ + bool executing = m_pipeline && m_pipeline->isExecuting(); + + // Clear stopping flag when execution finishes. + if (!executing) { + m_stopping = false; + } + + // Button icon + if (executing) { + m_button->setIcon(QIcon(":/icons/pqVcrStop.svg")); + m_button->setToolTip(tr("Stop execution")); + } else if (m_pipeline && m_pipeline->isPaused()) { + m_button->setIcon(QIcon(":/pqWidgets/Icons/pqVcrPlay.svg")); + m_button->setToolTip(tr("Resume automatic execution")); + } else { + m_button->setIcon(QIcon(":/pqWidgets/Icons/pqVcrPause.svg")); + m_button->setToolTip(tr("Pause automatic execution")); + } + + // Status message and spinner + if (m_stopping && executing) { + m_statusLabel->setText(tr("Stopping...")); + m_statusLabel->show(); + if (!m_spinnerTimer->isActive()) { + m_spinnerTimer->start(); + } + m_spinnerLabel->show(); + } else if (executing) { + m_statusLabel->hide(); + if (!m_spinnerTimer->isActive()) { + m_spinnerTimer->start(); + } + m_spinnerLabel->show(); + } else if (m_pipeline && m_pipeline->isPaused()) { + m_statusLabel->setText(tr("Automatic execution paused")); + m_statusLabel->show(); + m_spinnerTimer->stop(); + m_spinnerLabel->hide(); + } else { + m_statusLabel->hide(); + m_spinnerTimer->stop(); + m_spinnerLabel->hide(); + } +} + +void PipelineControlsWidget::onButtonClicked() +{ + if (!m_pipeline) { + return; + } + if (m_pipeline->isExecuting()) { + m_stopping = true; + m_pipeline->cancelExecution(); + updateState(); + } else { + m_pipeline->setPaused(!m_pipeline->isPaused()); + } +} + +void PipelineControlsWidget::setupPersistenceButton(QHBoxLayout* layout) +{ + m_persistenceButton = new QToolButton(this); + m_persistenceButton->setAutoRaise(true); + m_persistenceButton->setIconSize(QSize(20, 20)); + m_persistenceButton->setPopupMode(QToolButton::InstantPopup); + + auto* menu = new QMenu(m_persistenceButton); + auto addModeAction = [menu](const QString& iconPath, + const QString& label, + TransformPersistenceDefault mode) { + auto* action = menu->addAction(QIcon(iconPath), label); + action->setData(static_cast(mode)); + return action; + }; + addModeAction(QStringLiteral(":/pipeline/port_persistent_ram.svg"), + tr("Persist in Memory"), + TransformPersistenceDefault::InMemory); + addModeAction(QStringLiteral(":/pipeline/port_persistent_disk.svg"), + tr("Persist on Disk"), + TransformPersistenceDefault::OnDisk); + addModeAction(QStringLiteral(":/pipeline/port_transient.svg"), + tr("Transient"), TransformPersistenceDefault::Transient); + m_persistenceButton->setMenu(menu); + layout->addWidget(m_persistenceButton); + + connect(menu, &QMenu::triggered, this, [](QAction* action) { + PipelineSettings::instance().setTransformPersistenceDefault( + static_cast(action->data().toInt())); + }); + + // Keep the button face in sync with the current setting — both at + // construction and if the value is changed elsewhere. + syncPersistenceButton( + PipelineSettings::instance().transformPersistenceDefault()); + connect(&PipelineSettings::instance(), + &PipelineSettings::transformPersistenceDefaultChanged, this, + &PipelineControlsWidget::syncPersistenceButton); +} + +void PipelineControlsWidget::syncPersistenceButton( + TransformPersistenceDefault mode) +{ + if (!m_persistenceButton) { + return; + } + QString iconPath; + QString tooltip; + switch (mode) { + case TransformPersistenceDefault::InMemory: + iconPath = QStringLiteral(":/pipeline/port_persistent_ram.svg"); + tooltip = tr("Intermediate Data default: Persist in Memory"); + break; + case TransformPersistenceDefault::OnDisk: + iconPath = QStringLiteral(":/pipeline/port_persistent_disk.svg"); + tooltip = tr("Intermediate Data default: Persist on Disk"); + break; + case TransformPersistenceDefault::Transient: + iconPath = QStringLiteral(":/pipeline/port_transient.svg"); + tooltip = tr("Intermediate Data default: Transient"); + break; + } + m_persistenceButton->setIcon(QIcon(iconPath)); + m_persistenceButton->setToolTip(tooltip); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/PipelineControlsWidget.h b/tomviz/pipeline/PipelineControlsWidget.h new file mode 100644 index 000000000..6d036cf18 --- /dev/null +++ b/tomviz/pipeline/PipelineControlsWidget.h @@ -0,0 +1,60 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineControlsWidget_h +#define tomvizPipelineControlsWidget_h + +#include "PipelineSettings.h" + +#include + +class QHBoxLayout; +class QLabel; +class QToolButton; +class QTimer; + +namespace tomviz { +namespace pipeline { + +class Pipeline; + +class PipelineControlsWidget : public QWidget +{ + Q_OBJECT + +public: + explicit PipelineControlsWidget(QWidget* parent = nullptr); + + void setPipeline(Pipeline* pipeline); + Pipeline* pipeline() const; + + bool isDimmingEnabled() const; + +signals: + void dimmingToggled(bool enabled); + +private: + void updateState(); + void onButtonClicked(); + void paintSpinner(QPainter& painter, const QRect& rect); + void setupPersistenceButton(QHBoxLayout* layout); + void syncPersistenceButton(TransformPersistenceDefault mode); + + Pipeline* m_pipeline = nullptr; + QToolButton* m_button = nullptr; + QLabel* m_statusLabel = nullptr; + QLabel* m_spinnerLabel = nullptr; + QTimer* m_spinnerTimer = nullptr; + int m_spinnerAngle = 0; + bool m_stopping = false; + bool m_dimmingEnabled = false; + QToolButton* m_dimmingButton = nullptr; + QToolButton* m_persistenceButton = nullptr; + + static constexpr int SpinnerSize = 14; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/operators/EditOperatorWidget.cxx b/tomviz/pipeline/PipelineExecutor.cxx similarity index 55% rename from tomviz/operators/EditOperatorWidget.cxx rename to tomviz/pipeline/PipelineExecutor.cxx index f3bd614b7..8f3e981ca 100644 --- a/tomviz/operators/EditOperatorWidget.cxx +++ b/tomviz/pipeline/PipelineExecutor.cxx @@ -1,11 +1,12 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#include "EditOperatorWidget.h" +#include "PipelineExecutor.h" namespace tomviz { +namespace pipeline { -EditOperatorWidget::EditOperatorWidget(QWidget* p) : Superclass(p) {} +PipelineExecutor::PipelineExecutor(QObject* parent) : QObject(parent) {} -EditOperatorWidget::~EditOperatorWidget() {} +} // namespace pipeline } // namespace tomviz diff --git a/tomviz/pipeline/PipelineExecutor.h b/tomviz/pipeline/PipelineExecutor.h new file mode 100644 index 000000000..e352d9358 --- /dev/null +++ b/tomviz/pipeline/PipelineExecutor.h @@ -0,0 +1,47 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelinePipelineExecutor_h +#define tomvizPipelinePipelineExecutor_h + +#include +#include + +namespace tomviz { +namespace pipeline { + +class Node; +class Pipeline; + +class PipelineExecutor : public QObject +{ + Q_OBJECT + +public: + PipelineExecutor(QObject* parent = nullptr); + ~PipelineExecutor() override = default; + + virtual void execute(const QList& nodes, Pipeline* pipeline) = 0; + virtual void cancel() = 0; + + /// Cancel and block until execution has actually stopped. Callers that + /// are about to delete nodes (e.g. Pipeline::clear()) must use this, not + /// cancel(), so a worker thread can't still be executing a node we free. + /// The base implementation just cancels — enough for synchronous + /// executors, which can't be running when this is called from outside + /// execute(); ThreadedExecutor overrides it to join its worker. + virtual void cancelAndWait() { cancel(); } + + virtual bool isRunning() const = 0; + +signals: + void nodeExecutionStarted(Node* node); + void nodeExecutionFinished(Node* node, bool success); + void executionComplete(bool success); + void canceled(); +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/PipelineSettings.cxx b/tomviz/pipeline/PipelineSettings.cxx new file mode 100644 index 000000000..7a3f69e11 --- /dev/null +++ b/tomviz/pipeline/PipelineSettings.cxx @@ -0,0 +1,75 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PipelineSettings.h" + +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +constexpr auto kKey = "pipeline/transformPersistenceDefault"; +constexpr auto kInMemory = "InMemory"; +constexpr auto kOnDisk = "OnDisk"; +constexpr auto kTransient = "Transient"; + +TransformPersistenceDefault fromString(const QString& s) +{ + if (s == QLatin1String(kInMemory)) { + return TransformPersistenceDefault::InMemory; + } + if (s == QLatin1String(kTransient)) { + return TransformPersistenceDefault::Transient; + } + return TransformPersistenceDefault::OnDisk; +} + +QString toString(TransformPersistenceDefault mode) +{ + switch (mode) { + case TransformPersistenceDefault::InMemory: + return QString::fromLatin1(kInMemory); + case TransformPersistenceDefault::OnDisk: + return QString::fromLatin1(kOnDisk); + case TransformPersistenceDefault::Transient: + return QString::fromLatin1(kTransient); + } + return QString::fromLatin1(kOnDisk); +} + +} // namespace + +PipelineSettings& PipelineSettings::instance() +{ + static PipelineSettings s; + return s; +} + +PipelineSettings::PipelineSettings() +{ + QSettings s; + m_transformDefault = + fromString(s.value(kKey, QString::fromLatin1(kOnDisk)).toString()); +} + +TransformPersistenceDefault PipelineSettings::transformPersistenceDefault() const +{ + return m_transformDefault; +} + +void PipelineSettings::setTransformPersistenceDefault( + TransformPersistenceDefault mode) +{ + if (m_transformDefault == mode) { + return; + } + m_transformDefault = mode; + QSettings s; + s.setValue(kKey, toString(mode)); + emit transformPersistenceDefaultChanged(mode); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/PipelineSettings.h b/tomviz/pipeline/PipelineSettings.h new file mode 100644 index 000000000..4452a84c1 --- /dev/null +++ b/tomviz/pipeline/PipelineSettings.h @@ -0,0 +1,49 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelinePipelineSettings_h +#define tomvizPipelinePipelineSettings_h + +#include + +namespace tomviz { +namespace pipeline { + +/// Application-wide default for transform output port persistence, +/// applied when a node doesn't explicitly request a specific mode +/// (e.g. a schema-v2 operator JSON without a `persistent` field, or a +/// C++ TransformNode subclass that just calls addOutput). +enum class TransformPersistenceDefault +{ + InMemory, ///< persistent + InMemory + OnDisk, ///< persistent + OnDisk + Transient ///< not persistent (released when consumers drop) +}; + +/// Singleton accessor for the application-wide pipeline defaults. +/// Backed by QSettings so the choice survives restarts. +class PipelineSettings : public QObject +{ + Q_OBJECT + +public: + static PipelineSettings& instance(); + + TransformPersistenceDefault transformPersistenceDefault() const; + void setTransformPersistenceDefault(TransformPersistenceDefault mode); + +signals: + /// Emitted when the default mode changes. The PipelineControls UI + /// uses this to keep its button group in sync across sessions / + /// other widgets that might also expose the setting. + void transformPersistenceDefaultChanged(TransformPersistenceDefault mode); + +private: + PipelineSettings(); + TransformPersistenceDefault m_transformDefault; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/PipelineStateIO.cxx b/tomviz/pipeline/PipelineStateIO.cxx new file mode 100644 index 000000000..b7e4477a0 --- /dev/null +++ b/tomviz/pipeline/PipelineStateIO.cxx @@ -0,0 +1,319 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PipelineStateIO.h" + +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "NodeExecutorFactory.h" +#include "NodeFactory.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "SourceNode.h" +#include "sinks/LegacyModuleSink.h" + +#include +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +constexpr int kSchemaVersion = 2; + +} // namespace + +bool PipelineStateIO::save(Pipeline* pipeline, QJsonObject& outState) +{ + if (!pipeline) { + return false; + } + NodeFactory::registerBuiltins(); + NodeExecutorFactory::registerBuiltins(); + + // Assign ids to every node up front so link references are resolvable. + for (auto* node : pipeline->nodes()) { + pipeline->nodeId(node); + } + + QJsonArray nodesJson; + for (auto* node : pipeline->nodes()) { + QJsonObject entry = node->serialize(); + entry[QStringLiteral("id")] = pipeline->nodeId(node); + entry[QStringLiteral("type")] = NodeFactory::typeName(node); + nodesJson.append(entry); + } + + QJsonArray linksJson; + for (auto* node : pipeline->nodes()) { + for (auto* input : node->inputPorts()) { + auto* link = input->link(); + if (!link || !link->from() || !link->to()) { + continue; + } + auto* fromNode = link->from()->node(); + auto* toNode = link->to()->node(); + if (!fromNode || !toNode) { + continue; + } + QJsonObject from; + from[QStringLiteral("node")] = pipeline->nodeId(fromNode); + from[QStringLiteral("port")] = link->from()->name(); + QJsonObject to; + to[QStringLiteral("node")] = pipeline->nodeId(toNode); + to[QStringLiteral("port")] = link->to()->name(); + QJsonObject entry; + entry[QStringLiteral("from")] = from; + entry[QStringLiteral("to")] = to; + linksJson.append(entry); + } + } + + QJsonObject pipelineJson; + pipelineJson[QStringLiteral("nextNodeId")] = pipeline->nextNodeId(); + pipelineJson[QStringLiteral("nodes")] = nodesJson; + pipelineJson[QStringLiteral("links")] = linksJson; + + outState[QStringLiteral("schemaVersion")] = kSchemaVersion; + outState[QStringLiteral("pipeline")] = pipelineJson; + // Views / layouts / palette are written by the caller via + // ViewsLayoutsSerializer — they depend on the live ParaView proxy + // manager, which isn't wired up in unit-test contexts. Keeping that + // out of this function lets tests round-trip the pipeline graph + // without initializing pqApplicationCore. + return true; +} + +bool PipelineStateIO::load(Pipeline* pipeline, const QJsonObject& state, + const QMap& viewIdMap, + const PreExecuteHook& preExecuteHook) +{ + if (!pipeline) { + return false; + } + NodeFactory::registerBuiltins(); + NodeExecutorFactory::registerBuiltins(); + + auto pipelineJson = state.value(QStringLiteral("pipeline")).toObject(); + if (pipelineJson.isEmpty()) { + qWarning() << "PipelineStateIO::load: missing 'pipeline' section"; + return false; + } + + // Pass 1: instantiate every node and assign it the serialized id. + // Keep a local id->Node map so links can be resolved even if a node + // was already renumbered on an earlier load (paranoia — the id + // allocator on Pipeline is authoritative). + QHash idToNode; + auto nodesJson = pipelineJson.value(QStringLiteral("nodes")).toArray(); + for (const auto& nv : nodesJson) { + auto entry = nv.toObject(); + auto typeName = entry.value(QStringLiteral("type")).toString(); + int id = entry.value(QStringLiteral("id")).toInt(-1); + if (typeName.isEmpty() || id < 0) { + qWarning() << "PipelineStateIO::load: skipping node with missing id/type"; + continue; + } + Node* node = NodeFactory::create(typeName); + if (!node) { + qWarning() << "PipelineStateIO::load: unknown node type" << typeName + << "id" << id; + continue; + } + // For visualization sinks, bind the view BEFORE deserialize runs + // — the saved "visible" flag and other sink state assume a view + // is in place. View is looked up by the legacy GlobalID stamped + // into the JSON at save time. Deserialize itself is deferred to + // the pipeline's first executionFinished to avoid the "Input port + // 0 … has 0 connections" render warnings that would fire if + // setVisibility(true) flipped the VTK actor on before consume() + // had wired data into the filter. + if (auto* sink = dynamic_cast(node)) { + int viewId = entry.value(QStringLiteral("viewId")).toInt(-1); + if (viewId >= 0) { + if (auto* view = viewIdMap.value(viewId, nullptr)) { + sink->initialize(view); + } + } + // Sink actors are visible by default; a render between here and + // the deferred deserialize below would hit "Input port 0 … has + // 0 connections" warnings because consume() hasn't wired data + // into the filter yet. Hide the actor now — the deferred + // handler flips it back on once the sink is Current. + sink->setVisibility(false); + pipeline->addNode(node); + pipeline->setNodeId(node, id); + idToNode.insert(id, node); + + // Strip any saved NodeState from the sink JSON: a sink is only + // legitimately Current after consume() has run *in the current + // session* (wiring data into the VTK filter). Keeping the saved + // state=Current would cause Pipeline::execute() to skip the + // sink on the user's next manual run, leaving vtkOutlineFilter + // (and similar) with no input and logging "Input port 0 has 0 + // connections" warnings on render. + QJsonObject sinkJson = entry; + sinkJson.remove(QStringLiteral("state")); + QObject::connect( + pipeline, &Pipeline::executionFinished, sink, + [sink, sinkJson, pipeline]() { + const bool targetVis = sinkJson.value("visible").toBool(true); + const bool sinkIsCurrent = (sink->state() == NodeState::Current); + if (sinkIsCurrent || !targetVis) { + sink->deserialize(sinkJson); + return; + } + // Apply everything except visible=true; hook the visibility + // flip to the next executionFinished that leaves the sink + // Current (i.e. consume() actually ran). + QJsonObject later = sinkJson; + later["visible"] = false; + sink->deserialize(later); + auto conn = std::make_shared(); + *conn = QObject::connect( + pipeline, &Pipeline::executionFinished, sink, + [sink, targetVis, conn]() { + if (sink->state() == NodeState::Current) { + sink->setVisibility(targetVis); + QObject::disconnect(*conn); + } + }); + }, + Qt::SingleShotConnection); + continue; + } + + if (!node->deserialize(entry)) { + qWarning() << "PipelineStateIO::load: deserialize failed for node" + << typeName << "id" << id; + delete node; + continue; + } + pipeline->addNode(node); + pipeline->setNodeId(node, id); + idToNode.insert(id, node); + } + + // Restore nextNodeId so later additions don't collide. + if (pipelineJson.contains(QStringLiteral("nextNodeId"))) { + pipeline->setNextNodeId( + pipelineJson.value(QStringLiteral("nextNodeId")).toInt(1)); + } + + // Pass 2: resolve links. + auto linksJson = pipelineJson.value(QStringLiteral("links")).toArray(); + for (const auto& lv : linksJson) { + auto entry = lv.toObject(); + auto from = entry.value(QStringLiteral("from")).toObject(); + auto to = entry.value(QStringLiteral("to")).toObject(); + int fromId = from.value(QStringLiteral("node")).toInt(-1); + int toId = to.value(QStringLiteral("node")).toInt(-1); + Node* fromNode = idToNode.value(fromId, nullptr); + Node* toNode = idToNode.value(toId, nullptr); + if (!fromNode || !toNode) { + qWarning() << "PipelineStateIO::load: link references unknown node" + << fromId << "->" << toId; + continue; + } + auto* outPort = + fromNode->outputPort(from.value(QStringLiteral("port")).toString()); + auto* inPort = + toNode->inputPort(to.value(QStringLiteral("port")).toString()); + if (!outPort || !inPort) { + qWarning() << "PipelineStateIO::load: link references unknown port" + << from.value(QStringLiteral("port")).toString() << "->" + << to.value(QStringLiteral("port")).toString(); + continue; + } + pipeline->createLink(outPort, inPort); + } + + // Optional hook — container-format loaders (e.g. Tvh5Format) use + // this to drop voxel data onto source output ports before the + // fallback eager-execute pass runs. Anything that has already + // populated a port's data will be skipped below. + if (preExecuteHook) { + preExecuteHook(pipeline, pipelineJson); + } + + // Pass 2.5: eagerly execute source nodes whose output ports are + // still empty so their data (file contents for ReaderSourceNode, + // deterministic volumes for SphereSource, etc.) is present on the + // output port even when the caller declines auto-execute. Matches + // LegacyStateLoader, which resolves source data synchronously + // inside buildSource() via LoadDataReaction::loadData. + for (auto* node : pipeline->nodes()) { + if (!dynamic_cast(node)) { + continue; + } + bool hasData = false; + for (auto* port : node->outputPorts()) { + if (port->hasData()) { + hasData = true; + break; + } + } + if (!hasData) { + node->execute(); + } + } + + // Pass 2.75: re-apply the saved state for every non-sink node. + // Pass 2 (createLink) cascades markStale on every downstream node + // as a side effect, wiping the state Node::deserialize applied in + // pass 1. Re-apply explicitly so transforms saved as Current go + // back to Current (they won't re-execute) — provided their output + // ports really have data, which pass 3 below will verify. + // setStateNoCascade avoids re-staling downstream: we already know + // the full graph's intended state, so cascades would just fight us. + // Sinks are skipped: their saved state is intentionally dropped + // (consume() has to run in the current session to make them + // legitimately Current). + for (const auto& nv : nodesJson) { + auto entry = nv.toObject(); + int nid = entry.value(QStringLiteral("id")).toInt(-1); + if (nid < 0) { + continue; + } + auto* n = pipeline->nodeById(nid); + if (!n || dynamic_cast(n)) { + continue; + } + auto s = entry.value(QStringLiteral("state")).toString(); + if (s == QLatin1String("Current")) { + n->setStateNoCascade(NodeState::Current); + } else if (s == QLatin1String("Stale")) { + n->setStateNoCascade(NodeState::Stale); + } + // Missing / "New" — leave at whatever markStale-from-createLink + // left. A fresh Stale is fine for a node that was never executed. + } + + // Pass 3: a node was saved as Current because its outputs carried + // data at save time. Transient ports never serialize their payload, + // and persistent ports may be empty too (.tvsm, or .tvh5 files where + // voxels weren't yet reconstructed) — so the restored "Current" + // state is a lie whenever an output that downstream depends on (or + // a persistent output that should round-trip its payload) is empty. + // Downgrade those nodes to Stale; markStale cascades through the + // links created in pass 2 so downstream nodes also re-execute. + for (auto* node : pipeline->nodes()) { + if (node->state() != NodeState::Current) { + continue; + } + for (auto* port : node->outputPorts()) { + bool needsData = port->isPersistent() || !port->links().isEmpty(); + if (needsData && !port->hasData()) { + node->markStale(); + break; + } + } + } + + return true; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/PipelineStateIO.h b/tomviz/pipeline/PipelineStateIO.h new file mode 100644 index 000000000..765c76656 --- /dev/null +++ b/tomviz/pipeline/PipelineStateIO.h @@ -0,0 +1,70 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelinePipelineStateIO_h +#define tomvizPipelinePipelineStateIO_h + +#include +#include + +#include + +class vtkSMViewProxy; + +namespace tomviz { +namespace pipeline { + +class Pipeline; + +/// Save / load the new-format Tomviz state-file schema (schemaVersion >= 2). +/// Mirrors LegacyStateLoader on the legacy side: this handles only the new +/// graph-native format. The legacy loader stays in service for files that +/// lack schemaVersion. +/// +/// The JSON payload has the shape: +/// { +/// "schemaVersion": 2, +/// "pipeline": { +/// "nextNodeId": , +/// "nodes": [ { id, type, label, ... , config, outputPorts, inputPorts } ], +/// "links": [ { from: {node,port}, to: {node,port} } ] +/// }, +/// "views": [ ... ], (added later) +/// "layouts": [ ... ], (added later) +/// "paletteColor": [ r, g, b ] (added later) +/// } +class PipelineStateIO +{ +public: + /// Serialize @a pipeline and (eventually) the surrounding view/layout + /// state into @a outState. Only the "schemaVersion" and "pipeline" + /// sections are populated; views/layouts/palette are appended by the + /// caller via ViewsLayoutsSerializer for now. + static bool save(Pipeline* pipeline, QJsonObject& outState); + + /// Populate @a pipeline from @a state produced by save(). The pipeline + /// must be cleared by the caller beforehand. Returns false on + /// unrecoverable parse errors; individual unknown node types / bad + /// links are skipped with a warning. + /// @a viewIdMap — if views have already been restored (typically via + /// LegacyStateLoader::restoreViewsLayoutsAndPalette), pass the legacy + /// view-id → proxy map here so each LegacyModuleSink can be bound to + /// its saved view. + /// @a preExecuteHook — invoked after nodes and links are built but + /// before source nodes are eagerly executed. Containers that embed + /// source payloads (e.g. Tvh5Format with HDF5 voxel groups) use this + /// to populate source output ports directly, so the eager-execute + /// pass becomes a no-op for those sources and the originals don't + /// get re-read from disk. + using PreExecuteHook = + std::function; + static bool load( + Pipeline* pipeline, const QJsonObject& state, + const QMap& viewIdMap = {}, + const PreExecuteHook& preExecuteHook = {}); +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/PipelineStripWidget.cxx b/tomviz/pipeline/PipelineStripWidget.cxx new file mode 100644 index 000000000..40559339a --- /dev/null +++ b/tomviz/pipeline/PipelineStripWidget.cxx @@ -0,0 +1,3250 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PipelineStripWidget.h" + +#include "InputPort.h" +#include "Link.h" +#include "Node.h" +#include "NodeExecState.h" +#include "OutputPort.h" +#include "Pipeline.h" +#include "PortType.h" +#include "SinkGroupNode.h" +#include "SinkNode.h" +#include "SourceNode.h" +#include "TransformNode.h" +#include "sinks/LegacyModuleSink.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace tomviz { +namespace pipeline { + +// Breakpoints are only offered on data-producing nodes (sources and +// transforms). Sinks and sink groups don't run any logic of interest +// to pause at — gating the UI here keeps the affordance off them +// while leaving Node::setBreakpoint() available at the API level. +static bool canHaveBreakpoint(Node* node) +{ + return qobject_cast(node) != nullptr || + qobject_cast(node) != nullptr; +} + +// Invert an RGB color, preserving alpha. Used to make a selected link (and +// its endpoint port outlines) read as clearly picked against its normal +// port-type color. +static QColor invertColor(const QColor& c) +{ + return QColor(255 - c.red(), 255 - c.green(), 255 - c.blue(), c.alpha()); +} + +// Marching-ants geometry (in pen-width units) and per-tick phase step. +// Keep kLinkDashPeriod = on + off so the timer can wrap the phase. +static constexpr qreal kLinkDashOn = 1.0; +static constexpr qreal kLinkDashOff = 0.5; +static constexpr qreal kLinkDashPeriod = kLinkDashOn + kLinkDashOff; +static constexpr qreal kLinkDashStep = 0.18; + +// Render an icon tinted to the given color, preserving alpha/opacity. +static void paintTintedIcon(QPainter& painter, const QIcon& icon, + const QRect& rect, const QColor& color) +{ + if (rect.isEmpty() || icon.isNull()) { + return; + } + qreal dpr = painter.device()->devicePixelRatioF(); + QSize pxSize(qRound(rect.width() * dpr), qRound(rect.height() * dpr)); + QPixmap pix = icon.pixmap(pxSize); + pix.setDevicePixelRatio(dpr); + QPainter p(&pix); + p.setCompositionMode(QPainter::CompositionMode_SourceIn); + p.fillRect(pix.rect(), color); + p.end(); + painter.drawPixmap(rect, pix); +} + +// Draw a circular badge centered on @a cornerCenter — used to anchor +// the persistent / has-data corner cues to a port square's corner so +// they read as attached to it rather than floating on top of it. +static void paintCornerBadge(QPainter& painter, const QIcon& icon, + const QPoint& cornerCenter, int size, + const QColor& color, const QColor& bgColor) +{ + if (icon.isNull() || size <= 0) { + return; + } + QRect badgeRect(cornerCenter.x() - size / 2, + cornerCenter.y() - size / 2, size, size); + painter.setBrush(bgColor); + painter.setPen(QPen(color, 0.5)); + painter.drawEllipse(badgeRect); + // Smaller inset so the icon fills more of the badge. + QRect iconRect = badgeRect.adjusted(1, 1, -1, -1); + paintTintedIcon(painter, icon, iconRect, color); +} + +// Build a QPainterPath through a sequence of points, rounding each interior +// corner with a quadratic B��zier curve. The radius is clamped so it never +// exceeds half the length of an adjacent segment. +static QPainterPath roundedPolyline(const QList& pts, qreal radius) +{ + QPainterPath path; + if (pts.size() < 2) { + return path; + } + path.moveTo(pts.first()); + for (int i = 1; i < pts.size() - 1; ++i) { + QPointF d1 = pts[i] - pts[i - 1]; + QPointF d2 = pts[i + 1] - pts[i]; + qreal len1 = std::sqrt(d1.x() * d1.x() + d1.y() * d1.y()); + qreal len2 = std::sqrt(d2.x() * d2.x() + d2.y() * d2.y()); + qreal r = qMin(radius, qMin(len1, len2) / 2.0); + if (r <= 0) { + path.lineTo(pts[i]); + continue; + } + QPointF before = pts[i] - d1 / len1 * r; + QPointF after = pts[i] + d2 / len2 * r; + path.lineTo(before); + path.quadTo(pts[i], after); + } + path.lineTo(pts.last()); + return path; +} + +PipelineStripWidget::PipelineStripWidget(QWidget* parent) : QWidget(parent) +{ + setFocusPolicy(Qt::StrongFocus); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); + setMouseTracking(true); + setAutoFillBackground(true); + + m_spinnerTimer.setInterval(50); + connect(&m_spinnerTimer, &QTimer::timeout, this, [this]() { + m_spinnerAngle = (m_spinnerAngle + 30) % 360; + update(); + }); + + // Marching-ants flow along a hovered/selected link. The dash offset is + // expressed in pen-width units; the pattern period is dash + gap (see + // paintConnections), so wrap the phase at that period to bound it. + m_marchTimer.setInterval(40); + connect(&m_marchTimer, &QTimer::timeout, this, [this]() { + m_marchPhase = std::fmod( + m_marchPhase - kLinkDashStep + kLinkDashPeriod, kLinkDashPeriod); + update(); + }); +} + +void PipelineStripWidget::setPipeline(Pipeline* pipeline) +{ + if (m_pipeline == pipeline) { + return; + } + disconnectPipeline(); + m_pipeline = pipeline; + m_selectedIndex = -1; + m_selectedPort = nullptr; + m_selectedLink = nullptr; + m_hoveredLink = nullptr; + m_expandedNodes.clear(); + connectPipeline(); + rebuildLayout(); + update(); +} + +Pipeline* PipelineStripWidget::pipeline() const +{ + return m_pipeline; +} + +void PipelineStripWidget::setSortOrder(SortOrder order) +{ + if (m_sortOrder != order) { + m_sortOrder = order; + rebuildLayout(); + update(); + } +} + +SortOrder PipelineStripWidget::sortOrder() const +{ + return m_sortOrder; +} + +void PipelineStripWidget::setDimLevel(qreal level) +{ + m_dimLevel = qBound(0.0, level, 1.0); + update(); +} + +qreal PipelineStripWidget::dimLevel() const +{ + return m_dimLevel; +} + +void PipelineStripWidget::setNodeDimmed(Node* node, bool dim) +{ + if (dim) { + m_brightNodes.remove(node); + } else { + m_brightNodes.insert(node); + } + update(); +} + +void PipelineStripWidget::setPortDimmed(OutputPort* port, bool dim) +{ + if (dim) { + m_brightPorts.remove(port); + } else { + m_brightPorts.insert(port); + } + update(); +} + +void PipelineStripWidget::setLinkDimmed(Link* link, bool dim) +{ + if (dim) { + m_brightLinks.remove(link); + } else { + m_brightLinks.insert(link); + } + update(); +} + +bool PipelineStripWidget::isNodeDimmed(Node* node) const +{ + return m_hasBrightSelection && !m_brightNodes.contains(node); +} + +bool PipelineStripWidget::isPortDimmed(OutputPort* port) const +{ + return m_hasBrightSelection && !m_brightPorts.contains(port); +} + +bool PipelineStripWidget::isLinkDimmed(Link* link) const +{ + return m_hasBrightSelection && !m_brightLinks.contains(link); +} + +void PipelineStripWidget::clearDimming() +{ + m_hasBrightSelection = false; + m_brightNodes.clear(); + m_brightPorts.clear(); + m_brightLinks.clear(); + update(); +} + +void PipelineStripWidget::setDimmingEnabled(bool enabled) +{ + if (m_dimmingEnabled == enabled) { + return; + } + m_dimmingEnabled = enabled; + if (m_dimmingEnabled) { + updateDimming(); + update(); + } else { + clearDimming(); + } +} + +bool PipelineStripWidget::isDimmingEnabled() const +{ + return m_dimmingEnabled; +} + +void PipelineStripWidget::updateDimming() +{ + if (!m_dimmingEnabled || !m_pipeline) { + clearDimming(); + return; + } + + // --- Determine the seed for the hop-based brightening --- + Node* seedNode = nullptr; + bool hasSelection = false; + + OutputPort* selPort = m_selectedPort; + if (!selPort && m_selectedIndex >= 0 && m_selectedIndex < m_layout.size() && + m_layout[m_selectedIndex].type == LayoutItem::PortCard) { + selPort = m_layout[m_selectedIndex].port; + } + + if (selPort) { + hasSelection = true; + seedNode = selPort->node(); + } else if (m_selectedLink) { + hasSelection = true; + // A link connects two nodes; treat the source node as seed + seedNode = m_selectedLink->from()->node(); + } else if (m_selectedMember) { + hasSelection = true; + seedNode = m_selectedMember; + } else if (m_selectedIndex >= 0 && m_selectedIndex < m_layout.size() && + (m_layout[m_selectedIndex].type == LayoutItem::NodeCard || + m_layout[m_selectedIndex].type == LayoutItem::GroupMemberCard)) { + hasSelection = true; + seedNode = m_layout[m_selectedIndex].node; + } + + if (!hasSelection) { + clearDimming(); + return; + } + + // --- BFS brightening with hop counting --- + // + // Hop rules: + // node → port it owns : 1 hop (unless the node is a SinkGroupNode → 0) + // port → node it belongs : 1 hop (unless the node is a SinkGroupNode → 0) + // port → linked port : 0 hops (following a link is free) + // + // When the starting point is a node, the hop budget is maxHops + 1 to + // account for the initial node → port step that hasn't happened yet. + constexpr int maxHops = 1; + + m_brightNodes.clear(); + m_brightPorts.clear(); + m_brightLinks.clear(); + + // Cost to enter a node from one of its ports. + auto entryCost = [](Node* n) -> int { + return qobject_cast(n) ? 0 : 1; + }; + + // Cost to go from a node to its output ports. + // If the seed is a member of a SinkGroupNode, exiting that group to its + // output ports costs 1 — this prevents lateral shortcuts between siblings. + auto outputExitCost = [seedNode](Node* n) -> int { + if (auto* sg = qobject_cast(n)) { + for (auto* sink : sg->sinks()) { + if (static_cast(sink) == seedNode) { + return 1; + } + } + return 0; + } + return 1; + }; + + // Cost to go from a node to its input ports. + auto inputExitCost = [](Node* n) -> int { + return qobject_cast(n) ? 0 : 1; + }; + + // Helper: try to add a node to the BFS queue if within hop budget. + QList> queue; + int budget = maxHops; + auto enqueueNode = [&](Node* n, int hops) { + if (hops <= budget && !m_brightNodes.contains(n)) { + m_brightNodes.insert(n); + queue.append({ n, hops }); + } + }; + + // --- Phase 1: Seed from the selected element --- + if (selPort) { + // Port selected: seed from the port at 0 hops. + m_brightPorts.insert(selPort); + enqueueNode(selPort->node(), entryCost(selPort->node())); + for (auto* link : selPort->links()) { + m_brightLinks.insert(link); + enqueueNode(link->to()->node(), entryCost(link->to()->node())); + } + } else if (m_selectedLink) { + // Link selected: seed from both endpoint ports. + m_brightLinks.insert(m_selectedLink); + m_brightPorts.insert(m_selectedLink->from()); + enqueueNode(m_selectedLink->from()->node(), + entryCost(m_selectedLink->from()->node())); + enqueueNode(m_selectedLink->to()->node(), + entryCost(m_selectedLink->to()->node())); + } else { + // Node or member selected: extra hop budget for the initial node→port step. + budget = maxHops + 1; + enqueueNode(seedNode, 0); + } + + // --- Phase 2: BFS from seeded nodes --- + while (!queue.isEmpty()) { + auto [node, hops] = queue.takeFirst(); + + // Downstream: node →(outputExitCost) port →(free) link →(free) port →(entryCost) node + int outPortHops = hops + outputExitCost(node); + if (outPortHops <= budget) { + for (auto* port : node->outputPorts()) { + m_brightPorts.insert(port); + for (auto* link : port->links()) { + auto* dst = link->to()->node(); + int dstHops = outPortHops + entryCost(dst); + if (dstHops <= budget) { + m_brightLinks.insert(link); + if (!m_brightNodes.contains(dst)) { + m_brightNodes.insert(dst); + queue.append({ dst, dstHops }); + } + } + } + } + } + + // Upstream: node →(inputExitCost) port →(free) link →(free) port →(entryCost) node + int inPortHops = hops + inputExitCost(node); + if (inPortHops <= budget) { + for (auto* inPort : node->inputPorts()) { + if (!inPort->link()) { + continue; + } + auto* link = inPort->link(); + auto* src = link->from()->node(); + int srcHops = inPortHops + entryCost(src); + if (srcHops <= budget) { + m_brightLinks.insert(link); + m_brightPorts.insert(link->from()); + if (!m_brightNodes.contains(src)) { + m_brightNodes.insert(src); + queue.append({ src, srcHops }); + } + } + } + } + } + + m_hasBrightSelection = true; +} + +Node* PipelineStripWidget::selectedNode() const +{ + if (m_selectedIndex >= 0 && m_selectedIndex < m_layout.size()) { + return m_layout[m_selectedIndex].node; + } + return nullptr; +} + +OutputPort* PipelineStripWidget::selectedPort() const +{ + if (m_selectedPort) { + return m_selectedPort; + } + if (m_selectedIndex >= 0 && m_selectedIndex < m_layout.size()) { + auto& item = m_layout[m_selectedIndex]; + if (item.type == LayoutItem::PortCard) { + return item.port; + } + } + return nullptr; +} + +Link* PipelineStripWidget::selectedLink() const +{ + return m_selectedLink; +} + +void PipelineStripWidget::setSelectedNode(Node* node) +{ + int index = -1; + if (node) { + for (int i = 0; i < m_layout.size(); ++i) { + if (m_layout[i].node == node && + m_layout[i].type == LayoutItem::NodeCard) { + index = i; + break; + } + } + if (index < 0) { + return; // node not visible in layout + } + } + if (index == m_selectedIndex && !m_selectedPort && !m_selectedLink) { + return; + } + m_selectedIndex = index; + m_selectedPort = nullptr; + m_selectedLink = nullptr; + updateDimming(); + update(); +} + +void PipelineStripWidget::setSelectedPort(OutputPort* port) +{ + if (!port) { + // Clear port-dot selection only; preserve node card selection. + if (m_selectedPort) { + m_selectedPort = nullptr; + updateDimming(); + update(); + } + return; + } + // Check for a visible port card first. + int index = -1; + for (int i = 0; i < m_layout.size(); ++i) { + if (m_layout[i].type == LayoutItem::PortCard && + m_layout[i].port == port) { + index = i; + break; + } + } + if (index >= 0) { + if (index == m_selectedIndex && !m_selectedPort && !m_selectedLink) { + return; + } + m_selectedIndex = index; + m_selectedPort = nullptr; + m_selectedLink = nullptr; + } else { + // No port card — select via output-dot highlight. + if (m_selectedPort == port && m_selectedIndex < 0 && !m_selectedLink) { + return; + } + m_selectedIndex = -1; + m_selectedPort = port; + m_selectedLink = nullptr; + } + updateDimming(); + update(); +} + +void PipelineStripWidget::setSelectedLink(Link* link) +{ + if (!link) { + // Clear link selection only; preserve node/port selection. + if (m_selectedLink) { + m_selectedLink = nullptr; + updateDimming(); + update(); + } + return; + } + if (link == m_selectedLink && m_selectedIndex < 0 && !m_selectedPort) { + return; + } + m_selectedIndex = -1; + m_selectedPort = nullptr; + m_selectedLink = link; + updateDimming(); + update(); +} + +void PipelineStripWidget::setTipOutputPort(OutputPort* port) +{ + // If the tip targets a SinkGroupNode passthrough port, redirect to the + // upstream port that the group's corresponding input is connected to. + if (port && qobject_cast(port->node())) { + auto* groupNode = static_cast(port->node()); + int idx = groupNode->outputPorts().indexOf(port); + if (idx >= 0 && idx < groupNode->inputPorts().size()) { + auto* inp = groupNode->inputPorts()[idx]; + if (inp->link() && inp->link()->from()) { + port = inp->link()->from(); + } + } + } + if (m_tipOutputPort != port) { + m_tipOutputPort = port; + update(); + } +} + +OutputPort* PipelineStripWidget::tipOutputPort() const +{ + return m_tipOutputPort; +} + +void PipelineStripWidget::setNodeMenuProvider(NodeMenuProvider provider) +{ + m_nodeMenuProvider = std::move(provider); +} + +void PipelineStripWidget::setPortMenuProvider(PortMenuProvider provider) +{ + m_portMenuProvider = std::move(provider); +} + +void PipelineStripWidget::setLinkMenuProvider(LinkMenuProvider provider) +{ + m_linkMenuProvider = std::move(provider); +} + +void PipelineStripWidget::setLinkValidator(LinkValidator validator) +{ + m_linkValidator = std::move(validator); +} + +bool PipelineStripWidget::isExpanded(Node* node) const +{ + return m_expandedNodes.contains(node); +} + +void PipelineStripWidget::setExpanded(Node* node, bool expanded) +{ + if (expanded) { + m_expandedNodes.insert(node); + } else { + m_expandedNodes.remove(node); + } + rebuildLayout(); + update(); +} + +void PipelineStripWidget::setLinkAnimationOnHover(bool enabled) +{ + if (m_animateLinkOnHover != enabled) { + m_animateLinkOnHover = enabled; + updateLinkAnimationTimer(); + update(); + } +} + +void PipelineStripWidget::setLinkAnimationOnSelection(bool enabled) +{ + if (m_animateLinkOnSelection != enabled) { + m_animateLinkOnSelection = enabled; + updateLinkAnimationTimer(); + update(); + } +} + +void PipelineStripWidget::updateLinkAnimationTimer() +{ + bool shouldRun = (m_hoveredLink && m_animateLinkOnHover) || + (m_selectedLink && m_animateLinkOnSelection); + if (shouldRun && !m_marchTimer.isActive()) { + m_marchTimer.start(); + } else if (!shouldRun && m_marchTimer.isActive()) { + m_marchTimer.stop(); + m_marchPhase = 0.0; + } +} + +QSize PipelineStripWidget::minimumSizeHint() const +{ + return QSize(100, 50); +} + +QSize PipelineStripWidget::sizeHint() const +{ + if (m_layout.isEmpty()) { + return QSize(200, 50); + } + auto& last = m_layout.last(); + int h = last.rect.bottom() + Padding + CardSpacing; + return QSize(200, h); +} + +void PipelineStripWidget::rebuildLayout() +{ + // Remember what was selected so we can restore after rebuilding + Node* selectedNode = nullptr; + OutputPort* selectedPort = nullptr; + Node* selectedMember = m_selectedMember; + if (m_selectedIndex >= 0 && m_selectedIndex < m_layout.size()) { + auto& sel = m_layout[m_selectedIndex]; + if (sel.type == LayoutItem::GroupMemberCard) { + selectedMember = sel.node; // preserve as member selection + } else { + selectedNode = sel.node; + } + selectedPort = sel.port; // non-null only for PortCard + } + // Also consider a selected output dot + if (m_selectedPort) { + selectedPort = m_selectedPort; + } + + m_layout.clear(); + m_linkGeometries.clear(); + m_selectedIndex = -1; + m_selectedPort = nullptr; + + if (!m_pipeline) { + updateGeometry(); + return; + } + + auto sorted = m_pipeline->topologicalSort({}, m_sortOrder); + int y = Padding; + int cardWidth = qMax(width() - GutterWidth - Padding * 2, 80); + + // Collect sinks that are members of a SinkGroupNode — they are rendered + // inside their group card and should be excluded from the main layout. + QSet groupedSinks; + for (auto* node : sorted) { + if (auto* sg = qobject_cast(node)) { + for (auto* sink : sg->sinks()) { + groupedSinks.insert(sink); + } + } + } + + bool anyExecuting = false; + + Node* prevNode = nullptr; + bool prevCollapsed = false; + + for (int ni = 0; ni < sorted.size(); ++ni) { + auto* node = sorted[ni]; + auto* nextNode = (ni + 1 < sorted.size()) ? sorted[ni + 1] : nullptr; + + // Skip sinks that are rendered inside a SinkGroupNode card. + if (groupedSinks.contains(node)) { + continue; + } + + // --- Input side clearance --- + auto inputs = node->inputPorts(); + if (!inputs.isEmpty()) { + // Physical overflow of input dots above the node rect + y += DotRadius; + + // Link routing space + int nGutterInputs = 0; + int nDirectInputs = 0; + for (auto* inp : inputs) { + if (!inp->link()) { + continue; + } + Node* src = inp->link()->from()->node(); + bool isDirect = prevCollapsed && src == prevNode; + if (isDirect) { + nDirectInputs++; + } else { + nGutterInputs++; + } + } + // Gutter approach lanes sit at DotClearance above the node top — i.e. + // PortClearance above the DotRadius dot overflow reserved above — with + // each further lane a LaneSpacing higher. Reserve up to the topmost + // lane plus a small margin so the horizontal run clears the node above. + // (Reserving only nGutter*LaneSpacing left it a pixel short.) + int gutterInputSpace = + nGutterInputs > 0 + ? PortClearance + (nGutterInputs - 1) * LaneSpacing + 3 + : 0; + int inputLinkSpace = 0; + if (nGutterInputs > 0 && nDirectInputs > 0) { + inputLinkSpace = qMax(gutterInputSpace, DirectConnectionSpacing); + } else if (nGutterInputs > 0) { + inputLinkSpace = gutterInputSpace; + } else if (nDirectInputs > 0) { + inputLinkSpace = DirectConnectionSpacing; + } + y += inputLinkSpace; + } + + // Port sub-cards (shown only when node is expanded) + auto outputs = node->outputPorts(); + auto* sinkGroup = qobject_cast(node); + QList groupMembers; + if (sinkGroup) { + groupMembers = sinkGroup->sinks(); + } + bool showPorts = + !outputs.isEmpty() && m_expandedNodes.contains(node) && !sinkGroup; + bool showMembers = sinkGroup && m_expandedNodes.contains(node) && + !groupMembers.isEmpty(); + + // Compute node card height: base height + port cards inside when expanded + int nodeHeight = NodeCardHeight; + if (showPorts) { + nodeHeight += PortCardSpacing; // top padding below header + nodeHeight += outputs.size() * PortCardHeight; + nodeHeight += (outputs.size() - 1) * PortCardSpacing; + nodeHeight += PortContentPad; // bottom padding + } else if (showMembers) { + nodeHeight += PortCardSpacing; // top padding below header + nodeHeight += groupMembers.size() * PortCardHeight; + nodeHeight += (groupMembers.size() - 1) * PortCardSpacing; + nodeHeight += PortContentPad; // bottom padding + } + + // Compute indentation level based on node type and connections. + // - All inputs disconnected: indent 0 + // - Source nodes: always indent 0 + // - Transform nodes with connections: indent 1 + // - Sink nodes with connections: indent 2 if connected to at least one + // transform, otherwise indent 1 + int indentLevel = 0; + bool hasConnectedInput = false; + for (auto* inp : inputs) { + if (inp->link()) { + hasConnectedInput = true; + break; + } + } + if (hasConnectedInput && !qobject_cast(node)) { + if (qobject_cast(node) || sinkGroup) { + // SinkNode and SinkGroupNode follow the same indentation rules + bool connectedToTransform = false; + for (auto* inp : inputs) { + if (inp->link() && + qobject_cast(inp->link()->from()->node())) { + connectedToTransform = true; + break; + } + } + indentLevel = connectedToTransform ? 2 : 1; + } else { + // TransformNode (or any non-source, non-sink) + indentLevel = 1; + } + } + int indent = indentLevel * IndentWidth; + + // Node card + LayoutItem nodeItem; + nodeItem.type = LayoutItem::NodeCard; + nodeItem.node = node; + nodeItem.rect = + QRect(GutterWidth + Padding + indent, y, cardWidth - indent, nodeHeight); + m_layout.append(nodeItem); + + if (showPorts) { + int portY = y + NodeCardHeight + PortCardSpacing; + for (auto* port : outputs) { + LayoutItem portItem; + portItem.type = LayoutItem::PortCard; + portItem.node = node; + portItem.port = port; + portItem.rect = + QRect(GutterWidth + Padding + indent + PortContentPad, portY, + cardWidth - indent - 2 * PortContentPad, PortCardHeight); + m_layout.append(portItem); + portY += PortCardHeight + PortCardSpacing; + } + } else if (showMembers) { + int portY = y + NodeCardHeight + PortCardSpacing; + for (auto* member : groupMembers) { + LayoutItem memberItem; + memberItem.type = LayoutItem::GroupMemberCard; + memberItem.node = member; // the member sink (for selection) + memberItem.rect = + QRect(GutterWidth + Padding + indent + PortContentPad, portY, + cardWidth - indent - 2 * PortContentPad, PortCardHeight); + m_layout.append(memberItem); + portY += PortCardHeight + PortCardSpacing; + } + } + y += nodeHeight; + + // --- Output side clearance --- + bool hasBottomDots = !showPorts && !outputs.isEmpty(); + // SinkGroupNode also shows member circles in collapsed mode + if (!hasBottomDots && sinkGroup && !showMembers && + !groupMembers.isEmpty()) { + hasBottomDots = true; + } + if (hasBottomDots) { + // Physical overflow of output shapes below the node rect + y += OutputSquareEdge - OutputSquareOverlap; + + // Link routing space + int nGutterOutputs = 0; + int nDirectOutputs = 0; + for (int i = 0; i < outputs.size(); ++i) { + if (outputs[i]->links().isEmpty()) { + continue; + } + bool allDirect = (nextNode != nullptr); + if (allDirect) { + for (auto* link : outputs[i]->links()) { + if (link->to()->node() != nextNode) { + allDirect = false; + break; + } + } + } + if (allDirect) { + nDirectOutputs++; + } else { + nGutterOutputs++; + } + } + int outputLinkSpace = 0; + if (nGutterOutputs > 0 && nDirectOutputs > 0) { + outputLinkSpace = + qMax(nGutterOutputs * LaneSpacing, DirectConnectionSpacing); + } else if (nGutterOutputs > 0) { + outputLinkSpace = nGutterOutputs * LaneSpacing; + } else if (nDirectOutputs > 0) { + outputLinkSpace = DirectConnectionSpacing; + } + y += outputLinkSpace; + } + + // Static margin between nodes + y += CardSpacing; + + prevNode = node; + prevCollapsed = !showPorts && !outputs.isEmpty(); + } + + Q_UNUSED(anyExecuting); + + // Restore selection by matching the previously selected node/port + if (selectedPort) { + // Try to find as a port card first + bool found = false; + for (int i = 0; i < m_layout.size(); ++i) { + if (m_layout[i].port == selectedPort) { + m_selectedIndex = i; + found = true; + break; + } + } + // If not a port card (collapsed), restore as selected dot + if (!found) { + m_selectedPort = selectedPort; + } + } else if (selectedMember) { + // A collapsed group member was selected — try to find it as an + // expanded GroupMemberCard, otherwise keep it as m_selectedMember. + bool found = false; + for (int i = 0; i < m_layout.size(); ++i) { + if (m_layout[i].type == LayoutItem::GroupMemberCard && + m_layout[i].node == selectedMember) { + m_selectedIndex = i; + found = true; + break; + } + } + if (!found) { + m_selectedMember = selectedMember; + } + } else if (selectedNode) { + for (int i = 0; i < m_layout.size(); ++i) { + if ((m_layout[i].type == LayoutItem::NodeCard || + m_layout[i].type == LayoutItem::GroupMemberCard) && + m_layout[i].node == selectedNode) { + m_selectedIndex = i; + break; + } + } + } + + computeLinkGeometries(); + updateGeometry(); +} + +void PipelineStripWidget::connectPipeline() +{ + if (!m_pipeline) { + return; + } + + auto connectNode = [this](Node* node) { + connect(node, &Node::stateChanged, this, + QOverload<>::of(&QWidget::update)); + connect(node, &Node::execStateChanged, this, [this](NodeExecState state) { + if (state == NodeExecState::Running) { + if (!m_spinnerTimer.isActive()) { + m_spinnerTimer.start(); + } + } else { + // Stop the spinner only if no other node is still running + bool anyRunning = false; + for (const auto& item : m_layout) { + if (item.node && item.node->execState() == NodeExecState::Running) { + anyRunning = true; + break; + } + } + if (!anyRunning) { + m_spinnerTimer.stop(); + } + } + update(); + }); + connect(node, &Node::editingChanged, this, + QOverload<>::of(&QWidget::update)); + connect(node, &Node::labelChanged, this, + QOverload<>::of(&QWidget::update)); + connect(node, &Node::breakpointChanged, this, + QOverload<>::of(&QWidget::update)); + // Port-level badges (persistent pin, data-location memory/disk) + // depend on OutputPort state that the existing node-level signals + // don't surface — subscribe directly so the badges refresh when + // data is published, evicted, or swapped between memory and disk. + for (auto* port : node->outputPorts()) { + connect(port, &OutputPort::dataChanged, this, + QOverload<>::of(&QWidget::update)); + connect(port, &OutputPort::dataLocationChanged, this, + [this](DataLocation) { update(); }); + connect(port, &OutputPort::persistenceChanged, this, + QOverload<>::of(&QWidget::update)); + } + auto* sink = qobject_cast(node); + if (sink) { + connect(sink, &LegacyModuleSink::visibilityChanged, this, + QOverload<>::of(&QWidget::update)); + } + }; + + connect(m_pipeline, &Pipeline::nodeAdded, this, [this, connectNode](Node* node) { + connectNode(node); + rebuildLayout(); + update(); + }); + + connect(m_pipeline, &Pipeline::nodeRemoved, this, [this](Node* node) { + node->disconnect(this); + if (m_selectedIndex >= 0 && m_selectedIndex < m_layout.size() && + m_layout[m_selectedIndex].node == node) { + m_selectedIndex = -1; + } + m_expandedNodes.remove(node); + rebuildLayout(); + update(); + }); + + connect(m_pipeline, &Pipeline::linkCreated, this, [this](Link* link) { + connect(link, &Link::validityChanged, this, [this]() { + rebuildLayout(); + updateDimming(); + update(); + }); + rebuildLayout(); + updateDimming(); + update(); + }); + + connect(m_pipeline, &Pipeline::linkRemoved, this, [this](Link*) { + rebuildLayout(); + updateDimming(); + update(); + }); + + // Connect existing nodes + for (auto* node : m_pipeline->nodes()) { + connectNode(node); + } + + // Connect existing links for validity change notifications + for (auto* link : m_pipeline->links()) { + connect(link, &Link::validityChanged, this, [this]() { + rebuildLayout(); + update(); + }); + } +} + +void PipelineStripWidget::disconnectPipeline() +{ + if (!m_pipeline) { + return; + } + m_pipeline->disconnect(this); + for (auto* node : m_pipeline->nodes()) { + node->disconnect(this); + } + m_spinnerTimer.stop(); +} + +void PipelineStripWidget::selectItem(int index) +{ + if (index == m_selectedIndex && !m_selectedPort && !m_selectedLink && + !m_selectedMember) { + return; + } + m_selectedIndex = index; + m_selectedPort = nullptr; + m_selectedMember = nullptr; + m_selectedLink = nullptr; + updateDimming(); + update(); + + if (index >= 0 && index < m_layout.size()) { + auto& item = m_layout[index]; + if (item.type == LayoutItem::PortCard) { + emit portSelected(item.port); + } else { + emit nodeSelected(item.node); + } + } else { + emit selectionCleared(); + } +} + +int PipelineStripWidget::hitTest(const QPoint& pos) const +{ + // Search in reverse so port cards (which are inside their parent node card's + // rect) are found before the enclosing node card. + for (int i = m_layout.size() - 1; i >= 0; --i) { + if (m_layout[i].rect.contains(pos)) { + return i; + } + } + return -1; +} + +int PipelineStripWidget::selectedIndex() const +{ + return m_selectedIndex; +} + +void PipelineStripWidget::selectLink(Link* link) +{ + if (link == m_selectedLink && m_selectedIndex < 0 && !m_selectedPort && + !m_selectedMember) { + return; + } + m_selectedIndex = -1; + m_selectedPort = nullptr; + m_selectedMember = nullptr; + m_selectedLink = link; + updateDimming(); + update(); + emit linkSelected(link); +} + +void PipelineStripWidget::navigateVertical(int direction) +{ + // Build a vertically-ordered list of navigable targets: every layout item + // (nodes, port cards, group members) plus every link. Links live outside + // m_layout, so keyboard traversal has to merge the two by screen position. + struct Nav + { + int y; + int layoutIndex; // >= 0 for a layout item, -1 for a link + Link* link; // non-null for a link + }; + QList