diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 4f3fa114f..d7a7c05b4 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: @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.11, 3.12, 3.13] + python-version: [3.11, 3.12, 3.13, 3.14] config: - { name: Linux, @@ -77,6 +77,10 @@ jobs: - name: Install Test Requirements run: | conda install -y --override-channels -c conda-forge --file tomviz/tests/python/requirements-dev.txt + # The pipeline engine is the standalone tomviz-pipeline package + # from PyPI; the compatible range comes from the one place it is + # defined, and its dependencies come from runtime_requirements.txt. + pip install --no-deps -U "$(python tomviz/tomviz/python/tomviz/_pipeline_requirement.py)" pip install --no-build-isolation --no-deps -U tomviz/tomviz/python pip install --no-build-isolation --no-deps -U tomviz/acquisition # These do not have to be in the same environment as Tomviz, but the dependencies @@ -91,7 +95,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/.github/workflows/runtime_requirements.txt b/.github/workflows/runtime_requirements.txt index d7bae88b6..f5a6e83cb 100644 --- a/.github/workflows/runtime_requirements.txt +++ b/.github/workflows/runtime_requirements.txt @@ -9,4 +9,5 @@ pygments pystackreg h5py click +tqdm scikit-image diff --git a/.gitignore b/.gitignore index 212a8717a..ea68804d9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .cache *.egg-info __pycache__ +tomviz/python/build diff --git a/BUILDING.md b/BUILDING.md index b87a0d051..f537b0654 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -43,15 +43,35 @@ Tomviz python library itself. ```bash conda install -y --override-channels -c conda-forge --file tomviz/.github/workflows/runtime_requirements.txt +pip install --no-deps -U "$(python tomviz/tomviz/python/tomviz/_pipeline_requirement.py)" pip install --no-build-isolation --no-deps -U tomviz/tomviz/python ``` +The pipeline engine is the separately released +[tomviz-pipeline](https://pypi.org/project/tomviz-pipeline/) package; +`tomviz/python` is the application-side layer on top of it. The +compatible tomviz-pipeline range is defined once, in +`tomviz/python/tomviz/_pipeline_requirement.py`, which prints it when +run as a script — hence the `$(python ...)` above. Both installs use +`--no-deps` so pip never replaces the conda-managed packages. If your +environment predates that split it still has the old in-tree package +installed under the same name and the two will shadow each other — run +`pip uninstall tomviz-pipeline tomviz-app` and delete +`tomviz/tomviz/python/build/` before the two `pip install` lines above. + Tomviz may then be ran as follows: ```bash ./tomviz-build/bin/tomviz ``` +To run the test suite (`ctest` in the build directory), also install the +test requirements: + +```bash +conda install -y --override-channels -c conda-forge --file tomviz/tests/python/requirements-dev.txt +``` + All of the build instructions above put into a single script are as follows: ```bash @@ -67,5 +87,6 @@ bash tomviz/.github/workflows/scripts/build_tomviz.sh # Install runtime dependencies conda install -y --override-channels -c conda-forge --file tomviz/.github/workflows/runtime_requirements.txt +pip install --no-deps -U "$(python tomviz/tomviz/python/tomviz/_pipeline_requirement.py)" pip install --no-build-isolation --no-deps -U tomviz/tomviz/python ``` diff --git a/CMakeLists.txt b/CMakeLists.txt index 102412bcd..4b09f126c 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}") @@ -100,21 +104,34 @@ include(tomvizDetermineVersion) 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) +# The version being developed here. Authoritative, because "git +# describe" keeps reporting the previous release until the matching tag +# is created, and a 3.1 build must not label itself 3.0.0. Also what +# source tarballs (no git at all) fall back on. +set(tomviz_version_major 3) +set(tomviz_version_minor 1) set(tomviz_version_patch 0) set(tomviz_version_extra) set(tomviz_version "${tomviz_version_major}.${tomviz_version_minor}.${tomviz_version_patch}") -if(tomviz_VERSION) - set(tomviz_version_major ${tomviz_VERSION_MAJOR}) - set(tomviz_version_minor ${tomviz_VERSION_MINOR}) - set(tomviz_version_patch ${tomviz_VERSION_PATCH}) + +# git describe refines that in two ways: it supplies the commit-count +# and sha suffix that distinguishes a development build from the release +# (empty when sitting exactly on the tag), and once a tag newer than the +# version above exists it takes over the number too, so tagging 3.1.1 +# needs no edit here. Packaging that builds outside a git checkout can +# pass -DTOMVIZ_GIT_DESCRIBE= to drive both. +if(tomviz_VERSION_FULL) set(tomviz_version_extra ${tomviz_VERSION_PATCH_EXTRA}) + set(tomviz_git_version + "${tomviz_VERSION_MAJOR}.${tomviz_VERSION_MINOR}.${tomviz_VERSION_PATCH}") + if("${tomviz_git_version}" VERSION_GREATER "${tomviz_version}") + set(tomviz_version_major ${tomviz_VERSION_MAJOR}) + set(tomviz_version_minor ${tomviz_VERSION_MINOR}) + set(tomviz_version_patch ${tomviz_VERSION_PATCH}) + set(tomviz_version "${tomviz_git_version}") + endif() endif() -set(tomviz_version - "${tomviz_version_major}.${tomviz_version_minor}.${tomviz_version_patch}") option(TOMVIZ_USE_EXTERNAL_VTK "Use an external VTK." OFF) mark_as_advanced(TOMVIZ_USE_EXTERNAL_VTK) @@ -149,6 +166,21 @@ find_package(ITK 4.9) set(PYBIND11_CPP_STANDARD "-std=c++17" CACHE STRING "") add_subdirectory(${PROJECT_SOURCE_DIR}/thirdparty/pybind11) +# Route pybind11's GIL management through CPython's PyGILState_Ensure / +# PyGILState_Release (see pybind11/gil_simple.h) instead of its own +# thread-state caching. Tomviz embeds Python alongside VTK/ParaView, +# which pervasively use PyGILState_Ensure (e.g. vtkSmartPyObject's +# destructor). Mixing the two GIL bookkeeping schemes across threads — +# a pipeline worker running a Python transform via pybind while the +# main thread tears down a Python-bearing widget via VTK — corrupts the +# worker's thread state (PyThreadState with a NULL interpreter), a +# SIGSEGV in the next Python allocation. Defined here as an INTERFACE +# definition so every translation unit that includes pybind11/gil.h +# agrees on the gil_scoped_* type (it changes a type alias; a per-target +# definition would be an ODR violation). +target_compile_definitions(pybind11::pybind11 + INTERFACE PYBIND11_SIMPLE_GIL_MANAGEMENT) + option(tomviz_ENABLE_LOOKING_GLASS "Enable the looking glass plugin" OFF) if (tomviz_ENABLE_LOOKING_GLASS) if (NOT DEFINED tomviz_LOOKING_GLASS_PLUGIN_PATH) 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/docker/tomviz-builder/Dockerfile b/docker/tomviz-builder/Dockerfile index 66f5b8763..6e082bbdd 100644 --- a/docker/tomviz-builder/Dockerfile +++ b/docker/tomviz-builder/Dockerfile @@ -15,6 +15,7 @@ RUN echo "#!/bin/bash" > build.sh && \ echo "pip2 install -q -r ../tomviz/tests/python/requirements-dev.txt" >> build.sh && \ echo "pip3 install -q -r ../tomviz/tests/python/requirements-dev.txt" >> build.sh && \ echo "pip2 install -q -r ../tomviz/acquisition/requirements-dev.txt" >> build.sh && \ + echo "pip3 install -q \"tomviz-pipeline>=3.1.3\"" >> build.sh && \ echo "pip3 install -q ../tomviz/tomviz/python/" >> build.sh && \ echo "ctest -VV -S /tomviz/cmake/CircleContinuous.cmake" >> build.sh && \ chmod u+x build.sh diff --git a/docker/tomviz-pipeline/Dockerfile b/docker/tomviz-pipeline/Dockerfile index fc800b46f..edec1a841 100644 --- a/docker/tomviz-pipeline/Dockerfile +++ b/docker/tomviz-pipeline/Dockerfile @@ -1,13 +1,13 @@ -FROM library/python:3.7-slim +FROM library/python:3.12-slim MAINTAINER Chris Harris ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 ENV PYTHONUNBUFFERED=ON -COPY tomviz/python/ /tmp/python/ - -RUN pip install /tmp/python/ && \ - rm -rf /tmp/python/ +# The pipeline runtime is the standalone tomviz-pipeline package. scipy +# is not a dependency of the engine but is widely used by operator +# scripts. +RUN pip install "tomviz-pipeline>=3.1.3" scipy ENTRYPOINT ["tomviz-pipeline"] diff --git a/docker/tomviz-tomopy-pipeline/Dockerfile b/docker/tomviz-tomopy-pipeline/Dockerfile index 4db7050de..94de24d24 100644 --- a/docker/tomviz-tomopy-pipeline/Dockerfile +++ b/docker/tomviz-tomopy-pipeline/Dockerfile @@ -6,10 +6,10 @@ ENV LANG=C.UTF-8 # Install tomopy RUN conda install -c conda-forge tomopy -COPY tomviz/python/ /tmp/python/ - -RUN pip install /tmp/python/ && \ - rm -rf /tmp/python/ +# The pipeline runtime is the standalone tomviz-pipeline package. scipy +# is not a dependency of the engine but is widely used by operator +# scripts. +RUN pip install "tomviz-pipeline>=3.1.3" scipy ENTRYPOINT ["tomviz-pipeline"] diff --git a/examples/batch/README.md b/examples/batch/README.md new file mode 100644 index 000000000..8b6cf7e44 --- /dev/null +++ b/examples/batch/README.md @@ -0,0 +1,50 @@ +# Batch processing example + +One pipeline, many datasets, no GUI. This directory builds a small +reproducible case for the `tomviz-pipeline` command line tool: + + * `make_example.py` writes four volumes with a different number of + spheres in each (`example/inputs/particles_0N.emd`) and a pipeline + `example/particles.tvsm` that blurs, thresholds and labels the spheres. + The pipeline is assembled from the operator scripts in `tomviz/python`, + the same thing you get by saving it from the tomviz GUI. + * `run.sh` runs that pipeline over every input in one call. + +```sh +pip install "tomviz-pipeline>=3.1.7" # 3.1.7 added the label map writer +./run.sh +``` + +`tomviz-pipeline` runs the state file once per matched input and writes +each run's leaf outputs (here, the label map from Connected Components) +to its own directory: + +``` +example/results/ + run_0/4_Connected_Components__volume.emd + run_0/state.tvsm + run_1/4_Connected_Components__volume.emd + run_1/state.tvsm + run_2/... + run_3/... +``` + +Each `state.tvsm` is the pipeline as it ran, with that run's input pinned; +it can be opened in tomviz or passed back to `tomviz-pipeline -s`. + +Open a label map in tomviz, or read it from Python: + +```python +from tomviz_pipeline.io import load_dataset + +labels = load_dataset('example/results/run_2/4_Connected_Components__volume.emd') +print(labels.active_scalars.max(), 'spheres') # 4 +``` + +To apply your own pipeline the same way, save it from the GUI +(`File > Save State`) and point `tomviz-pipeline -s` at it; the `--input` +override works with any state file whose only source is a file reader. +The full set of options, including multi-source pipelines and running in +an external Python environment, is in the +[External Pipelines](https://tomviz.readthedocs.io/en/latest/pipelines.html) +documentation. diff --git a/examples/batch/make_example.py b/examples/batch/make_example.py new file mode 100755 index 000000000..99f002850 --- /dev/null +++ b/examples/batch/make_example.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +"""Build the inputs and the pipeline for the batch-processing example. + + python make_example.py [DIRECTORY] [--count N] [--size S] + +writes DIRECTORY/inputs/particles_00.emd ... particles_0N.emd, each a +noisy volume with a different number of spheres in it, and +DIRECTORY/particles.tvsm, a pipeline that blurs, thresholds and labels +the spheres. The pipeline is assembled from the operator scripts shipped +in tomviz/python, so it is the same thing saving it from the GUI gives +you, and the example never drifts from the operators. + +Needs the tomviz-pipeline package (pip install tomviz-pipeline). +""" +import argparse +import json +from pathlib import Path + +import numpy as np + +from tomviz_pipeline import Dataset +from tomviz_pipeline.writers.emd import write_emd + +OPERATORS = Path(__file__).resolve().parents[2] / 'tomviz' / 'python' + + +def make_volume(index, size, rng): + """A volume with ``2 + index`` non-overlapping spheres in noise. + + The spheres sit on a jittered 2 x 2 x 2 lattice, so they never touch + and Connected Components finds exactly as many as were placed.""" + spheres = 2 + index + volume = rng.normal(0.0, 0.15, (size, size, size)).astype(np.float32) + lattice = [(i, j, k) for i in (0.25, 0.75) for j in (0.25, 0.75) + for k in (0.25, 0.75)] + rng.shuffle(lattice) + x, y, z = np.mgrid[:size, :size, :size] + for site in lattice[:spheres]: + center = np.array(site) * size + rng.uniform(-size / 16, size / 16, 3) + radius = rng.uniform(size / 12, size / 8) + inside = ((x - center[0])**2 + (y - center[1])**2 + + (z - center[2])**2) <= radius**2 + volume[inside] = 1.0 + dataset = Dataset({'Scalars': np.asfortranarray(volume)}, 'Scalars') + dataset.spacing = (1.0, 1.0, 1.0) + return dataset, spheres + + +def operator_node(node_id, name, label, **arguments): + """A Python operator node the way a state file stores it: the + description and the script travel inside the file.""" + return { + 'id': node_id, + 'type': 'transform.legacyPython', + 'label': label, + 'description': (OPERATORS / f'{name}.json').read_text(), + 'script': (OPERATORS / f'{name}.py').read_text(), + 'arguments': arguments, + } + + +def make_state(first_input): + nodes = [ + {'id': 1, 'type': 'source.reader', 'label': 'Reader', + 'fileNames': [str(first_input)]}, + operator_node(2, 'GaussianFilter', 'Gaussian Blur', sigma=1.0), + operator_node(3, 'BinaryThreshold', 'Binary Threshold', + lower_threshold=0.5, upper_threshold=10.0), + operator_node(4, 'ConnectedComponents', 'Connected Components', + background_value=0, connectivity=3, min_size=10), + ] + # Binary Threshold delivers its label map on a port named after its + # child dataset, which is where Connected Components reads from. + links = [ + {'from': {'node': 1, 'port': 'volume'}, + 'to': {'node': 2, 'port': 'volume'}}, + {'from': {'node': 2, 'port': 'volume'}, + 'to': {'node': 3, 'port': 'volume'}}, + {'from': {'node': 3, 'port': 'thresholded_segmentation'}, + 'to': {'node': 4, 'port': 'volume'}}, + ] + return { + 'schemaVersion': 2, + 'pipeline': {'nextNodeId': 5, 'nodes': nodes, 'links': links}, + } + + +def build(directory, count=4, size=64, seed=0): + """Write the inputs and the state file. Returns the state file path, + the input paths and how many spheres each input holds.""" + directory = Path(directory) + inputs_dir = directory / 'inputs' + inputs_dir.mkdir(parents=True, exist_ok=True) + rng = np.random.default_rng(seed) + + inputs, spheres = [], [] + for index in range(count): + dataset, n = make_volume(index, size, rng) + path = inputs_dir / f'particles_{index:02d}.emd' + write_emd(dataset, path) + inputs.append(path) + spheres.append(n) + + state_path = directory / 'particles.tvsm' + state_path.write_text(json.dumps(make_state(inputs[0]), indent=2)) + return state_path, inputs, spheres + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) + parser.add_argument('directory', nargs='?', default='example') + parser.add_argument('--count', type=int, default=4, + help='how many input volumes to write') + parser.add_argument('--size', type=int, default=64, + help='edge length of each volume in voxels') + args = parser.parse_args() + + state_path, inputs, spheres = build(args.directory, args.count, + args.size) + for path, n in zip(inputs, spheres): + print(f'{path}: {n} spheres') + print(f'{state_path}: Gaussian Blur -> Binary Threshold -> ' + 'Connected Components') + + +if __name__ == '__main__': + main() diff --git a/examples/batch/run.sh b/examples/batch/run.sh new file mode 100755 index 000000000..b4848f5ff --- /dev/null +++ b/examples/batch/run.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Run the batch-processing example end to end: make the inputs and the +# pipeline, then process every input with one tomviz-pipeline call. +set -e +cd "$(dirname "$0")" + +python make_example.py example +tomviz-pipeline -s example/particles.tvsm -o example/results \ + --input 'example/inputs/*.emd' + +echo +echo "Results:" +find example/results -type f | sort diff --git a/tests/cxx/AnimationHelperOrbitTest.cxx b/tests/cxx/AnimationHelperOrbitTest.cxx new file mode 100644 index 000000000..c079fd390 --- /dev/null +++ b/tests/cxx/AnimationHelperOrbitTest.cxx @@ -0,0 +1,604 @@ +/* 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 +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include "ActiveObjects.h" +#include "AnimationHelperDialog.h" +#include "AnimationSerializer.h" +#include "animations/AnimationSceneGuard.h" +#include "animations/CameraViewpoints.h" +#include "animations/ModuleAnimation.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" + +using namespace tomviz; + +namespace { + +pqAnimationScene* scene() +{ + return pqPVApplicationCore::instance()->animationManager()->getActiveScene(); +} + +/// Counts ticks, and presses a button on one of them. +struct TickProbe : ModuleAnimation +{ + TickProbe() : ModuleAnimation(nullptr) {} + int ticks = 0; + int pressAt = -1; + QPushButton* button = nullptr; + std::function action; + pqRenderView* view = nullptr; + double lowestX = 0.0; + double highestX = 0.0; + void reset() + { + ticks = 0; + pressAt = -1; + button = nullptr; + action = nullptr; + lowestX = 1e30; + highestX = -1e30; + } + double sweep() const { return highestX - lowestX; } + void onTimeChanged() override + { + if (++ticks == pressAt) { + if (button) { + button->click(); + } + if (action) { + action(); + } + } + if (view) { + double position[3]; + view->getRenderViewProxy()->GetActiveCamera()->GetPosition(position); + lowestX = std::min(lowestX, position[0]); + highestX = std::max(highestX, position[0]); + } + } +}; + +void play(int frames) +{ + auto* proxy = scene()->getProxy(); + pqSMAdaptor::setEnumerationProperty(proxy->GetProperty("PlayMode"), + "Sequence"); + pqSMAdaptor::setElementProperty(proxy->GetProperty("NumberOfFrames"), + frames); + proxy->UpdateVTKObjects(); + proxy->InvokeCommand("Play"); +} + +} // namespace + +/// Drives the Animation Helper's camera tab the way a user would, with +/// no display: the orbit controls, the always-on flight and Clear All. +class AnimationHelperOrbitTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + m_view = qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); + QVERIFY(m_view); + pqActiveObjects::instance().setActiveView(m_view); + // Something loaded, so Play has a view worth an opening orbit + m_pipeline.addNode(new pipeline::SourceNode()); + ActiveObjects::instance().setPipeline(&m_pipeline); + } + + void init() + { + CameraViewpoints::instance().clear(); + m_dialog = new AnimationHelperDialog(nullptr); + m_add = m_dialog->findChild("addViewpoint"); + m_clear = m_dialog->findChild("clearAllAnimations"); + m_remove = m_dialog->findChild("removeViewpoint"); + m_export = m_dialog->findChild("exportMovie"); + m_list = m_dialog->findChild("viewpointList"); + m_orbit = m_dialog->findChild("viewpointOrbit"); + m_turns = m_dialog->findChild("orbitTurns"); + m_direction = m_dialog->findChild("orbitDirection"); + m_duration = m_dialog->findChild("orbitFrames"); + m_frames = m_dialog->findChild("numberOfFrames"); + m_framesLabel = m_dialog->findChild("numberOfFramesLabel"); + QVERIFY(m_add && m_clear && m_remove && m_export && m_list && m_orbit && + m_turns && m_direction && m_duration && m_frames && m_framesLabel); + } + + void cleanup() + { + delete m_dialog; + m_dialog = nullptr; + CameraViewpoints::instance().clear(); + } + + void orbitControlsFollowTheSelectedViewpoint() + { + auto& viewpoints = CameraViewpoints::instance(); + + // Nothing selected: the box is there but off, the details hidden + QVERIFY(!m_orbit->isEnabled()); + QVERIFY(!m_orbit->isChecked()); + QVERIFY(m_turns->isHidden()); + + // One plain viewpoint is not a path, so nothing flies yet + m_add->click(); + QCOMPARE(viewpoints.size(), 1); + QCOMPARE(m_list->currentRow(), 0); + QVERIFY(!viewpoints.isPath()); + QVERIFY(!viewpoints.isFlying()); + QVERIFY(m_orbit->isEnabled()); + QVERIFY(!m_orbit->isChecked()); + QVERIFY(m_turns->isHidden()); + QVERIFY(m_direction->isHidden()); + QVERIFY(m_duration->isHidden()); + + // Ticking Add orbit makes the lone viewpoint a path, arms the flight + // and reveals the details with their defaults + m_orbit->setChecked(true); + QCOMPARE(viewpoints.at(0).orbitTurns, 1); + QCOMPARE(viewpoints.at(0).orbitFrames, 120); + QVERIFY(viewpoints.isPath()); + QVERIFY(viewpoints.isFlying()); + QVERIFY(!m_turns->isHidden()); + QVERIFY(!m_direction->isHidden()); + QVERIFY(!m_duration->isHidden()); + QCOMPARE(m_turns->value(), 1); + QCOMPARE(m_direction->currentIndex(), 0); + QVERIFY(m_export->isEnabled()); + // Its easing is off, for a constant-speed spin, and its own + auto* eased = m_dialog->findChild("orbitEased"); + QVERIFY(eased); + QVERIFY(!eased->isHidden()); + QVERIFY(!eased->isChecked()); + QVERIFY(!viewpoints.at(0).orbitEased); + eased->setChecked(true); + QVERIFY(viewpoints.at(0).orbitEased); + eased->setChecked(false); + QVERIFY(!viewpoints.at(0).orbitEased); + + // The details write straight through to the viewpoint + m_turns->setValue(3); + QCOMPARE(viewpoints.at(0).orbitTurns, 3); + m_direction->setCurrentIndex(1); + QCOMPARE(viewpoints.at(0).orbitTurns, -3); + m_duration->setValue(150); + QCOMPARE(viewpoints.at(0).orbitFrames, 150); + // And survive the list being rebuilt around them + QCOMPARE(m_turns->value(), 3); + QCOMPARE(m_direction->currentIndex(), 1); + QCOMPARE(m_duration->value(), 150); + // The path sets the frame count, and the dialog shows the total in + // place of the box + QVERIFY(m_frames->isHidden()); + QCOMPARE(m_framesLabel->text(), QString("Total: 150 frames")); + auto* sceneProxy = scene()->getProxy(); + QCOMPARE(vtkSMPropertyHelper(sceneProxy, "NumberOfFrames").GetAsInt(), 150); + + // Unticking takes the orbit away, and with it the path and the flight + m_orbit->setChecked(false); + QCOMPARE(viewpoints.at(0).orbitTurns, 0); + QVERIFY(!viewpoints.isPath()); + QVERIFY(!viewpoints.isFlying()); + QVERIFY(m_turns->isHidden()); + + // Back on it starts afresh, one turn counterclockwise; set it up + // again, then a second viewpoint: the controls show each row's own + // orbit, and the second viewpoint alone keeps the path flying + m_orbit->setChecked(true); + QCOMPARE(viewpoints.at(0).orbitTurns, 1); + QCOMPARE(viewpoints.at(0).orbitFrames, 150); + m_turns->setValue(3); + m_direction->setCurrentIndex(1); + QCOMPARE(viewpoints.at(0).orbitTurns, -3); + m_add->click(); + QCOMPARE(viewpoints.size(), 2); + QCOMPARE(m_list->currentRow(), 1); + QVERIFY(!m_orbit->isChecked()); + QVERIFY(m_turns->isHidden()); + QVERIFY(viewpoints.isFlying()); + m_list->setCurrentRow(0); + QVERIFY(m_orbit->isChecked()); + QCOMPARE(m_turns->value(), 3); + QCOMPARE(m_direction->currentIndex(), 1); + QCOMPARE(m_duration->value(), 150); + // Two viewpoints: the orbit plus the leg between them + QCOMPARE(viewpoints.totalFrames(), 150 + 100); + QCOMPARE(vtkSMPropertyHelper(scene()->getProxy(), "NumberOfFrames").GetAsInt(), 250); + m_list->setCurrentRow(1); + m_orbit->setChecked(false); + QVERIFY2(viewpoints.at(0).orbitTurns == -3, "the other row was edited"); + QVERIFY(viewpoints.isFlying()); + m_orbit->setChecked(false); + m_list->setCurrentRow(0); + m_orbit->setChecked(false); + QCOMPARE(viewpoints.at(0).orbitTurns, 0); + QVERIFY2(viewpoints.isFlying(), "two viewpoints are still a path"); + + // Clear All removes the viewpoints and stops the camera with them + m_clear->click(); + QCOMPARE(viewpoints.size(), 0); + QVERIFY(!viewpoints.isFlying()); + QVERIFY(!m_export->isEnabled()); + QVERIFY(!m_orbit->isEnabled()); + // No path: the frame count is the user's again + QVERIFY(!m_frames->isHidden()); + QCOMPARE(m_framesLabel->text(), QString("Number of Frames:")); + } + + // An edit made while the animation plays stops it after that tick and + // rewinds it, so the next Play shows the edit from the top. + void editingWhilePlayingStopsAndRewinds() + { + auto& viewpoints = CameraViewpoints::instance(); + m_add->click(); + QCOMPARE(viewpoints.size(), 1); + + TickProbe probe; + probe.pressAt = 20; + probe.button = m_add; + play(100); + QCOMPARE(viewpoints.size(), 2); + QVERIFY2(probe.ticks < 30, + qPrintable(QString("ran on for %1 ticks").arg(probe.ticks))); + + // The rewind is deferred until the play loop has unwound + QTest::qWait(50); + auto* proxy = scene()->getProxy(); + proxy->UpdatePropertyInformation(); + QCOMPARE(vtkSMPropertyHelper(proxy, "AnimationTime").GetAsDouble(), 0.0); + + // From the top, all the way through: the path now sets the count, + // one 100-frame leg + probe.pressAt = -1; + probe.ticks = 0; + proxy->InvokeCommand("Play"); + QCOMPARE(vtkSMPropertyHelper(proxy, "NumberOfFrames").GetAsInt(), 100); + QVERIFY2(probe.ticks >= 98, + qPrintable(QString("only %1 ticks").arg(probe.ticks))); + + // A paused animation is not disturbed by an edit + m_orbit->setChecked(true); + QTest::qWait(50); + proxy->UpdatePropertyInformation(); + QVERIFY(vtkSMPropertyHelper(proxy, "AnimationTime").GetAsDouble() > 0.5); + } + + // The opening Camera Orbit removed while it plays: the playback stops, + // and a path built afterwards flies as usual. + void removingTheOnlyViewpointWhilePlayingLeavesTheNextPathWorking() + { + auto& viewpoints = CameraViewpoints::instance(); + auto* camera = m_view->getRenderViewProxy()->GetActiveCamera(); + camera->SetPosition(0, 0, 10); + camera->SetFocalPoint(0, 0, 0); + camera->SetViewUp(0, 1, 0); + m_add->click(); + m_orbit->setChecked(true); + QVERIFY(viewpoints.isFlying()); + + TickProbe probe; + probe.view = m_view; + probe.pressAt = 20; + probe.button = m_remove; + m_list->setCurrentRow(0); + play(100); + QCOMPARE(viewpoints.size(), 0); + QVERIFY(!viewpoints.isFlying()); + QVERIFY(probe.ticks < 30); + QTest::qWait(50); + + // Nothing set up any more, so Play makes a new Camera Orbit from + // the view as it is and flies it, all the way round + camera->SetPosition(0, 0, 10); + probe.reset(); + scene()->getProxy()->InvokeCommand("Play"); + QCOMPARE(viewpoints.size(), 1); + QCOMPARE(viewpoints.at(0).name, QString("Camera Orbit")); + QVERIFY(viewpoints.isFlying()); + QVERIFY2(probe.ticks >= 98, qPrintable(QString("%1 ticks").arg(probe.ticks))); + QVERIFY2(probe.highestX > 8.0, "never swung out to the right"); + QVERIFY2(probe.lowestX < -8.0, "never swung out to the left"); + QCOMPARE(m_list->count(), 1); + } + + // Play with nothing to animate makes the opening orbit, once; a lone + // plain viewpoint or an authored animation is left alone. + void playWithNothingSetUpMakesACameraOrbit() + { + auto& viewpoints = CameraViewpoints::instance(); + auto* camera = m_view->getRenderViewProxy()->GetActiveCamera(); + camera->SetPosition(0, 0, 10); + camera->SetFocalPoint(0, 0, 0); + camera->SetViewUp(0, 1, 0); + QCOMPARE(viewpoints.size(), 0); + + TickProbe probe; + probe.view = m_view; + play(60); + QCOMPARE(viewpoints.size(), 1); + QCOMPARE(viewpoints.at(0).name, QString("Camera Orbit")); + QCOMPARE(viewpoints.at(0).orbitTurns, 1); + // As long as the animation was set to be + QCOMPARE(viewpoints.at(0).orbitFrames, 60); + QVERIFY(!viewpoints.at(0).scene.isEmpty()); + QVERIFY(viewpoints.isFlying()); + QVERIFY(probe.ticks >= 58); + QVERIFY(probe.sweep() > 8.0); + + // Playing again does not add another + probe.reset(); + scene()->getProxy()->InvokeCommand("Play"); + QCOMPARE(viewpoints.size(), 1); + QVERIFY(probe.sweep() > 8.0); + + // A single plain viewpoint is a path in the making: Play leaves it + // alone and moves nothing + m_clear->click(); + m_add->click(); + QCOMPARE(viewpoints.size(), 1); + probe.reset(); + scene()->getProxy()->InvokeCommand("Play"); + QCOMPARE(viewpoints.size(), 1); + QCOMPARE(viewpoints.at(0).orbitTurns, 0); + QVERIFY(probe.sweep() < 1e-9); + } + + // Every edit a user can make from the dialog, made from inside a + // tick of a running playback. After each: the playback has stopped + // and rewound, the flight matches the path, and a fresh Play runs + // through and moves the camera whenever there is a path to fly. + void everyEditDuringPlaybackLeavesTheAnimationWorking() + { + auto& viewpoints = CameraViewpoints::instance(); + auto* camera = m_view->getRenderViewProxy()->GetActiveCamera(); + auto* proxy = scene()->getProxy(); + auto* update = m_dialog->findChild("updateViewpoint"); + auto* duration = m_dialog->findChild("segmentDuration"); + auto* frames = m_dialog->findChild("numberOfFrames"); + QVERIFY(update && duration && frames); + + camera->SetPosition(0, 0, 10); + camera->SetFocalPoint(0, 0, 0); + camera->SetViewUp(0, 1, 0); + m_add->click(); + m_orbit->setChecked(true); + camera->SetPosition(20, 0, 10); + m_add->click(); + QCOMPARE(viewpoints.size(), 2); + + TickProbe probe; + probe.view = m_view; + struct Edit + { + const char* name; + std::function act; + bool rewinds = true; + }; + const QList edits = { + { "update from view", [&]() { m_list->setCurrentRow(1); update->click(); } }, + { "reorder", [&]() { viewpoints.move(0, 1); } }, + // Go To stops the playback but keeps the camera where it was sent + { "go to", [&]() { emit m_list->itemDoubleClicked(m_list->item(0)); }, false }, + // Rename applies on the next event-loop turn, once the list widget + // is done with its own edit + { "rename", [&]() { m_list->item(0)->setText("Hero shot"); } }, + { "leg frames", [&]() { m_list->setCurrentRow(0); duration->setValue(80); } }, + { "orbit turns", [&]() { m_list->setCurrentRow(0); m_orbit->setChecked(true); m_turns->setValue(2); } }, + { "orbit off", [&]() { m_list->setCurrentRow(0); m_orbit->setChecked(false); } }, + { "frame count", [&]() { frames->setValue(80); } }, + { "remove one", [&]() { m_list->setCurrentRow(1); m_remove->click(); } }, + { "clear all", [&]() { m_clear->click(); } }, + }; + for (const auto& edit : edits) { + const int before = viewpoints.size(); + probe.reset(); + probe.pressAt = 10; + probe.action = edit.act; + play(60); + QVERIFY2(probe.ticks < 25, + qPrintable(QString("%1: ran on for %2 ticks").arg(edit.name).arg(probe.ticks))); + QTest::qWait(50); + proxy->UpdatePropertyInformation(); + const double time = vtkSMPropertyHelper(proxy, "AnimationTime").GetAsDouble(); + QVERIFY2(edit.rewinds ? time == 0.0 : time > 0.0, + qPrintable(QString("%1: time %2").arg(edit.name).arg(time))); + QVERIFY2(viewpoints.isFlying() == viewpoints.isPath(), + qPrintable(QString("%1: flight does not match the path").arg(edit.name))); + + probe.reset(); + proxy->InvokeCommand("Play"); + // A resumed playback picks up after the tick that stopped it + const int expected = vtkSMPropertyHelper(proxy, "NumberOfFrames").GetAsInt() - + (edit.rewinds ? 0 : 10); + QVERIFY2(probe.ticks >= expected - 2, + qPrintable(QString("%1: only %2 of %3 ticks").arg(edit.name).arg(probe.ticks).arg(expected))); + if (viewpoints.isPath()) { + QVERIFY2(probe.sweep() > 1.0, + qPrintable(QString("%1: the camera did not move").arg(edit.name))); + } else { + QVERIFY2(probe.sweep() < 1e-9, + qPrintable(QString("%1: nothing to fly, yet the camera moved").arg(edit.name))); + } + Q_UNUSED(before); + } + // Clear All emptied the list, and that last Play made the opening + // orbit out of the emptiness + QCOMPARE(viewpoints.size(), 1); + QCOMPARE(viewpoints.at(0).name, QString("Camera Orbit")); + } + + // A rename commits after the list widget has finished its own edit, + // and the list and the model agree afterwards. + void renamingAViewpointRebuildsTheListSafely() + { + auto& viewpoints = CameraViewpoints::instance(); + m_add->click(); + m_add->click(); + QCOMPARE(viewpoints.size(), 2); + m_list->setCurrentRow(1); + auto* item = m_list->item(1); + item->setFlags(item->flags() | Qt::ItemIsEditable); + m_list->editItem(item); + QVERIFY(m_list->findChild() != nullptr); + // Setting the flag alone reports the item as changed. The editor the + // user is typing into has to survive the event loop that follows. + QTest::qWait(20); + QVERIFY(m_list->findChild() != nullptr); + QCOMPARE(m_list->item(1), item); + item->setText("Detail"); + m_list->closePersistentEditor(item); + // Not yet: the widget is still the owner of that edit + QCOMPARE(viewpoints.at(1).name, QString("Viewpoint 2")); + QTest::qWait(20); + QCOMPARE(viewpoints.at(1).name, QString("Detail")); + QCOMPARE(m_list->count(), 2); + QCOMPARE(m_list->item(1)->text(), QString("Detail")); + QCOMPARE(m_list->currentRow(), 1); + + // An empty name is rejected and the old one comes back + m_list->item(1)->setText(" "); + QTest::qWait(20); + QCOMPARE(viewpoints.at(1).name, QString("Detail")); + QCOMPARE(m_list->item(1)->text(), QString("Detail")); + } + + // The view the flight was armed in goes away; the next render view + // picks the path up, with no help from the dialog. + void aNewRenderViewPicksUpTheFlight() + { + auto& viewpoints = CameraViewpoints::instance(); + auto* camera = m_view->getRenderViewProxy()->GetActiveCamera(); + camera->SetPosition(0, 0, 10); + camera->SetFocalPoint(0, 0, 0); + camera->SetViewUp(0, 1, 0); + m_add->click(); + m_orbit->setChecked(true); + QVERIFY(viewpoints.isFlying()); + + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + builder->destroy(m_view); + QTest::qWait(50); + QVERIFY(!viewpoints.isFlying()); + + m_view = qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); + QVERIFY(m_view); + pqActiveObjects::instance().setActiveView(m_view); + QTest::qWait(50); + QVERIFY2(viewpoints.isFlying(), "the new view did not pick up the path"); + + TickProbe probe; + probe.view = m_view; + probe.reset(); + play(60); + QVERIFY(probe.ticks >= 58); + QVERIFY2(probe.sweep() > 8.0, "the camera did not fly in the new view"); + } + + void legacyOrbitStateBecomesAnOrbitingViewpoint() + { + auto& viewpoints = CameraViewpoints::instance(); + QJsonObject animation; + animation["viewpoints"] = QJsonArray(); + animation["cameraOrbit"] = true; + QJsonObject doc; + doc["animation"] = animation; + + AnimationSerializer::restore(doc, &m_pipeline); + QCOMPARE(viewpoints.size(), 1); + QCOMPARE(viewpoints.at(0).orbitTurns, 1); + QCOMPARE(viewpoints.at(0).name, QString("Camera Orbit")); + QVERIFY(viewpoints.isFlying()); + QCOMPARE(m_list->count(), 1); + QCOMPARE(m_list->currentRow(), -1); + + // A file with a path of its own is left alone + QJsonObject saved; + AnimationSerializer::save(saved); + QVERIFY(!saved["animation"].toObject().contains("cameraOrbit")); + auto again = saved["animation"].toObject(); + again["cameraOrbit"] = true; + doc["animation"] = again; + AnimationSerializer::restore(doc, &m_pipeline); + QCOMPARE(viewpoints.size(), 1); + QVERIFY(viewpoints.isFlying()); + + // And one with no path at all flies nothing + QJsonObject empty; + empty["viewpoints"] = QJsonArray(); + doc["animation"] = empty; + AnimationSerializer::restore(doc, &m_pipeline); + QCOMPARE(viewpoints.size(), 0); + QVERIFY(!viewpoints.isFlying()); + } + +private: + pipeline::Pipeline m_pipeline; + pqRenderView* m_view = nullptr; + AnimationHelperDialog* m_dialog = nullptr; + QPushButton* m_add = nullptr; + QPushButton* m_clear = nullptr; + QPushButton* m_remove = nullptr; + QPushButton* m_export = nullptr; + QListWidget* m_list = nullptr; + QCheckBox* m_orbit = nullptr; + QSpinBox* m_turns = nullptr; + QComboBox* m_direction = nullptr; + QSpinBox* m_duration = nullptr; + QSpinBox* m_frames = nullptr; + QLabel* m_framesLabel = nullptr; +}; + +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:")); + // Installed by the main window in the application + AnimationSceneGuard guard; + AnimationHelperOrbitTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "AnimationHelperOrbitTest.moc" diff --git a/tests/cxx/AnimationPlaybackTest.cxx b/tests/cxx/AnimationPlaybackTest.cxx new file mode 100644 index 000000000..71c5d825f --- /dev/null +++ b/tests/cxx/AnimationPlaybackTest.cxx @@ -0,0 +1,751 @@ +/* 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 +#include +#include +#include +#include +#include + +#include + +#include "AnimationSceneGuard.h" +#include "CameraViewpoints.h" +#include "Utilities.h" +#include "ModuleAnimation.h" + +using namespace tomviz; + +namespace { + +/// Records what a module animation sees on every tick of a playback. +class Probe : public ModuleAnimation +{ +public: + Probe() : ModuleAnimation(nullptr) {} + + void onTimeChanged() override + { + progressSamples.append(progress()); + if (view) { + double pos[3]; + view->getRenderViewProxy()->GetActiveCamera()->GetPosition(pos); + cameraX.append(pos[0]); + } + } + + QList progressSamples; + QList cameraX; + pqRenderView* view = nullptr; +}; + +Viewpoint viewpointAt(double x) +{ + Viewpoint viewpoint; + viewpoint.position = { x, 0, 10 }; + viewpoint.focalPoint = { x, 0, 0 }; + viewpoint.eased = false; + return viewpoint; +} + +pqAnimationScene* scene() +{ + return pqPVApplicationCore::instance()->animationManager()->getActiveScene(); +} + +void play(int frames) +{ + auto* proxy = scene()->getProxy(); + pqSMAdaptor::setEnumerationProperty(proxy->GetProperty("PlayMode"), + "Sequence"); + pqSMAdaptor::setElementProperty(proxy->GetProperty("NumberOfFrames"), + frames); + proxy->UpdateVTKObjects(); + proxy->InvokeCommand("Play"); +} + +} // namespace + +class AnimationPlaybackTest : public QObject +{ + Q_OBJECT + +private slots: + void cleanup() { CameraViewpoints::instance().clear(); } + + // The whole animation system hangs off pqTimeKeeper::timeChanged and + // ModuleAnimation::progress(). A plain Sequence playback with no time + // series loaded must tick every frame and run progress from 0 to 1. + void playbackTicksModuleAnimationsFromStartToEnd() + { + Probe probe; + play(10); + + // The time keeper only announces changes, so a playback that starts + // where the clock already sits (time 0 on a fresh session) does not + // tick its first frame; startFlight's snapToHead covers that. From + // then on every frame ticks, through to the end. + QVERIFY2(probe.progressSamples.size() >= 5, + qPrintable(QString("only %1 ticks").arg(probe.progressSamples.size()))); + QVERIFY(probe.progressSamples.first() < 0.2); + QVERIFY(probe.progressSamples.last() > 0.95); + for (int i = 1; i < probe.progressSamples.size(); ++i) { + QVERIFY(probe.progressSamples[i] >= probe.progressSamples[i - 1]); + } + } + + // Two viewpoints and an armed flight: playing must carry the camera + // from the first to the second, through positions in between. + void cameraFliesBetweenViewpointsDuringPlayback() + { + auto* server = pqActiveObjects::instance().activeServer(); + QVERIFY(server); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + auto* view = qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); + QVERIFY(view); + + auto& viewpoints = CameraViewpoints::instance(); + viewpoints.append(viewpointAt(0.0)); + viewpoints.append(viewpointAt(10.0)); + // As addViewpoint does: armed, no snap to the head + viewpoints.startFlight(view, /*snapToHead=*/false); + QVERIFY(viewpoints.isFlying()); + + // Connected after the flight, so each tick reads the camera the + // flight just moved + // The interpolation itself, independent of any view: a straight + // line between the two, sampled where the camera should be + vtkNew camera; + for (double t : { 0.0, 0.25, 0.5, 0.75, 1.0 }) { + viewpoints.interpolate(t, camera); + double pos[3]; + camera->GetPosition(pos); + QVERIFY2(std::abs(pos[0] - 10.0 * t) < 1e-6, + qPrintable(QString("t=%1 x=%2").arg(t).arg(pos[0]))); + QVERIFY(std::abs(pos[2] - 10.0) < 1e-6); + } + + // Three viewpoints curve through the middle one + viewpoints.append(viewpointAt(20.0)); + viewpoints.interpolate(0.5, camera); + double mid[3]; + camera->GetPosition(mid); + QVERIFY(std::abs(mid[0] - 10.0) < 1e-6); + viewpoints.removeAt(2); + + Probe probe; + probe.view = view; + play(10); + + QVERIFY(probe.cameraX.size() >= 5); + QVERIFY(std::abs(probe.cameraX.last() - 10.0) < 0.5); + bool between = false; + for (double x : probe.cameraX) { + between = between || (x > 2.0 && x < 8.0); + } + QVERIFY2(between, "the camera jumped rather than flew"); + + viewpoints.stopFlight(); + builder->destroy(view); + } + + // A lone viewpoint with an orbit is a whole animation: played through + // the scene it carries the camera right round the focal point, out + // to both sides, and back to where it started. + // Fifty frames reach the last one a rounding error short of the end, + // and ParaView's player only rewinds from the end itself, so without + // the guard the second Play ticks once and stops. Plays must keep + // running from the top. + void animationReplaysAfterReachingTheEnd() + { + Probe probe; + for (int frames : { 50, 200, 100 }) { + for (int round = 0; round < 3; ++round) { + probe.progressSamples.clear(); + play(frames); + QVERIFY2(probe.progressSamples.size() >= frames - 2, + qPrintable(QString("%1 frames, round %2: only %3 ticks") + .arg(frames) + .arg(round) + .arg(probe.progressSamples.size()))); + QVERIFY(probe.progressSamples.first() < 0.1); + QVERIFY(probe.progressSamples.last() > 0.95); + } + } + } + + // Changing the frame count while playing (from a tick, as the frame + // box's edit does) ends that playback early; the next one resumes and + // the ones after run from the top again. + void frameCountChangeMidPlayDoesNotStrandTheAnimation() + { + struct Shrinker : ModuleAnimation + { + Shrinker() : ModuleAnimation(nullptr) {} + int ticks = 0; + void onTimeChanged() override + { + if (++ticks == 100) { + tomviz::setAnimationNumberOfFrames(50); + } + } + } shrinker; + Probe probe; + play(200); + QVERIFY(probe.progressSamples.size() >= 90); + QVERIFY(probe.progressSamples.size() <= 110); + + auto* proxy = scene()->getProxy(); + probe.progressSamples.clear(); + proxy->InvokeCommand("Play"); + QVERIFY2(probe.progressSamples.size() >= 15, "did not resume"); + QVERIFY(probe.progressSamples.last() > 0.95); + + for (int round = 0; round < 2; ++round) { + probe.progressSamples.clear(); + proxy->InvokeCommand("Play"); + QVERIFY2(probe.progressSamples.size() >= 48, + qPrintable(QString("round %1: only %2 ticks") + .arg(round) + .arg(probe.progressSamples.size()))); + QVERIFY(probe.progressSamples.first() < 0.1); + QVERIFY(probe.progressSamples.last() > 0.95); + } + } + + void cameraOrbitsALoneViewpointDuringPlayback() + { + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + auto* view = qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); + QVERIFY(view); + + auto& viewpoints = CameraViewpoints::instance(); + Viewpoint orbiting = viewpointAt(0.0); + orbiting.viewUp = { 0, 1, 0 }; + orbiting.orbitTurns = 1; + viewpoints.append(orbiting); + // The guard arms the flight as soon as the path exists + QVERIFY(viewpoints.isFlying()); + QVERIFY(!viewpoints.syncFlight(view)); + + Probe probe; + probe.view = view; + play(20); + + QVERIFY(probe.cameraX.size() >= 10); + double lowest = 0.0, highest = 0.0; + for (double x : probe.cameraX) { + lowest = std::min(lowest, x); + highest = std::max(highest, x); + } + QVERIFY2(highest > 8.0, "never swung out to the right"); + QVERIFY2(lowest < -8.0, "never swung out to the left"); + QVERIFY2(std::abs(probe.cameraX.last()) < 0.5, "did not come back"); + + // Taking the orbit away leaves no path, and the model drops the + // flight on its own + Viewpoint still = orbiting; + still.orbitTurns = 0; + viewpoints.replace(0, still); + QVERIFY(!viewpoints.isFlying()); + QVERIFY(!viewpoints.syncFlight(view)); + builder->destroy(view); + } +}; + +#include "AnimatableProperties.h" +#include "CutOutAnimation.h" +#include "ExplodedAnimation.h" +#include "ModuleAnimations.h" +#include "ThresholdAnimation.h" +#include "OpacityAnimation.h" +#include "RecordedAnimations.h" +#include "SceneSnapshot.h" +#include "pipeline/Pipeline.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ThresholdSink.h" +#include "pipeline/sinks/VolumeSink.h" + +// The animations built from recorded viewpoint state: they need a +// ParaView core to exist, so they are exercised here rather than in the +// plain gtest suite. +class RecordedAnimationTest : public QObject +{ + Q_OBJECT + + // Two viewpoints, one straight leg between them: path time is the + // progress along it + static void twoViewpoints() + { + auto& viewpoints = CameraViewpoints::instance(); + Viewpoint first; + first.eased = false; + Viewpoint second = first; + second.position = { 0, 0, 5 }; + viewpoints.append(first); + viewpoints.append(second); + } + +private slots: + void cleanup() + { + ModuleAnimations::instance().clear(); + CameraViewpoints::instance().clear(); + } + + // Adding a visualization animation while the scene plays (from a + // tick, as the dialog's Add button would) stops that playback and + // rewinds it; the next one runs through with the new animation in it. + void addingAModuleAnimationDuringPlaybackStopsAndRewinds() + { + pipeline::Pipeline pipeline; + auto* slice = new pipeline::SliceSink(); + pipeline.addNode(slice); + slice->setOpacity(1.0); + + struct Adder : ModuleAnimation + { + Adder() : ModuleAnimation(nullptr) {} + int ticks = 0; + pipeline::SliceSink* slice = nullptr; + void onTimeChanged() override + { + if (++ticks == 10) { + ModuleAnimations::instance().add(new OpacityAnimation(slice, 1.0, 0.2)); + } + } + } adder; + adder.slice = slice; + play(60); + QVERIFY2(adder.ticks < 25, qPrintable(QString("ran on for %1 ticks").arg(adder.ticks))); + QCOMPARE(ModuleAnimations::instance().animations().size(), 1); + + QTest::qWait(50); + auto* proxy = scene()->getProxy(); + proxy->UpdatePropertyInformation(); + QCOMPARE(vtkSMPropertyHelper(proxy, "AnimationTime").GetAsDouble(), 0.0); + + adder.ticks = 100; // no second add + Probe probe; + proxy->InvokeCommand("Play"); + QVERIFY2(probe.progressSamples.size() >= 58, + qPrintable(QString("only %1 ticks").arg(probe.progressSamples.size()))); + // The new animation ran: the slice faded down to its end value + QVERIFY(slice->opacity() < 0.25); + } + + void recordedOpacityFadesInAndYieldsToAuthoredAnimations() + { + pipeline::Pipeline pipeline; + Viewpoint first = viewpointAt(0.0); + first.scene = SceneSnapshot::capture(&pipeline); + CameraViewpoints::instance().append(first); + + auto* slice = new pipeline::SliceSink(); + pipeline.addNode(slice); + slice->setOpacity(0.6); + Viewpoint second = viewpointAt(10.0); + second.scene = SceneSnapshot::capture(&pipeline); + CameraViewpoints::instance().append(second); + + auto& recorded = RecordedAnimations::instance(); + recorded.sync(&pipeline); + auto animations = ModuleAnimations::instance().animations(); + QCOMPARE(animations.size(), 1); + auto* fade = qobject_cast(animations.first()); + QVERIFY(fade); + QVERIFY(fade->recorded()); + QCOMPARE(fade->type(), QString("opacity")); + + // Hidden at the head (keeping the opacity it will fade up to), half + // way at the middle, arrived at the end + fade->applyPathTime(0.0); + QVERIFY(!slice->visibility()); + QCOMPARE(slice->opacity(), 0.6); + fade->applyPathTime(0.5); + QVERIFY(slice->visibility()); + QCOMPARE(slice->opacity(), 0.3); + fade->applyPathTime(1.0); + QVERIFY(slice->visibility()); + QCOMPARE(slice->opacity(), 0.6); + + // An authored opacity animation on the same leg takes it over: the + // recorded one leaves the slice alone there and the list shows the + // authored row instead + auto* authored = new OpacityAnimation(slice, 1.0, 0.2); + authored->segment = 0; + ModuleAnimations::instance().add(authored); + recorded.sync(&pipeline); + RecordedAnimation* rebuilt = nullptr; + for (auto* animation : ModuleAnimations::instance().animations()) { + if (animation->recorded()) { + rebuilt = qobject_cast(animation); + } + } + QVERIFY(rebuilt); + slice->setOpacity(0.9); + rebuilt->applyPathTime(0.5); + QCOMPARE(slice->opacity(), 0.9); + QVERIFY(recorded.changes(&pipeline).isEmpty()); + + // A slice moved between viewpoints slides its index along the leg + // (recorded alongside the fade, since both changed) + slice->setSlice(10); + Viewpoint moved = viewpointAt(20.0); + moved.scene = SceneSnapshot::capture(&pipeline); + slice->setSlice(50); + CameraViewpoints::instance().replace(1, moved); + Viewpoint third = viewpointAt(30.0); + third.scene = SceneSnapshot::capture(&pipeline); + CameraViewpoints::instance().append(third); + recorded.sync(&pipeline); + RecordedAnimation* plane = nullptr; + for (auto* animation : ModuleAnimations::instance().animations()) { + if (animation->recorded() && animation->type() == "slice") { + plane = qobject_cast(animation); + } + } + QVERIFY(plane); + // Stops: 0, 0.5, 1 with equal legs; the slice changes on the second + plane->applyPathTime(0.5); + QCOMPARE(slice->slice(), 10); + plane->applyPathTime(0.75); + QCOMPARE(slice->slice(), 30); + plane->applyPathTime(1.0); + QCOMPARE(slice->slice(), 50); + CameraViewpoints::instance().removeAt(2); + slice->setSlice(10); + recorded.sync(&pipeline); + + // Not saved: rebuilt from the viewpoints instead + auto json = ModuleAnimations::instance().serialize(&pipeline); + QCOMPARE(json["modules"].toArray().size(), 1); + + // Removing the recorded change edits the viewpoint, and a sync then + // has nothing recorded left to build + ModuleAnimations::instance().remove(authored); + recorded.sync(&pipeline); + auto changes = recorded.changes(&pipeline); + QCOMPARE(changes.size(), 1); + recorded.remove(changes.first(), &pipeline); + recorded.sync(&pipeline); + for (auto* animation : ModuleAnimations::instance().animations()) { + QVERIFY(!animation->recorded()); + } + } + + // An authored exploded-view animation sweeps the gap and the chunk + // count during playback, switching the exploded view on, and comes + // back from a state file with its unit. + void explodedAnimationSweepsTheViewAndSurvivesAStateFile() + { + pipeline::Pipeline pipeline; + auto* volume = new pipeline::VolumeSink(); + pipeline.addNode(volume); + volume->setExplodedGap(0.0); + QVERIFY(!volume->explodedEnabled()); + + auto& animations = ModuleAnimations::instance(); + animations.add( + new ExplodedAnimation(volume, 0.0, 0.5, ExplodedAnimation::Gap)); + animations.add( + new ExplodedAnimation(volume, 2, 8, ExplodedAnimation::Chunks)); + // The offset is stored as swept and clamped when the slabs are cut, + // so it reads back as the sweep's end even without data + animations.add( + new ExplodedAnimation(volume, -3, 3, ExplodedAnimation::Offset)); + + play(10); + QVERIFY(volume->explodedEnabled()); + // The last tick lands at progress 1 or just short of it + QVERIFY2(std::abs(volume->explodedGap() - 0.5) < 0.06, + qPrintable(QString("gap %1").arg(volume->explodedGap()))); + QCOMPARE(volume->explodedChunks(), 8); + + QCOMPARE(volume->explodedOffset(), 3); + + auto json = animations.serialize(&pipeline); + auto entries = json["modules"].toArray(); + QCOMPARE(entries.size(), 3); + QCOMPARE(entries[0].toObject()["type"].toString(), QString("exploded")); + QCOMPARE(entries[0].toObject()["unit"].toString(), QString("gap")); + QCOMPARE(entries[1].toObject()["unit"].toString(), QString("chunks")); + QCOMPARE(entries[2].toObject()["unit"].toString(), QString("offset")); + + animations.deserialize(json, &pipeline); + auto restored = animations.animations(); + QCOMPARE(restored.size(), 3); + auto* gap = qobject_cast(restored[0]); + QVERIFY(gap); + QCOMPARE(gap->unit, ExplodedAnimation::Gap); + QCOMPARE(gap->startValue, 0.0); + QCOMPARE(gap->stopValue, 0.5); + QCOMPARE(gap->baseNode.data(), volume); + auto* chunks = qobject_cast(restored[1]); + QVERIFY(chunks); + QCOMPARE(chunks->unit, ExplodedAnimation::Chunks); + QCOMPARE(chunks->stopValue, 8.0); + auto* offset = qobject_cast(restored[2]); + QVERIFY(offset); + QCOMPARE(offset->unit, ExplodedAnimation::Offset); + QCOMPARE(offset->startValue, -3.0); + + // Only a volume can be exploded + auto* slice = new pipeline::SliceSink(); + pipeline.addNode(slice); + QJsonObject wrong; + wrong["modules"] = QJsonArray{ QJsonObject( + { { "type", "exploded" }, + { "node", pipeline.nodeId(slice) }, + { "start", 0 }, + { "stop", 1 }, + { "unit", "gap" } }) }; + animations.deserialize(wrong, &pipeline); + QVERIFY(animations.animations().isEmpty()); + } + + // Turning the exploded view on grows the gap from nothing, turning it + // off closes it, and new slabs close up and open out again; nothing + // pops in a single frame. + void recordedExplodedViewSlidesOnOffAndBetweenSetups() + { + twoViewpoints(); + pipeline::Pipeline pipeline; + auto* volume = new pipeline::VolumeSink(); + pipeline.addNode(volume); + + ExplodedKey off; + off.chunks = 4; + off.gap = 0.4; + ExplodedKey on = off; + on.enabled = true; + RecordedExplodedAnimation turningOn(volume, { { 0, off }, { 1, on } }); + turningOn.applyPathTime(0.0); + QVERIFY(!volume->explodedEnabled()); + turningOn.applyPathTime(0.5); + QVERIFY(volume->explodedEnabled()); + QVERIFY(qFuzzyCompare(volume->explodedGap(), 0.2)); + turningOn.applyPathTime(1.0); + QVERIFY(qFuzzyCompare(volume->explodedGap(), 0.4)); + + RecordedExplodedAnimation turningOff(volume, { { 0, on }, { 1, off } }); + turningOff.applyPathTime(0.25); + QVERIFY(volume->explodedEnabled()); + QVERIFY(qFuzzyCompare(volume->explodedGap(), 0.3)); + turningOff.applyPathTime(1.0); + QVERIFY(!volume->explodedEnabled()); + + // Four slabs to eight: closed at the midpoint, where the count + // changes, and open again by the end + ExplodedKey eight = on; + eight.chunks = 8; + RecordedExplodedAnimation resliced(volume, { { 0, on }, { 1, eight } }); + resliced.applyPathTime(0.25); + QCOMPARE(volume->explodedChunks(), 4); + QVERIFY(qFuzzyCompare(volume->explodedGap(), 0.2)); + resliced.applyPathTime(0.5); + QCOMPARE(volume->explodedChunks(), 8); + QCOMPARE(volume->explodedGap(), 0.0); + resliced.applyPathTime(0.75); + QVERIFY(qFuzzyCompare(volume->explodedGap(), 0.2)); + QVERIFY(volume->explodedEnabled()); + } + + // The cut-out box grows out of its corner, shrinks back into it, and + // moves corners by way of an empty box. Swapped for the exploded view + // (the two cannot both be on), each takes half the leg. + void recordedCutOutGrowsShrinksAndHandsOverToTheExplodedView() + { + twoViewpoints(); + pipeline::Pipeline pipeline; + auto* volume = new pipeline::VolumeSink(); + pipeline.addNode(volume); + + CutOutKey off; + off.corner = 7; // high X, Y and Z: it grows down from (1, 1, 1) + off.position = { 0.5, 0.5, 0.5 }; + CutOutKey on = off; + on.enabled = true; + RecordedCutOutAnimation turningOn(volume, { { 0, off }, { 1, on } }); + turningOn.applyPathTime(0.0); + QVERIFY(!volume->cutOutEnabled()); + turningOn.applyPathTime(0.5); + QVERIFY(volume->cutOutEnabled()); + QCOMPARE(volume->cutOutPosition(0), 0.75); + turningOn.applyPathTime(1.0); + QCOMPARE(volume->cutOutPosition(2), 0.5); + + CutOutKey lowCorner = on; + lowCorner.corner = 0; // grows up from (0, 0, 0) + RecordedCutOutAnimation moved(volume, { { 0, on }, { 1, lowCorner } }); + moved.applyPathTime(0.25); + QCOMPARE(volume->cutOutCorner(), 7); + QCOMPARE(volume->cutOutPosition(1), 0.75); + moved.applyPathTime(0.75); + QCOMPARE(volume->cutOutCorner(), 0); + QCOMPARE(volume->cutOutPosition(1), 0.25); + + // Cut-out at the first viewpoint, exploded view at the second + CutOutKey cutFirst = on; + CutOutKey cutAfter = off; + cutAfter.otherOn = true; + ExplodedKey explodedFirst; + explodedFirst.gap = 0.4; + explodedFirst.otherOn = true; + ExplodedKey explodedAfter = explodedFirst; + explodedFirst.enabled = false; + explodedAfter.enabled = true; + explodedAfter.otherOn = false; + RecordedCutOutAnimation cut(volume, { { 0, cutFirst }, { 1, cutAfter } }); + RecordedExplodedAnimation exploded( + volume, { { 0, explodedFirst }, { 1, explodedAfter } }); + auto at = [&](double t) { + cut.applyPathTime(t); + exploded.applyPathTime(t); + }; + at(0.25); + QVERIFY(volume->cutOutEnabled()); + QVERIFY(!volume->explodedEnabled()); + QCOMPARE(volume->cutOutPosition(0), 0.75); + at(0.75); + QVERIFY(!volume->cutOutEnabled()); + QVERIFY(volume->explodedEnabled()); + QVERIFY(qFuzzyCompare(volume->explodedGap(), 0.2)); + } + + // Solidity is recorded with a viewpoint, comes back from a state file, + // and plays between viewpoints geometrically: halfway from 0.1 to 1 is + // their geometric mean. An authored sweep does the same and survives a + // state file. + void solidityIsRecordedAndSweptGeometrically() + { + twoViewpoints(); + pipeline::Pipeline pipeline; + auto* volume = new pipeline::VolumeSink(); + pipeline.addNode(volume); + + volume->setSolidity(0.25); + auto snapshot = SinkSnapshot::capture(volume); + QVERIFY(snapshot.solidity); + QVERIFY(qFuzzyCompare(*snapshot.solidity, 0.25)); + auto restored = SinkSnapshot::deserialize(snapshot.serialize()); + QVERIFY(restored.solidity); + QVERIFY(qFuzzyCompare(*restored.solidity, 0.25)); + + // The panel follows through a signal, as it does for the cut-out + double announced = 0.0; + connect(volume, &pipeline::VolumeSink::solidityChanged, + [&announced](double value) { announced = value; }); + RecordedSolidityAnimation recorded(volume, { { 0, 0.1 }, { 1, 1.0 } }); + recorded.applyPathTime(0.5); + QVERIFY(qFuzzyCompare(volume->solidity(), std::sqrt(0.1))); + QVERIFY(qFuzzyCompare(announced, std::sqrt(0.1))); + recorded.applyPathTime(1.0); + QVERIFY(qFuzzyCompare(volume->solidity(), 1.0)); + + auto* property = animatableProperty("solidity"); + QVERIFY(property); + QVERIFY(property->applies(volume)); + auto& animations = ModuleAnimations::instance(); + animations.add(property->make(volume, 1.0, 0.1)); + auto json = animations.serialize(&pipeline); + animations.deserialize(json, &pipeline); + QCOMPARE(animations.animations().size(), 1); + double start = 0, stop = 0; + QCOMPARE(animatablePropertyOf(animations.animations().first(), start, + stop), + property); + QCOMPARE(start, 1.0); + QCOMPARE(stop, 0.1); + } + + // A threshold range end and a cut-out position sweep during playback + // and come back from a state file, built through the property table + // the Animation Helper authors with. + void thresholdAndCutOutSweepsPlayAndSurviveAStateFile() + { + pipeline::Pipeline pipeline; + auto* threshold = new pipeline::ThresholdSink(); + pipeline.addNode(threshold); + threshold->setThresholdRange(10.0, 90.0); + auto* volume = new pipeline::VolumeSink(); + pipeline.addNode(volume); + QVERIFY(!volume->cutOutEnabled()); + + const auto* lower = animatableProperty("thresholdLower"); + const auto* cutOutY = animatableProperty("cutOutY"); + QVERIFY(lower && cutOutY); + QVERIFY(lower->applies(threshold) && !lower->applies(volume)); + QVERIFY(cutOutY->applies(volume) && !cutOutY->applies(threshold)); + // The lower end's default sweep grows the segmentation from where + // the panel has it + QCOMPARE(lower->range(threshold).start, 10.0); + + auto& animations = ModuleAnimations::instance(); + animations.add(lower->make(threshold, 10.0, 40.0)); + animations.add(cutOutY->make(volume, 0.0, 1.0)); + + play(10); + QVERIFY2(std::abs(threshold->lowerThreshold() - 40.0) < 3.0, + qPrintable(QString("lower %1").arg(threshold->lowerThreshold()))); + QCOMPARE(threshold->upperThreshold(), 90.0); + QVERIFY(volume->cutOutEnabled()); + QVERIFY(std::abs(volume->cutOutPosition(1) - 1.0) < 0.1); + QCOMPARE(volume->cutOutPosition(0), 0.5); + + auto json = animations.serialize(&pipeline); + animations.deserialize(json, &pipeline); + auto restored = animations.animations(); + QCOMPARE(restored.size(), 2); + double start = 0.0; + double stop = 0.0; + const auto* first = animatablePropertyOf(restored[0], start, stop); + QVERIFY(first); + QCOMPARE(first->id, QString("thresholdLower")); + QCOMPARE(start, 10.0); + QCOMPARE(stop, 40.0); + const auto* second = animatablePropertyOf(restored[1], start, stop); + QVERIFY(second); + QCOMPARE(second->id, QString("cutOutY")); + QCOMPARE(stop, 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:")); + // Installed by the main window in the application + AnimationSceneGuard guard; + AnimationPlaybackTest tc; + int status = QTest::qExec(&tc, argc, argv); + RecordedAnimationTest recorded; + status |= QTest::qExec(&recorded, argc, argv); + return status; +} + +#include "AnimationPlaybackTest.moc" diff --git a/tests/cxx/AnimationTest.cxx b/tests/cxx/AnimationTest.cxx new file mode 100644 index 000000000..50ebd4c18 --- /dev/null +++ b/tests/cxx/AnimationTest.cxx @@ -0,0 +1,994 @@ +/* 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 "CameraViewpoints.h" +#include "ContourAnimation.h" +#include "ModuleAnimations.h" +#include "OpacityInterpolation.h" +#include "OpacityAnimation.h" +#include "RecordedAnimations.h" +#include "SceneSnapshot.h" +#include "Pipeline.h" +#include "SliceAnimation.h" +#include "sinks/ClipSink.h" +#include "sinks/ContourSink.h" +#include "sinks/SliceSink.h" +#include "sinks/ThresholdSink.h" + +#include +#include +#include + +#include + +#include + +using tomviz::CameraViewpoints; +using tomviz::ContourAnimation; +using tomviz::interpolateOpacity; +using tomviz::ModuleAnimations; +using tomviz::OpacityAnimation; +using tomviz::SceneSnapshot; +using tomviz::RecordedAnimations; +using tomviz::SliceAnimation; +using tomviz::SinkSnapshot; +using tomviz::Viewpoint; +using tomviz::pipeline::planeTravelRange; + +namespace { + +Viewpoint viewpointAt(double x, int legFrames, bool eased) +{ + Viewpoint viewpoint; + viewpoint.position = { x, 0, 10 }; + viewpoint.focalPoint = { x, 0, 0 }; + viewpoint.legFrames = legFrames; + viewpoint.eased = eased; + return viewpoint; +} + +// The viewpoint list is shared by the whole application, so each test +// starts from an empty one. +class AnimationTest : public ::testing::Test +{ +protected: + void SetUp() override { clearAll(); } + void TearDown() override { clearAll(); } + + void clearAll() + { + CameraViewpoints::instance().clear(); + ModuleAnimations::instance().clear(); + } + + CameraViewpoints& viewpoints() { return CameraViewpoints::instance(); } +}; + +} // namespace + +TEST_F(AnimationTest, PlaneTravelRangeCoversTheDataAlongTheNormal) +{ + double bounds[6] = { 0, 10, 0, 20, 0, 30 }; + + double minDistance = 0; + double maxDistance = 0; + double z[3] = { 0, 0, 1 }; + planeTravelRange(bounds, z, minDistance, maxDistance); + EXPECT_DOUBLE_EQ(minDistance, -15.0); + EXPECT_DOUBLE_EQ(maxDistance, 15.0); + + // Along a diagonal the box is wider than along either of its axes, and + // the normal does not have to arrive normalized. + double diagonal[3] = { 3, 3, 0 }; + planeTravelRange(bounds, diagonal, minDistance, maxDistance); + double expected = (5.0 + 10.0) / std::sqrt(2.0); + EXPECT_NEAR(maxDistance, expected, 1e-9); + EXPECT_NEAR(minDistance, -expected, 1e-9); + + // A plane with no normal has nowhere to travel. + double degenerate[3] = { 0, 0, 0 }; + planeTravelRange(bounds, degenerate, minDistance, maxDistance); + EXPECT_DOUBLE_EQ(minDistance, 0.0); + EXPECT_DOUBLE_EQ(maxDistance, 0.0); +} + +TEST_F(AnimationTest, SegmentDurationsDivideUpTheAnimation) +{ + EXPECT_TRUE(viewpoints().stops().isEmpty()); + + viewpoints().append(viewpointAt(0, 1, false)); + EXPECT_TRUE(viewpoints().stops().isEmpty()) << "one viewpoint is not a path"; + + viewpoints().append(viewpointAt(1, 3, false)); + viewpoints().append(viewpointAt(2, 1, false)); + + // The first leg is a quarter as long as the second, so it is over a + // quarter of the way through. + auto stops = viewpoints().stops(); + ASSERT_EQ(stops.size(), 3); + EXPECT_DOUBLE_EQ(stops[0], 0.0); + EXPECT_DOUBLE_EQ(stops[1], 0.25); + EXPECT_DOUBLE_EQ(stops[2], 1.0); + + // Durations that leave no time at all fall back to equal legs. + viewpoints().replace(0, viewpointAt(0, 0, false)); + viewpoints().replace(1, viewpointAt(1, 0, false)); + stops = viewpoints().stops(); + ASSERT_EQ(stops.size(), 3); + EXPECT_DOUBLE_EQ(stops[1], 0.5); +} + +TEST_F(AnimationTest, RemappingProgressLeavesTheViewpointsWhereTheyAre) +{ + viewpoints().append(viewpointAt(0, 3, true)); + viewpoints().append(viewpointAt(1, 1, true)); + viewpoints().append(viewpointAt(2, 1, true)); + + // Easing changes the pacing inside a leg, never when a leg ends. + for (auto stop : viewpoints().stops()) { + EXPECT_NEAR(viewpoints().remapProgress(stop), stop, 1e-12); + } + + double previous = -1; + for (int i = 0; i <= 100; ++i) { + double remapped = viewpoints().remapProgress(i / 100.0); + EXPECT_GE(remapped, previous) << "time ran backwards at " << i; + EXPECT_GE(remapped, 0.0); + EXPECT_LE(remapped, 1.0); + previous = remapped; + } + + // Out-of-range progress clamps rather than running off the path. + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(-0.5), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(1.5), 1.0); +} + +TEST_F(AnimationTest, EasingSlowsTheEndsOfALegAndNotItsMiddle) +{ + viewpoints().append(viewpointAt(0, 1, false)); + viewpoints().append(viewpointAt(1, 1, false)); + + // Without easing the camera moves at a constant rate. + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(0.25), 0.25); + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(0.75), 0.75); + + viewpoints().replace(0, viewpointAt(0, 1, true)); + + // With it, the camera has covered less of the leg by the first quarter + // and more by the last, and the two ends stay symmetric. + double quarter = viewpoints().remapProgress(0.25); + double threeQuarters = viewpoints().remapProgress(0.75); + EXPECT_LT(quarter, 0.25); + EXPECT_GT(threeQuarters, 0.75); + EXPECT_NEAR(quarter, 1.0 - threeQuarters, 1e-12); + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(0.5), 0.5); +} + +TEST_F(AnimationTest, BindingToALegConfinesAnAnimationToIt) +{ + viewpoints().append(viewpointAt(0, 1, false)); + viewpoints().append(viewpointAt(1, 3, false)); + viewpoints().append(viewpointAt(2, 1, false)); + // Legs run 0 -> 0.25 -> 1. + + // The first leg: done by the time the camera reaches viewpoint 2, and + // held at either end rather than running on. + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.0, 0), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.125, 0), 0.5); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.25, 0), 1.0); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.9, 0), 1.0); + + // The second leg has not started while the first one is running. + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.1, 1), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.625, 1), 0.5); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(1.0, 1), 1.0); + + // An animation bound to a leg that is not there any more runs over the + // whole timeline instead of freezing. + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.4, 7), 0.4); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.4, -1), 0.4); +} + +TEST_F(AnimationTest, ThePathPassesThroughEveryViewpoint) +{ + viewpoints().append(viewpointAt(0, 1, true)); + viewpoints().append(viewpointAt(5, 3, true)); + viewpoints().append(viewpointAt(9, 1, true)); + + auto stops = viewpoints().stops(); + ASSERT_EQ(stops.size(), 3); + + vtkNew camera; + for (int i = 0; i < 3; ++i) { + viewpoints().interpolate(stops[i], camera); + double position[3]; + camera->GetPosition(position); + EXPECT_NEAR(position[0], viewpoints().at(i).position[0], 1e-6) + << "viewpoint " << i << " was not reached"; + } +} + +namespace { + +Viewpoint lookingAtOrigin(const std::array& position, int turns) +{ + Viewpoint viewpoint; + viewpoint.position = position; + viewpoint.focalPoint = { 0, 0, 0 }; + viewpoint.viewUp = { 0, 1, 0 }; + viewpoint.orbitTurns = turns; + viewpoint.eased = false; + // Equal shares for legs and orbits, so the stops below come out round + viewpoint.legFrames = 1; + viewpoint.orbitFrames = 1; + return viewpoint; +} + +std::array positionAt(double t) +{ + vtkNew camera; + CameraViewpoints::instance().interpolate(t, camera); + std::array position; + camera->GetPosition(position.data()); + return position; +} + +void expectNear(const std::array& a, const std::array& b, + const char* what) +{ + for (int k = 0; k < 3; ++k) { + EXPECT_NEAR(a[k], b[k], 1e-6) << what << " component " << k; + } +} + +} // namespace + +// A viewpoint that orbits spins the camera once around the focal point +// on the view-up axis, at the same distance all the way, and hands it +// back where it started. Alone, it is an animation by itself. +TEST_F(AnimationTest, AnOrbitingViewpointSpinsInPlace) +{ + viewpoints().append(lookingAtOrigin({ 0, 0, 10 }, 1)); + EXPECT_TRUE(viewpoints().isPath()); + ASSERT_EQ(viewpoints().stops().size(), 1); + EXPECT_DOUBLE_EQ(viewpoints().departures()[0], 1.0); + + expectNear(positionAt(0.0), { 0, 0, 10 }, "start"); + // Counterclockwise seen from above (+y): the camera goes to +x first + expectNear(positionAt(0.25), { 10, 0, 0 }, "quarter turn"); + expectNear(positionAt(0.5), { 0, 0, -10 }, "half turn"); + expectNear(positionAt(0.75), { -10, 0, 0 }, "three quarters"); + expectNear(positionAt(1.0), { 0, 0, 10 }, "full turn"); + for (int i = 0; i <= 20; ++i) { + auto p = positionAt(i / 20.0); + EXPECT_NEAR(std::sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]), 10.0, + 1e-6) + << "distance changed at " << i; + EXPECT_NEAR(p[1], 0.0, 1e-6) << "left the orbit plane at " << i; + } + + // Clockwise goes the other way, and two turns pass the far side twice + viewpoints().replace(0, lookingAtOrigin({ 0, 0, 10 }, -1)); + expectNear(positionAt(0.25), { -10, 0, 0 }, "clockwise quarter"); + viewpoints().replace(0, lookingAtOrigin({ 0, 0, 10 }, 2)); + expectNear(positionAt(0.25), { 0, 0, -10 }, "two turns, first far side"); + expectNear(positionAt(0.5), { 0, 0, 10 }, "two turns, halfway"); + + // Without the orbit a lone viewpoint is no path at all + viewpoints().replace(0, lookingAtOrigin({ 0, 0, 10 }, 0)); + EXPECT_FALSE(viewpoints().isPath()); + EXPECT_TRUE(viewpoints().stops().isEmpty()); +} + +// An orbit takes its own share of the timeline: the camera arrives, +// spins, then flies on. Whatever is bound to the leg waits for the spin +// to finish, and a curve keyed to the viewpoint holds through it. +TEST_F(AnimationTest, AnOrbitHoldsTheLegAndTheAnchorsStill) +{ + auto first = lookingAtOrigin({ 0, 0, 10 }, 1); + first.legFrames = 1; + first.orbitFrames = 1; + viewpoints().append(first); + viewpoints().append(lookingAtOrigin({ 0, 0, -10 }, 0)); + + // Orbit 0 to 0.5, leg 0.5 to 1 + auto stops = viewpoints().stops(); + auto departures = viewpoints().departures(); + ASSERT_EQ(stops.size(), 2); + EXPECT_DOUBLE_EQ(stops[0], 0.0); + EXPECT_DOUBLE_EQ(departures[0], 0.5); + EXPECT_DOUBLE_EQ(stops[1], 1.0); + EXPECT_DOUBLE_EQ(departures[1], 1.0); + + expectNear(positionAt(0.125), { 10, 0, 0 }, "quarter turn"); + expectNear(positionAt(0.5), { 0, 0, 10 }, "back at the start"); + expectNear(positionAt(0.75), { 0, 0, 0 }, "halfway along the leg"); + expectNear(positionAt(1.0), { 0, 0, -10 }, "arrived"); + + // The leg's own progress and the anchor spans skip the spin + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.25, 0), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.5, 0), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(0.75, 0), 0.5); + EXPECT_DOUBLE_EQ(viewpoints().segmentProgress(1.0, 0), 1.0); + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(0), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().departureTime(0), 0.5); + QList anchors = { 0, 1 }; + EXPECT_DOUBLE_EQ(tomviz::anchorSpanAt(anchors, 0.25).u, 0.0); + EXPECT_DOUBLE_EQ(tomviz::anchorSpanAt(anchors, 0.75).u, 0.5); + + // A longer orbit takes a bigger share + first.orbitFrames = 3; + viewpoints().replace(0, first); + EXPECT_DOUBLE_EQ(viewpoints().departures()[0], 0.75); +} + +// An orbit spins at constant speed unless its own easing is on; the +// leg's easing does not reach it. A file from before orbits had their +// own easing plays them as the leg's easing did. +TEST_F(AnimationTest, OrbitsEaseOnlyWhenAskedTo) +{ + auto spinning = lookingAtOrigin({ 0, 0, 10 }, 1); + spinning.eased = true; + viewpoints().append(spinning); + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(0.25), 0.25); + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(0.75), 0.75); + + spinning.orbitEased = true; + viewpoints().replace(0, spinning); + double quarter = viewpoints().remapProgress(0.25); + EXPECT_LT(quarter, 0.25); + EXPECT_NEAR(quarter, 1.0 - viewpoints().remapProgress(0.75), 1e-12); + EXPECT_DOUBLE_EQ(viewpoints().remapProgress(1.0), 1.0); + + spinning.orbitEased = false; + viewpoints().replace(0, spinning); + auto json = viewpoints().serialize(); + auto list = json["viewpoints"].toArray(); + auto old = list[0].toObject(); + old.remove("orbitEased"); + list[0] = old; + json["viewpoints"] = list; + ASSERT_TRUE(viewpoints().deserialize(json)); + EXPECT_TRUE(viewpoints().at(0).orbitEased); + + // A viewpoint with no orbit, from such a file or a new one, gets the + // default rather than the leg's easing + auto still = lookingAtOrigin({ 0, 0, 10 }, 0); + still.eased = true; + viewpoints().replace(0, still); + json = viewpoints().serialize(); + ASSERT_TRUE(viewpoints().deserialize(json)); + EXPECT_FALSE(viewpoints().at(0).orbitEased); + list = json["viewpoints"].toArray(); + old = list[0].toObject(); + old.remove("orbitEased"); + list[0] = old; + json["viewpoints"] = list; + ASSERT_TRUE(viewpoints().deserialize(json)); + EXPECT_FALSE(viewpoints().at(0).orbitEased); +} + +// The last viewpoint can orbit, ending the animation on a spin, and a +// spin in the middle of a path leaves the legs either side straight. +TEST_F(AnimationTest, OrbitsAtTheEndAndInTheMiddleOfAPath) +{ + viewpoints().append(lookingAtOrigin({ 0, 0, 10 }, 0)); + viewpoints().append(lookingAtOrigin({ 0, 0, -10 }, 1)); + // Leg 0 to 0.5, orbit at the end 0.5 to 1 + expectNear(positionAt(0.25), { 0, 0, 0 }, "halfway along the leg"); + expectNear(positionAt(0.5), { 0, 0, -10 }, "arrived"); + // At the far side the camera's own e1 points to -z, so a quarter turn + // counterclockwise takes it to -x + expectNear(positionAt(0.625), { -10, 0, 0 }, "quarter turn at the end"); + expectNear(positionAt(1.0), { 0, 0, -10 }, "ends where it stopped"); + + viewpoints().append(lookingAtOrigin({ 20, 0, -10 }, 0)); + // Leg 0 to 1/3, orbit 1/3 to 2/3, leg 2/3 to 1 + auto stops = viewpoints().stops(); + ASSERT_EQ(stops.size(), 3); + EXPECT_NEAR(stops[1], 1.0 / 3.0, 1e-12); + EXPECT_NEAR(viewpoints().departures()[1], 2.0 / 3.0, 1e-12); + expectNear(positionAt(1.0 / 6.0), { 0, 0, 0 }, "first leg midpoint"); + expectNear(positionAt(0.5), { 0, 0, 10 }, "half turn in the middle"); + expectNear(positionAt(2.0 / 3.0), { 0, 0, -10 }, "spin over"); + expectNear(positionAt(5.0 / 6.0), { 10, 0, -10 }, "second leg midpoint"); +} + +// A camera looking straight down its own up axis has no radius to +// swing; the orbit holds it in place rather than producing NaNs. +TEST_F(AnimationTest, AnOrbitOnTheAxisHoldsStill) +{ + viewpoints().append(lookingAtOrigin({ 0, 10, 0 }, 1)); + auto p = positionAt(0.5); + for (int k = 0; k < 3; ++k) { + EXPECT_FALSE(std::isnan(p[k])); + } + expectNear(p, { 0, 10, 0 }, "stays put"); +} + +// Legs of no length are cuts. The path still has a start and an end to +// play, and a run whose cameras all sit at one time has nothing for a +// spline to curve through, which must not come out as garbage. +TEST_F(AnimationTest, LegsOfNoLengthAreCuts) +{ + viewpoints().append(viewpointAt(0, 0, false)); + viewpoints().append(viewpointAt(1, 0, false)); + viewpoints().append(viewpointAt(2, 0, false)); + EXPECT_EQ(viewpoints().totalFrames(), 2); + for (double t : { 0.0, 0.3, 0.5, 0.7, 1.0 }) { + auto p = positionAt(t); + for (int k = 0; k < 3; ++k) { + EXPECT_FALSE(std::isnan(p[k])) << "t = " << t; + } + EXPECT_GE(p[0], 0.0) << "t = " << t; + EXPECT_LE(p[0], 2.0) << "t = " << t; + } + expectNear(positionAt(1.0), { 2, 0, 10 }, "ends at the last viewpoint"); + + // One cut among real legs: the path jumps there and flies the rest + viewpoints().clear(); + viewpoints().append(viewpointAt(0, 30, false)); + viewpoints().append(viewpointAt(1, 0, false)); + viewpoints().append(viewpointAt(2, 30, false)); + viewpoints().append(viewpointAt(3, 30, false)); + EXPECT_EQ(viewpoints().totalFrames(), 30 + 0 + 30); + expectNear(positionAt(0.0), { 0, 0, 10 }, "start"); + expectNear(positionAt(1.0), { 3, 0, 10 }, "end"); + for (double t : { 0.1, 1.0 / 3.0, 0.5, 0.9 }) { + auto p = positionAt(t); + for (int k = 0; k < 3; ++k) { + EXPECT_FALSE(std::isnan(p[k])) << "t = " << t; + } + } +} + +// The animation is as long as the path: legs and orbits added up. +TEST_F(AnimationTest, ThePathSetsTheFrameCount) +{ + EXPECT_EQ(viewpoints().totalFrames(), 0); + viewpoints().append(viewpointAt(0, 40, false)); + EXPECT_EQ(viewpoints().totalFrames(), 0) << "one plain viewpoint is no path"; + viewpoints().append(viewpointAt(1, 25, false)); + EXPECT_EQ(viewpoints().totalFrames(), 40) << "the last leg count is unused"; + auto spinning = viewpointAt(2, 7, false); + spinning.orbitTurns = 1; + spinning.orbitFrames = 30; + viewpoints().append(spinning); + EXPECT_EQ(viewpoints().totalFrames(), 40 + 25 + 30); + // A lone orbit is an animation of its own length + viewpoints().clear(); + viewpoints().append(spinning); + EXPECT_EQ(viewpoints().totalFrames(), 30); +} + +// A file from before legs had frame counts carried relative durations +// and one total; each piece gets its share of that total. +TEST_F(AnimationTest, LegacyDurationsBecomeFrameShares) +{ + QJsonObject a; + a["duration"] = 1.0; + QJsonObject b; + b["duration"] = 3.0; + b["orbitTurns"] = 1; + b["orbitDuration"] = 2.0; + QJsonObject c; + QJsonObject file; + file["viewpoints"] = QJsonArray{ a, b, c }; + file["numberOfFrames"] = 120; + ASSERT_TRUE(viewpoints().deserialize(file)); + // Weights 1 + 3 + 2 = 6 over 120 frames + EXPECT_EQ(viewpoints().at(0).legFrames, 20); + EXPECT_EQ(viewpoints().at(1).legFrames, 60); + EXPECT_EQ(viewpoints().at(1).orbitFrames, 40); + EXPECT_EQ(viewpoints().totalFrames(), 120); + + // A current file is taken as written + QJsonObject d; + d["legFrames"] = 15; + QJsonObject e; + e["legFrames"] = 99; + QJsonObject current; + current["viewpoints"] = QJsonArray{ d, e }; + current["numberOfFrames"] = 1000; + ASSERT_TRUE(viewpoints().deserialize(current)); + EXPECT_EQ(viewpoints().at(0).legFrames, 15); + EXPECT_EQ(viewpoints().totalFrames(), 15); +} + +TEST_F(AnimationTest, ViewpointsSurviveAStateFileRoundTrip) +{ + Viewpoint saved; + saved.position = { 1, 2, 3 }; + saved.focalPoint = { 4, 5, 6 }; + saved.viewUp = { 0, 0, 1 }; + saved.viewAngle = 45; + saved.parallelScale = 2.5; + saved.parallelProjection = true; + saved.legFrames = 25; + saved.eased = false; + saved.orbitTurns = -2; + saved.orbitFrames = 50; + saved.orbitEased = true; + saved.name = "Money shot"; + saved.thumbnail = QByteArray("not really a png, but it should come back"); + + viewpoints().append(saved); + viewpoints().append(viewpointAt(7, 1, true)); + viewpoints().setCaptionPosition(0.6, 0.9); + auto json = viewpoints().serialize(); + + viewpoints().clear(); + viewpoints().setCaptionPosition(0.02, 0.03); + ASSERT_TRUE(viewpoints().deserialize(json)); + ASSERT_EQ(viewpoints().size(), 2); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[0], 0.6); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[1], 0.9); + + const auto& restored = viewpoints().at(0); + EXPECT_EQ(restored.position, saved.position); + EXPECT_EQ(restored.focalPoint, saved.focalPoint); + EXPECT_EQ(restored.viewUp, saved.viewUp); + EXPECT_DOUBLE_EQ(restored.viewAngle, saved.viewAngle); + EXPECT_DOUBLE_EQ(restored.parallelScale, saved.parallelScale); + EXPECT_TRUE(restored.parallelProjection); + EXPECT_EQ(restored.legFrames, 25); + EXPECT_FALSE(restored.eased); + EXPECT_EQ(restored.orbitTurns, -2); + EXPECT_EQ(restored.orbitFrames, 50); + EXPECT_TRUE(restored.orbitEased) << "an orbit's easing is its own"; + EXPECT_EQ(viewpoints().at(1).orbitTurns, 0) << "absent means no orbit"; + EXPECT_EQ(restored.thumbnail, saved.thumbnail); + EXPECT_EQ(restored.name, saved.name); + // The second viewpoint was saved without a name, as older files were, + // and gets a positional one rather than none. + EXPECT_EQ(viewpoints().at(1).name, QString("Viewpoint 2")); + + // A file from before captions could be moved centers them at the + // bottom middle, as does one saved with the old corner default, which + // would cut a centered caption in half; and the position never leaves + // the view. + json.remove("captionPosition"); + ASSERT_TRUE(viewpoints().deserialize(json)); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[0], 0.5); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[1], 0.05); + json["captionPosition"] = QJsonArray{ 0.02, 0.03 }; + ASSERT_TRUE(viewpoints().deserialize(json)); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[0], 0.5); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[1], 0.05); + viewpoints().setCaptionPosition(-1.0, 4.0); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[0], 0.0); + EXPECT_DOUBLE_EQ(viewpoints().captionPosition()[1], 1.0); + + // A state file with no viewpoints in it is not a list of none. + EXPECT_FALSE(viewpoints().deserialize(QJsonObject())); +} + +// Building a live animation needs a running ParaView application (the +// base class reaches ActiveObjects for the time keeper), so what is +// checked here is the half that does not: an entry is only rebuilt if +// the state file still describes something that can carry it. +TEST_F(AnimationTest, SnapshotsKeepThresholdsAndHiddenLabelsThroughJson) +{ + SinkSnapshot snapshot; + snapshot.visible = false; + snapshot.thresholdLower = 12.5; + snapshot.thresholdUpper = 80.0; + snapshot.hiddenLabels = QVector{ 2, 5, 9 }; + + auto restored = SinkSnapshot::deserialize(snapshot.serialize()); + EXPECT_FALSE(restored.visible); + ASSERT_TRUE(restored.thresholdLower && restored.thresholdUpper); + EXPECT_DOUBLE_EQ(*restored.thresholdLower, 12.5); + EXPECT_DOUBLE_EQ(*restored.thresholdUpper, 80.0); + ASSERT_TRUE(restored.hiddenLabels); + EXPECT_EQ(*restored.hiddenLabels, (QVector{ 2, 5, 9 })); + + // Fields a module does not have stay unset + SinkSnapshot plain; + auto plainRestored = SinkSnapshot::deserialize(plain.serialize()); + EXPECT_FALSE(plainRestored.thresholdLower); + EXPECT_FALSE(plainRestored.hiddenLabels); +} + +TEST_F(AnimationTest, RecordedThresholdChangesNameTheEndThatMoved) +{ + tomviz::pipeline::Pipeline pipeline; + auto* threshold = new tomviz::pipeline::ThresholdSink(); + pipeline.addNode(threshold); + const int id = pipeline.nodeId(threshold); + + auto record = [&](double lower, double upper) { + Viewpoint viewpoint = viewpointAt(0.0, 1, false); + SinkSnapshot snapshot; + snapshot.thresholdLower = lower; + snapshot.thresholdUpper = upper; + viewpoint.scene.sinks.insert(id, snapshot); + viewpoint.scene.recorded = true; + viewpoints().append(viewpoint); + }; + record(10.0, 90.0); + record(40.0, 90.0); + record(40.0, 60.0); + record(20.0, 70.0); + + auto rows = RecordedAnimations::instance().changes(&pipeline); + ASSERT_EQ(rows.size(), 3); + EXPECT_EQ(rows[0].property, "threshold"); + EXPECT_EQ(rows[0].controlProperty, "thresholdLower"); + EXPECT_DOUBLE_EQ(*rows[0].startValue, 10.0); + EXPECT_DOUBLE_EQ(*rows[0].stopValue, 40.0); + EXPECT_EQ(rows[1].controlProperty, "thresholdUpper"); + EXPECT_DOUBLE_EQ(*rows[1].stopValue, 60.0); + // Both ends moved: nothing a single control can show + EXPECT_EQ(rows[2].controlProperty, ""); + EXPECT_FALSE(rows[2].startValue); + + // Removing the row gives the later viewpoint the earlier range + RecordedAnimations::instance().remove(rows[0], &pipeline); + const auto& later = viewpoints().at(1).scene.sinks[id]; + EXPECT_DOUBLE_EQ(*later.thresholdLower, 10.0); +} + +TEST_F(AnimationTest, SavedAnimationsWithoutTheirVisualizationAreDropped) +{ + tomviz::pipeline::Pipeline pipeline; + auto* contour = new tomviz::pipeline::ContourSink(); + pipeline.addNode(contour); + int contourId = pipeline.nodeId(contour); + + QJsonArray entries; + // A visualization that is not in this state file. + entries.append(QJsonObject({ { "type", "contour" }, + { "node", contourId + 100 }, + { "start", 0 }, + { "stop", 1 } })); + // A kind of animation this build does not have. + entries.append(QJsonObject({ { "type", "hologram" }, + { "node", contourId }, + { "start", 0 }, + { "stop", 1 } })); + // An animation on a visualization of the wrong type. + entries.append(QJsonObject({ { "type", "clip" }, + { "node", contourId }, + { "start", 0 }, + { "stop", 1 }, + { "unit", "slice" } })); + + QJsonObject json; + json["modules"] = entries; + + auto& animations = ModuleAnimations::instance(); + animations.deserialize(json, &pipeline); + EXPECT_TRUE(animations.animations().isEmpty()); + + // A state file with no animation section leaves nothing behind. + animations.deserialize(QJsonObject(), &pipeline); + EXPECT_TRUE(animations.animations().isEmpty()); + EXPECT_TRUE(animations.serialize(&pipeline)["modules"].toArray().isEmpty()); +} + +TEST_F(AnimationTest, DefaultViewpointNamesAreNotReused) +{ + auto first = viewpointAt(0, 1, true); + first.name = viewpoints().nextDefaultName(); + viewpoints().append(first); + EXPECT_EQ(first.name, QString("Viewpoint 1")); + + auto second = viewpointAt(1, 1, true); + second.name = viewpoints().nextDefaultName(); + viewpoints().append(second); + EXPECT_EQ(second.name, QString("Viewpoint 2")); + + // Deleting the first viewpoint must not hand its name to the next one: + // anything referring to "Viewpoint 1" would silently mean a different + // camera position. + viewpoints().removeAt(0); + EXPECT_EQ(viewpoints().nextDefaultName(), QString("Viewpoint 3")); + + // A rename frees nothing; numbering keys off the names still in use. + auto renamed = viewpoints().at(0); + renamed.name = "Money shot"; + viewpoints().replace(0, renamed); + EXPECT_EQ(viewpoints().nextDefaultName(), QString("Viewpoint 1")); +} + +TEST_F(AnimationTest, CurveAnchorsResolveThroughTheCameraPath) +{ + // With no path, anchors still span the animation, so a morph keyed to + // them behaves like a plain start-to-end morph. + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(0), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(1), 1.0); + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(5), 1.0); + + viewpoints().append(viewpointAt(0, 1, false)); + viewpoints().append(viewpointAt(1, 3, false)); + viewpoints().append(viewpointAt(2, 1, false)); + + // Anchored curves land when the camera does, so they follow the same + // stops that pace it, retiming and all. + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(0), 0.0); + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(1), 0.25); + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(2), 1.0); + + // Anchors to viewpoints that no longer exist clamp instead of running + // off the path. + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(9), 1.0); + EXPECT_DOUBLE_EQ(viewpoints().anchorTime(-3), 0.0); +} + +namespace { + +vtkSmartPointer curve( + std::initializer_list> points) +{ + auto function = vtkSmartPointer::New(); + for (const auto& point : points) { + function->AddPoint(point[0], point[1]); + } + return function; +} + +} // namespace + +TEST_F(AnimationTest, BlendingOpacityCurvesReproducesItsEndpoints) +{ + auto from = curve({ { 0, 0 }, { 40, 1 }, { 100, 0 } }); + auto sparse = curve({ { 0, 0 }, { 100, 1 } }); + double range[2] = { 0, 100 }; + + auto out = vtkSmartPointer::New(); + + // Matched point counts blend point by point, so the ends come back exactly. + auto matched = curve({ { 0, 0.2 }, { 60, 0.5 }, { 100, 0.9 } }); + interpolateOpacity(from, matched, 0.0, range, out); + EXPECT_EQ(out->GetSize(), 3); + EXPECT_DOUBLE_EQ(out->GetValue(40), 1.0); + interpolateOpacity(from, matched, 1.0, range, out); + EXPECT_DOUBLE_EQ(out->GetValue(60), 0.5); + + // Mismatched counts go through a table, so the ends come back to within + // the sampling resolution rather than exactly. + interpolateOpacity(from, sparse, 0.0, range, out); + EXPECT_NEAR(out->GetValue(40), 1.0, 0.02); + interpolateOpacity(from, sparse, 1.0, range, out); + EXPECT_NEAR(out->GetValue(50), 0.5, 0.02); + + // Out of range values clamp rather than extrapolating off either end. + interpolateOpacity(from, matched, 4.0, range, out); + EXPECT_DOUBLE_EQ(out->GetValue(60), 0.5); +} + +TEST_F(AnimationTest, BlendingKeepsCurvesEditableWhenItCan) +{ + double range[2] = { 0, 100 }; + auto out = vtkSmartPointer::New(); + + // Same number of points: the result stays as small as its inputs, and + // sharpness carries across instead of being baked into samples. + auto from = vtkSmartPointer::New(); + from->AddPoint(0, 0.0, 0.5, 1.0); + from->AddPoint(100, 1.0, 0.5, 1.0); + auto to = vtkSmartPointer::New(); + to->AddPoint(0, 0.0, 0.5, 0.0); + to->AddPoint(100, 1.0, 0.5, 0.0); + + interpolateOpacity(from, to, 0.5, range, out); + ASSERT_EQ(out->GetSize(), 2); + double node[4]; + out->GetNodeValue(0, node); + EXPECT_DOUBLE_EQ(node[3], 0.5) << "sharpness should blend, not reset"; + + // Different counts cannot be paired up, so the result is a sampled curve. + auto three = curve({ { 0, 0 }, { 50, 1 }, { 100, 0 } }); + interpolateOpacity(from, three, 0.5, range, out); + EXPECT_GT(out->GetSize(), 3); +} + +TEST_F(AnimationTest, BlendingReadsBothCurvesOverTheSameWindow) +{ + // The same shape stored over two different data windows. Blended over one + // reference window they agree, so the midpoint matches the endpoints + // instead of sliding sideways. + auto narrow = curve({ { 0, 0 }, { 10, 1 }, { 20, 0 } }); + auto wide = curve({ { 0, 0 }, { 50, 1 }, { 100, 0 } }); + double range[2] = { 0, 100 }; + + auto out = vtkSmartPointer::New(); + interpolateOpacity(narrow, wide, 0.5, range, out); + + // Halfway between a peak at 10 and one at 50 is a peak at 30. + double node[4]; + out->GetNodeValue(1, node); + EXPECT_DOUBLE_EQ(node[0], 30.0); + EXPECT_DOUBLE_EQ(node[1], 1.0); +} + +TEST_F(AnimationTest, BlendingSurvivesEmptyAndDegenerateInput) +{ + double range[2] = { 0, 100 }; + auto out = vtkSmartPointer::New(); + auto real = curve({ { 0, 0 }, { 100, 1 } }); + auto empty = vtkSmartPointer::New(); + + // Nothing to blend towards: the curve that exists stands for the whole + // animation rather than fading to nothing. + interpolateOpacity(real, empty, 0.5, range, out); + EXPECT_DOUBLE_EQ(out->GetValue(100), 1.0); + interpolateOpacity(empty, real, 0.5, range, out); + EXPECT_DOUBLE_EQ(out->GetValue(100), 1.0); + + interpolateOpacity(empty, empty, 0.5, range, out); + EXPECT_EQ(out->GetSize(), 0); + + // A collapsed window has nothing to sample over. + double collapsed[2] = { 5, 5 }; + auto three = curve({ { 0, 0 }, { 50, 1 }, { 100, 0 } }); + interpolateOpacity(real, three, 0.9, collapsed, out); + EXPECT_EQ(out->GetSize(), 3) << "should hold the nearer curve"; + + // Writing back over an input must not read freed or half-written state. + interpolateOpacity(real, three, 0.5, range, real); + EXPECT_GT(real->GetSize(), 0); +} + +TEST_F(AnimationTest, RecordedModuleStateSurvivesAStateFileRoundTrip) +{ + tomviz::pipeline::Pipeline pipeline; + auto* slice = new tomviz::pipeline::SliceSink(); + pipeline.addNode(slice); + slice->setOpacity(0.3); + slice->setVisibility(false); + + Viewpoint viewpoint; + viewpoint.label = "Cu channel"; + viewpoint.scene = SceneSnapshot::capture(&pipeline); + auto restored = Viewpoint::deserialize(viewpoint.serialize()); + EXPECT_EQ(restored.label, "Cu channel"); + + int id = pipeline.nodeId(slice); + ASSERT_TRUE(restored.scene.sinks.contains(id)); + EXPECT_FALSE(restored.scene.sinks[id].visible); + ASSERT_TRUE(restored.scene.sinks[id].opacity.has_value()); + EXPECT_DOUBLE_EQ(*restored.scene.sinks[id].opacity, 0.3); +} + +TEST_F(AnimationTest, RecordedChangesListWhatDiffersBetweenViewpoints) +{ + tomviz::pipeline::Pipeline pipeline; + auto* older = new tomviz::pipeline::SliceSink(); + pipeline.addNode(older); + older->setOpacity(1.0); + Viewpoint first; + first.name = "Start"; + first.scene = SceneSnapshot::capture(&pipeline); + viewpoints().append(first); + + // A blank viewpoint in the middle records nothing and is skipped over + Viewpoint blank; + blank.name = "Blank"; + viewpoints().append(blank); + + auto* added = new tomviz::pipeline::SliceSink(); + pipeline.addNode(added); + added->setOpacity(0.6); + older->setOpacity(0.4); + Viewpoint last; + last.name = "End"; + last.scene = SceneSnapshot::capture(&pipeline); + viewpoints().append(last); + + auto& recorded = RecordedAnimations::instance(); + auto changes = recorded.changes(&pipeline); + ASSERT_EQ(changes.size(), 2); + // Pipeline order: the older slice first + EXPECT_EQ(changes[0].nodeId, pipeline.nodeId(older)); + EXPECT_EQ(changes[0].property, "opacity"); + EXPECT_EQ(changes[0].description, "opacity 1 to 0.4"); + EXPECT_EQ(changes[0].fromAnchor, 0); + EXPECT_EQ(changes[0].toAnchor, 2); + EXPECT_EQ(changes[1].nodeId, pipeline.nodeId(added)); + EXPECT_EQ(changes[1].description, "fades in to 0.6"); + + // Both slices are known to the path; Go To the first viewpoint hides + // the one it never saw, and a blank viewpoint touches nothing + auto known = recorded.recordedNodeIds(); + EXPECT_TRUE(known.contains(pipeline.nodeId(added))); + viewpoints().at(0).scene.apply(&pipeline, &known); + EXPECT_FALSE(added->visibility()); + EXPECT_TRUE(older->visibility()); + added->setVisibility(true); + viewpoints().at(1).scene.apply(&pipeline, &known); + EXPECT_TRUE(added->visibility()); + // A module no viewpoint recorded is left alone by Go To + auto* stranger = new tomviz::pipeline::SliceSink(); + pipeline.addNode(stranger); + viewpoints().at(0).scene.apply(&pipeline, &known); + EXPECT_TRUE(stranger->visibility()); + + // Removing a row edits the later viewpoint: the older slice keeps its + // opacity of 1 there, so only the fade-in is left + recorded.remove(changes[0], &pipeline); + changes = recorded.changes(&pipeline); + ASSERT_EQ(changes.size(), 1); + EXPECT_EQ(changes[0].nodeId, pipeline.nodeId(added)); + const auto& edited = viewpoints().at(2).scene.sinks[pipeline.nodeId(older)]; + EXPECT_DOUBLE_EQ(*edited.opacity, 1.0); + + // Removing the fade-in leaves the added slice hidden at the end too + recorded.remove(changes[0], &pipeline); + EXPECT_TRUE(recorded.changes(&pipeline).isEmpty()); + EXPECT_FALSE(viewpoints().at(2).scene.sinks[pipeline.nodeId(added)].visible); +} + +TEST_F(AnimationTest, RecordedChangesIncludeSlicePositionsAndIsoValues) +{ + tomviz::pipeline::Pipeline pipeline; + auto* slice = new tomviz::pipeline::SliceSink(); + auto* contour = new tomviz::pipeline::ContourSink(); + pipeline.addNode(slice); + pipeline.addNode(contour); + slice->setSlice(10); + contour->setIsoValue(100.0); + Viewpoint first; + first.name = "A"; + first.scene = SceneSnapshot::capture(&pipeline); + viewpoints().append(first); + + slice->setSlice(30); + contour->setIsoValue(250.0); + Viewpoint second; + second.name = "B"; + second.scene = SceneSnapshot::capture(&pipeline); + viewpoints().append(second); + + auto& recorded = RecordedAnimations::instance(); + auto changes = recorded.changes(&pipeline); + ASSERT_EQ(changes.size(), 2); + EXPECT_EQ(changes[0].property, "slice"); + EXPECT_EQ(changes[0].description, "slice 10 to 30"); + ASSERT_TRUE(changes[0].startValue && changes[0].stopValue); + EXPECT_DOUBLE_EQ(*changes[0].startValue, 10.0); + EXPECT_DOUBLE_EQ(*changes[0].stopValue, 30.0); + EXPECT_EQ(changes[1].property, "iso"); + EXPECT_EQ(changes[1].description, "iso value 100 to 250"); + + // The positions survive a state file round trip and Go To restores + // them + auto restored = Viewpoint::deserialize(second.serialize()); + slice->setSlice(0); + contour->setIsoValue(0.0); + restored.scene.apply(&pipeline); + EXPECT_EQ(slice->slice(), 30); + EXPECT_DOUBLE_EQ(contour->isoValue(), 250.0); + + // Removing the slice row pins B's slice to A's + recorded.remove(changes[0], &pipeline); + changes = recorded.changes(&pipeline); + ASSERT_EQ(changes.size(), 1); + EXPECT_EQ(changes[0].property, "iso"); + EXPECT_EQ(*viewpoints().at(1).scene.sinks[pipeline.nodeId(slice)].sliceIndex, + 10); +} + +TEST_F(AnimationTest, AnchorSpansFollowThePathStops) +{ + viewpoints().append(viewpointAt(0, 1, false)); + viewpoints().append(viewpointAt(1, 1, false)); + viewpoints().append(viewpointAt(2, 2, false)); + // Stops at 0, 0.25, 1; anchors 0 and 2 recorded + QList anchors = { 0, 2 }; + auto before = tomviz::anchorSpanAt(anchors, -0.1); + EXPECT_EQ(before.from, 0); + EXPECT_EQ(before.to, 0); + auto middle = tomviz::anchorSpanAt(anchors, 0.5); + EXPECT_EQ(middle.from, 0); + EXPECT_EQ(middle.to, 2); + EXPECT_DOUBLE_EQ(middle.u, 0.5); + auto after = tomviz::anchorSpanAt(anchors, 1.0); + EXPECT_EQ(after.from, 2); + EXPECT_EQ(after.to, 2); + EXPECT_DOUBLE_EQ(after.u, 1.0); +} + diff --git a/tests/cxx/AutoExecuteTest.cxx b/tests/cxx/AutoExecuteTest.cxx new file mode 100644 index 000000000..5eee77457 --- /dev/null +++ b/tests/cxx/AutoExecuteTest.cxx @@ -0,0 +1,220 @@ +/* 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 "AutoExecuteController.h" +#include "EditNodeWidget.h" +#include "Node.h" +#include "Pipeline.h" +#include "transforms/LegacyPythonTransform.h" +#include "transforms/PythonTransform.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "TomvizTest.h" + +using tomviz::pipeline::AutoExecuteController; +using tomviz::pipeline::EditNodeWidget; +using tomviz::pipeline::LegacyPythonTransform; +using tomviz::pipeline::Node; +using tomviz::pipeline::Pipeline; +using tomviz::pipeline::PythonTransform; + +namespace { + +const char* kV2Description = R"({ + "schemaVersion": 2, + "name": "MultiplyBy", + "label": "Multiply By", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [ + {"name": "factor", "type": "double", "default": 1.0} + ] +})"; + +// No "schemaVersion" → v1 (legacy operator API, no +// should_auto_execute hook). +const char* kV1Description = R"({ + "name": "LegacyOp", + "label": "Legacy Op", + "parameters": [] +})"; + +// A slow should_auto_execute() query, which must not outlive its node +class SlowQueryNode : public Node +{ +public: + std::atomic querying{ false }; + + bool queryShouldAutoExecute() override + { + querying = true; + QThread::msleep(300); + querying = false; + return false; + } + + ~SlowQueryNode() override { EXPECT_FALSE(querying); } +}; + +} // namespace + +TEST(AutoExecuteTest, RemovingANodeWaitsForItsQuery) +{ + tomviz_test::ensureQApp(); + + Pipeline pipeline; + AutoExecuteController controller(&pipeline); + auto* node = new SlowQueryNode; + node->setAutoExecuteIntervalSeconds(1); + node->setAutoExecuteEnabled(true); + pipeline.addNode(node); + + for (int i = 0; i < 1000 && !node->querying; ++i) { + QCoreApplication::processEvents(); + QThread::msleep(5); + } + ASSERT_TRUE(node->querying); + pipeline.removeNode(node); +} + +TEST(AutoExecuteTest, EditorShowsControlsForV2AndAppliesToNode) +{ + tomviz_test::ensureQApp(); + + Pipeline pipeline; + auto* transform = new PythonTransform(); + transform->setJSONDescription(kV2Description); + transform->setScript("import tomviz.nodes\n" + "class MultiplyBy(tomviz.nodes.TransformNode):\n" + " def transform(self, inputs, factor=1.0):\n" + " return None\n"); + pipeline.addNode(transform); + + EXPECT_FALSE(transform->autoExecuteEnabled()); + EXPECT_EQ(transform->autoExecuteIntervalSeconds(), + Node::kDefaultAutoExecuteIntervalSeconds); + + auto* editor = transform->createPropertiesWidget(&pipeline, nullptr); + auto* check = editor->findChild("autoExecuteCheck"); + auto* spin = editor->findChild("autoExecuteIntervalSpin"); + ASSERT_NE(check, nullptr); + ASSERT_NE(spin, nullptr); + + // Off by default, with the interval control gated on the checkbox. + EXPECT_FALSE(check->isChecked()); + EXPECT_FALSE(spin->isEnabled()); + EXPECT_EQ(spin->value(), Node::kDefaultAutoExecuteIntervalSeconds); + + QSignalSpy spy(transform, &Node::autoExecuteChanged); + check->setChecked(true); + EXPECT_TRUE(spin->isEnabled()); + spin->setValue(60); + editor->applyChangesToOperator(); + + EXPECT_TRUE(transform->autoExecuteEnabled()); + EXPECT_EQ(transform->autoExecuteIntervalSeconds(), 60); + EXPECT_GE(spy.count(), 1); + delete editor; + + // Reopening seeds the controls from the node. + auto* editor2 = transform->createPropertiesWidget(&pipeline, nullptr); + auto* check2 = editor2->findChild("autoExecuteCheck"); + auto* spin2 = editor2->findChild("autoExecuteIntervalSpin"); + ASSERT_NE(check2, nullptr); + ASSERT_NE(spin2, nullptr); + EXPECT_TRUE(check2->isChecked()); + EXPECT_TRUE(spin2->isEnabled()); + EXPECT_EQ(spin2->value(), 60); + + // Applying with the box unticked turns the feature back off. + check2->setChecked(false); + editor2->applyChangesToOperator(); + EXPECT_FALSE(transform->autoExecuteEnabled()); + EXPECT_EQ(transform->autoExecuteIntervalSeconds(), 60); + delete editor2; +} + +TEST(AutoExecuteTest, EditorOmitsControlsForLegacyV1) +{ + tomviz_test::ensureQApp(); + + Pipeline pipeline; + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(kV1Description); + transform->setScript("def transform(dataset):\n pass\n"); + pipeline.addNode(transform); + + auto* editor = transform->createPropertiesWidget(&pipeline, nullptr); + EXPECT_EQ(editor->findChild("autoExecuteCheck"), nullptr); + EXPECT_EQ(editor->findChild("autoExecuteIntervalSpin"), + nullptr); + + // Applying must leave the node's (default) setting untouched: + // autoExecuteEdited stays false without the controls. + editor->applyChangesToOperator(); + EXPECT_FALSE(transform->autoExecuteEnabled()); + delete editor; +} + +TEST(AutoExecuteTest, NodeSerializeRoundTrip) +{ + tomviz_test::ensureQApp(); + + // Defaults leave no footprint in the serialized form. + Node defaultNode; + EXPECT_FALSE(defaultNode.serialize().contains("autoExecute")); + + Node node; + node.setAutoExecuteEnabled(true); + node.setAutoExecuteIntervalSeconds(45); + QJsonObject json = node.serialize(); + ASSERT_TRUE(json.contains("autoExecute")); + EXPECT_TRUE(json["autoExecute"].toObject()["enabled"].toBool()); + EXPECT_EQ(json["autoExecute"].toObject()["intervalSeconds"].toInt(), 45); + + Node restored; + QSignalSpy spy(&restored, &Node::autoExecuteChanged); + ASSERT_TRUE(restored.deserialize(json)); + EXPECT_TRUE(restored.autoExecuteEnabled()); + EXPECT_EQ(restored.autoExecuteIntervalSeconds(), 45); + // Deserialize routes through the setters so the controller (which + // may have seen the node before its state was applied) re-syncs. + EXPECT_GE(spy.count(), 1); + + // A remembered interval round-trips even while disabled. + Node disabledNode; + disabledNode.setAutoExecuteIntervalSeconds(120); + Node restored2; + ASSERT_TRUE(restored2.deserialize(disabledNode.serialize())); + EXPECT_FALSE(restored2.autoExecuteEnabled()); + EXPECT_EQ(restored2.autoExecuteIntervalSeconds(), 120); +} + +TEST(AutoExecuteTest, UserStateIsRuntimeOnly) +{ + tomviz_test::ensureQApp(); + + Node node; + QVariantMap state; + state["counter"] = 3; + state["label"] = QStringLiteral("last-run"); + node.setUserState(state); + EXPECT_EQ(node.userState().value("counter").toInt(), 3); + + // Not serialized — a save/load round trip starts with an empty bag. + QJsonObject json = node.serialize(); + EXPECT_FALSE(json.contains("userState")); + Node restored; + ASSERT_TRUE(restored.deserialize(json)); + EXPECT_TRUE(restored.userState().isEmpty()); +} diff --git a/tests/cxx/CMakeLists.txt b/tests/cxx/CMakeLists.txt index 0fd492c16..aa2f54630 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,26 +50,92 @@ 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(Animation) 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) +add_cxx_test(PythonEnvironmentCheck) +add_cxx_test(AutoExecute) +add_cxx_test(NodeDefinition) +add_cxx_test(TiltSeriesSinogram) +add_cxx_test(LabelSelection) +add_cxx_test(LabelMapSurface) +add_cxx_test(ExplodedGeometry) +add_cxx_test(LightingPresetStore) +add_cxx_test(CustomOperatorManagerDialog) +add_cxx_test(OperatorSearchDialog) +add_cxx_test(CustomOperatorEditDialog) +# 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(LabelMap) +add_cxx_qtest(AnimationPlayback) +add_cxx_qtest(AnimationHelperOrbit) +add_cxx_qtest(SegmentationColorMap) +add_cxx_qtest(ImageViewerMode) +add_cxx_qtest(MultiVolume) +add_cxx_qtest(LabelSurfaceRender) +add_cxx_qtest(ClippingRange) +add_cxx_qtest(HistogramRange) +add_cxx_qtest(RecordedPlane) add_cxx_qtest(PtychoWorkflow PYTHONPATH ${_pythonpath}) -set_tests_properties(PtychoWorkflow PROPERTIES TIMEOUT 1800) +# initTestCase downloads the test data; QtTest aborts any test function +# after five minutes unless told otherwise, and a slow mirror can take +# longer than that. +set_tests_properties(PtychoWorkflow PROPERTIES TIMEOUT 1800 + ENVIRONMENT "QTEST_FUNCTION_TIMEOUT=1800000") add_cxx_qtest(PyXRFWorkflow PYTHONPATH ${_pythonpath}) -set_tests_properties(PyXRFWorkflow PROPERTIES TIMEOUT 1800) +set_tests_properties(PyXRFWorkflow PROPERTIES TIMEOUT 1800 + ENVIRONMENT "QTEST_FUNCTION_TIMEOUT=1800000") # Generate the executable create_test_executable(tomvizTests) +# tomviz's icons are compiled into the app executable, not the library; +# tests that build widgets showing them need the same resources. +target_sources(tomvizTests PRIVATE "${PROJECT_SOURCE_DIR}/tomviz/resources.qrc") target_link_libraries(tomvizTests Qt6::Test) + +# NodeDefinitionTest round-trips every shipped operator descriptor +# through the definition-form mutators. +target_compile_definitions(tomvizTests PRIVATE + "TOMVIZ_PYTHON_DIR=\"${PROJECT_SOURCE_DIR}/tomviz/python\"") + +# 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/ClippingRangeTest.cxx b/tests/cxx/ClippingRangeTest.cxx new file mode 100644 index 000000000..ca5bb0387 --- /dev/null +++ b/tests/cxx/ClippingRangeTest.cxx @@ -0,0 +1,184 @@ +/* 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 +#include +#include +#include +#include +#include +#include + +#include "ClippingRangeBehavior.h" + +#include +#include + +using namespace tomviz; + +// Tomviz visualizations add their props to the renderer directly, not as +// ParaView representations, so ParaView's clipping range never saw them +// grow or move. +class ClippingRangeTest : public QObject +{ + Q_OBJECT + +private slots: + // Same OpenGL probe as LabelSurfaceRenderTest: CI runners without GL + // crash on the first render, so check before any view exists. + void initTestCase() + { + vtkNew capture; + auto* previous = vtkOutputWindow::GetInstance(); + previous->Register(nullptr); + vtkOutputWindow::SetInstance(capture); + { + vtkNew probe; + probe->SetOffScreenRendering(1); + probe->Initialize(); + } + vtkOutputWindow::SetInstance(previous); + previous->Delete(); + const std::string said = capture->GetOutput(); + for (const char* complaint : { "OpenGL", "X server", "EGL", "OSMesa", + "pixel format", "PixelFormat" }) { + if (said.find(complaint) != std::string::npos) { + QSKIP("no OpenGL available for an offscreen render"); + } + } + } + + // A render view proxy with a real offscreen window. The offscreen Qt + // platform cannot draw through a Qt GL widget, so this skips pqView and + // renders through the proxy, which is the path the application's + // renders take (vtkSMViewProxy::StillRender). + void init() + { + auto* pxm = + pqActiveObjects::instance().activeServer()->proxyManager(); + m_proxy.TakeReference( + vtkSMViewProxy::SafeDownCast(pxm->NewProxy("views", "RenderView"))); + QVERIFY(m_proxy); + m_proxy->UpdateVTKObjects(); + m_pvView = vtkPVRenderView::SafeDownCast(m_proxy->GetClientSideView()); + QVERIFY(m_pvView); + auto* window = m_proxy->GetRenderWindow(); + window->SetOffScreenRendering(1); + window->SetSize(200, 200); + ClippingRangeBehavior::watch(m_proxy); + } + + void cleanup() + { + m_pvView = nullptr; + m_proxy = nullptr; + } + + // A live source's data growing between renders + void growingDataIsNotClipped() + { + vtkNew sphere; + sphere->SetRadius(1.0); + addActor(sphere); + lookFrom(200.0); + m_proxy->StillRender(); + expectCovers(199.0, 201.0); + // Fitted, not the camera's default range: the frame really rendered + QVERIFY(clippingRange()[0] > 150.0); + + sphere->SetRadius(50.0); + m_proxy->StillRender(); + expectCovers(150.0, 250.0); + } + + // A new dataset or a transform's output appearing somewhere else + void dataAddedElsewhereIsNotClipped() + { + vtkNew near; + near->SetRadius(1.0); + addActor(near); + lookFrom(10.0); + m_proxy->StillRender(); + expectCovers(9.0, 11.0); + QVERIFY(clippingRange()[1] < 50.0); + + vtkNew far; + far->SetCenter(0.0, 0.0, -100.0); + far->SetRadius(1.0); + addActor(far); + m_proxy->StillRender(); + expectCovers(9.0, 101.0); + } + +private: + void addActor(vtkSphereSource* source) + { + vtkNew mapper; + mapper->SetInputConnection(source->GetOutputPort()); + vtkNew actor; + actor->SetMapper(mapper); + m_pvView->AddPropToRenderer(actor); + } + + void lookFrom(double z) + { + auto* camera = m_pvView->GetActiveCamera(); + camera->SetFocalPoint(0.0, 0.0, 0.0); + camera->SetPosition(0.0, 0.0, z); + camera->SetViewUp(0.0, 1.0, 0.0); + } + + std::array clippingRange() + { + std::array range; + m_pvView->GetActiveCamera()->GetClippingRange(range.data()); + return range; + } + + // The near and far planes lie outside [nearest, farthest] distances + void expectCovers(double nearest, double farthest) + { + const auto range = clippingRange(); + QVERIFY2(range[0] <= nearest && range[1] >= farthest, + qPrintable(QString("clipping range %1 to %2 does not cover " + "%3 to %4") + .arg(range[0]) + .arg(range[1]) + .arg(nearest) + .arg(farthest))); + } + + vtkSmartPointer m_proxy; + vtkPVRenderView* m_pvView = nullptr; +}; + +int main(int argc, char** argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + pqPVApplicationCore appCore(argc, argv); + // A plain render window that can draw offscreen, not the one a Qt + // widget hosts + vtkPVView::SetUseGenericOpenGLRenderWindow(false); + pqApplicationCore::instance()->getObjectBuilder()->createServer( + pqServerResource("builtin:")); + ClippingRangeTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "ClippingRangeTest.moc" diff --git a/tests/cxx/CustomOperatorEditDialogTest.cxx b/tests/cxx/CustomOperatorEditDialogTest.cxx new file mode 100644 index 000000000..8f9cd6d3d --- /dev/null +++ b/tests/cxx/CustomOperatorEditDialogTest.cxx @@ -0,0 +1,570 @@ +/* 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 "CustomOperatorEditDialog.h" +#include "Utilities.h" +#include "pipeline/NodeDefinitionWidget.h" +#include "pipeline/NodeEditDialog.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PythonNodeEditorWidget.h" +#include "pipeline/PythonScriptEdit.h" +#include "pipeline/transforms/LegacyPythonTransform.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "TomvizTest.h" + +using tomviz::CustomOperatorDraft; +using tomviz::CustomOperatorEditDialog; +using tomviz::markDescriptionAsCopy; +using tomviz::sanitizedOperatorStem; +using tomviz::uniqueOperatorStem; +using tomviz::withDescriptionIdentity; +using tomviz::pipeline::NodeDefinitionWidget; +using tomviz::pipeline::PythonScriptEdit; + +namespace { + +const char* kScript = "def transform(self, volume, sigma=1.0):\n" + " return volume\n"; + +const char* kTransform = R"({ + "schemaVersion": 2, + "name": "Blur", + "label": "Blur", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [{"name": "sigma", "type": "double", "default": 1.0}] +})"; + +// Source-shaped: no inputs. A live transform node could never adopt it. +const char* kSource = R"({ + "schemaVersion": 2, + "name": "Noise", + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [] +})"; + +bool writeFile(const QString& path, const QString& text) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + file.write(text.toUtf8()); + return true; +} + +QString readFile(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return QString(); + } + return QString::fromUtf8(file.readAll()); +} + +QJsonObject parse(const QString& json) +{ + return QJsonDocument::fromJson(json.toUtf8()).object(); +} + +// Restores an environment variable to whatever the process had. +struct ScopedEnv +{ + explicit ScopedEnv(const char* variable) + : name(variable), had(qEnvironmentVariableIsSet(variable)), + old(qgetenv(variable)) + { + } + ~ScopedEnv() + { + if (had) { + qputenv(name, old); + } else { + qunsetenv(name); + } + } + const char* name; + bool had; + QByteArray old; +}; + +class CustomOperatorEditDialogTest : public ::testing::Test +{ +protected: + void SetUp() override + { + tomviz_test::ensureQApp(); + ASSERT_TRUE(dir.isValid()); + scriptPath = dir.path() + "/Blur.py"; + jsonPath = dir.path() + "/Blur.json"; + ASSERT_TRUE(writeFile(scriptPath, kScript)); + ASSERT_TRUE(writeFile(jsonPath, kTransform)); + } + + std::unique_ptr open(const QString& description) + { + auto dialog = std::make_unique( + "Blur", scriptPath, description); + EXPECT_TRUE(dialog->loadError().isEmpty()) + << dialog->loadError().toStdString(); + watch(dialog.get()); + dialog->show(); + return dialog; + } + + std::unique_ptr openDraft( + const QString& stem, const QString& script, const QString& description) + { + CustomOperatorDraft draft; + draft.directory = dir.path(); + draft.stem = stem; + draft.script = script; + draft.description = description; + auto dialog = std::make_unique(draft); + watch(dialog.get()); + dialog->show(); + return dialog; + } + + void watch(CustomOperatorEditDialog* dialog) + { + QObject::connect(dialog, &CustomOperatorEditDialog::saved, + [this]() { ++saves; }); + } + + static QTextEdit* jsonEditor(QDialog* dialog) + { + return dialog->findChild("definitionRawEditor"); + } + + static PythonScriptEdit* scriptEditor(QDialog* dialog) + { + return dialog->findChild(); + } + + static QLineEdit* stemEdit(QDialog* dialog) + { + return dialog->findChild("operatorStemEdit"); + } + + static QLabel* stemIssue(QDialog* dialog) + { + return dialog->findChild("operatorStemIssue"); + } + + static QPushButton* saveButton(QDialog* dialog) + { + return dialog->findChild()->button( + QDialogButtonBox::Save); + } + + // Validation is debounced; the host flushes it before deciding. + static void setDefinition(QDialog* dialog, const QString& json) + { + jsonEditor(dialog)->setPlainText(json); + dialog->findChild()->flushPendingValidation(); + } + + QStringList filesInDir() const + { + return QDir(dir.path()).entryList(QDir::Files, QDir::Name); + } + + QTemporaryDir dir; + QString scriptPath; + QString jsonPath; + int saves = 0; +}; + +} // namespace + +TEST_F(CustomOperatorEditDialogTest, shows_definition_and_script_tabs) +{ + auto dialog = open(jsonPath); + auto* tabs = dialog->findChild(); + ASSERT_NE(tabs, nullptr); + ASSERT_EQ(tabs->count(), 2); + EXPECT_EQ(tabs->tabText(0), "Definition"); + EXPECT_EQ(tabs->tabText(1), "Script"); + + EXPECT_EQ(jsonEditor(dialog.get())->toPlainText(), kTransform); + EXPECT_EQ(scriptEditor(dialog.get())->toPlainText(), kScript); + EXPECT_EQ(stemEdit(dialog.get())->text(), "Blur"); + EXPECT_TRUE(dialog->isBacked()); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); +} + +TEST_F(CustomOperatorEditDialogTest, save_writes_both_files_and_accepts) +{ + auto dialog = open(jsonPath); + + // Non-ASCII on purpose: an older save path wrote Latin-1 and mangled it. + // The literal is split so the "d" is not read as part of the escape. + const QString newScript = QString::fromUtf8( + "def transform(self, volume, sigma=2.0):\n" + " # \xC3\xA9" "dited\n" + " return volume * 2\n"); + scriptEditor(dialog.get())->setPlainText(newScript); + + QJsonObject obj = parse(kTransform); + obj["label"] = "Blurred"; + const QString newJson = QString::fromUtf8(QJsonDocument(obj).toJson()); + setDefinition(dialog.get(), newJson); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + + saveButton(dialog.get())->click(); + + EXPECT_EQ(dialog->result(), QDialog::Accepted); + EXPECT_FALSE(dialog->isVisible()); + EXPECT_EQ(saves, 1); + EXPECT_EQ(readFile(scriptPath), newScript); + EXPECT_EQ(readFile(jsonPath), newJson); +} + +TEST_F(CustomOperatorEditDialogTest, broken_definition_disables_save) +{ + auto dialog = open(jsonPath); + setDefinition(dialog.get(), "{ not json"); + EXPECT_FALSE(saveButton(dialog.get())->isEnabled()); + + // Fixing it re-enables Save; nothing was written meanwhile. + setDefinition(dialog.get(), kTransform); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); + EXPECT_EQ(readFile(jsonPath), kTransform); + EXPECT_EQ(saves, 0); +} + +TEST_F(CustomOperatorEditDialogTest, file_edits_may_change_shape_and_ports) +{ + // A live transform node rejects a source-shaped description; a file + // has no such identity, so this must be allowed and saved. + auto dialog = open(jsonPath); + setDefinition(dialog.get(), kSource); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + EXPECT_EQ(readFile(jsonPath), kSource); +} + +TEST_F(CustomOperatorEditDialogTest, script_only_operator_gains_a_description) +{ + ASSERT_TRUE(QFile::remove(jsonPath)); + + // No description file: the path is derived, the tab starts empty, and + // saving leaves the operator script-only. + auto dialog = open(QString()); + EXPECT_EQ(dialog->descriptionPath(), jsonPath); + EXPECT_TRUE(jsonEditor(dialog.get())->toPlainText().isEmpty()); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); + + scriptEditor(dialog.get())->setPlainText("def transform(self, v):\n" + " return v\n"); + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + EXPECT_FALSE(QFile::exists(jsonPath)); + + // Typing a description creates the file beside the script. + auto second = open(QString()); + setDefinition(second.get(), kSource); + ASSERT_TRUE(saveButton(second.get())->isEnabled()); + saveButton(second.get())->click(); + EXPECT_EQ(second->result(), QDialog::Accepted); + EXPECT_EQ(readFile(jsonPath), kSource); + EXPECT_EQ(saves, 2); +} + +TEST_F(CustomOperatorEditDialogTest, description_path_keeps_a_dotted_stem) +{ + // "my.operator.py" pairs with "my.operator.json", not "my.json". + EXPECT_EQ(tomviz::pipeline::PythonNodeEditorWidget::descriptionPathFor( + dir.path() + "/my.operator.py"), + dir.path() + "/my.operator.json"); +} + +TEST_F(CustomOperatorEditDialogTest, unreadable_script_is_reported) +{ + CustomOperatorEditDialog dialog("Missing", dir.path() + "/Missing.py", + QString()); + EXPECT_FALSE(dialog.loadError().isEmpty()); +} + +TEST_F(CustomOperatorEditDialogTest, renaming_moves_both_files_on_save) +{ + auto dialog = open(jsonPath); + stemEdit(dialog.get())->setText("Sharpen"); + EXPECT_FALSE(stemIssue(dialog.get())->isVisible()); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + + const QString newScript = "def transform(self, volume):\n" + " return volume\n"; + scriptEditor(dialog.get())->setPlainText(newScript); + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + + const QString movedScript = dir.path() + "/Sharpen.py"; + const QString movedJson = dir.path() + "/Sharpen.json"; + EXPECT_EQ(readFile(movedScript), newScript); + EXPECT_EQ(readFile(movedJson), kTransform); + EXPECT_FALSE(QFile::exists(scriptPath)); + EXPECT_FALSE(QFile::exists(jsonPath)); + EXPECT_EQ(dialog->scriptPath(), movedScript); + EXPECT_EQ(dialog->descriptionPath(), movedJson); + EXPECT_EQ(saves, 1); +} + +TEST_F(CustomOperatorEditDialogTest, renaming_a_script_only_operator) +{ + ASSERT_TRUE(QFile::remove(jsonPath)); + auto dialog = open(QString()); + stemEdit(dialog.get())->setText("Sharpen"); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + + EXPECT_EQ(filesInDir(), QStringList{ "Sharpen.py" }); + EXPECT_EQ(dialog->descriptionPath(), dir.path() + "/Sharpen.json"); +} + +TEST_F(CustomOperatorEditDialogTest, case_only_rename_is_allowed) +{ + auto dialog = open(jsonPath); + stemEdit(dialog.get())->setText("blur"); + EXPECT_FALSE(stemIssue(dialog.get())->isVisible()); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + + EXPECT_EQ(filesInDir(), (QStringList{ "blur.json", "blur.py" })); + EXPECT_EQ(readFile(dir.path() + "/blur.py"), kScript); +} + +TEST_F(CustomOperatorEditDialogTest, name_of_another_operator_blocks_save) +{ + ASSERT_TRUE(writeFile(dir.path() + "/Other.py", "pass\n")); + auto dialog = open(jsonPath); + + stemEdit(dialog.get())->setText("Other"); + EXPECT_FALSE(saveButton(dialog.get())->isEnabled()); + EXPECT_TRUE(stemIssue(dialog.get())->isVisible()); + EXPECT_TRUE(stemIssue(dialog.get())->text().contains("Other")); + + // A stray description with that name counts too. + ASSERT_TRUE(writeFile(dir.path() + "/Stray.json", "{}")); + stemEdit(dialog.get())->setText("Stray"); + EXPECT_FALSE(saveButton(dialog.get())->isEnabled()); + + stemEdit(dialog.get())->setText("Blur"); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); + EXPECT_FALSE(stemIssue(dialog.get())->isVisible()); + EXPECT_EQ(saves, 0); +} + +TEST_F(CustomOperatorEditDialogTest, file_name_is_sanitised) +{ + auto dialog = open(jsonPath); + auto* edit = stemEdit(dialog.get()); + + // Typed characters outside the allowed set are refused outright. + edit->clear(); + QTest::keyClicks(edit, "my op.v2!"); + EXPECT_EQ(edit->text(), "myopv2"); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); + + // Text that arrives some other way still has to pass. + edit->setText("bad name"); + EXPECT_FALSE(saveButton(dialog.get())->isEnabled()); + EXPECT_TRUE(stemIssue(dialog.get())->isVisible()); + + edit->clear(); + EXPECT_FALSE(saveButton(dialog.get())->isEnabled()); + + edit->setText("fine_name-2"); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); +} + +// --- drafts: create new, clone, save as --------------------------------- + +TEST_F(CustomOperatorEditDialogTest, draft_touches_nothing_until_saved) +{ + auto dialog = openDraft("Sharpen", kScript, kSource); + EXPECT_FALSE(dialog->isBacked()); + EXPECT_EQ(stemEdit(dialog.get())->text(), "Sharpen"); + EXPECT_EQ(dialog->scriptPath(), dir.path() + "/Sharpen.py"); + EXPECT_EQ(scriptEditor(dialog.get())->toPlainText(), kScript); + EXPECT_EQ(jsonEditor(dialog.get())->toPlainText(), kSource); + EXPECT_EQ(filesInDir(), (QStringList{ "Blur.json", "Blur.py" })); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + + // The name can still change before anything exists. + stemEdit(dialog.get())->setText("Sharpen2"); + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + EXPECT_TRUE(dialog->isBacked()); + EXPECT_EQ(saves, 1); + EXPECT_EQ(filesInDir(), (QStringList{ "Blur.json", "Blur.py", + "Sharpen2.json", "Sharpen2.py" })); + EXPECT_EQ(readFile(dir.path() + "/Sharpen2.py"), kScript); + EXPECT_EQ(readFile(dir.path() + "/Sharpen2.json"), kSource); + EXPECT_EQ(dialog->scriptPath(), dir.path() + "/Sharpen2.py"); +} + +TEST_F(CustomOperatorEditDialogTest, draft_may_not_take_an_existing_name) +{ + auto dialog = openDraft("Blur", kScript, kSource); + EXPECT_FALSE(saveButton(dialog.get())->isEnabled()); + EXPECT_TRUE(stemIssue(dialog.get())->isVisible()); + + stemEdit(dialog.get())->setText("Fresh"); + EXPECT_TRUE(saveButton(dialog.get())->isEnabled()); + EXPECT_EQ(saves, 0); +} + +TEST_F(CustomOperatorEditDialogTest, script_only_draft_writes_no_description) +{ + auto dialog = openDraft("Bare", kScript, QString()); + ASSERT_TRUE(saveButton(dialog.get())->isEnabled()); + saveButton(dialog.get())->click(); + EXPECT_EQ(dialog->result(), QDialog::Accepted); + EXPECT_EQ(filesInDir(), (QStringList{ "Bare.py", "Blur.json", "Blur.py" })); +} + +TEST_F(CustomOperatorEditDialogTest, unique_stem_skips_taken_names) +{ + int number = 0; + EXPECT_EQ(uniqueOperatorStem(dir.path(), "Fresh", &number), "Fresh"); + EXPECT_EQ(number, 1); + + // Blur.py and Blur.json exist; a lone json counts as taken too. + EXPECT_EQ(uniqueOperatorStem(dir.path(), "Blur", &number), "Blur2"); + EXPECT_EQ(number, 2); + ASSERT_TRUE(writeFile(dir.path() + "/Blur2.json", "{}")); + EXPECT_EQ(uniqueOperatorStem(dir.path(), "Blur", &number), "Blur3"); + EXPECT_EQ(number, 3); +} + +TEST_F(CustomOperatorEditDialogTest, stems_are_sanitised_and_never_empty) +{ + EXPECT_EQ(sanitizedOperatorStem("my op.v2!"), "myopv2"); + EXPECT_EQ(sanitizedOperatorStem("Fine_name-2"), "Fine_name-2"); + EXPECT_EQ(sanitizedOperatorStem(QString::fromUtf8("caf\xC3\xA9 au lait")), + "cafaulait"); + EXPECT_EQ(uniqueOperatorStem(dir.path(), "my op.v2!"), "myopv2"); + EXPECT_EQ(uniqueOperatorStem(dir.path(), "!!!"), "CustomTransform"); +} + +TEST_F(CustomOperatorEditDialogTest, copies_are_marked_in_name_and_label) +{ + QJsonObject first = parse(markDescriptionAsCopy(kTransform, 1)); + EXPECT_EQ(first.value("name").toString(), "BlurCopy"); + EXPECT_EQ(first.value("label").toString(), "Blur (copy)"); + // Everything else survives. + EXPECT_EQ(first.value("parameters").toArray().size(), 1); + + QJsonObject third = parse(markDescriptionAsCopy(kTransform, 3)); + EXPECT_EQ(third.value("name").toString(), "BlurCopy3"); + EXPECT_EQ(third.value("label").toString(), "Blur (copy 3)"); + + // A description without a label is told apart by its file name; no + // label is invented for it. + QJsonObject unlabeled = parse(markDescriptionAsCopy(kSource, 1)); + EXPECT_EQ(unlabeled.value("name").toString(), "NoiseCopy"); + EXPECT_FALSE(unlabeled.contains("label")); + + // Not JSON (a script-only operator has none): untouched. + EXPECT_TRUE(markDescriptionAsCopy(QString(), 1).isEmpty()); + EXPECT_EQ(markDescriptionAsCopy("{ nope", 1), "{ nope"); +} + +TEST_F(CustomOperatorEditDialogTest, identity_helper_only_sets_present_keys) +{ + QJsonObject renamed = + parse(withDescriptionIdentity(kTransform, "Fresh", "Fresh Label")); + EXPECT_EQ(renamed.value("name").toString(), "Fresh"); + EXPECT_EQ(renamed.value("label").toString(), "Fresh Label"); + + QJsonObject partial = parse(withDescriptionIdentity(kSource, "Fresh", "L")); + EXPECT_EQ(partial.value("name").toString(), "Fresh"); + EXPECT_FALSE(partial.contains("label")); + + EXPECT_EQ(withDescriptionIdentity("not json", "a", "b"), "not json"); +} + +TEST_F(CustomOperatorEditDialogTest, + node_editor_offers_save_as_custom_transform) +{ + // The button lives on the node editor's action bar and hands the + // editor's current script and description to a draft in the user + // directory; nothing is written until that draft is saved. + ScopedEnv guard("TOMVIZ_USER_DIRECTORY"); + QTemporaryDir userDir; + ASSERT_TRUE(userDir.isValid()); + qputenv("TOMVIZ_USER_DIRECTORY", userDir.path().toLocal8Bit()); + + const QString original = tomviz::readInJSONDescription("GaussianFilter"); + ASSERT_FALSE(original.isEmpty()); + const QString expectedStem = + sanitizedOperatorStem(parse(original).value("name").toString()); + ASSERT_FALSE(expectedStem.isEmpty()); + + tomviz::pipeline::Pipeline pipeline; + auto* node = new tomviz::pipeline::LegacyPythonTransform(); + node->setJSONDescription(original); + const QString script = "def transform(dataset):\n pass\n"; + node->setScript(script); + pipeline.addNode(node); + + tomviz::pipeline::NodeEditDialog editor(node, &pipeline); + editor.show(); + auto* button = editor.findChild("saveAsCustomOperatorButton"); + ASSERT_NE(button, nullptr); + button->click(); + + // The node editor is done once the draft exists, as if cancelled. + EXPECT_FALSE(editor.isVisible()); + EXPECT_EQ(editor.result(), QDialog::Rejected); + + CustomOperatorEditDialog* draft = nullptr; + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (auto* candidate = qobject_cast(widget)) { + draft = candidate; + break; + } + } + ASSERT_NE(draft, nullptr); + EXPECT_FALSE(draft->isBacked()); + EXPECT_EQ(stemEdit(draft)->text(), expectedStem); + EXPECT_EQ(scriptEditor(draft)->toPlainText(), script); + // A text edit stores line breaks as "\n" whatever the file used (a + // Windows checkout ships the description with "\r\n"). + EXPECT_EQ(jsonEditor(draft)->toPlainText(), + QString(original).replace("\r\n", "\n")); + EXPECT_TRUE(QDir(userDir.path()).entryList(QDir::Files).isEmpty()); + + ASSERT_TRUE(saveButton(draft)->isEnabled()); + saveButton(draft)->click(); + EXPECT_EQ(QDir(userDir.path()).entryList(QDir::Files, QDir::Name), + (QStringList{ expectedStem + ".json", expectedStem + ".py" })); + EXPECT_EQ(readFile(userDir.path() + "/" + expectedStem + ".py"), script); + // The draft deletes itself on close; let that happen before the + // temporary directory goes away. + QTest::qWait(10); +} diff --git a/tests/cxx/CustomOperatorManagerDialogTest.cxx b/tests/cxx/CustomOperatorManagerDialogTest.cxx new file mode 100644 index 000000000..5f0108ae9 --- /dev/null +++ b/tests/cxx/CustomOperatorManagerDialogTest.cxx @@ -0,0 +1,322 @@ +/* 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 "CustomOperatorManagerDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "TomvizTest.h" + +using tomviz::CustomOperatorManagerDialog; +using tomviz::OperatorDescription; + +namespace { + +OperatorDescription describe(const QString& label, const QString& dir, + bool userOwned, + OperatorDescription::Type type = + OperatorDescription::Type::Transform) +{ + OperatorDescription op; + op.label = label; + op.pythonPath = dir + "/" + label + ".py"; + op.jsonPath = dir + "/" + label + ".json"; + op.userOwned = userOwned; + op.type = type; + return op; +} + +// A directory as the dialog displays it: absolute, with the platform's +// separators (and drive letter on Windows). +QString nativeDirectory(const QString& directory) +{ + return QDir::toNativeSeparators(QFileInfo(directory).absoluteFilePath()); +} + +class CustomOperatorManagerDialogTest : public ::testing::Test +{ +protected: + void SetUp() override + { + tomviz_test::ensureQApp(); + operators = { + describe("Blur", "/user", true), + describe("Noise", "/elsewhere", false, OperatorDescription::Type::Source), + describe("Broken", "/user", true), + }; + operators[2].valid = false; + operators[2].loadError = "boom: line 3"; + + // The provider hands out whatever the test has put in `operators`, so + // a refresh can be exercised without touching the filesystem. + dialog = std::make_unique( + [this]() { return operators; }); + QObject::connect(dialog.get(), + &CustomOperatorManagerDialog::createRequested, + [this]() { ++creates; }); + QObject::connect(dialog.get(), &CustomOperatorManagerDialog::cloneRequested, + [this](const OperatorDescription& op) { + requested = op.label; + ++clones; + }); + QObject::connect(dialog.get(), &CustomOperatorManagerDialog::editRequested, + [this](const OperatorDescription& op) { + requested = op.label; + ++edits; + }); + QObject::connect(dialog.get(), + &CustomOperatorManagerDialog::deleteRequested, + [this](const OperatorDescription& op) { + requested = op.label; + ++deletes; + }); + QObject::connect(dialog.get(), + &CustomOperatorManagerDialog::openDirectoryRequested, + [this](const OperatorDescription& op) { + requested = op.label; + ++opens; + }); + dialog->show(); + settle(); + } + + // Layouts are applied on posted events. + static void settle() { QCoreApplication::processEvents(); } + + /// The widget of type T named @a objectName on the row for @a label. + template + T* rowWidget(const char* objectName, const QString& label) const + { + for (T* widget : dialog->template findChildren(objectName)) { + if (widget->property("operatorLabel").toString() == label) { + return widget; + } + } + return nullptr; + } + + QToolButton* button(const char* name, const QString& label) const + { + return rowWidget(name, label); + } + + QLabel* section(const QString& title) const + { + for (QLabel* label : + dialog->findChildren("customOperatorSection")) { + if (label->text() == title) { + return label; + } + } + return nullptr; + } + + std::vector operators; + std::unique_ptr dialog; + QString requested; + int creates = 0; + int clones = 0; + int edits = 0; + int deletes = 0; + int opens = 0; +}; + +} // namespace + +TEST_F(CustomOperatorManagerDialogTest, groups_sources_above_transforms) +{ + auto* sources = section("Sources"); + auto* transforms = section("Transforms"); + auto* noise = rowWidget("customOperatorName", "Noise"); + auto* blur = rowWidget("customOperatorName", "Blur"); + auto* broken = rowWidget("customOperatorName", "Broken"); + ASSERT_NE(sources, nullptr); + ASSERT_NE(transforms, nullptr); + ASSERT_NE(noise, nullptr); + ASSERT_NE(blur, nullptr); + ASSERT_NE(broken, nullptr); + + // Top to bottom: Sources, Noise, Transforms, Blur, Broken. + EXPECT_LT(sources->y(), noise->y()); + EXPECT_LT(noise->y(), transforms->y()); + EXPECT_LT(transforms->y(), blur->y()); + EXPECT_LT(blur->y(), broken->y()); + + // No table header and no type column: the grouping says it all. + EXPECT_EQ(dialog->findChildren("customOperatorSection").size(), 2); + EXPECT_FALSE( + dialog->findChild("customOperatorEmptyHint")->isVisible()); +} + +TEST_F(CustomOperatorManagerDialogTest, rows_show_path_and_state) +{ + // The directory is shown the way the platform writes it (a drive letter + // and backslashes on Windows). + auto* blurPath = rowWidget("customOperatorPath", "Blur"); + ASSERT_NE(blurPath, nullptr); + EXPECT_EQ(blurPath->toolTip(), nativeDirectory("/user")); + EXPECT_EQ(blurPath->text(), nativeDirectory("/user")); + + auto* noisePath = rowWidget("customOperatorPath", "Noise"); + ASSERT_NE(noisePath, nullptr); + EXPECT_EQ(noisePath->toolTip(), nativeDirectory("/elsewhere")); + + // A broken definition is greyed and says why on hover. + EXPECT_FALSE(rowWidget("customOperatorName", "Broken")->isEnabled()); + auto* status = rowWidget("customOperatorStatus", "Broken"); + ASSERT_NE(status, nullptr); + EXPECT_TRUE(status->toolTip().contains("boom")); + EXPECT_EQ(rowWidget("customOperatorStatus", "Blur"), nullptr); +} + +TEST_F(CustomOperatorManagerDialogTest, long_paths_are_elided_in_the_middle) +{ + operators = { describe("Deep", + "/a/very/long/directory/name/that/keeps/going/on/" + "and/on/well/past/any/reasonable/dialog/width/" + "until/it/has/to/give", + true) }; + dialog->refresh(); + dialog->resize(600, 300); + settle(); + + // Both ends survive, the middle goes, and the tooltip keeps it all. + auto* path = rowWidget("customOperatorPath", "Deep"); + ASSERT_NE(path, nullptr); + const QString full = nativeDirectory("/a/very/long/directory"); + const QString shown = path->text(); + EXPECT_TRUE(shown.contains(QChar(0x2026))) << shown.toStdString(); + EXPECT_LT(shown.size(), path->toolTip().size()); + EXPECT_TRUE(shown.startsWith(full.left(4))) << shown.toStdString(); + EXPECT_TRUE(shown.endsWith("give")) << shown.toStdString(); + EXPECT_TRUE(path->toolTip().startsWith(full)) << full.toStdString(); + EXPECT_FALSE(path->toolTip().contains(QChar(0x2026))); +} + +TEST_F(CustomOperatorManagerDialogTest, buttons_follow_ownership) +{ + // The user's own files: everything. + EXPECT_TRUE(button("editCustomOperator", "Blur")->isEnabled()); + EXPECT_TRUE(button("deleteCustomOperator", "Blur")->isEnabled()); + EXPECT_TRUE(button("cloneCustomOperator", "Blur")->isEnabled()); + + // From another directory: clone only, and the buttons say why. + EXPECT_FALSE(button("editCustomOperator", "Noise")->isEnabled()); + EXPECT_FALSE(button("deleteCustomOperator", "Noise")->isEnabled()); + EXPECT_TRUE(button("cloneCustomOperator", "Noise")->isEnabled()); + EXPECT_FALSE(button("editCustomOperator", "Noise")->toolTip().isEmpty()); + + // A broken definition in the user's directory can still be fixed, + // copied or removed. + EXPECT_TRUE(button("editCustomOperator", "Broken")->isEnabled()); + EXPECT_TRUE(button("deleteCustomOperator", "Broken")->isEnabled()); + EXPECT_TRUE(button("cloneCustomOperator", "Broken")->isEnabled()); + + // The folder can always be opened. + for (const char* label : { "Blur", "Noise", "Broken" }) { + EXPECT_TRUE(button("openCustomOperatorDirectory", label)->isEnabled()); + } +} + +TEST_F(CustomOperatorManagerDialogTest, buttons_are_icons_with_tooltips) +{ + // The icon resources are registered. Whether an SVG renders here + // depends on the test process finding Qt's SVG plugin, so a button is + // allowed to fall back to its verb as text; it never comes up blank. + for (const char* resource : + { ":/icons/edit.png", ":/icons/clone.svg", ":/icons/folder.svg" }) { + EXPECT_TRUE(QFile::exists(resource)) << resource; + } + for (const char* name : { "editCustomOperator", "deleteCustomOperator", + "cloneCustomOperator", + "openCustomOperatorDirectory" }) { + auto* blur = button(name, "Blur"); + ASSERT_NE(blur, nullptr) << name; + EXPECT_TRUE(!blur->icon().isNull() || !blur->text().isEmpty()) << name; + EXPECT_FALSE(blur->toolTip().isEmpty()) << name; + } + EXPECT_EQ(button("editCustomOperator", "Blur")->toolTip(), "Edit"); + EXPECT_EQ(button("deleteCustomOperator", "Blur")->toolTip(), "Delete"); + EXPECT_EQ(button("cloneCustomOperator", "Blur")->toolTip(), "Clone"); + EXPECT_EQ(button("openCustomOperatorDirectory", "Blur")->toolTip(), + "Open containing folder"); +} + +TEST_F(CustomOperatorManagerDialogTest, buttons_request_their_own_row) +{ + dialog->findChild("createCustomOperator")->click(); + EXPECT_EQ(creates, 1); + + button("cloneCustomOperator", "Noise")->click(); + EXPECT_EQ(clones, 1); + EXPECT_EQ(requested, "Noise"); + + button("editCustomOperator", "Blur")->click(); + EXPECT_EQ(edits, 1); + EXPECT_EQ(requested, "Blur"); + + button("deleteCustomOperator", "Broken")->click(); + EXPECT_EQ(deletes, 1); + EXPECT_EQ(requested, "Broken"); + + button("openCustomOperatorDirectory", "Noise")->click(); + EXPECT_EQ(opens, 1); + EXPECT_EQ(requested, "Noise"); + EXPECT_EQ(clones, 1); + EXPECT_EQ(creates, 1); +} + +TEST_F(CustomOperatorManagerDialogTest, a_handler_may_refresh_mid_click) +{ + // Deleting rebuilds the list from inside the click of a button that the + // rebuild destroys; the request must still carry the right operator. + QObject::connect(dialog.get(), &CustomOperatorManagerDialog::deleteRequested, + [this](const OperatorDescription& op) { + operators.erase( + std::remove_if(operators.begin(), operators.end(), + [&op](const OperatorDescription& other) { + return other.label == op.label; + }), + operators.end()); + dialog->refresh(); + }); + button("deleteCustomOperator", "Blur")->click(); + settle(); + EXPECT_EQ(requested, "Blur"); + EXPECT_EQ(button("deleteCustomOperator", "Blur"), nullptr); + EXPECT_NE(button("deleteCustomOperator", "Broken"), nullptr); +} + +TEST_F(CustomOperatorManagerDialogTest, refresh_requeries_the_provider) +{ + operators.erase(operators.begin() + 1); + dialog->refresh(); + settle(); + EXPECT_EQ(rowWidget("customOperatorName", "Noise"), nullptr); + EXPECT_EQ(section("Sources"), nullptr); + EXPECT_NE(section("Transforms"), nullptr); + + // The Refresh button does the same as refresh(); an empty list shows + // the hint and keeps Create New available. + operators.clear(); + dialog->findChild("refreshCustomOperators")->click(); + settle(); + EXPECT_TRUE(dialog->findChildren("customOperatorName").isEmpty()); + EXPECT_TRUE( + dialog->findChild("customOperatorEmptyHint")->isVisible()); + EXPECT_TRUE( + dialog->findChild("createCustomOperator")->isEnabled()); +} 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/ExplodedGeometryTest.cxx b/tests/cxx/ExplodedGeometryTest.cxx new file mode 100644 index 000000000..8e8c7c1e4 --- /dev/null +++ b/tests/cxx/ExplodedGeometryTest.cxx @@ -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". */ + +#include + +#include "sinks/ExplodedGeometry.h" + +#include + +using tomviz::pipeline::explodedDirection; +using tomviz::pipeline::explodedExtent; +using tomviz::pipeline::kExplodedCustomAxis; + +TEST(ExplodedGeometryTest, AxesAreUnitVectorsAndCustomIsNormalized) +{ + const std::array custom = { 3.0, 0.0, 4.0 }; + EXPECT_EQ(explodedDirection(0, custom), (std::array{ 1, 0, 0 })); + EXPECT_EQ(explodedDirection(2, custom), (std::array{ 0, 0, 1 })); + auto dir = explodedDirection(kExplodedCustomAxis, custom); + EXPECT_DOUBLE_EQ(dir[0], 0.6); + EXPECT_DOUBLE_EQ(dir[1], 0.0); + EXPECT_DOUBLE_EQ(dir[2], 0.8); + // A zero vector cannot be normalized; +Z is the harmless choice + EXPECT_EQ(explodedDirection(kExplodedCustomAxis, { 0, 0, 0 }), + (std::array{ 0, 0, 1 })); +} + +TEST(ExplodedGeometryTest, OffsetIsInVoxelsAndKeepsEverySlabAVoxelThick) +{ + using tomviz::pipeline::explodedOffsetLimit; + using tomviz::pipeline::explodedShift; + using tomviz::pipeline::explodedVoxelStep; + + const double spacing[3] = { 1.0, 2.0, 4.0 }; + EXPECT_DOUBLE_EQ(explodedVoxelStep({ 1, 0, 0 }, spacing), 1.0); + EXPECT_DOUBLE_EQ(explodedVoxelStep({ 0, 0, 1 }, spacing), 4.0); + const double s = 1.0 / std::sqrt(2.0); + EXPECT_NEAR(explodedVoxelStep({ s, s, 0 }, spacing), + std::sqrt(0.5 + 2.0), 1e-12); + + // 100 long, 4 slabs of 25, voxels of 2: 24 voxels of room per slab + // minus the one that must stay, so 11 + EXPECT_EQ(explodedOffsetLimit(100.0, 4, 2.0), 11); + EXPECT_EQ(explodedOffsetLimit(100.0, 4, 30.0), 0); + EXPECT_EQ(explodedOffsetLimit(100.0, 4, 0.0), 0); + EXPECT_DOUBLE_EQ(explodedShift(3, 100.0, 4, 2.0), 6.0); + EXPECT_DOUBLE_EQ(explodedShift(-3, 100.0, 4, 2.0), -6.0); + EXPECT_DOUBLE_EQ(explodedShift(50, 100.0, 4, 2.0), 22.0); + EXPECT_DOUBLE_EQ(explodedShift(-50, 100.0, 4, 2.0), -22.0); +} + +TEST(ExplodedGeometryTest, ExtentMatchesTheBoxAlongAnAxisAndItsDiagonal) +{ + const double bounds[6] = { 0.0, 10.0, -5.0, 5.0, 2.0, 6.0 }; + double lo = 0.0; + double length = 0.0; + explodedExtent(bounds, { 1, 0, 0 }, lo, length); + EXPECT_DOUBLE_EQ(lo, -5.0); + EXPECT_DOUBLE_EQ(length, 10.0); + explodedExtent(bounds, { 0, 0, 1 }, lo, length); + EXPECT_DOUBLE_EQ(lo, -2.0); + EXPECT_DOUBLE_EQ(length, 4.0); + + // Along the diagonal of a cube the span is the diagonal's length + const double cube[6] = { 0.0, 2.0, 0.0, 2.0, 0.0, 2.0 }; + const double s = 1.0 / std::sqrt(3.0); + explodedExtent(cube, { s, s, s }, lo, length); + EXPECT_NEAR(length, 2.0 * std::sqrt(3.0), 1e-12); + EXPECT_NEAR(lo, -std::sqrt(3.0), 1e-12); + // Sign of the direction does not change the span + explodedExtent(cube, { -s, -s, -s }, lo, length); + EXPECT_NEAR(length, 2.0 * std::sqrt(3.0), 1e-12); +} 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/HistogramRangeTest.cxx b/tests/cxx/HistogramRangeTest.cxx new file mode 100644 index 000000000..2a6dba205 --- /dev/null +++ b/tests/cxx/HistogramRangeTest.cxx @@ -0,0 +1,89 @@ +/* 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 "HistogramWidget.h" +#include "pipeline/data/VolumeData.h" + +using namespace tomviz; +using namespace tomviz::pipeline; + +class HistogramRangeTest : public QObject +{ + Q_OBJECT + +private slots: + // Every range change strips the placeholder nodes the widget keeps on + // the data ends, rescales the rest and puts them back. The opacity + // proxy only learns of client-side edits on the next event-loop turn, + // so the rescale used to act on the stale nodes: Reset Range left a + // narrowed opacity window in place, and each auto-contrast Apply + // compressed the window further until VTK ran out of texture size. + void rescaleUsesTheCurrentOpacityNodes() + { + auto image = vtkSmartPointer::New(); + image->SetDimensions(101, 1, 1); + vtkNew values; + values->SetName("values"); + values->SetNumberOfTuples(101); + for (int i = 0; i <= 100; ++i) { + values->SetValue(i, i); + } + image->GetPointData()->SetScalars(values); + + auto volume = std::make_shared(image); + volume->initColorMap(); + auto* lut = vtkDiscretizableColorTransferFunction::SafeDownCast( + volume->colorMap()->GetClientSideObject()); + QVERIFY(lut); + + HistogramWidget widget; + widget.setVolumeData(volume); + widget.setLUTProxy(volume->colorMap()); + + // A window on [40, 60], with placeholders on the data ends + auto* opacity = lut->GetScalarOpacityFunction(); + QVERIFY(opacity); + opacity->RemoveAllPoints(); + opacity->AddPoint(0, 0); + opacity->AddPoint(40, 0); + opacity->AddPoint(60, 1); + opacity->AddPoint(100, 1); + QCoreApplication::processEvents(); + + widget.onResetRangeClicked(); + + // The window now spans the data: a ramp from 0 to 1 + QVERIFY(qAbs(opacity->GetValue(25) - 0.25) < 1e-6); + QVERIFY(qAbs(opacity->GetValue(75) - 0.75) < 1e-6); + } +}; + +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:")); + HistogramRangeTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "HistogramRangeTest.moc" diff --git a/tests/cxx/ImageViewerModeTest.cxx b/tests/cxx/ImageViewerModeTest.cxx new file mode 100644 index 000000000..e96a2a041 --- /dev/null +++ b/tests/cxx/ImageViewerModeTest.cxx @@ -0,0 +1,419 @@ +/* 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 "ActiveObjects.h" +#include "ImageViewerModeBehavior.h" +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/OutlineSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/VolumeSink.h" + +#include + +using namespace tomviz; + +namespace { + +const int MODE_2D = vtkPVRenderView::INTERACTION_MODE_2D; +const int MODE_3D = vtkPVRenderView::INTERACTION_MODE_3D; +const int MODE_SELECTION = vtkPVRenderView::INTERACTION_MODE_SELECTION; + +pqRenderView* createRenderView() +{ + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + return qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); +} + +/// What the toolbar's 2D/3D button does. +void setInteractionMode(pqRenderView* view, int mode) +{ + vtkSMPropertyHelper(view->getProxy(), "InteractionMode").Set(mode); + view->getProxy()->UpdateProperty("InteractionMode", 1); +} + +vtkCamera* camera(pqRenderView* view) +{ + return view->getRenderViewProxy()->GetActiveCamera(); +} + +void placeCamera(pqRenderView* view, double px, double py, double pz, + double fx, double fy, double fz) +{ + auto* cam = camera(view); + cam->SetPosition(px, py, pz); + cam->SetFocalPoint(fx, fy, fz); + cam->SetViewUp(0, 0, 1); +} + +bool near(const double a[3], double x, double y, double z, double tol = 1e-6) +{ + return std::abs(a[0] - x) < tol && std::abs(a[1] - y) < tol && + std::abs(a[2] - z) < tol; +} + +/// A source carrying a volume, with the SinkGroupNode sinks hang off. +struct Dataset +{ + pipeline::SourceNode* source = nullptr; + pipeline::SinkGroupNode* group = nullptr; + pipeline::OutputPort* port() const { return source->outputPorts()[0]; } + pipeline::OutputPort* sinkPort() const { return group->outputPorts()[0]; } +}; + +Dataset addDataset(pipeline::Pipeline* pip, int nx, int ny, int nz) +{ + vtkNew image; + image->SetDimensions(nx, ny, nz); + image->AllocateScalars(VTK_FLOAT, 1); + auto vol = std::make_shared( + vtkSmartPointer(image)); + + Dataset ds; + ds.source = new pipeline::SourceNode(); + ds.source->addOutput("volume", pipeline::PortType::Volume); + pip->addNode(ds.source); + ds.source->setOutputData( + "volume", pipeline::PortData(vol, pipeline::PortType::Volume)); + + ds.group = new pipeline::SinkGroupNode(); + ds.group->addPassthrough("volume", pipeline::PortType::ImageData); + pip->addNode(ds.group); + pip->createLink(ds.port(), ds.group->inputPorts()[0]); + return ds; +} + +template +Sink* addSink(pipeline::Pipeline* pip, const Dataset& ds, pqRenderView* view) +{ + auto* sink = new Sink(); + sink->initialize(view->getViewProxy()); + pip->addNode(sink); + pip->createLink(ds.sinkPort(), sink->inputPorts()[0]); + return sink; +} + +QList slicesIn(pipeline::Pipeline* pip, + pqRenderView* view) +{ + QList slices; + for (auto* node : pip->nodes()) { + auto* slice = qobject_cast(node); + if (slice && slice->view() == view->getViewProxy()) { + slices.append(slice); + } + } + return slices; +} + +} // namespace + +class ImageViewerModeTest : public QObject +{ + Q_OBJECT + +public: + ImageViewerModeTest(ImageViewerModeBehavior* behavior, + pipeline::Pipeline* pipeline) + : m_behavior(behavior), m_pip(pipeline) + {} + +private slots: + void init() + { + m_view = createRenderView(); + QVERIFY(m_view); + } + + void cleanup() + { + ActiveObjects::instance().clearActiveSelection(); + m_pip->clear(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + for (auto* view : m_extraViews) { + builder->destroy(view); + } + m_extraViews.clear(); + builder->destroy(m_view); + m_view = nullptr; + } + + // The toolbar's 2D toggle hides everything but the slice, aims the + // camera down the slice normal, and the 3D toggle puts it all back: + // the sinks' visibility and the camera's position, direction and up. + void twoDShowsOnlyTheSliceAndThreeDRestores() + { + auto ds = addDataset(m_pip, 20, 30, 40); + auto* outline = addSink(m_pip, ds, m_view); + auto* volume = addSink(m_pip, ds, m_view); + auto* slice = addSink(m_pip, ds, m_view); + slice->setDirection(pipeline::SliceSink::XZ); + ActiveObjects::instance().setActivePort(ds.port()); + + placeCamera(m_view, 5, 7, 9, 1, 2, 3); + + setInteractionMode(m_view, MODE_2D); + QVERIFY(m_behavior->isActive(m_view)); + QCOMPARE(m_behavior->slice(m_view), slice); + QVERIFY(!outline->visibility()); + QVERIFY(!volume->visibility()); + QVERIFY(slice->visibility()); + QVERIFY(!slice->showArrow()); + // Down the XZ normal from the side the camera was on (it looked + // toward -y), with its z up kept + double dir[3], up[3]; + camera(m_view)->GetDirectionOfProjection(dir); + camera(m_view)->GetViewUp(up); + QVERIFY2(near(dir, 0, -1, 0), "camera should keep looking toward -y"); + QVERIFY2(near(up, 0, 0, 1), "z should be up"); + QCOMPARE(camera(m_view)->GetParallelProjection(), 1); + + setInteractionMode(m_view, MODE_3D); + QVERIFY(!m_behavior->isActive(m_view)); + QVERIFY(outline->visibility()); + QVERIFY(volume->visibility()); + QVERIFY(slice->visibility()); + QVERIFY(slice->showArrow()); + double pos[3], focal[3]; + camera(m_view)->GetPosition(pos); + camera(m_view)->GetFocalPoint(focal); + camera(m_view)->GetViewUp(up); + QVERIFY2(near(pos, 5, 7, 9), "camera position should be restored"); + QVERIFY2(near(focal, 1, 2, 3), "focal point should be restored"); + QVERIFY2(near(up, 0, 0, 1), "view up should be restored"); + } + + // 2D keeps the camera on its side of the slice and turns it as little + // as it can: up becomes the in-plane axis nearest the current up. With + // no hint from the camera (edge-on, up along the normal) the defaults + // apply. + void keepsTheCamerasSideAndNearestUp() + { + auto ds = addDataset(m_pip, 20, 30, 40); + auto* slice = addSink(m_pip, ds, m_view); + slice->setDirection(pipeline::SliceSink::XY); + ActiveObjects::instance().setActivePort(ds.port()); + + // From below, rolled so that roughly -x is up + placeCamera(m_view, 0, 0, -10, 0, 0, 0); + camera(m_view)->SetViewUp(-0.9, 0.1, 0); + setInteractionMode(m_view, MODE_2D); + double dir[3], up[3]; + camera(m_view)->GetDirectionOfProjection(dir); + camera(m_view)->GetViewUp(up); + QVERIFY2(near(dir, 0, 0, 1), "camera should stay below, looking up"); + QVERIFY2(near(up, -1, 0, 0), "-x should be up"); + setInteractionMode(m_view, MODE_3D); + + // Edge-on, with z (the normal) up: the XY defaults + placeCamera(m_view, 10, 0, 0, 0, 0, 0); + setInteractionMode(m_view, MODE_2D); + camera(m_view)->GetDirectionOfProjection(dir); + camera(m_view)->GetViewUp(up); + QVERIFY2(near(dir, 0, 0, -1), "an XY slice defaults to the view from +z"); + QVERIFY2(near(up, 0, 1, 0), "y defaults to up"); + setInteractionMode(m_view, MODE_3D); + } + + // A view with no slice gets one on the tip port. Leaving 2D hides it + // rather than deleting it, so the next 2D toggle finds it again. + void createsASliceOnTheTipPortWhenTheViewHasNone() + { + auto ds = addDataset(m_pip, 20, 20, 20); + auto* outline = addSink(m_pip, ds, m_view); + ActiveObjects::instance().setActivePort(ds.port()); + + setInteractionMode(m_view, MODE_2D); + QVERIFY(m_behavior->isActive(m_view)); + auto slices = slicesIn(m_pip, m_view); + QCOMPARE(slices.size(), 1); + auto* slice = slices.first(); + QCOMPARE(m_behavior->slice(m_view), slice); + QVERIFY(slice->visibility()); + QCOMPARE(slice->inputPorts()[0]->link()->from(), ds.sinkPort()); + QVERIFY(!outline->visibility()); + double dir[3]; + camera(m_view)->GetDirectionOfProjection(dir); + QVERIFY2(near(dir, 0, 0, -1), "a new XY slice is viewed from +z"); + + setInteractionMode(m_view, MODE_3D); + QVERIFY(outline->visibility()); + QCOMPARE(slicesIn(m_pip, m_view).size(), 1); + QVERIFY(!slice->visibility()); + + // Back in 2D the same slice is reused, not a second one made + setInteractionMode(m_view, MODE_2D); + QCOMPARE(m_behavior->slice(m_view), slice); + QCOMPARE(slicesIn(m_pip, m_view).size(), 1); + QVERIFY(slice->visibility()); + setInteractionMode(m_view, MODE_3D); + } + + // With several datasets, the slice fed by the tip port is the one the + // camera aims at, and a view showing the tip port's data elsewhere is + // not touched. A view with none of the tip port's slices gets its own + // slice of the tip port instead of being emptied out. + void picksTheTipPortsSliceAndLeavesOtherViewsAlone() + { + auto* other = createRenderView(); + QVERIFY(other); + m_extraViews.append(other); + + auto a = addDataset(m_pip, 10, 10, 10); + auto b = addDataset(m_pip, 10, 10, 10); + auto* volumeA = addSink(m_pip, a, m_view); + auto* sliceA = addSink(m_pip, a, m_view); + sliceA->setDirection(pipeline::SliceSink::XY); + auto* volumeB = addSink(m_pip, b, m_view); + auto* sliceB = addSink(m_pip, b, m_view); + sliceB->setDirection(pipeline::SliceSink::YZ); + auto* outlineB = addSink(m_pip, b, other); + ActiveObjects::instance().setActivePort(b.port()); + + setInteractionMode(m_view, MODE_2D); + QCOMPARE(m_behavior->slice(m_view), sliceB); + QVERIFY(!volumeA->visibility()); + QVERIFY(!volumeB->visibility()); + QVERIFY(sliceA->visibility()); + QVERIFY(sliceB->visibility()); + double dir[3]; + camera(m_view)->GetDirectionOfProjection(dir); + QVERIFY2(near(dir, -1, 0, 0), "camera should look along the YZ normal"); + // The other view is not an image viewer and nothing in it changed + QVERIFY(!m_behavior->isActive(other)); + QVERIFY(outlineB->visibility()); + + // The tip port's slices live in m_view; the other view gets its own + setInteractionMode(other, MODE_2D); + QVERIFY(m_behavior->isActive(other)); + auto* created = m_behavior->slice(other); + QVERIFY(created); + QCOMPARE(created->view(), other->getViewProxy()); + QCOMPARE(created->inputPorts()[0]->link()->from(), b.sinkPort()); + QVERIFY(!outlineB->visibility()); + QCOMPARE(m_behavior->slice(m_view), sliceB); + + setInteractionMode(other, MODE_3D); + QVERIFY(outlineB->visibility()); + QVERIFY(!created->visibility()); + QVERIFY(m_behavior->isActive(m_view)); + setInteractionMode(m_view, MODE_3D); + QVERIFY(volumeA->visibility()); + QVERIFY(volumeB->visibility()); + } + + // Rubber-band selection borrows the interactor and hands back to 2D: + // that round trip must not re-enter and must not restore anything. + void transientModesKeepTheImageViewer() + { + auto ds = addDataset(m_pip, 10, 10, 10); + auto* volume = addSink(m_pip, ds, m_view); + auto* slice = addSink(m_pip, ds, m_view); + ActiveObjects::instance().setActivePort(ds.port()); + + setInteractionMode(m_view, MODE_2D); + setInteractionMode(m_view, MODE_SELECTION); + QVERIFY(m_behavior->isActive(m_view)); + QVERIFY(!volume->visibility()); + setInteractionMode(m_view, MODE_2D); + QVERIFY(m_behavior->isActive(m_view)); + QVERIFY(!volume->visibility()); + QCOMPARE(m_behavior->slice(m_view), slice); + + setInteractionMode(m_view, MODE_3D); + QVERIFY(!m_behavior->isActive(m_view)); + QVERIFY(volume->visibility()); + } + + // Right after a load nothing is selected yet; the first branch's tip + // stands in for the tip port, as it does elsewhere in tomviz. + void fallsBackToTheFirstBranchTipWithoutASelection() + { + auto ds = addDataset(m_pip, 10, 10, 10); + auto* volume = addSink(m_pip, ds, m_view); + ActiveObjects::instance().clearActiveSelection(); + QVERIFY(!ActiveObjects::instance().activeTipOutputPort()); + + setInteractionMode(m_view, MODE_2D); + QVERIFY(m_behavior->isActive(m_view)); + auto* slice = m_behavior->slice(m_view); + QVERIFY(slice); + QCOMPARE(slice->inputPorts()[0]->link()->from(), ds.sinkPort()); + QVERIFY(!volume->visibility()); + setInteractionMode(m_view, MODE_3D); + QVERIFY(volume->visibility()); + } + + // No data: the toggle is just a 2D interactor, and the view is left + // alone rather than emptied. + void nothingToShowLeavesTheViewAlone() + { + setInteractionMode(m_view, MODE_2D); + QVERIFY(!m_behavior->isActive(m_view)); + QVERIFY(slicesIn(m_pip, m_view).isEmpty()); + setInteractionMode(m_view, MODE_3D); + QVERIFY(!m_behavior->isActive(m_view)); + } + + // A view that goes away while in 2D drops its record; the sinks it + // showed are not touched afterwards. + void removedViewIsForgotten() + { + auto* doomed = createRenderView(); + QVERIFY(doomed); + auto ds = addDataset(m_pip, 10, 10, 10); + addSink(m_pip, ds, doomed); + ActiveObjects::instance().setActivePort(ds.port()); + setInteractionMode(doomed, MODE_2D); + QVERIFY(m_behavior->isActive(doomed)); + pqApplicationCore::instance()->getObjectBuilder()->destroy(doomed); + QVERIFY(!m_behavior->isActive(doomed)); + } + +private: + ImageViewerModeBehavior* m_behavior; + pipeline::Pipeline* m_pip; + pqRenderView* m_view = nullptr; + QList m_extraViews; +}; + +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:")); + pipeline::Pipeline pipeline; + ActiveObjects::instance().setPipeline(&pipeline); + ImageViewerModeBehavior behavior; + ImageViewerModeTest tc(&behavior, &pipeline); + return QTest::qExec(&tc, argc, argv); +} + +#include "ImageViewerModeTest.moc" 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/LabelMapSurfaceTest.cxx b/tests/cxx/LabelMapSurfaceTest.cxx new file mode 100644 index 000000000..3165c4a23 --- /dev/null +++ b/tests/cxx/LabelMapSurfaceTest.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 + +#include "pipeline/data/LabelMapData.h" +#include "pipeline/sinks/LabelMapSurface.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +using namespace tomviz::pipeline; + +namespace { + +// Three labelled blocks in a 12x12x12 volume: 1 and 2 share a face, 3 +// is isolated. Background is 0. +vtkSmartPointer threeLabels() +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(12, 12, 12); + image->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + for (int z = 0; z < 12; ++z) { + for (int y = 0; y < 12; ++y) { + for (int x = 0; x < 12; ++x) { + unsigned char v = 0; + if (z >= 2 && z < 6 && y >= 2 && y < 6 && x >= 2 && x < 6) { + v = 1; + } else if (z >= 2 && z < 6 && y >= 2 && y < 6 && x >= 6 && x < 10) { + v = 2; + } else if (z >= 8 && z < 11 && y >= 8 && y < 11 && x >= 8 && x < 11) { + v = 3; + } + *static_cast(image->GetScalarPointer(x, y, z)) = v; + } + } + } + return image; +} + +LabelTable tableFor() +{ + LabelTable table; + QVector> scanned = { { 0.0, 1 }, { 1.0, 1 }, + { 2.0, 1 }, { 3.0, 1 } }; + table.reconcile(scanned, false); + table.setColor(table.indexOfValue(1.0), QColor(255, 0, 0)); + table.setColor(table.indexOfValue(2.0), QColor(0, 255, 0)); + table.setColor(table.indexOfValue(3.0), QColor(0, 0, 255)); + return table; +} + +std::set> distinctColors(vtkPolyData* surface) +{ + std::set> colors; + auto* arr = vtkUnsignedCharArray::SafeDownCast( + surface->GetCellData()->GetArray(kLabelColorsArrayName)); + if (!arr) { + return colors; + } + for (vtkIdType c = 0; c < arr->GetNumberOfTuples(); ++c) { + unsigned char rgb[3]; + arr->GetTypedTuple(c, rgb); + colors.insert({ rgb[0], rgb[1], rgb[2] }); + } + return colors; +} + +void checkColoring(int smoothing) +{ + auto image = threeLabels(); + LabelTable table = tableFor(); + const double background = 0.0; + auto surface = + extractLabelSurface(image, regionLabels(table, background), + visibleLabels(table, background), smoothing, background); + ASSERT_GT(surface->GetNumberOfCells(), 0); + auto* boundary = surface->GetCellData()->GetArray("BoundaryLabels"); + ASSERT_NE(boundary, nullptr) << "smoothing=" << smoothing; + EXPECT_EQ(boundary->GetNumberOfComponents(), 2); + EXPECT_EQ(boundary->GetNumberOfTuples(), surface->GetNumberOfCells()); + + colorLabelSurface(surface, table, background); + auto colors = distinctColors(surface); + EXPECT_EQ(colors.size(), 3u) << "smoothing=" << smoothing; + EXPECT_TRUE(colors.count({ 255, 0, 0 })); + EXPECT_TRUE(colors.count({ 0, 255, 0 })); + EXPECT_TRUE(colors.count({ 0, 0, 255 })); + EXPECT_FALSE(colors.count({ 128, 128, 128 })) << "grey fallback used"; +} + +} // namespace + +TEST(LabelMapSurfaceTest, ColorsEachLabelUnsmoothed) +{ + checkColoring(0); +} + +TEST(LabelMapSurfaceTest, ColorsEachLabelSmoothed) +{ + checkColoring(20); +} + +namespace { + +// A radius-4 sphere of label 1 centered in a 16^3 volume: 257 voxels, a +// particle of the size a segmentation is full of. +vtkSmartPointer smallSphere(const double spacing[3]) +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(16, 16, 16); + image->SetSpacing(spacing); + image->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + for (int z = 0; z < 16; ++z) { + for (int y = 0; y < 16; ++y) { + for (int x = 0; x < 16; ++x) { + const int dx = x - 8, dy = y - 8, dz = z - 8; + *static_cast(image->GetScalarPointer(x, y, z)) = + dx * dx + dy * dy + dz * dz <= 16 ? 1 : 0; + } + } + } + return image; +} + +double enclosedVolume(vtkPolyData* surface) +{ + vtkNew triangles; + triangles->SetInputData(surface); + vtkNew normals; + normals->SetInputConnection(triangles->GetOutputPort()); + normals->ConsistencyOn(); + normals->AutoOrientNormalsOn(); + normals->SplittingOff(); + vtkNew mass; + mass->SetInputConnection(normals->GetOutputPort()); + mass->Update(); + return mass->GetVolume(); +} + +} // namespace + +// Surface Nets' own smoother rounded a particle this size down to 58% of +// its voxels; the surface has to keep the volume the voxels have. +TEST(LabelMapSurfaceTest, SmoothingKeepsTheVolume) +{ + const double unit[3] = { 1.0, 1.0, 1.0 }; + auto image = smallSphere(unit); + const QVector regions{ 1.0 }; + auto raw = extractLabelMesh(image, regions, 0, 0.0); + EXPECT_NEAR(enclosedVolume(raw), 257.0, 1e-6); + for (int iterations : { 8, 16, kMaxSurfaceSmoothing }) { + auto smooth = extractLabelMesh(image, regions, iterations, 0.0); + const double volume = enclosedVolume(smooth); + EXPECT_GT(volume, 0.95 * 257.0) << "iterations=" << iterations; + EXPECT_LT(volume, 1.05 * 257.0) << "iterations=" << iterations; + EXPECT_TRUE(smooth->GetPointData()->GetNormals()); + } +} + +// Whatever the iteration count, no point leaves the voxel shell by more +// than half a voxel diagonal, measured in world units. +TEST(LabelMapSurfaceTest, SmoothingStaysWithinHalfAVoxel) +{ + const double spacing[3] = { 1.0, 1.0, 2.5 }; + auto image = smallSphere(spacing); + const QVector regions{ 1.0 }; + auto raw = extractLabelMesh(image, regions, 0, 0.0); + auto smooth = extractLabelMesh(image, regions, 200, 0.0); + ASSERT_EQ(smooth->GetNumberOfPoints(), raw->GetNumberOfPoints()); + const double limit = 0.5 * vtkMath::Norm(spacing); + double moved = 0.0; + for (vtkIdType i = 0; i < raw->GetNumberOfPoints(); ++i) { + double a[3], b[3]; + raw->GetPoint(i, a); + smooth->GetPoint(i, b); + moved = std::max(moved, std::sqrt(vtkMath::Distance2BetweenPoints(a, b))); + } + EXPECT_GT(moved, 0.0) << "nothing was smoothed"; + EXPECT_LE(moved, limit + 1e-6); +} diff --git a/tests/cxx/LabelMapTest.cxx b/tests/cxx/LabelMapTest.cxx new file mode 100644 index 000000000..c0cb5eadf --- /dev/null +++ b/tests/cxx/LabelMapTest.cxx @@ -0,0 +1,201 @@ +/* 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 "pipeline/data/LabelMapData.h" + +using namespace tomviz::pipeline; + +namespace { + +// A volume carrying labels 0, 1 and 2 in equal measure. +vtkSmartPointer threeLabelImage() +{ + vtkNew image; + image->SetDimensions(9, 9, 9); + image->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (int i = 0; i < 9 * 9 * 9; ++i) { + p[i] = static_cast(i % 3); + } + return vtkSmartPointer(image.GetPointer()); +} + +// A volume of a given scalar type whose values sweep a range. +vtkSmartPointer rampImage(int vtkType, double span) +{ + vtkNew image; + image->SetDimensions(16, 16, 16); + image->AllocateScalars(vtkType, 1); + auto* scalars = image->GetPointData()->GetScalars(); + for (vtkIdType i = 0; i < scalars->GetNumberOfTuples(); ++i) { + scalars->SetTuple1(i, (i * span) / scalars->GetNumberOfTuples()); + } + scalars->Modified(); + return vtkSmartPointer(image.GetPointer()); +} + +// Labels 0..5, with the values in @a removed sent to the background +vtkSmartPointer sixLabelImage(std::set removed = {}) +{ + vtkNew image; + image->SetDimensions(12, 12, 12); + image->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (int i = 0; i < 12 * 12 * 12; ++i) { + const int label = i % 6; + p[i] = static_cast(removed.count(label) ? 0 : label); + } + return vtkSmartPointer(image.GetPointer()); +} + +QColor colorOf(LabelMapData& data, double value) +{ + const int index = data.labels().indexOfValue(value); + return index >= 0 ? data.labels().at(index).color : QColor(); +} + +} // namespace + +class LabelMapTest : public QObject +{ + Q_OBJECT + +private slots: + // Hiding a label drives its opacity to zero and leaves its neighbors + // alone, and the choice survives the producing node running again. + // Each execution publishes a brand new payload, so without the + // hand-off in adoptLabelsFrom() every re-run would silently switch + // the label back on. + // A segmentation read from a file arrives as a plain volume, because + // the reader cannot know its numbers are labels. What can be offered + // as a label map is decided from the data rather than the port type. + void plainVolumesAreReadableAsLabelsWhenTheirValuesAllow() + { + auto labels = std::make_shared(threeLabelImage()); + QVERIFY(canInterpretAsLabelMap(labels)); + + // Continuous data rules itself out by its scalar type alone: there + // is nothing to enumerate in a float field. + auto continuous = + std::make_shared(rampImage(VTK_FLOAT, 100.0)); + QVERIFY(!canInterpretAsLabelMap(continuous)); + + // So does integer data spanning more values than the table holds. + auto wide = std::make_shared( + rampImage(VTK_INT, 4.0 * LabelTable::maxLabels)); + QVERIFY(!canInterpretAsLabelMap(wide)); + + // Right at the limit it is still offered. + auto atLimit = std::make_shared( + rampImage(VTK_INT, LabelTable::maxLabels - 1)); + QVERIFY(canInterpretAsLabelMap(atLimit)); + + // Nothing loaded is not something to offer it for. + QVERIFY(!canInterpretAsLabelMap(nullptr)); + } + + // A label map adopted from a plain volume keeps its table in the sink + // rather than in the port's payload, so this round trip is the only + // thing carrying the user's colors and names into a state file. + void labelTableSurvivesAStateFile() + { + auto data = std::make_shared(threeLabelImage()); + data->refreshLabels(); + auto& labels = data->labels(); + QCOMPARE(labels.count(), 3); + + labels.setName(1, "particle A"); + labels.setColor(1, QColor(10, 200, 30)); + labels.setVisible(2, false); + const auto json = labels.serialize(); + + LabelTable restored; + restored.deserialize(json); + QCOMPARE(restored.count(), 3); + QCOMPARE(restored.at(1).name, QString("particle A")); + QCOMPARE(restored.at(1).color, QColor(10, 200, 30)); + QCOMPARE(restored.at(2).visible, false); + // Label 0 is background and starts hidden; that must not come back + // as visible either. + QCOMPARE(restored.at(0).visible, false); + } + + void hiddenLabelSurvivesReexecution() + { + auto first = std::make_shared(threeLabelImage()); + first->refreshLabels(); + first->applyLabels(); + + auto& labels = first->labels(); + QCOMPARE(labels.count(), 3); + + const int hidden = labels.indexOfValue(1.0); + QVERIFY(hidden >= 0); + labels.setVisible(hidden, false); + first->applyLabels(); + + auto* opacity = first->scalarOpacity(); + QVERIFY(opacity); + QCOMPARE(opacity->GetValue(1.0), 0.0); + QCOMPARE(opacity->GetValue(2.0), 1.0); + + // The node re-runs: same labels, different payload. + auto second = std::make_shared(threeLabelImage()); + second->adoptLabelsFrom(*first); + second->applyLabels(); + + QCOMPARE(second->labels().count(), 3); + QCOMPARE(second->labels().at(hidden).visible, false); + QCOMPARE(second->scalarOpacity()->GetValue(1.0), 0.0); + QCOMPARE(second->scalarOpacity()->GetValue(2.0), 1.0); + } + + // Removing labels from the middle leaves every other label its color: + // colors follow the label values, not the order labels were found in + void labelColorsFollowTheirValues() + { + auto all = std::make_shared(sixLabelImage()); + all->refreshLabels(); + auto fewer = std::make_shared(sixLabelImage({ 2, 3 })); + fewer->refreshLabels(); + + QCOMPARE(fewer->labels().count(), 4); + for (double value : { 1.0, 4.0, 5.0 }) { + QVERIFY(colorOf(*fewer, value).isValid()); + QCOMPARE(colorOf(*fewer, value), colorOf(*all, value)); + } + // Neighbors still differ + QVERIFY(colorOf(*all, 4.0) != colorOf(*all, 5.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:")); + LabelMapTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "LabelMapTest.moc" diff --git a/tests/cxx/LabelSelectionTest.cxx b/tests/cxx/LabelSelectionTest.cxx new file mode 100644 index 000000000..00c51677e --- /dev/null +++ b/tests/cxx/LabelSelectionTest.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 + +#include "LabelSelection.h" + +using tomviz::formatLabelList; +using tomviz::parseLabelList; + +TEST(LabelSelectionTest, ParsesValuesRangesAndLooseSeparators) +{ + EXPECT_EQ(parseLabelList("3, 5, 10-12"), + (QVector{ 3, 5, 10, 11, 12 })); + EXPECT_EQ(parseLabelList("7;1 4\n2"), (QVector{ 1, 2, 4, 7 })); + // Reversed and overlapping runs, duplicates + EXPECT_EQ(parseLabelList("5-3, 4, 4"), (QVector{ 3, 4, 5 })); + EXPECT_EQ(parseLabelList("1 - 3"), (QVector{ 1, 2, 3 })); + EXPECT_TRUE(parseLabelList("").isEmpty()); + EXPECT_TRUE(parseLabelList(" , ; ").isEmpty()); + // Things that are not labels are skipped rather than fatal + EXPECT_EQ(parseLabelList("2, two, 3"), (QVector{ 2, 3 })); +} + +TEST(LabelSelectionTest, FormatsRunsOfThreeOrMoreAsRanges) +{ + EXPECT_EQ(formatLabelList({ 3, 5, 10, 11, 12 }), "3, 5, 10-12"); + // Two in a row stay separate; order and duplicates are tidied + EXPECT_EQ(formatLabelList({ 2, 1, 1 }), "1, 2"); + EXPECT_EQ(formatLabelList({ 4, 3, 2, 1, 9 }), "1-4, 9"); + EXPECT_EQ(formatLabelList({}), ""); +} + +TEST(LabelSelectionTest, FormatAndParseRoundTrip) +{ + const QVector labels{ 1, 2, 3, 7, 20, 21, 22, 23, 40 }; + EXPECT_EQ(parseLabelList(formatLabelList(labels)), labels); +} diff --git a/tests/cxx/LabelSurfaceRenderTest.cxx b/tests/cxx/LabelSurfaceRenderTest.cxx new file mode 100644 index 000000000..8d3ebdb82 --- /dev/null +++ b/tests/cxx/LabelSurfaceRenderTest.cxx @@ -0,0 +1,261 @@ +/* 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pipeline/InputPort.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/LabelMapData.h" +#include "pipeline/sinks/LabelMapSink.h" +#include "pipeline/sinks/LabelMapSurface.h" + +#include +#include +#include +#include + +using namespace tomviz; + +namespace { + +// Three blocks side by side along x, labels 1, 2, 3, in a 24^3 volume. +vtkSmartPointer threeBlocks() +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(24, 24, 24); + image->AllocateScalars(VTK_INT, 1); + for (int z = 0; z < 24; ++z) { + for (int y = 0; y < 24; ++y) { + for (int x = 0; x < 24; ++x) { + int v = 0; + if (z >= 6 && z < 18 && y >= 6 && y < 18) { + if (x >= 2 && x < 8) { + v = 1; + } else if (x >= 9 && x < 15) { + v = 2; + } else if (x >= 16 && x < 22) { + v = 3; + } + } + *static_cast(image->GetScalarPointer(x, y, z)) = v; + } + } + } + return image; +} + +std::set> distinctCellColors(vtkPolyData* surface) +{ + std::set> colors; + auto* arr = vtkUnsignedCharArray::SafeDownCast( + surface->GetCellData()->GetArray(pipeline::kLabelColorsArrayName)); + if (!arr) { + return colors; + } + for (vtkIdType c = 0; c < arr->GetNumberOfTuples(); ++c) { + unsigned char rgb[3]; + arr->GetTypedTuple(c, rgb); + colors.insert({ rgb[0], rgb[1], rgb[2] }); + } + return colors; +} + +} // namespace + +class LabelSurfaceRenderTest : public QObject +{ + Q_OBJECT + +public: + explicit LabelSurfaceRenderTest(pipeline::Pipeline* pipeline) + : m_pip(pipeline) + { + } + +private slots: + // A CI runner has no GPU and no software GL. There, a render window + // that fails to load its OpenGL functions crashes when torn down, and + // the view's window gets initialized as soon as the sink draws into + // it, so the check has to come before any view exists. It is done + // with a throwaway window of its own, watching what VTK says while + // that window is created and initialized: vtkRenderWindow::New() + // tries the backends in turn and each one that fails says so as it + // goes (no X server, no EGL device, no OSMesa, no pixel format on + // Windows), before any observer could be attached, and a healthy + // window is created without a word. SupportsOpenGL() is not enough: + // on the EGL runner it reports support despite all that. + void initTestCase() + { + vtkNew capture; + auto* previous = vtkOutputWindow::GetInstance(); + previous->Register(nullptr); + vtkOutputWindow::SetInstance(capture); + { + vtkNew probe; + probe->SetOffScreenRendering(1); + probe->Initialize(); + } + vtkOutputWindow::SetInstance(previous); + previous->Delete(); + const std::string said = capture->GetOutput(); + for (const char* complaint : { "OpenGL", "X server", "EGL", "OSMesa", + "pixel format", "PixelFormat" }) { + if (said.find(complaint) != std::string::npos) { + QSKIP(qPrintable(QString("no OpenGL available for an offscreen " + "render: %1") + .arg(QString::fromStdString(said).trimmed() + .left(160)))); + } + } + } + + void init() + { + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + m_view = qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); + QVERIFY(m_view); + } + + void cleanup() + { + m_pip->clear(); + pqApplicationCore::instance()->getObjectBuilder()->destroy(m_view); + m_view = nullptr; + } + + void surfaceCarriesOneColorPerLabel() + { + // Payloads travel as VolumeDataPtr (exact-type any_cast). + auto labelData = std::make_shared(threeBlocks()); + labelData->refreshLabels(); + pipeline::VolumeDataPtr vol = labelData; + auto* source = new pipeline::SourceNode(); + source->addOutput("volume", pipeline::PortType::Volume); + m_pip->addNode(source); + source->setOutputData( + "volume", pipeline::PortData(vol, pipeline::PortType::Volume)); + + auto* sink = new pipeline::LabelMapSink(); + sink->setLabel("labels"); + sink->initialize(m_view->getViewProxy()); + m_pip->addNode(sink); + m_pip->createLink(source->outputPorts()[0], sink->inputPorts()[0]); + sink->execute(); + QCOMPARE(sink->representation(), + pipeline::LabelMapSink::Representation::Surface); + + auto* surface = sink->surface(); + QVERIFY(surface); + QVERIFY(surface->GetNumberOfCells() > 0); + const auto cellColors = distinctCellColors(surface); + QCOMPARE(int(cellColors.size()), 3); + + // Now actually render and read back the pixels, with the sink's + // own actor (the one it put in the view's renderer) in a plain + // offscreen window, since the pq view does not draw under the + // offscreen QPA. + auto* rv = vtkPVRenderView::SafeDownCast( + m_view->getViewProxy()->GetClientSideView()); + QVERIFY(rv); + vtkActor* actor = nullptr; + auto* props = rv->GetRenderer()->GetViewProps(); + vtkCollectionSimpleIterator it; + props->InitTraversal(it); + while (auto* prop = props->GetNextProp(it)) { + auto* a = vtkActor::SafeDownCast(prop); + if (a && a->GetMapper() && a->GetMapper()->GetInput() == surface) { + actor = a; + } + } + QVERIFY2(actor, "sink's surface actor not found in the renderer"); + + vtkNew renderer; + vtkNew window; + window->SetOffScreenRendering(1); + window->AddRenderer(renderer); + renderer->AddActor(actor); + renderer->SetBackground(0, 0, 0); + window->SetSize(300, 300); + renderer->ResetCamera(); + window->Render(); + + vtkNew grab; + grab->SetInput(window); + grab->ReadFrontBufferOff(); + grab->Update(); + auto* img = grab->GetOutput(); + auto* px = vtkUnsignedCharArray::SafeDownCast( + img->GetPointData()->GetScalars()); + QVERIFY(px); + // Bucket pixels by dominant channel to count the hues on screen. + std::map dominant; + for (vtkIdType i = 0; i < px->GetNumberOfTuples(); ++i) { + unsigned char rgb[4] = { 0, 0, 0, 0 }; + px->GetTypedTuple(i, rgb); + int mx = std::max({ rgb[0], rgb[1], rgb[2] }); + int mn = std::min({ rgb[0], rgb[1], rgb[2] }); + if (mx - mn < 40) { + continue; // background / grey + } + int which = rgb[0] == mx ? 0 : (rgb[1] == mx ? 1 : 2); + dominant[which]++; + } + // With three palette colors at least two hues must dominate + // somewhere in the frame. + int hues = 0; + for (auto& kv : dominant) { + if (kv.second > 50) { + ++hues; + } + } + QVERIFY2(hues >= 2, "surface rendered in a single hue"); + } + +private: + pipeline::Pipeline* m_pip; + pqRenderView* m_view = nullptr; +}; + +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:")); + pipeline::Pipeline pipeline; + LabelSurfaceRenderTest tc(&pipeline); + return QTest::qExec(&tc, argc, argv); +} + +#include "LabelSurfaceRenderTest.moc" diff --git a/tests/cxx/LightingPresetStoreTest.cxx b/tests/cxx/LightingPresetStoreTest.cxx new file mode 100644 index 000000000..84ebe7ebd --- /dev/null +++ b/tests/cxx/LightingPresetStoreTest.cxx @@ -0,0 +1,77 @@ +/* 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/LightingPresetStore.h" + +#include + +using tomviz::pipeline::LightingPresetStore; +using tomviz::pipeline::UserLightingPreset; + +namespace { + +// The store is shared by the whole application (memory-only here, since +// there is no application core), so each test starts from an empty one. +class LightingPresetStoreTest : public ::testing::Test +{ +protected: + void SetUp() override { clear(); } + void TearDown() override { clear(); } + + void clear() + { + for (const auto& preset : store().presets()) { + store().remove(preset.name); + } + } + + LightingPresetStore& store() { return LightingPresetStore::instance(); } + + UserLightingPreset preset(const QString& name, double ambient) + { + UserLightingPreset p; + p.name = name; + p.ambient = ambient; + return p; + } +}; + +} // namespace + +TEST_F(LightingPresetStoreTest, RenameKeepsTheValuesUnderTheNewName) +{ + store().save(preset("Warm", 0.4)); + QSignalSpy changed(&store(), &LightingPresetStore::changed); + + EXPECT_TRUE(store().rename("Warm", "Warmer")); + EXPECT_FALSE(store().contains("Warm")); + ASSERT_TRUE(store().contains("Warmer")); + EXPECT_DOUBLE_EQ(store().preset("Warmer").ambient, 0.4); + EXPECT_EQ(changed.count(), 1); + + // Surrounding whitespace is not part of a name + EXPECT_TRUE(store().rename("Warmer", " Warmest ")); + EXPECT_TRUE(store().contains("Warmest")); +} + +TEST_F(LightingPresetStoreTest, RenameRefusesBlankTakenAndUnknownNames) +{ + store().save(preset("A", 0.1)); + store().save(preset("B", 0.2)); + QSignalSpy changed(&store(), &LightingPresetStore::changed); + + EXPECT_FALSE(store().rename("A", "")); + EXPECT_FALSE(store().rename("A", " ")); + EXPECT_FALSE(store().rename("A", "B")); + EXPECT_FALSE(store().rename("Nobody", "C")); + // Renaming to the same name is fine and changes nothing + EXPECT_TRUE(store().rename("A", "A")); + + EXPECT_EQ(changed.count(), 0); + EXPECT_TRUE(store().contains("A")); + EXPECT_TRUE(store().contains("B")); + EXPECT_DOUBLE_EQ(store().preset("B").ambient, 0.2); + EXPECT_EQ(store().presets().size(), 2); +} 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/MultiVolumeTest.cxx b/tests/cxx/MultiVolumeTest.cxx new file mode 100644 index 000000000..7be67cf9c --- /dev/null +++ b/tests/cxx/MultiVolumeTest.cxx @@ -0,0 +1,481 @@ +/* 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pipeline/InputPort.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/LabelMapSink.h" +#include "pipeline/sinks/MultiVolumeCoordinator.h" +#include "pipeline/sinks/VolumeSink.h" + +using namespace tomviz; +using pipeline::MultiVolumeCoordinator; + +namespace { + +pqRenderView* createRenderView() +{ + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + return qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); +} + +/// A float volume carrying one named array per entry of @a arrays; the +/// first one is the active scalars. +vtkSmartPointer floatImage( + const QStringList& arrays = { "scalars" }) +{ + vtkNew image; + image->SetDimensions(8, 8, 8); + for (const auto& name : arrays) { + vtkNew array; + array->SetName(name.toUtf8().constData()); + array->SetNumberOfTuples(image->GetNumberOfPoints()); + array->Fill(1.0f); + image->GetPointData()->AddArray(array); + } + image->GetPointData()->SetActiveScalars(arrays.first().toUtf8().constData()); + return vtkSmartPointer(image.GetPointer()); +} + +/// A volume whose values read as labels 0, 1 and 2. +vtkSmartPointer labelImage() +{ + vtkNew image; + image->SetDimensions(8, 8, 8); + image->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (int i = 0; i < 8 * 8 * 8; ++i) { + p[i] = static_cast(i % 3); + } + return vtkSmartPointer(image.GetPointer()); +} + +pipeline::SourceNode* addSource(pipeline::Pipeline* pip, + vtkSmartPointer image) +{ + auto vol = std::make_shared(image); + auto* source = new pipeline::SourceNode(); + source->addOutput("volume", pipeline::PortType::Volume); + pip->addNode(source); + source->setOutputData( + "volume", pipeline::PortData(vol, pipeline::PortType::Volume)); + return source; +} + +/// A sink on @a view fed by @a source, with the data consumed, as it +/// stands after the pipeline has run. +template +Sink* addSink(pipeline::Pipeline* pip, pipeline::SourceNode* source, + pqRenderView* view) +{ + auto* sink = new Sink(); + sink->setLabel(QString("Sink %1").arg(pip->nodes().size())); + sink->initialize(view->getViewProxy()); + pip->addNode(sink); + pip->createLink(source->outputPorts()[0], sink->inputPorts()[0]); + sink->execute(); + return sink; +} + +vtkRenderer* renderer(pqRenderView* view) +{ + auto* rv = vtkPVRenderView::SafeDownCast( + view->getViewProxy()->GetClientSideView()); + return rv ? rv->GetRenderer() : nullptr; +} + +bool inRenderer(pqRenderView* view, vtkProp* prop) +{ + auto* ren = renderer(view); + return ren && ren->HasViewProp(prop); +} + +/// The multi-volume prop the view draws, or null while it draws none. +vtkMultiVolume* multiVolumeIn(pqRenderView* view) +{ + auto* ren = renderer(view); + if (!ren) { + return nullptr; + } + auto* props = ren->GetViewProps(); + vtkCollectionSimpleIterator it; + props->InitTraversal(it); + while (auto* prop = props->GetNextProp(it)) { + if (auto* multi = vtkMultiVolume::SafeDownCast(prop)) { + return multi; + } + } + return nullptr; +} + +MultiVolumeCoordinator* coordinator(pqRenderView* view) +{ + return MultiVolumeCoordinator::find(view->getViewProxy()); +} + +bool rendersTogether(pqRenderView* view) +{ + auto* c = coordinator(view); + return c && c->active() && multiVolumeIn(view) != nullptr; +} + +} // namespace + +class MultiVolumeTest : public QObject +{ + Q_OBJECT + +public: + explicit MultiVolumeTest(pipeline::Pipeline* pipeline) : m_pip(pipeline) {} + +private slots: + void init() + { + m_view = createRenderView(); + QVERIFY(m_view); + } + + void cleanup() + { + m_pip->clear(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + for (auto* view : m_extraViews) { + builder->destroy(view); + } + m_extraViews.clear(); + builder->destroy(m_view); + m_view = nullptr; + } + + // A lone volume renders exactly as it always has: its own prop in the + // renderer, nothing shared. + void oneVolumeRendersOnItsOwn() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + QVERIFY(a->visibility()); + QVERIFY(inRenderer(m_view, a->volumeProp())); + QVERIFY(!multiVolumeIn(m_view)); + QVERIFY(!a->multiVolumeActive()); + auto* c = coordinator(m_view); + QVERIFY(c); + QVERIFY(!c->active()); + QCOMPARE(c->members().size(), 1); + } + + // The second volume on the view switches to the shared multi-volume: + // both sinks' own props leave the renderer and the mapper gets a port + // per volume, the first sink on port 0. + void aSecondVolumeSwitchesTheViewToTheMultiVolume() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + QVERIFY(rendersTogether(m_view)); + QVERIFY(!inRenderer(m_view, a->volumeProp())); + QVERIFY(!inRenderer(m_view, b->volumeProp())); + QVERIFY(a->multiVolumeActive()); + QVERIFY(b->multiVolumeActive()); + QVERIFY(a->multiVolumeLead()); + QVERIFY(!b->multiVolumeLead()); + + auto* c = coordinator(m_view); + QCOMPARE(c->lead(), a); + QCOMPARE(c->mapper()->GetInputCount(), 2); + QCOMPARE(c->multiVolume()->GetVolume(0), a->volumeProp()); + QCOMPARE(c->multiVolume()->GetVolume(1), b->volumeProp()); + // The multi-volume renders through the shared mapper, not either + // sink's own. + QCOMPARE(c->multiVolume()->GetMapper(), c->mapper()); + } + + // Hiding one of two volumes puts the other back on its own prop, and + // showing it again rejoins them. The hidden sink gets its (invisible) + // prop back too, so it renders as before once shown on its own. + void hidingAVolumeHandsTheOtherItsPropBack() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + QVERIFY(rendersTogether(m_view)); + + b->setVisibility(false); + QVERIFY(!rendersTogether(m_view)); + QVERIFY(!multiVolumeIn(m_view)); + QVERIFY(inRenderer(m_view, a->volumeProp())); + QVERIFY(inRenderer(m_view, b->volumeProp())); + QCOMPARE(b->volumeProp()->GetVisibility(), 0); + QVERIFY(!a->multiVolumeActive()); + QVERIFY(!b->multiVolumeActive()); + QCOMPARE(coordinator(m_view)->members().size(), 1); + + b->setVisibility(true); + QVERIFY(rendersTogether(m_view)); + QVERIFY(!inRenderer(m_view, a->volumeProp())); + QVERIFY(!inRenderer(m_view, b->volumeProp())); + } + + // Port 0 decides the shading of the whole set, so it must never go + // empty: when the lead leaves, the earliest remaining member takes it. + void theEarliestRemainingVolumeBecomesTheLead() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* cSink = addSink( + m_pip, addSource(m_pip, floatImage()), m_view); + auto* c = coordinator(m_view); + QCOMPARE(c->lead(), a); + QCOMPARE(c->mapper()->GetInputCount(), 3); + + a->setVisibility(false); + QVERIFY(rendersTogether(m_view)); + QCOMPARE(c->lead(), b); + QVERIFY(b->multiVolumeLead()); + QVERIFY(!cSink->multiVolumeLead()); + QCOMPARE(c->multiVolume()->GetVolume(0), b->volumeProp()); + QCOMPARE(c->mapper()->GetInputCount(), 2); + QCOMPARE(c->multiVolume()->GetVolume(2), cSink->volumeProp()); + } + + // Leaving the multi-volume releases the shared mapper's GPU resources, + // and VTK never marks a multi-input mapper initialized again after + // that: it would re-upload every volume on every frame. So the mapper + // is retired and the next activation gets a fresh one, fully wired. + void aReleasedMapperIsReplacedOnReactivation() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* c = coordinator(m_view); + QVERIFY(rendersTogether(m_view)); + auto* first = c->mapper(); + vtkNew plane; + a->addClippingPlane(plane); + + b->setVisibility(false); + b->setVisibility(true); + QVERIFY(rendersTogether(m_view)); + QVERIFY(c->mapper() != first); + QCOMPARE(c->multiVolume()->GetMapper(), c->mapper()); + QCOMPARE(c->mapper()->GetInputCount(), 2); + QVERIFY(c->mapper()->GetInputDataObject(0, 0)); + QVERIFY(c->mapper()->GetInputDataObject(1, 0)); + QCOMPARE(c->mapper()->GetClippingPlanes()->GetNumberOfItems(), 1); + // A release while active (a recreated GL context) is caught at the + // start of the next frame. + auto* second = c->mapper(); + second->ReleaseGraphicsResources(nullptr); + renderer(m_view)->InvokeEvent(vtkCommand::StartEvent); + QVERIFY(c->mapper() != second); + QCOMPARE(c->mapper()->GetInputCount(), 2); + } + + // A label map drawn as surfaces has no volume on screen, so it does not + // count; switched to its volume representation it does. + void labelMapsCountOnlyWhenDrawnAsVolumes() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* labels = addSink( + m_pip, addSource(m_pip, labelImage()), m_view); + QCOMPARE(labels->representation(), + pipeline::LabelMapSink::Representation::Surface); + QVERIFY(!rendersTogether(m_view)); + QVERIFY(inRenderer(m_view, a->volumeProp())); + + labels->setRepresentation(pipeline::LabelMapSink::Representation::Volume); + QVERIFY(rendersTogether(m_view)); + QVERIFY(labels->multiVolumeActive()); + // The label map asked for fine sampling, which the shared mapper has + // to honor for the set. + QCOMPARE(coordinator(m_view)->mapper()->GetAutoAdjustSampleDistances(), 0); + + labels->setRepresentation( + pipeline::LabelMapSink::Representation::Surface); + QVERIFY(!rendersTogether(m_view)); + QVERIFY(inRenderer(m_view, a->volumeProp())); + } + + // Two sinks on the same data can show different arrays: each port's + // input carries the array its sink selected, and the shared data is + // left alone. + void eachVolumeKeepsItsOwnScalars() + { + auto image = floatImage({ "first", "second" }); + auto* source = addSource(m_pip, image); + auto* a = addSink(m_pip, source, m_view); + auto* b = addSink(m_pip, source, m_view); + b->setActiveScalars(1); + QVERIFY(rendersTogether(m_view)); + + auto* c = coordinator(m_view); + auto* inputA = vtkImageData::SafeDownCast(c->mapper()->GetInputDataObject(0, 0)); + auto* inputB = vtkImageData::SafeDownCast(c->mapper()->GetInputDataObject(1, 0)); + QVERIFY(inputA && inputB); + QCOMPARE(QString(inputA->GetPointData()->GetScalars()->GetName()), + QString("first")); + QCOMPARE(QString(inputB->GetPointData()->GetScalars()->GetName()), + QString("second")); + QCOMPARE(QString(image->GetPointData()->GetScalars()->GetName()), + QString("first")); + // The copies share the voxels rather than duplicating them. + QCOMPARE(inputB->GetPointData()->GetArray("second"), + image->GetPointData()->GetArray("second")); + + // Re-selecting the same array hands the mapper nothing new. + auto* before = c->mapper()->GetInputDataObject(1, 0); + b->setActiveScalars(1); + QCOMPARE(c->mapper()->GetInputDataObject(1, 0), before); + QVERIFY(a->multiVolumeActive()); + } + + // Clipping planes are a mapper setting, so every member's planes gather + // on the shared mapper, each once. + void clippingPlanesGatherOnTheSharedMapper() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + vtkNew shared; + vtkNew onlyA; + a->addClippingPlane(shared); + a->addClippingPlane(onlyA); + b->addClippingPlane(shared); + auto* planes = coordinator(m_view)->mapper()->GetClippingPlanes(); + QVERIFY(planes); + QCOMPARE(planes->GetNumberOfItems(), 2); + + a->removeClippingPlane(onlyA); + QCOMPARE(planes->GetNumberOfItems(), 1); + b->setVisibility(false); + // Back on its own, the sink's own mapper still carries its plane. + QCOMPARE(a->clippingPlanes()->GetNumberOfItems(), 1); + } + + // Views are independent: a volume in another view neither joins nor + // counts. + void viewsDoNotShareAMultiVolume() + { + auto* other = createRenderView(); + QVERIFY(other); + m_extraViews.append(other); + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + other); + QVERIFY(!rendersTogether(m_view)); + QVERIFY(!rendersTogether(other)); + QVERIFY(inRenderer(m_view, a->volumeProp())); + QVERIFY(inRenderer(other, b->volumeProp())); + } + + // Deleting a member leaves the set cleanly, and the coordinator goes + // away with its last member. + void removingSinksTearsDownCleanly() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + QVERIFY(rendersTogether(m_view)); + + m_pip->removeNode(b); // deletes it + QVERIFY(!rendersTogether(m_view)); + QVERIFY(!multiVolumeIn(m_view)); + QVERIFY(inRenderer(m_view, a->volumeProp())); + QCOMPARE(coordinator(m_view)->members().size(), 1); + + m_pip->removeNode(a); + QVERIFY(!coordinator(m_view)); + QVERIFY(!multiVolumeIn(m_view)); + } + + // The panel greys out what the shared path takes away and shows what + // actually renders there, and hands the sink's own settings back + // afterwards. + void thePanelFollowsTheSharedPath() + { + auto* a = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + a->setBlendingMode(vtkVolumeMapper::MAXIMUM_INTENSITY_BLEND); + QScopedPointer panel(a->createSinkPropertiesWidget(nullptr)); + auto* blending = panel->findChild("cbBlending"); + QVERIFY(blending); + QVERIFY(blending->isEnabled()); + QCOMPARE(blending->currentIndex(), + static_cast(vtkVolumeMapper::MAXIMUM_INTENSITY_BLEND)); + + auto* b = addSink(m_pip, addSource(m_pip, floatImage()), + m_view); + QVERIFY(rendersTogether(m_view)); + QVERIFY(!blending->isEnabled()); + QCOMPARE(blending->currentIndex(), + static_cast(vtkVolumeMapper::COMPOSITE_BLEND)); + // The sink's own choice is untouched underneath. + QCOMPARE(a->blendingMode(), + static_cast(vtkVolumeMapper::MAXIMUM_INTENSITY_BLEND)); + + b->setVisibility(false); + QVERIFY(blending->isEnabled()); + QCOMPARE(blending->currentIndex(), + static_cast(vtkVolumeMapper::MAXIMUM_INTENSITY_BLEND)); + } + +private: + pipeline::Pipeline* m_pip; + pqRenderView* m_view = nullptr; + QList m_extraViews; +}; + +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:")); + pipeline::Pipeline pipeline; + MultiVolumeTest tc(&pipeline); + return QTest::qExec(&tc, argc, argv); +} + +#include "MultiVolumeTest.moc" diff --git a/tests/cxx/NodeDefinitionTest.cxx b/tests/cxx/NodeDefinitionTest.cxx new file mode 100644 index 000000000..ebdca4c8d --- /dev/null +++ b/tests/cxx/NodeDefinitionTest.cxx @@ -0,0 +1,839 @@ +/* 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 "InputPort.h" +#include "NodeDefinitionEdits.h" +#include "NodeDefinitionFormWidget.h" +#include "NodeDefinitionValidator.h" +#include "NodeDefinitionWidget.h" +#include "OutputPort.h" +#include "EnumOptions.h" +#include "NodePropertiesWidget.h" +#include "Pipeline.h" +#include "PythonNodeEditorWidget.h" +#include "SourceNode.h" +#include "Utilities.h" +#include "sources/PythonSource.h" +#include "transforms/LegacyPythonTransform.h" +#include "transforms/PythonTransform.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "TomvizTest.h" + +using namespace tomviz::pipeline; + +namespace { + +const char* kV2Transform = R"({ + "schemaVersion": 2, + "name": "InvertData", + "label": "Invert Data", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [ + {"name": "scale", "label": "Scale", "type": "double", "default": 1.0} + ] +})"; + +const char* kV2Source = R"({ + "schemaVersion": 2, + "name": "ConstantDataset", + "outputs": [{"name": "volume", "type": "ImageData", "persistent": true}], + "parameters": [ + {"name": "value", "type": "double", "default": 0.0} + ] +})"; + +const char* kV1Transform = R"({ + "name": "AddConstant", + "label": "Add Constant", + "inputType": "ImageData", + "results": [{"name": "stats", "type": "table"}], + "parameters": [ + {"name": "constant", "type": "double", "default": 0.0} + ] +})"; + +/// Replace the whole "parameters" array of @a json. +QString withParameters(const char* json, const QString& parameters) +{ + QJsonObject obj = QJsonDocument::fromJson(QByteArray(json)).object(); + obj["parameters"] = + QJsonDocument::fromJson(parameters.toUtf8()).array(); + return QString::fromUtf8(QJsonDocument(obj).toJson()); +} + +/// Set (or, with an undefined value, remove) a single top-level key. +QString withKey(const char* json, const QString& key, const QJsonValue& value) +{ + QJsonObject obj = QJsonDocument::fromJson(QByteArray(json)).object(); + if (value.isUndefined()) { + obj.remove(key); + } else { + obj[key] = value; + } + return QString::fromUtf8(QJsonDocument(obj).toJson()); +} + +DefinitionValidation checkV2Transform(const QString& candidate) +{ + return validateNodeDefinition(kV2Transform, candidate, NodeShape::Transform, + DefinitionSchema::V2); +} + +} // namespace + +TEST(NodeDefinitionTest, SchemaDetection) +{ + EXPECT_EQ(definitionSchema(kV2Transform), DefinitionSchema::V2); + EXPECT_EQ(definitionSchema(kV1Transform), DefinitionSchema::V1); + EXPECT_EQ(definitionSchema(QString()), DefinitionSchema::V1); + EXPECT_EQ(definitionSchema("not json at all"), DefinitionSchema::V1); +} + +TEST(NodeDefinitionTest, UnchangedDescriptionIsClean) +{ + auto result = checkV2Transform(kV2Transform); + EXPECT_FALSE(result.hasErrors()); + EXPECT_TRUE(result.issues.isEmpty()); +} + +TEST(NodeDefinitionTest, ReformattingIsNotAChange) +{ + // Same content, different whitespace and key order. + QString reformatted = QString::fromUtf8( + QJsonDocument(QJsonDocument::fromJson(QByteArray(kV2Transform)).object()) + .toJson(QJsonDocument::Compact)); + auto result = checkV2Transform(reformatted); + EXPECT_FALSE(result.hasErrors()); + EXPECT_TRUE(result.addedParameters.isEmpty()); + EXPECT_TRUE(result.removedParameters.isEmpty()); +} + +// --- identity: what a live node can never become --------------------- + +TEST(NodeDefinitionTest, SchemaVersionIsFrozen) +{ + auto result = checkV2Transform(withKey(kV2Transform, "schemaVersion", 1)); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, TransformCannotDropItsInputs) +{ + auto result = checkV2Transform(withKey(kV2Transform, "inputs", QJsonArray())); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, SourceCannotGrowInputs) +{ + QJsonArray inputs{ QJsonObject{ { "name", "volume" }, + { "type", "ImageData" } } }; + auto result = validateNodeDefinition( + kV2Source, withKey(kV2Source, "inputs", inputs), NodeShape::Source, + DefinitionSchema::V2); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, CustomWidgetIsFrozen) +{ + auto result = checkV2Transform(withKey(kV2Transform, "widget", "SomeWidget")); + EXPECT_TRUE(result.hasErrors()); +} + +// --- port topology --------------------------------------------------- + +TEST(NodeDefinitionTest, AddingAnOutputIsRejected) +{ + QJsonArray outputs{ + QJsonObject{ { "name", "volume" }, { "type", "ImageData" } }, + QJsonObject{ { "name", "stats" }, { "type", "Table" } } + }; + auto result = checkV2Transform(withKey(kV2Transform, "outputs", outputs)); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, RenamingAnInputIsRejected) +{ + QJsonArray inputs{ QJsonObject{ { "name", "source" }, + { "type", "ImageData" } } }; + auto result = checkV2Transform(withKey(kV2Transform, "inputs", inputs)); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, RetypingAPortIsRejected) +{ + QJsonArray outputs{ QJsonObject{ { "name", "volume" }, + { "type", "TiltSeries" } } }; + auto result = checkV2Transform(withKey(kV2Transform, "outputs", outputs)); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, AbsentAndEmptyOutputsAreTheSameThing) +{ + // The source has no "inputs" key at all; an explicit empty array must + // not read as a change. + auto result = validateNodeDefinition( + kV2Source, withKey(kV2Source, "inputs", QJsonArray()), NodeShape::Source, + DefinitionSchema::V2); + EXPECT_FALSE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, LegacyResultsAreFrozen) +{ + auto result = validateNodeDefinition( + kV1Transform, withKey(kV1Transform, "results", QJsonArray()), + NodeShape::Transform, DefinitionSchema::V1); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, LegacyPortTypeOverridesAreFrozen) +{ + auto result = validateNodeDefinition( + kV1Transform, withKey(kV1Transform, "inputType", "TiltSeries"), + NodeShape::Transform, DefinitionSchema::V1); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, LegacyDatasetParametersAreFrozen) +{ + // A "dataset" parameter is an input port in the v1 schema, so adding + // one has to be rejected even though it lives in "parameters". + QString candidate = withParameters( + kV1Transform, + R"([{"name": "constant", "type": "double", "default": 0.0}, + {"name": "other", "type": "dataset"}])"); + auto result = validateNodeDefinition(kV1Transform, candidate, + NodeShape::Transform, + DefinitionSchema::V1); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, LegacyParametersAreOtherwiseEditable) +{ + QString candidate = withParameters( + kV1Transform, + R"([{"name": "constant", "type": "double", "default": 0.0}, + {"name": "clamp", "type": "bool", "default": true}])"); + auto result = validateNodeDefinition(kV1Transform, candidate, + NodeShape::Transform, + DefinitionSchema::V1); + EXPECT_FALSE(result.hasErrors()); + EXPECT_EQ(result.addedParameters, QStringList{ "clamp" }); +} + +// --- parameter wellformedness --------------------------------------- + +TEST(NodeDefinitionTest, MalformedJsonIsAnError) +{ + auto result = checkV2Transform("{ \"name\": "); + EXPECT_TRUE(result.hasErrors()); +} + +TEST(NodeDefinitionTest, EmptyingTheDescriptionIsAnError) +{ + auto result = checkV2Transform(" "); + EXPECT_TRUE(result.hasErrors()); + + // Clearing the raw editor and switching back to the form spells the + // same loss as "{}", because the form cannot hold unparseable text. + EXPECT_TRUE(checkV2Transform("{}").hasErrors()); + + // A node that never had a description is not being emptied. + EXPECT_FALSE( + validateNodeDefinition("", "{}", NodeShape::Transform, + DefinitionSchema::V1) + .hasErrors()); +} + +TEST(NodeDefinitionTest, DuplicateParameterNamesAreAnError) +{ + QString candidate = withParameters( + kV2Transform, + R"([{"name": "scale", "type": "double", "default": 1.0}, + {"name": "scale", "type": "int", "default": 2}])"); + EXPECT_TRUE(checkV2Transform(candidate).hasErrors()); +} + +TEST(NodeDefinitionTest, EnumerationWithoutOptionsIsAnError) +{ + QString candidate = withParameters( + kV2Transform, R"([{"name": "mode", "type": "enumeration", "default": 0}])"); + EXPECT_TRUE(checkV2Transform(candidate).hasErrors()); +} + +TEST(NodeDefinitionTest, ParameterWithoutTypeIsAnError) +{ + QString candidate = + withParameters(kV2Transform, R"([{"name": "scale", "default": 1.0}])"); + EXPECT_TRUE(checkV2Transform(candidate).hasErrors()); +} + +TEST(NodeDefinitionTest, NamelessLayoutMarkerIsAllowed) +{ + QString candidate = withParameters( + kV2Transform, R"([{"type": "xyz_header"}, + {"name": "scale", "type": "double", "default": 1.0}])"); + EXPECT_FALSE(checkV2Transform(candidate).hasErrors()); +} + +TEST(NodeDefinitionTest, NamelessValueParameterIsAnError) +{ + QString candidate = + withParameters(kV2Transform, R"([{"type": "double", "default": 1.0}])"); + EXPECT_TRUE(checkV2Transform(candidate).hasErrors()); +} + +TEST(NodeDefinitionTest, UnrenderableParameterTypeIsOnlyAWarning) +{ + QString candidate = withParameters( + kV2Transform, R"([{"name": "map", "type": "label_map"}])"); + auto result = checkV2Transform(candidate); + EXPECT_FALSE(result.hasErrors()); + EXPECT_FALSE(result.messages(DefinitionIssue::Severity::Warning).isEmpty()); +} + +// --- parameter diff -------------------------------------------------- + +TEST(NodeDefinitionTest, ParameterDiffReportsAddedRemovedAndRetyped) +{ + QString candidate = withParameters( + kV2Transform, + R"([{"name": "scale", "type": "int", "default": 1}, + {"name": "offset", "type": "double", "default": 0.0}])"); + auto result = checkV2Transform(candidate); + EXPECT_FALSE(result.hasErrors()); + EXPECT_EQ(result.addedParameters, QStringList{ "offset" }); + EXPECT_EQ(result.retypedParameters, QStringList{ "scale" }); + EXPECT_TRUE(result.removedParameters.isEmpty()); + + QString dropped = withParameters(kV2Transform, "[]"); + auto droppedResult = checkV2Transform(dropped); + EXPECT_FALSE(droppedResult.hasErrors()); + EXPECT_EQ(droppedResult.removedParameters, QStringList{ "scale" }); +} + +TEST(NodeDefinitionTest, DeclaredTypesSkipLayoutAndDatasetEntries) +{ + auto types = parameterDeclaredTypes( + R"({"parameters": [{"type": "xyz_header"}, + {"name": "other", "type": "dataset"}, + {"name": "scale", "type": "double"}]})"); + EXPECT_EQ(types.size(), 1); + EXPECT_EQ(types.value("scale"), QString("double")); + + auto withDatasets = parameterDeclaredTypes( + R"({"parameters": [{"name": "other", "type": "dataset"}]})", true); + EXPECT_EQ(withDatasets.value("other"), QString("dataset")); +} + +// --- value carry-over ------------------------------------------------ + +TEST(NodeDefinitionTest, MergeKeepsValuesOfUnchangedParameters) +{ + QMap previousValues{ { "scale", 7.5 }, + { "gone", 3 } }; + QMap previousTypes{ { "scale", "double" }, + { "gone", "int" } }; + QMap newDefaults{ { "scale", 1.0 }, { "fresh", 2 } }; + QMap newTypes{ { "scale", "double" }, { "fresh", "int" } }; + + QStringList reset; + auto merged = mergeParameterValues(previousValues, previousTypes, + newDefaults, newTypes, {}, &reset); + + EXPECT_EQ(merged.value("scale"), QVariant(7.5)); + EXPECT_EQ(merged.value("fresh"), QVariant(2)); + EXPECT_FALSE(merged.contains("gone")); + EXPECT_TRUE(reset.isEmpty()); +} + +TEST(NodeDefinitionTest, MergeResetsRetypedParameters) +{ + QMap previousValues{ { "scale", 7.5 } }; + QMap previousTypes{ { "scale", "double" } }; + QMap newDefaults{ { "scale", 1 } }; + QMap newTypes{ { "scale", "int" } }; + + QStringList reset; + auto merged = mergeParameterValues(previousValues, previousTypes, + newDefaults, newTypes, {}, &reset); + + EXPECT_EQ(merged.value("scale"), QVariant(1)); + EXPECT_EQ(reset, QStringList{ "scale" }); +} + +TEST(NodeDefinitionTest, MergeResetsEnumValueNoLongerOffered) +{ + QJsonArray options{ QJsonObject{ { "Bicubic", "bicubic" } }, + QJsonObject{ { "Nearest", "nearest" } } }; + QMap enumOptions{ { "mode", options } }; + QMap types{ { "mode", "enumeration" } }; + + QStringList reset; + auto kept = mergeParameterValues({ { "mode", "nearest" } }, types, + { { "mode", "bicubic" } }, types, + enumOptions, &reset); + EXPECT_EQ(kept.value("mode"), QVariant("nearest")); + EXPECT_TRUE(reset.isEmpty()); + + reset.clear(); + auto dropped = mergeParameterValues({ { "mode", "lanczos" } }, types, + { { "mode", "bicubic" } }, types, + enumOptions, &reset); + EXPECT_EQ(dropped.value("mode"), QVariant("bicubic")); + EXPECT_EQ(reset, QStringList{ "mode" }); +} + +// --- form-mode JSON mutators ----------------------------------------- + +TEST(NodeDefinitionTest, JsonValuesRoundTrip) +{ + auto roundTrip = [](const char* text) { + return formatJsonValue(parseJsonValue(QString::fromUtf8(text))); + }; + EXPECT_EQ(roundTrip("1.5"), QString("1.5")); + EXPECT_EQ(roundTrip("42"), QString("42")); + EXPECT_EQ(roundTrip("true"), QString("true")); + EXPECT_EQ(roundTrip("\"nearest\""), QString("\"nearest\"")); + EXPECT_EQ(roundTrip("[128,128,128]"), QString("[128,128,128]")); + + // Blank and unparseable input both mean "no value", which callers + // turn into "remove the key". + EXPECT_TRUE(parseJsonValue(" ").isUndefined()); + EXPECT_TRUE(parseJsonValue("not json").isUndefined()); + EXPECT_TRUE(formatJsonValue(QJsonValue(QJsonValue::Undefined)).isEmpty()); +} + +TEST(NodeDefinitionTest, SetOrClearRemovesRatherThanWritingBlanks) +{ + QJsonObject obj{ { "label", "Scale" }, { "supportsCancel", true } }; + + setOrClear(obj, "label", QString()); + EXPECT_FALSE(obj.contains("label")); + + setOrClearBool(obj, "supportsCancel", false, false); + EXPECT_FALSE(obj.contains("supportsCancel")); + + setOrClearBool(obj, "externalOnly", true, false); + EXPECT_TRUE(obj.value("externalOnly").toBool()); + + setOrClearValue(obj, "default", QJsonValue(QJsonValue::Undefined)); + EXPECT_FALSE(obj.contains("default")); +} + +TEST(NodeDefinitionTest, ParameterEditPreservesKeysTheFormDoesNotRender) +{ + // Every one of these is read somewhere in the codebase but has no + // control in the form, so a rebuild-from-fields editor would drop it. + QJsonObject param{ + { "name", "scale" }, { "label", "Scale" }, + { "type", "double" }, { "default", 1.0 }, + { "filter", "advanced" }, { "bindToSink", "sink.volume" }, + { "input", "volume" }, { "apply_to_each_array", true }, + { "show_apply_all", true } + }; + + auto fields = readParameterFields(param); + fields.label = "Scale Factor"; + applyParameterFields(param, fields); + + EXPECT_EQ(param.value("label").toString(), QString("Scale Factor")); + EXPECT_EQ(param.value("filter").toString(), QString("advanced")); + EXPECT_EQ(param.value("bindToSink").toString(), QString("sink.volume")); + EXPECT_EQ(param.value("input").toString(), QString("volume")); + EXPECT_TRUE(param.value("apply_to_each_array").toBool()); + EXPECT_TRUE(param.value("show_apply_all").toBool()); +} + +TEST(NodeDefinitionTest, ParameterEditRoundTripsEveryShippedDescriptor) +{ + QDir dir(TOMVIZ_PYTHON_DIR); + const auto files = dir.entryList({ "*.json" }, QDir::Files, QDir::Name); + ASSERT_FALSE(files.isEmpty()) << "no descriptors found in " + << TOMVIZ_PYTHON_DIR; + + int parametersChecked = 0; + for (const QString& name : files) { + QFile file(dir.filePath(name)); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)) << name.toStdString(); + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + ASSERT_TRUE(doc.isObject()) << name.toStdString(); + + for (const auto& value : + doc.object().value("parameters").toArray()) { + const QJsonObject original = value.toObject(); + QJsonObject edited = original; + applyParameterFields(edited, readParameterFields(original)); + ++parametersChecked; + + // Read-then-write must be the identity, except that an empty + // string and an absent key mean the same thing and collapse. + for (auto it = original.constBegin(); it != original.constEnd(); + ++it) { + if (it.value().isString() && it.value().toString().isEmpty()) { + continue; + } + EXPECT_EQ(edited.value(it.key()), it.value()) + << name.toStdString() << " parameter " + << original.value("name").toString().toStdString() << " key " + << it.key().toStdString(); + } + for (auto it = edited.constBegin(); it != edited.constEnd(); ++it) { + EXPECT_TRUE(original.contains(it.key())) + << name.toStdString() << " gained key " + << it.key().toStdString(); + } + } + } + EXPECT_GT(parametersChecked, 200); +} + +TEST(NodeDefinitionTest, EnumerationValuesAndDefaultsResolveByValueAndIndex) +{ + // Values that are not their own indices, as Connected Components' + // connectivity has them + QJsonArray options{ QJsonObject{ { "Faces", 1 } }, + QJsonObject{ { "Edges", 2 } }, + QJsonObject{ { "Corners", 3 } } }; + using tomviz::pipeline::PythonNodeUtils::enumOptionIndex; + using tomviz::pipeline::PythonNodeUtils::resolveEnumDefault; + using tomviz::pipeline::PythonNodeUtils::resolveEnumValue; + + // A stored value is the value, whether it is written as int or double + EXPECT_EQ(resolveEnumValue(QJsonValue(1), options), QVariant(1)); + EXPECT_EQ(resolveEnumValue(QJsonValue(3.0), options), QVariant(3)); + // A number matching no value is an index, from files that saved one + EXPECT_EQ(resolveEnumValue(QJsonValue(0), options), QVariant(1)); + EXPECT_FALSE(resolveEnumValue(QJsonValue(7), options).isValid()); + // The declared default is an index first + EXPECT_EQ(resolveEnumDefault(QJsonValue(0), options), QVariant(1)); + EXPECT_EQ(resolveEnumDefault(QJsonValue(2), options), QVariant(3)); + EXPECT_EQ(enumOptionIndex(QVariant(2.0), options), 1); + EXPECT_EQ(enumOptionIndex(QVariant(9), options), -1); + + QJsonArray named{ QJsonObject{ { "Bicubic", "bicubic" } }, + QJsonObject{ { "Nearest", "nearest" } } }; + EXPECT_EQ(resolveEnumValue(QJsonValue("nearest"), named), + QVariant("nearest")); + EXPECT_EQ(resolveEnumDefault(QJsonValue(1), named), QVariant("nearest")); + EXPECT_EQ(enumOptionIndex(QVariant("bicubic"), named), 0); + + // The generated form opens on the stored value's option and reads it + // back as the value. No "label" on purpose: the builder used to lose a + // parameter's name when the optional key was missing. + tomviz_test::ensureQApp(); + QString json = withParameters( + kV2Transform, + R"([{"name": "connectivity", "type": "enumeration", "default": 0, + "options": [{"Faces": 1}, {"Edges": 2}, {"Corners": 3}]}])"); + tomviz::pipeline::NodePropertiesWidget form(json, { { "connectivity", 2 } }); + EXPECT_EQ(form.values().value("connectivity"), QVariant(2)); + tomviz::pipeline::NodePropertiesWidget fresh(json, {}); + EXPECT_EQ(fresh.values().value("connectivity"), QVariant(1)); +} + +TEST(NodeDefinitionTest, MalformedEnumerationOptionsAreAnError) +{ + // An empty option object is what you get from writing an undefined + // value into a QJsonObject; ParameterInterfaceBuilder used to read + // keys()[0] off the end of the list and segfault on it. + QString empty = withParameters( + kV2Transform, + R"([{"name": "mode", "type": "enumeration", "options": [{}]}])"); + EXPECT_TRUE(checkV2Transform(empty).hasErrors()); + + QString bare = withParameters( + kV2Transform, + R"([{"name": "mode", "type": "enumeration", "options": [1, 2]}])"); + EXPECT_TRUE(checkV2Transform(bare).hasErrors()); + + QString twoKeys = withParameters( + kV2Transform, + R"([{"name": "mode", "type": "enumeration", + "options": [{"A": 0, "B": 1}]}])"); + EXPECT_TRUE(checkV2Transform(twoKeys).hasErrors()); + + QString ok = withParameters( + kV2Transform, + R"([{"name": "mode", "type": "enumeration", + "options": [{"Box": 0}, {"Ball": 1}]}])"); + EXPECT_FALSE(checkV2Transform(ok).hasErrors()); +} + +TEST(NodeDefinitionTest, ShippedEnumerationOptionsAllValidate) +{ + // The new single-pair rule must not reject anything already on disk. + QDir dir(TOMVIZ_PYTHON_DIR); + const auto files = dir.entryList({ "*.json" }, QDir::Files, QDir::Name); + ASSERT_FALSE(files.isEmpty()); + + int enumerations = 0; + for (const QString& name : files) { + QFile file(dir.filePath(name)); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + const QString json = QString::fromUtf8(doc.toJson()); + NodeShape shape = + doc.object().value("inputs").toArray().isEmpty() && + doc.object().value("schemaVersion").toInt(1) == 2 + ? NodeShape::Source + : NodeShape::Transform; + auto result = + validateNodeDefinition(json, json, shape, definitionSchema(json)); + for (const auto& p : doc.object().value("parameters").toArray()) { + if (p.toObject().value("type").toString() == "enumeration") { + ++enumerations; + } + } + EXPECT_FALSE(result.hasErrors()) + << name.toStdString() << ": " + << result.messages(DefinitionIssue::Severity::Error) + .join("; ") + .toStdString(); + } + EXPECT_GT(enumerations, 30); +} + +// --- type inference must survive a definition edit -------------------- + +TEST(NodeDefinitionTest, ReconfigureKeepsInferredOutputType) +{ + // A v1 transform declaring ImageData in and out infers its output type + // from whatever it is connected to. Re-applying the description must + // not knock that back down to the declared base type. + const char* description = R"({ + "name": "AddConstant", + "label": "Add Constant", + "inputType": "ImageData", + "outputType": "ImageData", + "parameters": [{"name": "constant", "type": "double", "default": 0.0}] + })"; + + Pipeline pipeline; + auto* source = new SourceNode; + source->setLabel("Source"); + auto* sourceOut = source->addOutput("volume", PortType::Volume); + pipeline.addNode(source); + + auto* transform = new LegacyPythonTransform; + transform->setJSONDescription(QString::fromUtf8(description)); + pipeline.addNode(transform); + + ASSERT_TRUE(pipeline.createLink(sourceOut, transform->inputPort("volume"))); + auto* out = transform->outputPort("volume"); + ASSERT_NE(out, nullptr); + ASSERT_EQ(out->declaredType(), PortType::ImageData); + EXPECT_EQ(out->type(), PortType::Volume) << "inference did not run"; + + // Exactly what the Definition tab does on Apply. + transform->reconfigureDescription(QString::fromUtf8(description)); + + EXPECT_EQ(out->type(), PortType::Volume) + << "re-applying the description reset the inferred effective type"; +} + +TEST(NodeDefinitionTest, SchemaV2ReconfigureKeepsInferredOutputType) +{ + // Same guarantee for the v2 shells. Their reconfigure() re-parses + // without touching ports, but assert it rather than trusting it. + const char* description = R"({ + "schemaVersion": 2, + "name": "InvertData", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [{"name": "scale", "type": "double", "default": 1.0}] + })"; + + Pipeline pipeline; + auto* source = new SourceNode; + auto* sourceOut = source->addOutput("volume", PortType::Volume); + pipeline.addNode(source); + + auto* transform = new PythonTransform; + transform->setJSONDescription(QString::fromUtf8(description)); + pipeline.addNode(transform); + + ASSERT_TRUE(pipeline.createLink(sourceOut, transform->inputPort("volume"))); + auto* out = transform->outputPort("volume"); + ASSERT_NE(out, nullptr); + EXPECT_EQ(out->type(), PortType::Volume) << "inference did not run"; + + transform->reconfigureDescription(QString::fromUtf8(description)); + EXPECT_EQ(out->type(), PortType::Volume); + + // A parameter edit is the common case — it must not disturb ports. + QString edited = withParameters( + description, + R"([{"name": "scale", "type": "double", "default": 1.0}, + {"name": "offset", "type": "double", "default": 0.0}])"); + transform->reconfigureDescription(edited); + EXPECT_EQ(out->type(), PortType::Volume); + EXPECT_EQ(transform->outputPorts().size(), 1) << "ports were duplicated"; + EXPECT_EQ(transform->inputPorts().size(), 1) << "ports were duplicated"; +} + +// --- Save Script -------------------------------------------------------- + +TEST(NodeDefinitionTest, DefinitionShapeFollowsDeclaredInputs) +{ + EXPECT_EQ(definitionShape(kV2Transform), NodeShape::Transform); + EXPECT_EQ(definitionShape(kV2Source), NodeShape::Source); + EXPECT_EQ(definitionShape(kV1Transform), NodeShape::Transform); + EXPECT_EQ(definitionShape(QString()), NodeShape::Transform); +} + +TEST(NodeDefinitionTest, FileTargetLeavesIdentityAndPortsFree) +{ + // A definition file is bound to no node class, so what a live node + // rejects as an identity change is fine here, and there is no live + // value set to warn about either. + auto validation = + validateNodeDefinition(kV2Transform, kV2Source, NodeShape::Transform, + DefinitionSchema::V2, DefinitionTarget::File); + EXPECT_FALSE(validation.hasErrors()); + EXPECT_TRUE(validation.issues.isEmpty()); + + validation = + validateNodeDefinition(kV2Transform, kV1Transform, NodeShape::Transform, + DefinitionSchema::V2, DefinitionTarget::File); + EXPECT_FALSE(validation.hasErrors()); +} + +TEST(NodeDefinitionTest, FileTargetStillChecksTheDescriptionItself) +{ + auto check = [](const QString& candidate) { + return validateNodeDefinition(kV2Transform, candidate, + NodeShape::Transform, DefinitionSchema::V2, + DefinitionTarget::File); + }; + EXPECT_TRUE(check("{ not json").hasErrors()); + EXPECT_TRUE(check(QString()).hasErrors()) << "cannot blank an existing one"; + EXPECT_TRUE(check(withParameters(kV2Transform, + R"([{"name": "a", "type": "double"}, + {"name": "a", "type": "int"}])")) + .hasErrors()); +} + +namespace { + +QJsonObject rootOf(const NodeDefinitionFormWidget& form) +{ + return QJsonDocument::fromJson(form.json().toUtf8()).object(); +} + +} // namespace + +TEST(NodeDefinitionTest, FileModeFormEditsSchemaAndPorts) +{ + tomviz_test::ensureQApp(); + NodeDefinitionFormWidget form(NodeShape::Transform, DefinitionSchema::V2, + DefinitionTarget::File); + ASSERT_TRUE(form.setJson(kV2Transform)); + form.show(); + + auto* schema = form.findChild("definitionSchemaCombo"); + ASSERT_NE(schema, nullptr); + EXPECT_EQ(schema->currentData().toInt(), 2); + auto* addInput = form.findChild("addInputPortButton"); + ASSERT_NE(addInput, nullptr); + EXPECT_TRUE(addInput->isVisible()); + + // Removing the only input turns the description into a source, and + // adding one back makes it a transform again. + auto inputRows = form.findChildren("inputPortRow"); + ASSERT_EQ(inputRows.size(), 1); + inputRows.first()->findChild()->click(); + EXPECT_TRUE(rootOf(form).value("inputs").toArray().isEmpty()); + EXPECT_EQ(definitionShape(form.json()), NodeShape::Source); + auto* note = form.findChild("definitionShapeNote"); + ASSERT_NE(note, nullptr); + EXPECT_TRUE(note->text().contains("source")); + + addInput->click(); + EXPECT_EQ(definitionShape(form.json()), NodeShape::Transform); + EXPECT_EQ(form.findChildren("inputPortRow").size(), 1); + + // Switching to the legacy schema drops the key (shipped v1 descriptors + // have none) and swaps the port sections for the legacy type pickers. + schema->setCurrentIndex(schema->findData(1)); + EXPECT_FALSE(rootOf(form).contains("schemaVersion")); + EXPECT_EQ(definitionSchema(form.json()), DefinitionSchema::V1); + EXPECT_FALSE(addInput->isVisible()); + auto* inputType = form.findChild("legacyInputTypeCombo"); + ASSERT_NE(inputType, nullptr); + EXPECT_TRUE(inputType->isVisible()); + // Legacy operators only take volumes: the ImageData family is on + // offer and nothing else. + EXPECT_GE(inputType->findData("ImageData"), 0); + EXPECT_GE(inputType->findData("LabelMap"), 0); + EXPECT_GE(inputType->findData("TiltSeries"), 0); + EXPECT_LT(inputType->findData("Table"), 0); + EXPECT_LT(inputType->findData("Molecule"), 0); + inputType->setCurrentIndex(inputType->findData("LabelMap")); + EXPECT_EQ(rootOf(form).value("inputType").toString(), QString("LabelMap")); + + // And back: the v2 port lists survived the round trip. + schema->setCurrentIndex(schema->findData(2)); + EXPECT_EQ(definitionSchema(form.json()), DefinitionSchema::V2); + EXPECT_TRUE(addInput->isVisible()); + EXPECT_EQ(form.findChildren("inputPortRow").size(), 1); +} + +TEST(NodeDefinitionTest, LiveModeFormKeepsIdentityFixed) +{ + tomviz_test::ensureQApp(); + NodeDefinitionFormWidget form(NodeShape::Transform, DefinitionSchema::V2); + ASSERT_TRUE(form.setJson(kV2Transform)); + form.show(); + + EXPECT_EQ(form.findChild("definitionSchemaCombo"), nullptr); + EXPECT_TRUE(form.findChildren("inputPortRow").isEmpty()); + EXPECT_FALSE( + form.findChild("addInputPortButton")->isVisible()); +} + +namespace { + +QStringList tabTitles(const QWidget* widget) +{ + QStringList titles; + if (auto* tabs = widget->findChild()) { + for (int i = 0; i < tabs->count(); ++i) { + titles.append(tabs->tabText(i)); + } + } + return titles; +} + +} // namespace + +TEST(NodeDefinitionTest, SourceEditorOffersTheDefinitionTab) +{ + tomviz_test::ensureQApp(); + Pipeline pipeline; + auto* source = new PythonSource; + // Same order as AddPythonSourceReaction. + source->setJSONDescription(tomviz::readInJSONDescription("ConstantDataset")); + source->setScript(tomviz::readInPythonScript("ConstantDataset")); + pipeline.addNode(source); + + auto* editor = source->createPropertiesWidget(&pipeline, nullptr); + ASSERT_NE(editor, nullptr); + const QStringList titles = tabTitles(editor); + EXPECT_TRUE(titles.contains("Definition")) << titles.join(", ").toStdString(); + EXPECT_NE(editor->findChild(), nullptr); + delete editor; +} 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/OperatorSearchDialogTest.cxx b/tests/cxx/OperatorSearchDialogTest.cxx new file mode 100644 index 000000000..f06a439bd --- /dev/null +++ b/tests/cxx/OperatorSearchDialogTest.cxx @@ -0,0 +1,112 @@ +/* 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 "OperatorSearchDialog.h" + +#include +#include +#include +#include + +#include "TomvizTest.h" + +using tomviz::OperatorSearchDialog; + +namespace { + +class OperatorSearchDialogTest : public ::testing::Test +{ +protected: + void SetUp() override + { + tomviz_test::ensureQApp(); + dialog = new OperatorSearchDialog(); + blur = new QAction("Blur", dialog); + broken = new QAction("Broken", dialog); + broken->setEnabled(false); + crop = new QAction("Crop", dialog); + dialog->addOperatorAction(blur, "Custom Transforms", "Blurs things"); + dialog->addOperatorAction(broken, "Custom Transforms"); + dialog->addOperatorAction(crop, "Data Transforms"); + QObject::connect(blur, &QAction::triggered, [this]() { ++blurs; }); + } + + void TearDown() override { delete dialog; } + + // Showing the dialog rebuilds its lists from the registered entries. + QStringList listed() const + { + QStringList names; + for (auto* list : dialog->findChildren()) { + for (int i = 0; i < list->count(); ++i) { + names.append(list->item(i)->text()); + } + } + names.sort(); + return names; + } + + OperatorSearchDialog* dialog = nullptr; + QAction* blur = nullptr; + QAction* broken = nullptr; + QAction* crop = nullptr; + int blurs = 0; +}; + +} // namespace + +TEST_F(OperatorSearchDialogTest, lists_registered_actions_by_availability) +{ + dialog->show(); + EXPECT_EQ(listed(), (QStringList{ "Blur", "Broken", "Crop" })); + + // The disabled one sits in the "Unavailable" list, the others don't. + int withBroken = 0; + for (auto* list : dialog->findChildren()) { + for (int i = 0; i < list->count(); ++i) { + if (list->item(i)->text() == "Broken") { + ++withBroken; + EXPECT_EQ(list->count(), 1); + } + } + } + EXPECT_EQ(withBroken, 1); +} + +TEST_F(OperatorSearchDialogTest, a_category_can_be_replaced) +{ + // The custom menu rebuilds its actions on every open; the host drops + // the old entries and registers the new ones. + dialog->removeCategory("Custom Transforms"); + dialog->show(); + EXPECT_EQ(listed(), QStringList{ "Crop" }); + + auto* sharpen = new QAction("Sharpen", dialog); + dialog->addOperatorAction(sharpen, "Custom Transforms"); + dialog->hide(); + dialog->show(); + EXPECT_EQ(listed(), (QStringList{ "Crop", "Sharpen" })); +} + +TEST_F(OperatorSearchDialogTest, a_deleted_action_disappears_without_harm) +{ + delete blur; + blur = nullptr; + dialog->show(); + EXPECT_EQ(listed(), (QStringList{ "Broken", "Crop" })); +} + +TEST_F(OperatorSearchDialogTest, enter_triggers_the_filtered_match) +{ + dialog->show(); + auto* box = dialog->findChild(); + ASSERT_NE(box, nullptr); + box->setText("blu"); + EXPECT_EQ(listed(), QStringList{ "Blur" }); + + QTest::keyClick(dialog, Qt::Key_Return); + EXPECT_EQ(blurs, 1); + EXPECT_FALSE(dialog->isVisible()); +} 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..31dd07249 --- /dev/null +++ b/tests/cxx/PipelineLibTest.cxx @@ -0,0 +1,5823 @@ +/* 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 "ParameterInterfaceBuilder.h" +#include "PipelineUtils.h" +#include "SaveDataDialog.h" +#include "SinkGroupNode.h" +#include "Tvh5Format.h" +#include "data/VolumeData.h" +#include "sinks/VolumeStatsSink.h" +#include "sinks/LegacyModuleSink.h" +#include "sinks/LightingPresetStore.h" +#include "sinks/ExplodedGeometry.h" +#include "sinks/VolumeSink.h" +#include "sinks/LabelMapSink.h" +#include "sinks/LabelMapSurface.h" +#include "data/LabelMapData.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 "AutoExecuteController.h" +#include "ExternalNodeExecutor.h" +#include "transforms/PythonTransform.h" +#include "PipelineStripWidget.h" + +#include +#include +#include +#include +#include +#include +#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 +#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, HeldNodeWaitsForItsDialog) +{ + // source -> t1 (held, as a freshly inserted transform whose dialog is + // open) -> t2, plus t3 on the source directly + auto* source = new SourceNode(); + source->addOutput("out", PortType::ImageData); + pipeline->addNode(source); + auto* t1 = new DoubleTransform(); + auto* t2 = new DoubleTransform(); + auto* t3 = new DoubleTransform(); + 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(source->outputPort("out"), t3->inputPort("in")); + source->setOutputData("out", PortData(std::any(5), PortType::ImageData)); + + t1->setHeld(true); + Node* reachedNode = nullptr; + QObject::connect(pipeline, &Pipeline::breakpointReached, + [&reachedNode](Node* n) { reachedNode = n; }); + + // A global execute, as adding a module anywhere triggers, runs the + // rest of the pipeline and leaves the held node and its subtree alone + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(reachedNode, nullptr); + EXPECT_EQ(t3->state(), NodeState::Current); + EXPECT_NE(t1->state(), NodeState::Current); + EXPECT_NE(t2->state(), NodeState::Current); + + // Released (the dialog's Apply), it runs like any other node + t1->setHeld(false); + future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(t1->state(), NodeState::Current); + EXPECT_EQ(t2->state(), NodeState::Current); + EXPECT_EQ(t2->outputPort("out")->data().value(), 20); +} + +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"); +} + +TEST_F(PipelineLibTest, VolumeDataPercentile) +{ + // 1000 voxels holding 0..999: the p-th percentile is about 10 * p + vtkNew image; + image->SetDimensions(10, 10, 10); + image->AllocateScalars(VTK_FLOAT, 1); + auto* values = static_cast(image->GetScalarPointer()); + for (int i = 0; i < 1000; ++i) { + values[i] = static_cast(i); + } + VolumeData vol; + vol.setImageData(image); + + EXPECT_NEAR(vol.scalarPercentile(0.8), 800.0, 2.0); + EXPECT_NEAR(vol.scalarPercentile(0.0), 0.0, 1.0); + EXPECT_NEAR(vol.scalarPercentile(1.0), 999.0, 1.0); + // A NaN is ignored rather than poisoning the estimate + values[0] = std::numeric_limits::quiet_NaN(); + EXPECT_NEAR(vol.scalarPercentile(0.8), 800.0, 2.0); +} + +// --- 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, ThreadedExecutorLegacyPythonTransform) +{ + // Mirror the application: the transform's Python code runs on a + // ThreadedExecutor worker thread while the main thread spins the + // event loop (which is also where intermediate updates land). + pipeline->setExecutor(new ThreadedExecutor(pipeline)); + + 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()); + + auto* source = new SphereSource(); + source->setDimensions(8, 8, 8); + pipeline->addNode(source); + source->execute(); + auto inputRange = + source->outputPort("volume")->data().value() + ->scalarRange(); + + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + transform->setParameter("constant", 10.0); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + // The worker thread needs the GIL; release it from the test (main) + // thread for the duration of the run, as the application does after + // initializing Python. + py::gil_scoped_release releaseGil; + + auto* future = pipeline->execute(); + QSignalSpy spy(future, &ExecutionFuture::finished); + if (!future->isFinished()) { + ASSERT_TRUE(spy.wait(30000)); + } + + EXPECT_TRUE(future->succeeded()); + 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] + 10.0, 0.01); + EXPECT_NEAR(outputRange[1], inputRange[1] + 10.0, 0.01); + + // Re-execute once more (the app re-runs transforms on parameter + // edits) to shake out lifetime bugs across runs. + transform->setParameter("constant", 20.0); + transform->markStale(); + auto* future2 = pipeline->execute(); + QSignalSpy spy2(future2, &ExecutionFuture::finished); + if (!future2->isFinished()) { + ASSERT_TRUE(spy2.wait(30000)); + } + EXPECT_TRUE(future2->succeeded()); +} + +TEST_F(PipelinePythonTest, ThreadedExecutorReconStyleOperator) +{ + // The full recon-operator shape on the threaded executor: a v1 + // class-based operator that creates a child dataset, publishes + // intermediate previews through progress.data while running, and + // returns the child as its declared output. This is the most + // Python↔VTK-boundary-intensive path the application exercises. + pipeline->setExecutor(new ThreadedExecutor(pipeline)); + + QString jsonStr = R"({ + "name": "MiniRecon", + "label": "Mini Recon", + "children": [{"name": "recon", "label": "Reconstruction"}] + })"; + QString scriptStr = R"( +import numpy as np +import tomviz.operators + + +class MiniReconOperator(tomviz.operators.CancelableOperator): + + def transform(self, dataset): + self.progress.maximum = 4 + child = dataset.create_child_dataset() + recon = np.zeros((6, 6, 6), dtype=np.float32) + for i in range(4): + if self.canceled: + return + recon += float(i + 1) + child.set_scalars('recon', recon.copy()) + self.progress.value = i + 1 + self.progress.message = 'pass %d' % (i + 1) + self.progress.data = child + return {'recon': child} +)"; + + auto* source = new SphereSource(); + source->setDimensions(6, 6, 6); + pipeline->addNode(source); + source->execute(); + + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + // The "children" declaration renames the primary output port. + auto* outPort = transform->outputPort("recon"); + ASSERT_TRUE(outPort != nullptr); + int intermediateCount = 0; + QObject::connect(outPort, &OutputPort::intermediateDataApplied, + [&intermediateCount]() { ++intermediateCount; }); + + py::gil_scoped_release releaseGil; + + for (int run = 0; run < 2; ++run) { + auto* future = pipeline->execute(); + QSignalSpy spy(future, &ExecutionFuture::finished); + if (!future->isFinished()) { + ASSERT_TRUE(spy.wait(30000)); + } + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(transform->state(), NodeState::Current); + + auto outputData = outPort->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + // 1+2+3+4 accumulated into every voxel. + auto range = outputData->scalarRange(); + EXPECT_NEAR(range[0], 10.0, 1e-4); + EXPECT_NEAR(range[1], 10.0, 1e-4); + + transform->markStale(); + } + + EXPECT_EQ(transform->totalProgressSteps(), 4); + EXPECT_EQ(transform->progressStep(), 4); + EXPECT_GE(intermediateCount, 8); +} + +TEST_F(PipelinePythonTest, ThreadedExecutorMultiArrayOperator) +{ + // Multi-array volume through an @apply_to_each_array operator on the + // threaded executor: apply_to_each_array deep-copies the dataset once + // per array, so a naive backing-image reference gets cloned into a + // pile of VTK objects that are then destroyed during Python GC on the + // worker thread. This reproduces the in-app SIGSEGV. + pipeline->setExecutor(new ThreadedExecutor(pipeline)); + + QString jsonStr = readFile(QString(TOMVIZ_PYTHON_DIR) + + "/AddConstant.json"); + // Inline AddConstant-shaped operator that also cranks the cyclic GC + // to its most aggressive setting, so any object corrupted by the + // Python↔VTK boundary is tripped over deterministically rather than + // "sometimes, later". + QString scriptStr = R"( +import gc +gc.set_threshold(1, 1, 1) + + +def transform(dataset, constant=0.0): + import numpy as np + scalars = dataset.active_scalars + dataset.active_scalars = scalars + np.array([constant], dtype=scalars.dtype) +)"; + + // A source with THREE scalar arrays on its output volume. + auto* source = new SphereSource(); + source->setDimensions(12, 12, 12); + pipeline->addNode(source); + source->execute(); + { + auto vol = source->outputPort("volume")->data().value(); + ASSERT_TRUE(vol && vol->imageData()); + auto* image = vol->imageData(); + auto* pd = image->GetPointData(); + auto* base = pd->GetScalars(); + for (const char* name : { "Second", "Third" }) { + vtkNew extra; + extra->DeepCopy(base); + extra->SetName(name); + pd->AddArray(extra); + } + } + auto* transform = new LegacyPythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + transform->setParameter("constant", 10.0); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + py::gil_scoped_release releaseGil; + + for (int run = 0; run < 3; ++run) { + auto* future = pipeline->execute(); + QSignalSpy spy(future, &ExecutionFuture::finished); + if (!future->isFinished()) { + ASSERT_TRUE(spy.wait(30000)); + } + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(transform->state(), NodeState::Current); + auto outputData = + transform->outputPort("volume")->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + // Every one of the three arrays got the constant added. + EXPECT_EQ(outputData->imageData()->GetPointData()->GetNumberOfArrays(), + 3); + transform->markStale(); + } +} + +TEST_F(PipelinePythonTest, ThreadedExecutorPythonTransformV2) +{ + // Schema-v2 PythonTransform on the threaded executor — the path that + // crashes in-app (PythonNodeBackend::runImpl on a worker thread). + // Multi-array input + a downstream sink that consumes on the main + // thread, run several times, to mirror the application. + pipeline->setExecutor(new ThreadedExecutor(pipeline)); + + 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"] + for name in list(ds.scalars_names): + ds.set_scalars(name, ds.scalars(name) * factor) + return {"volume": ds} +)"; + + auto* source = new SphereSource(); + source->setDimensions(10, 10, 10); + pipeline->addNode(source); + source->execute(); + { + auto vol = source->outputPort("volume")->data().value(); + auto* pd = vol->imageData()->GetPointData(); + auto* base = pd->GetScalars(); + for (const char* name : { "Second", "Third" }) { + vtkNew extra; + extra->DeepCopy(base); + extra->SetName(name); + pd->AddArray(extra); + } + } + + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + transform->setParameter("factor", 2.0); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + py::gil_scoped_release releaseGil; + + for (int run = 0; run < 5; ++run) { + auto* future = pipeline->execute(); + QSignalSpy spy(future, &ExecutionFuture::finished); + if (!future->isFinished()) { + ASSERT_TRUE(spy.wait(30000)); + } + EXPECT_TRUE(future->succeeded()); + EXPECT_EQ(transform->state(), NodeState::Current); + auto outputData = + transform->outputPort("volume")->data().value(); + ASSERT_TRUE(outputData && outputData->isValid()); + transform->markStale(); + } +} + +TEST_F(PipelinePythonTest, PythonTransformV2ProgressUpdates) +{ + // Progress plumbing end to end on the numpy-dataset path: the script + // reports step count / value / message through self.progress, and + // publishes a live-preview payload — a fresh numpy Dataset — through + // self.progress.data. The preview must be converted to vtkImageData + // at the script boundary and applied to the output port as + // intermediate data while the transform is still running. + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "SlowMultiply", + "label": "Slow Multiply", + "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 +import numpy as np + +class SlowMultiply(tomviz.nodes.TransformNode): + def transform(self, inputs, factor=1.0): + ds = inputs["volume"] + self.progress.maximum = 3 + self.progress.value = 1 + self.progress.message = "halfway" + preview = self.create_dataset() + preview.set_scalars("Scalars", + np.full((2, 2, 2), 21.0, dtype=np.float32)) + preview.spacing = (1.0, 1.0, 1.0) + self.progress.data = preview + self.progress.value = 2 + 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); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + // Capture the live-preview payload the moment it is applied — the + // final output overwrites the port data afterwards. + auto* outPort = transform->outputPort("volume"); + int intermediateCount = 0; + double intermediateValue = 0.0; + QObject::connect(outPort, &OutputPort::intermediateDataApplied, + [&intermediateCount, &intermediateValue, outPort]() { + ++intermediateCount; + auto vol = outPort->data().value(); + if (vol && vol->isValid()) { + intermediateValue = vol->scalarRange()[0]; + } + }); + + auto* future = pipeline->execute(); + EXPECT_TRUE(future->isFinished()); + EXPECT_EQ(transform->state(), NodeState::Current); + + // Step-count / value / message reached the node. + EXPECT_EQ(transform->totalProgressSteps(), 3); + EXPECT_EQ(transform->progressStep(), 2); + EXPECT_EQ(transform->progressMessage(), "halfway"); + + // The preview Dataset was converted and applied exactly once, with + // the constant payload the script produced. + EXPECT_EQ(intermediateCount, 1); + EXPECT_NEAR(intermediateValue, 21.0, 1e-5); + + // And the final output still reflects the real transform result. + auto outputData = outPort->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 + +class ConstantVolume(tomviz.nodes.SourceNode): + def produce(self, value=0.0): + ds = self.create_dataset() + ds.set_scalars("Scalars", np.full((3, 3, 3), value, + dtype=np.float32)) + ds.spacing = (1.0, 1.0, 1.0) + return {"volume": ds} +)"; + + 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, ThreadedExecutorHeldNode) +{ + 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->setHeld(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->succeeded()); + EXPECT_EQ(reachedNode, nullptr); + EXPECT_NE(t1->state(), NodeState::Current); + EXPECT_NE(t2->state(), NodeState::Current); + + t1->setHeld(false); + future = pipeline->execute(); + QSignalSpy spy2(future, &ExecutionFuture::finished); + ASSERT_TRUE(spy2.wait(5000)); + EXPECT_EQ(t1->state(), NodeState::Current); + EXPECT_EQ(t2->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, LightingStateRoundTrip) +{ + using Preset = VolumeSink::LightingPreset; + + auto* sink = new VolumeSink(); + sink->applyLightingPreset(Preset::Soft); + // The shadow switch is independent of the preset: turning it off must not + // lose the scattering level the preset asked for, or the preset itself. + sink->setShadowsEnabled(false); + ASSERT_EQ(sink->currentLightingPreset(), Preset::Soft); + ASSERT_GT(sink->volumetricScattering(), 0.0); + + QJsonObject json = sink->serialize(); + auto light = json["lighting"].toObject(); + EXPECT_FALSE(light["shadowsEnabled"].toBool()); + EXPECT_GT(light["scattering"].toDouble(), 0.0); + + auto* restored = new VolumeSink(); + ASSERT_TRUE(restored->deserialize(json)); + EXPECT_EQ(restored->lighting(), sink->lighting()); + EXPECT_DOUBLE_EQ(restored->ambient(), sink->ambient()); + EXPECT_DOUBLE_EQ(restored->diffuse(), sink->diffuse()); + EXPECT_DOUBLE_EQ(restored->specular(), sink->specular()); + EXPECT_DOUBLE_EQ(restored->specularPower(), sink->specularPower()); + EXPECT_DOUBLE_EQ(restored->volumetricScattering(), + sink->volumetricScattering()); + EXPECT_DOUBLE_EQ(restored->shadowReach(), sink->shadowReach()); + EXPECT_DOUBLE_EQ(restored->scatteringAnisotropy(), + sink->scatteringAnisotropy()); + EXPECT_EQ(restored->smoothNormals(), sink->smoothNormals()); + EXPECT_FALSE(restored->shadowsEnabled()); + // The panel derives the highlighted button from the values, so this is + // also what keeps Soft lit after a reload. + EXPECT_EQ(restored->currentLightingPreset(), Preset::Soft); + + delete sink; + delete restored; +} + +TEST_F(PipelineLibTest, LightingStateRoundTripGentle) +{ + using Preset = VolumeSink::LightingPreset; + + auto* sink = new VolumeSink(); + sink->applyLightingPreset(Preset::Gentle); + ASSERT_EQ(sink->currentLightingPreset(), Preset::Gentle); + // Gentle gets its look without shadows, so it must not be confused with + // Soft or Simple after a round trip. + ASSERT_DOUBLE_EQ(sink->volumetricScattering(), 0.0); + + auto* restored = new VolumeSink(); + ASSERT_TRUE(restored->deserialize(sink->serialize())); + EXPECT_EQ(restored->currentLightingPreset(), Preset::Gentle); + + delete sink; + delete restored; +} + +TEST_F(PipelineLibTest, LightingStatePredatesShadowSwitch) +{ + using Preset = VolumeSink::LightingPreset; + + // A state file written before the shadow switch existed has no + // "shadowsEnabled" key, and its stored scattering level was always + // rendered. Those files must still come back with shadows on. + auto* sink = new VolumeSink(); + sink->applyLightingPreset(Preset::Full); + QJsonObject json = sink->serialize(); + auto light = json["lighting"].toObject(); + light.remove("shadowsEnabled"); + json["lighting"] = light; + + auto* restored = new VolumeSink(); + ASSERT_TRUE(restored->deserialize(json)); + EXPECT_TRUE(restored->shadowsEnabled()); + EXPECT_EQ(restored->currentLightingPreset(), Preset::Full); + + delete sink; + delete restored; +} + +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, SliceSinkLinkingPropagatesToLinkedPeers) +{ + auto* a = new SliceSink(); + auto* b = new SliceSink(); + auto* unlinked = new SliceSink(); + pipeline->addNode(a); + pipeline->addNode(b); + pipeline->addNode(unlinked); + + b->setLinked(true); + b->setSlice(5); + a->setSlice(9); + // Turning the link on adopts the newly linked view's state everywhere + a->setLinked(true); + EXPECT_EQ(b->slice(), 9); + + // Changes reach the linked peer, in both directions, but not the + // unlinked sink; direction is linked too + a->setSlice(12); + EXPECT_EQ(b->slice(), 12); + EXPECT_NE(unlinked->slice(), 12); + b->setSlice(3); + EXPECT_EQ(a->slice(), 3); + a->setDirection(SliceSink::YZ); + EXPECT_EQ(b->direction(), SliceSink::YZ); + EXPECT_EQ(unlinked->direction(), SliceSink::XY); + + // Unlinking stops propagation + b->setLinked(false); + a->setSlice(20); + EXPECT_NE(b->slice(), 20); +} + +TEST_F(PipelineLibTest, ClipSinkLinkingPropagatesToLinkedPeers) +{ + auto* a = new ClipSink(); + auto* b = new ClipSink(); + pipeline->addNode(a); + pipeline->addNode(b); + + a->setLinked(true); + b->setLinked(true); + + a->setSlice(8); + EXPECT_EQ(b->slice(), 8); + + a->setDirection(ClipSink::XZ); + EXPECT_EQ(b->direction(), ClipSink::XZ); +} + +TEST_F(PipelineLibTest, SinkLinkFlagSurvivesSerialization) +{ + SliceSink slice; + slice.setLinked(true); + SliceSink restoredSlice; + EXPECT_TRUE(restoredSlice.deserialize(slice.serialize())); + EXPECT_TRUE(restoredSlice.linked()); + + ClipSink clip; + clip.setLinked(true); + ClipSink restoredClip; + EXPECT_TRUE(restoredClip.deserialize(clip.serialize())); + EXPECT_TRUE(restoredClip.linked()); +} + +// A volume with the given dimensions and spacing, for geometry tests. +PortData makeVolumeWithGeometry(int nx, int ny, int nz, double sx, double sy, + double sz) +{ + vtkNew image; + image->SetDimensions(nx, ny, nz); + image->SetSpacing(sx, sy, sz); + vtkNew array; + array->SetName("scalars"); + array->SetNumberOfTuples(nx * ny * nz); + array->FillValue(1.0f); + image->GetPointData()->SetScalars(array); + return PortData(std::any(std::make_shared(image)), + PortType::ImageData); +} + +TEST_F(PipelineLibTest, LinkedSlicesMatchByPhysicalPosition) +{ + struct OpenSliceSink : SliceSink + { + using SliceSink::consume; + }; + auto* fine = new OpenSliceSink(); + auto* coarse = new OpenSliceSink(); + pipeline->addNode(fine); + pipeline->addNode(coarse); + + // Same 20-unit extent along Z: 21 slices 1 apart vs 11 slices 2 apart + QMap inputs; + inputs["volume"] = makeVolumeWithGeometry(4, 4, 21, 1, 1, 1); + ASSERT_TRUE(fine->consume(inputs)); + inputs["volume"] = makeVolumeWithGeometry(4, 4, 11, 1, 1, 2); + ASSERT_TRUE(coarse->consume(inputs)); + + fine->setLinked(true); + coarse->setLinked(true); + fine->setSlice(10); + EXPECT_EQ(coarse->slice(), 5); + coarse->setSlice(8); + EXPECT_EQ(fine->slice(), 16); + EXPECT_DOUBLE_EQ(fine->slicePosition(), coarse->slicePosition()); + + // A peer whose geometry is unknown still gets the raw index + auto* empty = new SliceSink(); + pipeline->addNode(empty); + empty->setLinked(true); + fine->setSlice(4); + EXPECT_EQ(empty->slice(), 4); +} + +TEST_F(PipelineLibTest, LinkedSlicesShareTheCustomPlane) +{ + auto* a = new SliceSink(); + auto* b = new SliceSink(); + pipeline->addNode(a); + pipeline->addNode(b); + a->setLinked(true); + b->setLinked(true); + + a->setDirection(SliceSink::Custom); + a->setPlaneNormal(0, 1, 0); + a->setPlaneCenter(1, 2, 3); + EXPECT_EQ(b->direction(), SliceSink::Custom); + double n[3], c[3]; + b->planeNormal(n); + b->planeCenter(c); + EXPECT_DOUBLE_EQ(n[1], 1.0); + EXPECT_DOUBLE_EQ(c[0], 1.0); + EXPECT_DOUBLE_EQ(c[2], 3.0); +} + +TEST_F(PipelineLibTest, LinkedClipsMatchByPhysicalPosition) +{ + struct OpenClipSink : ClipSink + { + using ClipSink::consume; + }; + auto* fine = new OpenClipSink(); + auto* coarse = new OpenClipSink(); + pipeline->addNode(fine); + pipeline->addNode(coarse); + + QMap inputs; + inputs["volume"] = makeVolumeWithGeometry(4, 4, 21, 1, 1, 1); + ASSERT_TRUE(fine->consume(inputs)); + inputs["volume"] = makeVolumeWithGeometry(4, 4, 11, 1, 1, 2); + ASSERT_TRUE(coarse->consume(inputs)); + + fine->setLinked(true); + coarse->setLinked(true); + fine->setSlice(10); + EXPECT_EQ(coarse->slice(), 5); + + fine->setDirection(ClipSink::Custom); + EXPECT_EQ(coarse->direction(), ClipSink::Custom); +} + +TEST_F(PipelineLibTest, VolumeSinkExplodedViewExcludesCutOut) +{ + struct OpenVolumeSink : VolumeSink + { + using VolumeSink::consume; + }; + auto* sink = new OpenVolumeSink(); + pipeline->addNode(sink); + QMap inputs; + inputs["volume"] = makeVolumeWithGeometry(8, 8, 16, 1, 1, 1); + ASSERT_TRUE(sink->consume(inputs)); + + sink->setCutOutEnabled(true); + sink->setExplodedEnabled(true); + EXPECT_TRUE(sink->explodedEnabled()); + EXPECT_FALSE(sink->cutOutEnabled()); + sink->setCutOutEnabled(true); + EXPECT_FALSE(sink->explodedEnabled()); + + // Parameters clamp to their supported ranges + sink->setExplodedChunks(100); + EXPECT_EQ(sink->explodedChunks(), 16); + sink->setExplodedGap(3.0); + EXPECT_DOUBLE_EQ(sink->explodedGap(), 1.0); + sink->setExplodedAxis(7); + EXPECT_EQ(sink->explodedAxis(), kExplodedCustomAxis); + sink->setExplodedAxis(-1); + EXPECT_EQ(sink->explodedAxis(), 0); +} + +// A 12^3 label map: label 1 fills a 4^3 block, label 2 a 2^3 block +// touching it, background 0 everywhere else. +PortData makeTwoLabelVolume() +{ + vtkNew image; + image->SetDimensions(12, 12, 12); + image->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + auto* p = static_cast(image->GetScalarPointer()); + std::fill(p, p + 12 * 12 * 12, 0); + for (int z = 3; z < 7; ++z) { + for (int y = 3; y < 7; ++y) { + for (int x = 3; x < 7; ++x) { + p[(z * 12 + y) * 12 + x] = 1; + } + for (int x = 7; x < 9; ++x) { + if (y < 5 && z < 5) { + p[(z * 12 + y) * 12 + x] = 2; + } + } + } + } + image->GetPointData()->GetScalars()->SetName("labels"); + return PortData(std::any(makeVolumeData(image, PortType::LabelMap)), + PortType::LabelMap); +} + +TEST_F(PipelineLibTest, LabelMapSurfaceExtractsOneClosedSurfacePerLabel) +{ + auto data = makeTwoLabelVolume(); + auto labels = labelMapData(data.value()); + ASSERT_TRUE(labels); + labels->refreshLabels(); + const auto& table = labels->labels(); + ASSERT_EQ(table.count(), 3); // 0, 1, 2 + + auto regions = regionLabels(table, 0.0); + EXPECT_EQ(regions, (QVector{ 1.0, 2.0 })); + // Label 0 is hidden by convention; 1 and 2 start visible + EXPECT_EQ(visibleLabels(table, 0.0), regions); + + // Unsmoothed: the raw voxel faces. A 4^3 block has 6 faces of 16 + // quads, minus the 2x2 patch label 2 covers on one of them; the + // shared patch is a face of label 2 too, so it is still output once. + auto surface = + extractLabelSurface(labels->imageData(), regions, regions, 0, 0.0); + ASSERT_TRUE(surface); + EXPECT_EQ(surface->GetNumberOfCells(), 6 * 16 + 6 * 4 - 4); + auto* boundary = surface->GetCellData()->GetArray("BoundaryLabels"); + ASSERT_TRUE(boundary); + EXPECT_EQ(boundary->GetNumberOfComponents(), 2); + + // Hiding label 2 drops its faces except the one it shares with 1, + // which now bounds 1 alone + auto only1 = extractLabelSurface(labels->imageData(), regions, + QVector{ 1.0 }, 0, 0.0); + EXPECT_EQ(only1->GetNumberOfCells(), 6 * 16); + + // Colors come from the table, per face + colorLabelSurface(surface, table, 0.0); + auto* colors = vtkUnsignedCharArray::SafeDownCast( + surface->GetCellData()->GetArray(kLabelColorsArrayName)); + ASSERT_TRUE(colors); + EXPECT_EQ(colors->GetNumberOfTuples(), surface->GetNumberOfCells()); + EXPECT_EQ(surface->GetCellData()->GetScalars(), colors); + int counted[3] = { 0, 0, 0 }; + for (vtkIdType c = 0; c < surface->GetNumberOfCells(); ++c) { + unsigned char rgb[3]; + colors->GetTypedTuple(c, rgb); + for (int i = 1; i <= 2; ++i) { + QColor expected = table.at(table.indexOfValue(i)).color; + if (rgb[0] == expected.red() && rgb[1] == expected.green() && + rgb[2] == expected.blue()) { + ++counted[i]; + } + } + } + EXPECT_EQ(counted[1] + counted[2], surface->GetNumberOfCells()); + EXPECT_GE(counted[2], 6 * 4 - 4); + + // Smoothing keeps the topology and adds point normals + auto smooth = + extractLabelSurface(labels->imageData(), regions, regions, 8, 0.0); + EXPECT_GT(smooth->GetNumberOfCells(), 0); + EXPECT_TRUE(smooth->GetPointData()->GetNormals()); + EXPECT_TRUE(smooth->GetCellData()->GetArray("BoundaryLabels")); + + // Selecting from the smoothed (triangle) mesh keeps exactly the faces + // that bound the chosen label, with their tags, on the shared points + auto smoothOnly1 = selectLabelFaces(smooth, QVector{ 1.0 }); + EXPECT_GT(smoothOnly1->GetNumberOfCells(), 0); + EXPECT_LT(smoothOnly1->GetNumberOfCells(), smooth->GetNumberOfCells()); + EXPECT_EQ(smoothOnly1->GetPoints(), smooth->GetPoints()); + auto* tags = smoothOnly1->GetCellData()->GetArray("BoundaryLabels"); + ASSERT_TRUE(tags); + ASSERT_EQ(tags->GetNumberOfTuples(), smoothOnly1->GetNumberOfCells()); + for (vtkIdType c = 0; c < tags->GetNumberOfTuples(); ++c) { + EXPECT_TRUE(tags->GetComponent(c, 0) == 1.0 || + tags->GetComponent(c, 1) == 1.0); + } + + // Nothing to draw: no visible labels, or a single slice + EXPECT_EQ(extractLabelSurface(labels->imageData(), regions, {}, 0, 0.0) + ->GetNumberOfCells(), + 0); + vtkNew slice; + slice->SetDimensions(12, 12, 1); + slice->AllocateScalars(VTK_UNSIGNED_CHAR, 1); + EXPECT_EQ(extractLabelSurface(slice, regions, regions, 0, 0.0) + ->GetNumberOfCells(), + 0); +} + +TEST_F(PipelineLibTest, LabelMapSinkSurfaceFollowsLabelsAndRepresentation) +{ + struct OpenLabelMapSink : LabelMapSink + { + using LabelMapSink::prepareConsume; + using LabelMapSink::consume; + }; + auto* sink = new OpenLabelMapSink(); + pipeline->addNode(sink); + EXPECT_EQ(sink->representation(), LabelMapSink::Representation::Surface); + EXPECT_TRUE(sink->fineSampling()); + + // The trio SinkNode::runConsume runs; the payload in `inputs` is what + // the sink's volumeData() weakly refers to afterwards. + QMap inputs; + inputs["volume"] = makeTwoLabelVolume(); + sink->prepareConsume(inputs); + ASSERT_TRUE(sink->consume(inputs)); + ASSERT_TRUE(sink->surface()); + const auto cells = sink->surface()->GetNumberOfCells(); + EXPECT_GT(cells, 0); + // Ambient floor for the volume representation, applied once + EXPECT_GE(sink->ambient(), 0.3); + + // A color edit only recolors. Hiding a label re-selects faces from + // the extracted mesh, whose points it keeps sharing: no new pass over + // the volume, which is what made a checkbox slow on a large + // segmentation. + auto labels = sink->labelMap(); + ASSERT_TRUE(labels); + auto* before = sink->surface(); + auto* meshPoints = before->GetPoints(); + labels->labels().setColor(labels->labels().indexOfValue(1.0), Qt::cyan); + sink->applyLabels(); + EXPECT_EQ(sink->surface(), before); + labels->labels().setVisible(labels->labels().indexOfValue(2.0), false); + sink->applyLabels(); + EXPECT_NE(sink->surface(), before); + EXPECT_EQ(sink->surface()->GetPoints(), meshPoints); + EXPECT_LT(sink->surface()->GetNumberOfCells(), cells); + // Showing it again restores every face + labels->labels().setVisible(labels->labels().indexOfValue(2.0), true); + sink->applyLabels(); + EXPECT_EQ(sink->surface()->GetNumberOfCells(), cells); + EXPECT_EQ(sink->surface()->GetPoints(), meshPoints); + + // Switching to the volume representation keeps the mesh around, and + // smoothing changes rebuild it + sink->setRepresentation(LabelMapSink::Representation::Volume); + EXPECT_EQ(sink->representation(), LabelMapSink::Representation::Volume); + sink->setRepresentation(LabelMapSink::Representation::Surface); + auto* unsmoothed = sink->surface(); + sink->setSurfaceSmoothing(0); + EXPECT_NE(sink->surface(), unsmoothed); + EXPECT_FALSE(sink->surface()->GetPointData()->GetNormals()); + + // Serialization round trip, and old files keep their volume look + sink->setSurfaceOpacity(0.4); + sink->setSurfaceSmoothing(3); + auto json = sink->serialize(); + LabelMapSink restored; + ASSERT_TRUE(restored.deserialize(json)); + EXPECT_EQ(restored.representation(), + LabelMapSink::Representation::Surface); + EXPECT_EQ(restored.surfaceSmoothing(), 3); + EXPECT_DOUBLE_EQ(restored.surfaceOpacity(), 0.4); + json.remove("representation"); + LabelMapSink older; + ASSERT_TRUE(older.deserialize(json)); + EXPECT_EQ(older.representation(), LabelMapSink::Representation::Volume); +} + +TEST_F(PipelineLibTest, VolumeSinkExplodedViewSerializationRoundTrip) +{ + VolumeSink sink; + sink.setExplodedAxis(1); + sink.setExplodedChunks(5); + sink.setExplodedGap(0.4); + sink.setExplodedEnabled(true); + + VolumeSink restored; + ASSERT_TRUE(restored.deserialize(sink.serialize())); + EXPECT_TRUE(restored.explodedEnabled()); + EXPECT_EQ(restored.explodedAxis(), 1); + EXPECT_EQ(restored.explodedChunks(), 5); + EXPECT_DOUBLE_EQ(restored.explodedGap(), 0.4); +} + +TEST_F(PipelineLibTest, UserLightingPresetsRoundTripAndApply) +{ + auto& store = LightingPresetStore::instance(); + auto named = [&store](const QString& name) { + int n = 0; + for (const auto& p : store.presets()) { + n += p.name == name ? 1 : 0; + } + return n; + }; + store.remove("Bench"); + + VolumeSink sink; + sink.setLighting(true); + sink.setAmbient(0.42); + sink.setDiffuse(0.6); + sink.setSpecularPower(12.0); + sink.setSmoothNormals(true); + auto preset = sink.currentLightingValues(); + preset.name = "Bench"; + store.save(preset); + EXPECT_EQ(sink.matchingUserLightingPreset(), "Bench"); + + // Saving under the same name replaces rather than duplicates + preset.ambient = 0.2; + store.save(preset); + EXPECT_EQ(named("Bench"), 1); + EXPECT_TRUE(sink.matchingUserLightingPreset().isEmpty()); + + VolumeSink other; + other.applyUserLightingPreset(UserLightingPreset::deserialize( + store.preset("Bench").serialize())); + EXPECT_DOUBLE_EQ(other.ambient(), 0.2); + EXPECT_DOUBLE_EQ(other.diffuse(), 0.6); + EXPECT_DOUBLE_EQ(other.specularPower(), 12.0); + EXPECT_TRUE(other.smoothNormals()); + EXPECT_EQ(other.matchingUserLightingPreset(), "Bench"); + + store.remove("Bench"); + EXPECT_EQ(named("Bench"), 0); +} + +TEST_F(PipelineLibTest, VolumeSinkCutOutProperties) +{ + VolumeSink sink; + EXPECT_FALSE(sink.cutOutEnabled()); + EXPECT_EQ(sink.cutOutCorner(), 0); + EXPECT_DOUBLE_EQ(sink.cutOutPosition(0), 0.5); + + sink.setCutOutEnabled(true); + EXPECT_TRUE(sink.cutOutEnabled()); + + sink.setCutOutCorner(5); + EXPECT_EQ(sink.cutOutCorner(), 5); + // Out-of-range corners clamp rather than indexing a bogus region + sink.setCutOutCorner(99); + EXPECT_EQ(sink.cutOutCorner(), 7); + sink.setCutOutCorner(-3); + EXPECT_EQ(sink.cutOutCorner(), 0); + + sink.setCutOutPosition(1, 0.25); + EXPECT_DOUBLE_EQ(sink.cutOutPosition(1), 0.25); + sink.setCutOutPosition(2, 5.0); + EXPECT_DOUBLE_EQ(sink.cutOutPosition(2), 1.0); + + // Bad axes are ignored rather than writing out of bounds + sink.setCutOutPosition(7, 0.3); + EXPECT_DOUBLE_EQ(sink.cutOutPosition(7), 0.5); +} + +TEST_F(PipelineLibTest, VolumeSinkCutOutSerializationRoundTrip) +{ + VolumeSink sink; + sink.setCutOutEnabled(true); + sink.setCutOutCorner(6); + sink.setCutOutPosition(0, 0.2); + sink.setCutOutPosition(1, 0.4); + sink.setCutOutPosition(2, 0.6); + + VolumeSink restored; + EXPECT_TRUE(restored.deserialize(sink.serialize())); + EXPECT_TRUE(restored.cutOutEnabled()); + EXPECT_EQ(restored.cutOutCorner(), 6); + EXPECT_DOUBLE_EQ(restored.cutOutPosition(0), 0.2); + EXPECT_DOUBLE_EQ(restored.cutOutPosition(1), 0.4); + EXPECT_DOUBLE_EQ(restored.cutOutPosition(2), 0.6); +} + +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)); +} + +TEST_F(PipelineLibTest, SliceSinkConsumeDoesNotRePropagateLink) +{ + struct OpenSliceSink : SliceSink + { + using SliceSink::consume; + }; + + auto* a = new OpenSliceSink(); + auto* b = new OpenSliceSink(); + pipeline->addNode(a); + pipeline->addNode(b); + a->setLinked(true); + b->setLinked(true); + a->setSlice(3); + ASSERT_EQ(b->slice(), 3); + + // Diverge b without the link noticing (deserialize sets the index + // directly), simulating a peer whose own extents clamped the index. + auto json = b->serialize(); + json["slice"] = 1; + ASSERT_TRUE(b->deserialize(json)); + ASSERT_EQ(b->slice(), 1); + ASSERT_EQ(a->slice(), 3); + + // consume()'s UI-sync notifications must not push b's index onto the + // other linked views: only user edits propagate. + QMap inputs; + inputs["volume"] = + PortData(std::any(makeVolume({ "scalars" })), PortType::ImageData); + EXPECT_TRUE(b->consume(inputs)); + EXPECT_EQ(b->slice(), 1); + EXPECT_EQ(a->slice(), 3); +} + +// 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, SaveDataAllPortsScopeIncludesTransientData) +{ + 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" }); + + // A transient port still holding its data can be saved like any other: + // persistence is about how long data is kept, not whether it may be + // written out. + auto all = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::AllPorts); + ASSERT_EQ(all.size(), 2); + EXPECT_TRUE(all.contains(source->outputPort("volume"))); + EXPECT_TRUE(all.contains(transform->outputPort("out"))); + EXPECT_TRUE(tomviz::SaveDataDialog::releasedPorts( + pipeline, tomviz::SaveDataDialog::Scope::AllPorts) + .isEmpty()); + + // 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")); + + // Once its data is released it cannot be saved, and the dialog is + // told so it can say why + transform->outputPort("out")->clearData(); + all = tomviz::SaveDataDialog::candidatePorts( + pipeline, tomviz::SaveDataDialog::Scope::AllPorts); + ASSERT_EQ(all.size(), 1); + EXPECT_EQ(all.first(), source->outputPort("volume")); + auto released = tomviz::SaveDataDialog::releasedPorts( + pipeline, tomviz::SaveDataDialog::Scope::AllPorts); + ASSERT_EQ(released.size(), 1); + EXPECT_EQ(released.first(), transform->outputPort("out")); + + // A persistent port with no data yet was never released + source->outputPort("volume")->clearData(); + released = tomviz::SaveDataDialog::releasedPorts( + source, tomviz::SaveDataDialog::Scope::AllPorts); + EXPECT_TRUE(released.isEmpty()); +} + +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::AllPorts) + .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::AllPorts); + ASSERT_EQ(sourcePorts.size(), 1); + EXPECT_EQ(sourcePorts.first(), source->outputPort("volume")); + + // Turning a published port transient releases its data: nothing to + // save, and the port is reported as released instead. + transform->outputPort("out")->setPersistent(false); + EXPECT_TRUE(tomviz::SaveDataDialog::candidatePorts( + transform, tomviz::SaveDataDialog::Scope::AllPorts) + .isEmpty()); + EXPECT_EQ(tomviz::SaveDataDialog::releasedPorts( + transform, tomviz::SaveDataDialog::Scope::AllPorts) + .size(), + 1); +} + +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::AllPorts) + .size(), + 2); + + // ...while the port view is just the one. + auto ports = tomviz::SaveDataDialog::candidatePorts( + source->outputPort("mask"), tomviz::SaveDataDialog::Scope::AllPorts); + 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::AllPorts) + .isEmpty()); +} + +TEST_F(PipelineLibTest, SaveDataDialogExplainsReleasedPorts) +{ + 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("Remove Labels"); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("in")); + transform->outputPort("out")->setPersistent(false); + + // Still in memory: offered, and nothing to explain + setVolumeData(transform->outputPort("out"), { "A" }); + { + tomviz::SaveDataDialog dialog(static_cast(transform)); + auto* note = dialog.findChild("releasedNote"); + ASSERT_NE(note, nullptr); + EXPECT_TRUE(note->isHidden()); + EXPECT_EQ(dialog.selectedEntries().size(), 1); + } + + // Released: nothing offered, and the note names the port and why + transform->outputPort("out")->clearData(); + { + tomviz::SaveDataDialog dialog(static_cast(transform)); + auto* note = dialog.findChild("releasedNote"); + ASSERT_NE(note, nullptr); + EXPECT_FALSE(note->isHidden()); + EXPECT_TRUE(note->text().contains("Remove Labels (out)")) + << note->text().toStdString(); + EXPECT_TRUE(note->text().contains("transient")); + EXPECT_TRUE(dialog.selectedEntries().isEmpty()); + } +} + +// visible_if hides a parameter's label with its field, whichever the +// label is attached to: a string's label is its field's buddy, a file's +// is the row's. +TEST_F(PipelineLibTest, VisibleIfHidesLabelsOfEveryParameterType) +{ + ParameterInterfaceBuilder builder; + builder.setJSONDescription(QString(R"({"parameters": [ + {"name": "auto", "label": "Auto", "type": "bool", "default": true}, + {"name": "centers", "label": "Centers", "type": "string", + "default": "", "visible_if": "auto == false"}, + {"name": "centers_file", "label": "Centers CSV", "type": "file", + "default": "", "visible_if": "auto == false"} + ]})")); + QWidget parent; + auto* form = builder.buildWidget(&parent); + ASSERT_NE(form, nullptr); + + auto labelFor = [&parent](const QString& text) -> QLabel* { + for (auto* label : parent.findChildren()) { + if (label->text() == text) { + return label; + } + } + return nullptr; + }; + auto* centers = labelFor("Centers"); + auto* centersFile = labelFor("Centers CSV"); + auto* autoCheck = parent.findChild("auto"); + ASSERT_NE(centers, nullptr); + ASSERT_NE(centersFile, nullptr); + ASSERT_NE(autoCheck, nullptr); + EXPECT_TRUE(centers->isHidden()); + EXPECT_TRUE(centersFile->isHidden()); + EXPECT_TRUE(parent.findChild("centers")->isHidden() || + parent.findChild("centers")->parentWidget() + ->isHidden()); + + autoCheck->setChecked(false); + EXPECT_FALSE(centers->isHidden()); + EXPECT_FALSE(centersFile->isHidden()); +} + +// Clone Data copies the source feeding the selected branch, which is +// not always the first source in the pipeline +TEST_F(PipelineLibTest, FeedingSourcePortWalksUpToTheBranchesSource) +{ + auto* first = new SourceNode(); + first->addOutput("volume", PortType::ImageData); + pipeline->addNode(first); + auto* second = new SourceNode(); + second->addOutput("volume", PortType::ImageData); + pipeline->addNode(second); + + auto* blur = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + pipeline->addNode(blur); + pipeline->createLink(second->outputPort("volume"), blur->inputPort("in")); + auto* threshold = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + pipeline->addNode(threshold); + pipeline->createLink(blur->outputPort("out"), threshold->inputPort("in")); + + // A visualization has no output; it is found through its input + auto* sink = new CollectorSink(); + pipeline->addNode(sink); + pipeline->createLink(threshold->outputPort("out"), sink->inputPort("in")); + + EXPECT_EQ(feedingSourcePort(threshold->outputPort("out")), + second->outputPort("volume")); + EXPECT_EQ(feedingSourcePort(sink), second->outputPort("volume")); + EXPECT_EQ(feedingSourcePort(first), first->outputPort("volume")); + + // Fed by nothing + auto* orphan = + new PassthroughTransform(PortType::ImageData, PortType::ImageData); + pipeline->addNode(orphan); + EXPECT_EQ(feedingSourcePort(orphan), nullptr); +} + +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::AllPorts) + .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")); +} + +// --- Periodic execution (should_auto_execute hook + controller) --- + +namespace { + +// Source whose poll/execute behavior the test scripts directly. The +// poll runs on the controller's worker thread, so its bookkeeping is +// atomic; execute() runs on the GUI thread via DefaultExecutor. +class AutoAnswerSource : public SourceNode +{ +public: + AutoAnswerSource() : SourceNode() + { + addOutput("out", PortType::ImageData); + } + + bool execute() override + { + executeCount++; + // setOutputData marks this node Current and downstream stale. + setOutputData("out", + PortData(std::any(executeCount), PortType::ImageData)); + return true; + } + + bool queryShouldAutoExecute() override + { + pollCount.fetch_add(1); + return answer.load(); + } + + int executeCount = 0; + std::atomic pollCount{ 0 }; + std::atomic answer{ false }; +}; + +} // namespace + +TEST_F(PipelinePythonTest, ShouldAutoExecuteHookAndState) +{ + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "Watcher", + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [ + {"name": "value", "type": "double", "default": 0.0} + ] + })"; + // The hook counts its own invocations in self.state and starts + // answering True on the second poll; it also sees the current + // parameter values under their declared names. + QString scriptStr = R"( +import tomviz.nodes + +class Watcher(tomviz.nodes.SourceNode): + def produce(self, value=0.0): + return None + + def should_auto_execute(self, value=0.0): + if value != 7.5: + raise ValueError('parameters not forwarded') + polls = self.state.get('polls', 0) + 1 + self.state['polls'] = polls + return polls >= 2 +)"; + + auto* source = new PythonSource(); + source->setJSONDescription(jsonStr); + source->setScript(scriptStr); + source->setParameter("value", 7.5); + pipeline->addNode(source); + + // First poll answers no, but its state mutation is kept. + EXPECT_FALSE(source->queryShouldAutoExecute()); + EXPECT_EQ(source->userState().value("polls").toInt(), 1); + + // Second poll sees polls==1 in state and answers yes. + EXPECT_TRUE(source->queryShouldAutoExecute()); + EXPECT_EQ(source->userState().value("polls").toInt(), 2); +} + +TEST_F(PipelinePythonTest, ShouldAutoExecuteDefaultsFalse) +{ + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "Quiet", + "outputs": [{"name": "volume", "type": "ImageData"}] + })"; + // No should_auto_execute override: the tomviz.nodes.Node base + // answers False. + QString scriptStr = R"( +import tomviz.nodes + +class Quiet(tomviz.nodes.SourceNode): + def produce(self): + return None +)"; + + auto* source = new PythonSource(); + source->setJSONDescription(jsonStr); + source->setScript(scriptStr); + pipeline->addNode(source); + + EXPECT_FALSE(source->queryShouldAutoExecute()); + EXPECT_TRUE(source->userState().isEmpty()); +} + +TEST_F(PipelinePythonTest, UserStatePersistsAcrossTransformRuns) +{ + QString jsonStr = R"({ + "schemaVersion": 2, + "name": "CountingPass", + "inputs": [{"name": "volume", "type": "ImageData"}], + "outputs": [{"name": "volume", "type": "ImageData", "persistent": true}] + })"; + // A fresh instance runs each time, so the run counter only grows if + // self.state actually round-trips through the host node. + QString scriptStr = R"( +import tomviz.nodes + +class CountingPass(tomviz.nodes.TransformNode): + def transform(self, inputs): + self.state['runs'] = self.state.get('runs', 0) + 1 + return {"volume": inputs["volume"]} + + def should_auto_execute(self): + return self.state.get('runs', 0) > 0 +)"; + + auto* source = new SphereSource(); + source->setDimensions(4, 4, 4); + pipeline->addNode(source); + + auto* transform = new PythonTransform(); + transform->setJSONDescription(jsonStr); + transform->setScript(scriptStr); + pipeline->addNode(transform); + pipeline->createLink(source->outputPort("volume"), + transform->inputPort("volume")); + + pipeline->execute(); + EXPECT_EQ(transform->state(), NodeState::Current); + EXPECT_EQ(transform->userState().value("runs").toInt(), 1); + + transform->markStale(); + pipeline->execute(); + EXPECT_EQ(transform->userState().value("runs").toInt(), 2); + + // The hook shares the same state the runs recorded. + EXPECT_TRUE(transform->queryShouldAutoExecute()); +} + +// --- Kernel parameter write-back (self.set_parameter) --- + +namespace { + +const char* kWriteBackDescription = R"({ + "schemaVersion": 2, + "name": "WriteBack", + "outputs": [{"name": "volume", "type": "ImageData"}], + "parameters": [ + {"name": "value", "type": "double", "default": 0.0}, + {"name": "frame", "type": "int", "default": 0}, + {"name": "mode", "type": "enumeration", "default": 0, + "options": [{"Fast": "fast"}, {"Slow": "slow"}]} + ] +})"; + +// produce() advances `frame` and publishes `value` (as a string, to +// exercise coercion); the hook flips `mode` and answers from `frame`. +const char* kWriteBackScript = R"( +import numpy as np +import tomviz.nodes + + +class WriteBack(tomviz.nodes.SourceNode): + def produce(self, value=0.0, frame=0, mode='fast'): + self.set_parameter('frame', frame + 1) + self.set_parameter('value', '2.5') + if self.parameter('frame') != frame + 1: + raise AssertionError('parameter() does not see the update') + ds = self.create_dataset() + ds.set_scalars('Scalars', np.full((2, 2, 2), value, dtype=np.float32)) + return {'volume': ds} + + def should_auto_execute(self, value=0.0, frame=0, mode='fast'): + self.set_parameter('mode', 'slow') + return frame >= 1 +)"; + +} // namespace + +TEST_F(PipelinePythonTest, SetParameterLandsOnNodeQuietly) +{ + auto* source = new PythonSource(); + source->setJSONDescription(kWriteBackDescription); + source->setScript(kWriteBackScript); + pipeline->addNode(source); + + QSignalSpy updated(source, &Node::parametersUpdated); + QSignalSpy applied(source, &Node::parametersApplied); + + pipeline->execute(); + EXPECT_EQ(source->state(), NodeState::Current); + EXPECT_EQ(source->parameter("frame").toInt(), 1); + EXPECT_DOUBLE_EQ(source->parameter("value").toDouble(), 2.5); + EXPECT_EQ(source->parameter("mode").toString(), QString("fast")); + + ASSERT_EQ(updated.count(), 1); + auto changed = updated.takeFirst().at(0).toMap(); + EXPECT_EQ(changed.size(), 2); + EXPECT_EQ(changed.value("frame").toInt(), 1); + EXPECT_DOUBLE_EQ(changed.value("value").toDouble(), 2.5); + // The quiet path: no editor-style apply, no re-execution. + EXPECT_EQ(applied.count(), 0); + + // The next run receives the new values; `value` is 2.5 again and is + // not reported a second time. + source->markStale(); + pipeline->execute(); + EXPECT_EQ(source->parameter("frame").toInt(), 2); + ASSERT_EQ(updated.count(), 1); + changed = updated.takeFirst().at(0).toMap(); + EXPECT_EQ(changed.keys(), QStringList{ "frame" }); + + // Serialized `arguments` carry the written-back values. + auto json = source->serialize(); + EXPECT_EQ(json.value("arguments").toObject().value("frame").toInt(), 2); +} + +TEST_F(PipelinePythonTest, SetParameterInShouldAutoExecute) +{ + auto* source = new PythonSource(); + source->setJSONDescription(kWriteBackDescription); + source->setScript(kWriteBackScript); + pipeline->addNode(source); + QSignalSpy updated(source, &Node::parametersUpdated); + + // The hook's write-back lands even when it answers "no"; the answer + // alone decides whether a run happens. + EXPECT_FALSE(source->queryShouldAutoExecute()); + EXPECT_EQ(source->parameter("mode").toString(), QString("slow")); + EXPECT_EQ(updated.count(), 1); + EXPECT_EQ(source->state(), NodeState::New); + + source->setParameter("frame", 3); + EXPECT_TRUE(source->queryShouldAutoExecute()); + EXPECT_EQ(updated.count(), 1); // mode is already 'slow' +} + +TEST_F(PipelinePythonTest, SetParameterUnknownNameFailsRunKeepsEarlier) +{ + QString scriptStr = R"( +import tomviz.nodes + +class Bad(tomviz.nodes.SourceNode): + def produce(self, value=0.0, frame=0, mode='fast'): + self.set_parameter('frame', 5) + self.set_parameter('nope', 1) + return None +)"; + auto* source = new PythonSource(); + source->setJSONDescription(kWriteBackDescription); + source->setScript(scriptStr); + pipeline->addNode(source); + + EXPECT_FALSE(source->execute()); + // Harvested even though produce() raised — parity with self.state + // under the Python runtime. + EXPECT_EQ(source->parameter("frame").toInt(), 5); + EXPECT_FALSE(source->parameters().contains("nope")); +} + +TEST_F(PipelinePythonTest, SetParameterRejectsUndeclaredEnumValue) +{ + QString scriptStr = R"( +import tomviz.nodes + +class Bad(tomviz.nodes.SourceNode): + def produce(self, value=0.0, frame=0, mode='fast'): + self.set_parameter('mode', 'medium') + return None +)"; + auto* source = new PythonSource(); + source->setJSONDescription(kWriteBackDescription); + source->setScript(scriptStr); + pipeline->addNode(source); + + EXPECT_FALSE(source->execute()); + EXPECT_EQ(source->parameter("mode").toString(), QString("fast")); +} + +TEST_F(PipelineLibTest, ApplyParameterUpdatesIsQuiet) +{ + auto* source = new PythonSource(); + source->setJSONDescription(kWriteBackDescription); + pipeline->addNode(source); + QSignalSpy updated(source, &Node::parametersUpdated); + QSignalSpy applied(source, &Node::parametersApplied); + + // Only values that differ are written and reported; nothing goes + // stale (the external executor takes this path after a child run). + source->applyParameterUpdates({ { "frame", 0 }, { "value", 1.0 } }); + EXPECT_EQ(source->state(), NodeState::New); + EXPECT_DOUBLE_EQ(source->parameter("value").toDouble(), 1.0); + ASSERT_EQ(updated.count(), 1); + EXPECT_EQ(updated.takeFirst().at(0).toMap().keys(), QStringList{ "value" }); + EXPECT_EQ(applied.count(), 0); + + source->applyParameterUpdates({ { "value", 1.0 } }); + EXPECT_EQ(updated.count(), 0); + + // Nodes without parameters ignore the map. + auto* plain = new SourceNode(); + pipeline->addNode(plain); + QSignalSpy plainUpdated(plain, &Node::parametersUpdated); + plain->applyParameterUpdates({ { "x", 1 } }); + EXPECT_EQ(plainUpdated.count(), 0); +} + +TEST_F(PipelineLibTest, AutoExecuteControllerTriggersExecution) +{ + auto* source = new AutoAnswerSource(); + pipeline->addNode(source); + auto* controller = new AutoExecuteController(pipeline, pipeline); + + source->execute(); + EXPECT_EQ(source->executeCount, 1); + EXPECT_EQ(source->state(), NodeState::Current); + + // Enabled with a "no" answer: polls happen, nothing re-executes. + source->setAutoExecuteEnabled(true); + source->setAutoExecuteIntervalSeconds(1); + for (int i = 0; i < 100 && source->pollCount.load() < 1; ++i) { + QTest::qWait(100); + } + ASSERT_GE(source->pollCount.load(), 1); + QTest::qWait(200); // let a (wrong) execution land before checking + EXPECT_EQ(source->executeCount, 1); + + // Flip to "yes": the next poll marks the node stale and re-executes. + source->answer.store(true); + for (int i = 0; i < 100 && source->executeCount < 2; ++i) { + QTest::qWait(100); + } + EXPECT_GE(source->executeCount, 2); + EXPECT_EQ(source->state(), NodeState::Current); + + // Disabling tears the timer down; polling stops. + source->setAutoExecuteEnabled(false); + QTest::qWait(300); // drain any in-flight poll + int polls = source->pollCount.load(); + int executions = source->executeCount; + QTest::qWait(1500); + EXPECT_EQ(source->pollCount.load(), polls); + EXPECT_EQ(source->executeCount, executions); + + delete controller; // joins the worker thread +} + +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/PythonEnvironmentCheckTest.cxx b/tests/cxx/PythonEnvironmentCheckTest.cxx new file mode 100644 index 000000000..e90581818 --- /dev/null +++ b/tests/cxx/PythonEnvironmentCheckTest.cxx @@ -0,0 +1,523 @@ +/* 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 "PythonEnvironmentCheck.h" +#include "Utilities.h" +#include "transforms/LegacyPythonTransform.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "TomvizTest.h" + +using tomviz::readInJSONDescription; +using tomviz::pipeline::EditNodeWidget; +using tomviz::pipeline::LegacyPythonTransform; +using tomviz::pipeline::Pipeline; +using tomviz::pipeline::PythonEnvironmentCheck; +using tomviz::pipeline::PythonEnvironmentInfo; +using Status = PythonEnvironmentInfo::Status; + +namespace { + +const char* kRequired = "3.1.3"; + +void writeFile(const QString& path, const QByteArray& content, + bool executable) +{ + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + << path.toStdString(); + file.write(content); + file.close(); + if (executable) { + ASSERT_TRUE(file.setPermissions( + file.permissions() | QFileDevice::ExeOwner | QFileDevice::ExeUser | + QFileDevice::ExeGroup | QFileDevice::ExeOther)); + } +} + +/// A fake environment: /bin/python (or python.exe on Windows), +/// without tomviz-pipeline until installCli() is called. +struct FakeEnv +{ + QTemporaryDir dir; + + QString root() const { return QDir::cleanPath(dir.path()); } +#if defined(Q_OS_WIN) + QString binDir() const { return root() + "/Scripts"; } + QString interpreter() const { return root() + "/python.exe"; } + QString cli() const { return binDir() + "/tomviz-pipeline.exe"; } +#else + QString binDir() const { return root() + "/bin"; } + QString interpreter() const { return binDir() + "/python"; } + QString cli() const { return binDir() + "/tomviz-pipeline"; } +#endif + + void create() + { + ASSERT_TRUE(dir.isValid()); + ASSERT_TRUE(QDir().mkpath(binDir())); + writeFile(interpreter(), "", true); + } + + /// POSIX: a shell script standing in for the click entry point. + void installCli(const QByteArray& script) + { + writeFile(cli(), script, true); + } + + void installCliReporting(const QString& version) + { + installCli("#!/bin/sh\necho \"tomviz-pipeline, version " + + version.toUtf8() + "\"\n"); + } +}; + +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); +} + +PythonEnvironmentInfo runAsync(const QString& path) +{ + tomviz_test::ensureQApp(); + PythonEnvironmentCheck check; + PythonEnvironmentInfo result; + bool done = false; + QObject::connect(&check, &PythonEnvironmentCheck::finished, &check, + [&](const PythonEnvironmentInfo& info) { + result = info; + done = true; + }); + check.start(path, 10000, kRequired); + EXPECT_TRUE(QTest::qWaitFor([&]() { return done; }, 15000)); + return result; +} + +} // namespace + +// ---- version rule ----------------------------------------------------------- + +TEST(PythonEnvironmentCheckTest, ParsesMajorMinorPatchPrefix) +{ + int major = -1, minor = -1, patch = -1; + EXPECT_TRUE(PythonEnvironmentCheck::parseVersion("3.1.3", major, minor, + patch)); + EXPECT_EQ(major, 3); + EXPECT_EQ(minor, 1); + EXPECT_EQ(patch, 3); + + EXPECT_TRUE(PythonEnvironmentCheck::parseVersion("3.2.0b1", major, minor, + patch)); + EXPECT_EQ(minor, 2); + EXPECT_EQ(patch, 0); + + EXPECT_TRUE(PythonEnvironmentCheck::parseVersion("v3.1", major, minor, + patch)); + EXPECT_EQ(patch, 0); + + EXPECT_TRUE(PythonEnvironmentCheck::parseVersion(" 3.1.4.dev0 ", major, + minor, patch)); + EXPECT_EQ(patch, 4); + + EXPECT_FALSE(PythonEnvironmentCheck::parseVersion("3", major, minor, + patch)); + EXPECT_FALSE(PythonEnvironmentCheck::parseVersion("beta", major, minor, + patch)); + EXPECT_FALSE(PythonEnvironmentCheck::parseVersion("", major, minor, + patch)); +} + +TEST(PythonEnvironmentCheckTest, AcceptsSameMajorNotOlderThanFloor) +{ + // >= 3.1.3 and < 4: newer minors keep the CLI contract, so only a + // new major is treated as incompatible. + EXPECT_TRUE(PythonEnvironmentCheck::isCompatibleVersion("3.1.3", "3.1.3")); + EXPECT_TRUE(PythonEnvironmentCheck::isCompatibleVersion("3.1.9", "3.1.3")); + EXPECT_TRUE( + PythonEnvironmentCheck::isCompatibleVersion("3.1.10.dev0", "3.1.3")); + EXPECT_TRUE(PythonEnvironmentCheck::isCompatibleVersion("3.2.0", "3.1.3")); + EXPECT_TRUE(PythonEnvironmentCheck::isCompatibleVersion("3.10.1", "3.1.3")); + EXPECT_FALSE(PythonEnvironmentCheck::isCompatibleVersion("3.1.2", "3.1.3")); + EXPECT_FALSE( + PythonEnvironmentCheck::isCompatibleVersion("3.0.0beta1", "3.1.3")); + EXPECT_FALSE(PythonEnvironmentCheck::isCompatibleVersion("4.0.0", "3.1.3")); + EXPECT_FALSE(PythonEnvironmentCheck::isCompatibleVersion("4.1.3", "3.1.3")); + EXPECT_FALSE(PythonEnvironmentCheck::isCompatibleVersion("junk", "3.1.3")); + // An unparsable floor disables the rule rather than rejecting all. + EXPECT_TRUE(PythonEnvironmentCheck::isCompatibleVersion("1.0.0", "???")); +} + +TEST(PythonEnvironmentCheckTest, RequirementSpecCoversNextMajor) +{ + EXPECT_EQ(PythonEnvironmentCheck::requirementSpec("3.1.3"), + "tomviz-pipeline>=3.1.3,<4"); + EXPECT_EQ(PythonEnvironmentCheck::requirementSpec("9.9.9"), + "tomviz-pipeline>=9.9.9,<10"); + // The build's own floor is well-formed. + int major = 0, minor = 0, patch = 0; + EXPECT_TRUE(PythonEnvironmentCheck::parseVersion( + PythonEnvironmentCheck::requiredVersion(), major, minor, patch)); +} + +// ---- environment root resolution ------------------------------------------- + +TEST(PythonEnvironmentCheckTest, ResolvesRootFromRootBinDirOrInterpreter) +{ + FakeEnv env; + env.create(); + + EXPECT_EQ(PythonEnvironmentCheck::resolveEnvironmentRoot(env.root()), + env.root()); + EXPECT_EQ(PythonEnvironmentCheck::resolveEnvironmentRoot(env.root() + "/"), + env.root()); + EXPECT_EQ(PythonEnvironmentCheck::resolveEnvironmentRoot(env.binDir()), + env.root()); + EXPECT_EQ( + PythonEnvironmentCheck::resolveEnvironmentRoot(env.interpreter()), + env.root()); + EXPECT_EQ(PythonEnvironmentCheck::resolveEnvironmentRoot(" " + env.root() + + " "), + env.root()); +} + +TEST(PythonEnvironmentCheckTest, RejectsNonEnvironments) +{ + QTemporaryDir plain; + ASSERT_TRUE(plain.isValid()); + ASSERT_TRUE(QDir().mkpath(plain.path() + "/bin")); + writeFile(plain.path() + "/notes.txt", "hi", false); + + EXPECT_TRUE(PythonEnvironmentCheck::resolveEnvironmentRoot("").isEmpty()); + EXPECT_TRUE( + PythonEnvironmentCheck::resolveEnvironmentRoot(plain.path()).isEmpty()); + EXPECT_TRUE(PythonEnvironmentCheck::resolveEnvironmentRoot(plain.path() + + "/bin") + .isEmpty()); + EXPECT_TRUE(PythonEnvironmentCheck::resolveEnvironmentRoot(plain.path() + + "/notes.txt") + .isEmpty()); + EXPECT_TRUE(PythonEnvironmentCheck::resolveEnvironmentRoot( + plain.path() + "/does/not/exist") + .isEmpty()); +} + +// ---- filesystem verdicts --------------------------------------------------- + +TEST(PythonEnvironmentCheckTest, ReportsMissingPathAndNonEnvironment) +{ + PythonEnvironmentInfo none = PythonEnvironmentCheck::check("", 1000, + kRequired); + EXPECT_EQ(none.status, Status::NoPath); + EXPECT_TRUE(none.message.isEmpty()); + + QTemporaryDir plain; + ASSERT_TRUE(plain.isValid()); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(plain.path(), 1000, kRequired); + EXPECT_EQ(info.status, Status::NotAnEnvironment); + EXPECT_FALSE(info.ok()); + EXPECT_TRUE(info.envPath.isEmpty()); + EXPECT_TRUE(info.message.contains("not a Python environment")); +} + +TEST(PythonEnvironmentCheckTest, ReportsMissingCli) +{ + FakeEnv env; + env.create(); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.binDir(), 1000, kRequired); + EXPECT_EQ(info.status, Status::CliMissing); + EXPECT_EQ(info.envPath, env.root()); + EXPECT_TRUE(info.cliPath.isEmpty()); + EXPECT_TRUE(info.message.contains("not installed in this environment")); + EXPECT_FALSE(info.message.contains(env.root())); + // Problem, blank line, then how to fix it. + EXPECT_TRUE(info.message.contains( + "\n\nTo fix: activate the environment, then run:\n" + "pip install \"tomviz-pipeline>=3.1.3,<4\"")); +} + +#if !defined(Q_OS_WIN) + +// ---- subprocess verdicts (POSIX: the fake CLI is a shell script) ----------- + +TEST(PythonEnvironmentCheckTest, AcceptsCompatibleVersion) +{ + FakeEnv env; + env.create(); + env.installCliReporting("3.1.5"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::Ok) << info.message.toStdString(); + EXPECT_TRUE(info.ok()); + EXPECT_EQ(info.version, "3.1.5"); + EXPECT_EQ(info.cliPath, env.cli()); + EXPECT_EQ(info.message, + "This environment is compatible: tomviz-pipeline 3.1.5"); +} + +TEST(PythonEnvironmentCheckTest, RejectsTooOldVersion) +{ + FakeEnv env; + env.create(); + env.installCliReporting("3.0.0beta1"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::VersionTooOld); + EXPECT_EQ(info.version, "3.0.0beta1"); + EXPECT_TRUE(info.message.contains("too old")); + EXPECT_TRUE(info.message.contains("pip install -U")); +} + +TEST(PythonEnvironmentCheckTest, SuggestsCondaWhenCondaInstalledIt) +{ + FakeEnv env; + env.create(); + env.installCliReporting("3.1.1"); + // conda's record of the package it installed. + ASSERT_TRUE(QDir().mkpath(env.root() + "/conda-meta")); + writeFile(env.root() + "/conda-meta/tomviz-pipeline-3.1.1-pyhd8ed1ab_0.json", + "{}", false); + + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::VersionTooOld); + EXPECT_TRUE(info.message.contains( + "then run:\nconda install -c conda-forge \"tomviz-pipeline>=3.1.3,<4\"")) + << info.message.toStdString(); + EXPECT_FALSE(info.message.contains("pip install")); + + // A conda env where conda did NOT install it (pip did, or nothing + // did) keeps the pip hint: conda has no record to disagree with. + ASSERT_TRUE(QFile::remove( + env.root() + "/conda-meta/tomviz-pipeline-3.1.1-pyhd8ed1ab_0.json")); + info = PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_TRUE(info.message.contains("pip install -U")); +} + +TEST(PythonEnvironmentCheckTest, AcceptsNextMinorRejectsNextMajor) +{ + FakeEnv env; + env.create(); + // A newer minor keeps the CLI contract: no false alarm the day the + // library ships 3.2. + env.installCliReporting("3.2.0"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::Ok); + + env.installCliReporting("4.0.0"); + info = PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::VersionTooNew); + EXPECT_TRUE(info.message.contains("newer")); +} + +TEST(PythonEnvironmentCheckTest, TreatsMissingVersionOptionAsTooOld) +{ + // tomviz-pipeline < 3.1 has no --version option: click exits 2 with + // "No such option". Report that as an old install with the upgrade + // command, not as a broken one. + FakeEnv env; + env.create(); + env.installCli("#!/bin/sh\necho \"Usage: tomviz-pipeline [OPTIONS]\" >&2\n" + "echo \"Try 'tomviz-pipeline --help' for help.\" >&2\n" + "echo >&2\necho \"Error: No such option '--version'.\" >&2\n" + "exit 2\n"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::VersionTooOld) << info.message.toStdString(); + EXPECT_TRUE(info.version.isEmpty()); + EXPECT_EQ(info.message, + "tomviz-pipeline in this environment predates 3.1.3 and is too " + "old.\n\nTo fix: activate the environment, then run:\n" + "pip install -U \"tomviz-pipeline>=3.1.3,<4\""); +} + +TEST(PythonEnvironmentCheckTest, ReportsCliThatFails) +{ + FakeEnv env; + env.create(); + env.installCli("#!/bin/sh\necho 'ModuleNotFoundError: No module named " + "numpy' >&2\nexit 1\n"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::CliBroken); + EXPECT_TRUE(info.message.contains("failed to run (exit code 1)")); + EXPECT_TRUE(info.message.contains("ModuleNotFoundError")); + EXPECT_TRUE(info.message.contains( + "\n\nCheck the error above. If a dependency is missing, activate the " + "environment, then run:\npip install \"tomviz-pipeline>=3.1.3,<4\"")); + EXPECT_FALSE(info.message.contains("force-reinstall")); +} + +TEST(PythonEnvironmentCheckTest, ReportsCliThatPrintsNoVersion) +{ + FakeEnv env; + env.create(); + env.installCli("#!/bin/sh\necho 'usage: something'\n"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::CliBroken); + EXPECT_TRUE(info.message.contains("no version")); +} + +TEST(PythonEnvironmentCheckTest, ReportsCliWithDanglingInterpreter) +{ + // A venv that was moved: the entry point's shebang names an + // interpreter that no longer exists. + FakeEnv env; + env.create(); + env.installCli("#!/nonexistent/env/bin/python\nprint('unreachable')\n"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 10000, kRequired); + EXPECT_EQ(info.status, Status::CliBroken); + EXPECT_TRUE(info.message.contains("could not be started") || + info.message.contains("failed to run")) + << info.message.toStdString(); + EXPECT_FALSE(info.message.contains("pip install")); +} + +TEST(PythonEnvironmentCheckTest, ReportsCliThatHangs) +{ + FakeEnv env; + env.create(); + env.installCli("#!/bin/sh\nsleep 30\n"); + PythonEnvironmentInfo info = + PythonEnvironmentCheck::check(env.root(), 500, kRequired); + EXPECT_EQ(info.status, Status::CliBroken); + EXPECT_TRUE(info.message.contains("did not respond")); + EXPECT_FALSE(info.message.contains("pip install")); +} + +TEST(PythonEnvironmentCheckTest, AsyncDeliversSameVerdicts) +{ + FakeEnv env; + env.create(); + + PythonEnvironmentInfo missing = runAsync(env.root()); + EXPECT_EQ(missing.status, Status::CliMissing); + + env.installCliReporting("3.1.3"); + PythonEnvironmentInfo ok = runAsync(env.interpreter()); + EXPECT_EQ(ok.status, Status::Ok) << ok.message.toStdString(); + EXPECT_EQ(ok.envPath, env.root()); + EXPECT_EQ(ok.version, "3.1.3"); +} + +TEST(PythonEnvironmentCheckTest, AsyncSupersededCheckIsDropped) +{ + tomviz_test::ensureQApp(); + FakeEnv slow; + slow.create(); + slow.installCli("#!/bin/sh\nsleep 2\necho 'tomviz-pipeline, version " + "3.1.3'\n"); + FakeEnv fast; + fast.create(); + fast.installCliReporting("3.1.4"); + + PythonEnvironmentCheck check; + QList results; + QObject::connect(&check, &PythonEnvironmentCheck::finished, &check, + [&](const PythonEnvironmentInfo& info) { + results.append(info); + }); + check.start(slow.root(), 10000, kRequired); + EXPECT_TRUE(check.isRunning()); + check.start(fast.root(), 10000, kRequired); + EXPECT_TRUE(QTest::qWaitFor([&]() { return !results.isEmpty(); }, 15000)); + // Give the superseded (killed) process time to have reported, had it + // not been dropped. + QTest::qWait(300); + ASSERT_EQ(results.size(), 1); + EXPECT_EQ(results.first().envPath, fast.root()); + EXPECT_EQ(results.first().version, "3.1.4"); +} + +// ---- editor integration ----------------------------------------------------- + +TEST(PythonEnvironmentCheckTest, EditorShowsVerdictAndCanonicalizesRoot) +{ + QCoreApplication::setOrganizationName("tomviz-tests"); + QCoreApplication::setApplicationName("tomviz-tests"); + tomviz_test::ensureQApp(); + QSettings().remove("externalEnvPaths/SAM2Segment3D"); + + FakeEnv env; + env.create(); + env.installCliReporting("3.1.7"); + + Pipeline pipeline; + auto* editor = makeSam2Editor(pipeline); + auto* envEdit = editor->findChild("executorEnvPathEdit"); + auto* status = editor->findChild("executorEnvStatusLabel"); + ASSERT_NE(envEdit, nullptr); + ASSERT_NE(status, nullptr); + EXPECT_TRUE(status->isHidden()); + + // Picking /bin: the verdict appears and the field is rewritten + // to the environment root. + envEdit->setText(env.binDir()); + EXPECT_TRUE(QTest::qWaitFor( + [&]() { return status->text().contains("3.1.7"); }, 15000)) + << status->text().toStdString(); + EXPECT_FALSE(status->isHidden()); + EXPECT_EQ(envEdit->text(), env.root()); + + // A directory that isn't an environment is called out. + QTemporaryDir plain; + ASSERT_TRUE(plain.isValid()); + envEdit->setText(plain.path()); + EXPECT_TRUE(QTest::qWaitFor( + [&]() { return status->text().contains("not a Python environment"); }, + 15000)) + << status->text().toStdString(); + EXPECT_EQ(envEdit->text(), plain.path()); + + // Clearing the path clears the verdict. + envEdit->clear(); + EXPECT_TRUE(QTest::qWaitFor([&]() { return status->isHidden(); }, + 5000)); + + delete editor; + QSettings().remove("externalEnvPaths/SAM2Segment3D"); +} + +TEST(PythonEnvironmentCheckTest, ApplyStoresEnvironmentRoot) +{ + QCoreApplication::setOrganizationName("tomviz-tests"); + QCoreApplication::setApplicationName("tomviz-tests"); + tomviz_test::ensureQApp(); + QSettings().remove("externalEnvPaths/SAM2Segment3D"); + + FakeEnv env; + env.create(); + + Pipeline pipeline; + auto* editor = makeSam2Editor(pipeline); + auto* envEdit = editor->findChild("executorEnvPathEdit"); + ASSERT_NE(envEdit, nullptr); + // Apply before the debounced check gets to run. + envEdit->setText(env.interpreter()); + editor->applyChangesToOperator(); + EXPECT_EQ(QSettings().value("externalEnvPaths/SAM2Segment3D").toString(), + env.root()); + delete editor; + QSettings().remove("externalEnvPaths/SAM2Segment3D"); +} + +#endif // !Q_OS_WIN diff --git a/tests/cxx/RecordedPlaneTest.cxx b/tests/cxx/RecordedPlaneTest.cxx new file mode 100644 index 000000000..17b56b948 --- /dev/null +++ b/tests/cxx/RecordedPlaneTest.cxx @@ -0,0 +1,213 @@ +/* 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 "animations/CameraViewpoints.h" +#include "animations/RecordedAnimations.h" +#include "animations/SceneSnapshot.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/SliceSink.h" + +#include + +using namespace tomviz; + +namespace { + +pipeline::SourceNode* addSource(pipeline::Pipeline* pip) +{ + vtkNew image; + image->SetDimensions(20, 30, 40); + image->AllocateScalars(VTK_FLOAT, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (vtkIdType i = 0; i < image->GetNumberOfPoints(); ++i) { + p[i] = static_cast(i % 7); + } + auto vol = std::make_shared( + vtkSmartPointer(image.GetPointer())); + auto* source = new pipeline::SourceNode(); + source->addOutput("volume", pipeline::PortType::Volume); + pip->addNode(source); + source->setOutputData( + "volume", pipeline::PortData(vol, pipeline::PortType::Volume)); + return source; +} + +template +Sink* addSink(pipeline::Pipeline* pip, pipeline::SourceNode* source, + pqRenderView* view) +{ + auto* sink = new Sink(); + sink->initialize(view->getViewProxy()); + pip->addNode(sink); + pip->createLink(source->outputPorts()[0], sink->inputPorts()[0]); + sink->execute(); + return sink; +} + +PlaneKey keyOf(pipeline::LegacyModuleSink* sink) +{ + auto snapshot = SinkSnapshot::capture(sink); + PlaneKey key; + key.direction = snapshot.planeDirection.value_or(0); + key.slice = snapshot.sliceIndex.value_or(0); + key.center = snapshot.planeCenter.value_or(key.center); + key.normal = snapshot.planeNormal.value_or(key.normal); + return key; +} + +std::array unit(const double n[3]) +{ + double length = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + return { n[0] / length, n[1] / length, n[2] / length }; +} + +} // namespace + +class RecordedPlaneTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + auto* server = pqActiveObjects::instance().activeServer(); + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + m_view = qobject_cast( + builder->createView(pqRenderView::renderViewType(), server)); + QVERIFY(m_view); + // Two viewpoints and one straight leg: path time is progress along it + Viewpoint first; + first.eased = false; + Viewpoint second = first; + second.position = { 0, 0, 5 }; + CameraViewpoints::instance().append(first); + CameraViewpoints::instance().append(second); + } + + // A slice that changes direction between viewpoints swings from one + // orientation to the other through custom planes, taking the short way + // round, and ends on the recorded direction and index. + void sliceSwingsToItsNewDirection() + { + pipeline::Pipeline pip; + auto* source = addSource(&pip); + auto* slice = addSink(&pip, source, m_view); + + slice->setDirection(pipeline::SliceSink::XY); + slice->setSlice(10); + const auto xy = keyOf(slice); + slice->setDirection(pipeline::SliceSink::XZ); + slice->setSlice(5); + const auto xz = keyOf(slice); + + RecordedPlaneAnimation animation(slice, { { 0, xy }, { 1, xz } }); + animation.applyPathTime(0.0); + QCOMPARE(slice->direction(), pipeline::SliceSink::XY); + QCOMPARE(slice->slice(), 10); + + animation.applyPathTime(0.5); + QCOMPARE(slice->direction(), pipeline::SliceSink::Custom); + double n[3]; + slice->planeNormal(n); + auto half = unit(n); + // Halfway between z and y, in the yz plane + QVERIFY2(std::abs(half[0]) < 1e-6, "the normal left the yz plane"); + QVERIFY2(std::abs(std::abs(half[1]) - std::sqrt(0.5)) < 1e-6, + "not halfway round"); + QVERIFY2(std::abs(std::abs(half[2]) - std::sqrt(0.5)) < 1e-6, + "not halfway round"); + + animation.applyPathTime(1.0); + QCOMPARE(slice->direction(), pipeline::SliceSink::XZ); + QCOMPARE(slice->slice(), 5); + } + + // A clip swings the same way, keeping the side its normal points to. + void clipSwingsToItsNewDirection() + { + pipeline::Pipeline pip; + auto* source = addSource(&pip); + auto* clip = addSink(&pip, source, m_view); + + clip->setDirection(pipeline::ClipSink::XY); + const auto xy = keyOf(clip); + clip->setDirection(pipeline::ClipSink::YZ); + const auto yz = keyOf(clip); + + RecordedPlaneAnimation animation(clip, { { 0, xy }, { 1, yz } }); + animation.applyPathTime(0.5); + QCOMPARE(clip->direction(), pipeline::ClipSink::Custom); + double n[3]; + clip->planeNormalInData(n); + auto half = unit(n); + auto from = unit(xy.normal.data()); + auto to = unit(yz.normal.data()); + // The same angle from both ends, a quarter turn in all + const double toStart = + half[0] * from[0] + half[1] * from[1] + half[2] * from[2]; + const double toEnd = half[0] * to[0] + half[1] * to[1] + half[2] * to[2]; + QVERIFY2(std::abs(toStart - toEnd) < 1e-6, "not halfway round"); + QVERIFY2(std::abs(toStart - std::sqrt(0.5)) < 1e-6, + "the clip turned the long way"); + + animation.applyPathTime(1.0); + QCOMPARE(clip->direction(), pipeline::ClipSink::YZ); + } + + // The arrow belongs to the plane: with Show Plane off it stays hidden + // when playback or Go To shows the clip again, and when a state file + // turns Show Arrow on after Show Plane off. + void aHiddenPlaneKeepsItsArrowHidden() + { + pipeline::Pipeline pip; + auto* source = addSource(&pip); + auto* clip = addSink(&pip, source, m_view); + QVERIFY(clip->arrowVisible()); + + clip->setShowPlane(false); + QVERIFY(!clip->arrowVisible()); + clip->setShowArrow(true); + QVERIFY(!clip->arrowVisible()); + clip->setVisibility(false); + clip->setVisibility(true); + QVERIFY2(!clip->arrowVisible(), "showing the clip again showed the arrow"); + + clip->setShowPlane(true); + QVERIFY(clip->arrowVisible()); + clip->setShowArrow(false); + QVERIFY(!clip->arrowVisible()); + } + +private: + pqRenderView* m_view = nullptr; +}; + +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:")); + RecordedPlaneTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "RecordedPlaneTest.moc" 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..0264c2180 --- /dev/null +++ b/tests/cxx/SegmentationColorMapTest.cxx @@ -0,0 +1,252 @@ +/* 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/TransformNode.h" +#include "pipeline/transforms/LegacyPythonTransform.h" +#include "pipeline/data/LabelMapData.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include +#include + +using namespace tomviz; +using namespace tomviz::pipeline; + +namespace { + +// A transform with a primary input and a second one, declared in that +// order, like Fourier Mask (volume, mask) or Image Math (volume, +// second_dataset). +class TwoInputNode : public TransformNode +{ +public: + explicit TwoInputNode(const QString& second) + { + addInput("volume", PortType::ImageData); + addInput(second, PortType::ImageData); + addOutput("volume", PortType::ImageData); + } + +protected: + QMap transform(const QMap&) override + { + return {}; + } +}; + +vtkSmartPointer smallImage(int labels) +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(8, 8, 8); + image->AllocateScalars(VTK_INT, 1); + auto* p = static_cast(image->GetScalarPointer()); + for (int i = 0; i < 8 * 8 * 8; ++i) { + p[i] = i % labels; + } + return image; +} + +// A plain volume with a one-color map, so a copy of it is recognizable +VolumeDataPtr coloredVolume(double r, double g, double b) +{ + auto vol = std::make_shared(smallImage(100)); + vol->initColorMap(); + auto* ctf = vol->colorTransferFunction(); + ctf->RemoveAllPoints(); + ctf->AddRGBPoint(0.0, r, g, b); + ctf->AddRGBPoint(99.0, r, g, b); + return vol; +} + +VolumeDataPtr labelMap() +{ + VolumeDataPtr labels = std::make_shared(smallImage(5)); + applyLabelMapColors(labels); + return labels; +} + +std::array colorAt(const VolumeDataPtr& vol, double value) +{ + std::array rgb; + vol->colorTransferFunction()->GetColor(value, rgb.data()); + return rgb; +} + +} // namespace + +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); + } + + // Fourier Mask's output is continuous data: the label map on its mask + // input must not color it, even though "mask" sorts before "volume". + void outputsNeverTakeALabelMapsColors() + { + TwoInputNode node("mask"); + auto primary = coloredVolume(1.0, 0.0, 0.0); + QMap inputs{ + { "volume", PortData(primary, PortType::Volume) }, + { "mask", PortData(labelMap(), PortType::LabelMap) }, + }; + QCOMPARE(colorMapSource(&node, inputs), primary); + + auto output = std::make_shared(smallImage(100)); + inheritOutputMetadata(&node, inputs, + { { "volume", PortData(output, PortType::Volume) } }); + QVERIFY(output->hasColorMap()); + QCOMPARE(colorAt(output, 50.0), (std::array{ 1.0, 0.0, 0.0 })); + + // With only a label map to copy from, the output starts afresh + QMap onlyLabels{ + { "volume", PortData(labelMap(), PortType::LabelMap) }, + { "mask", PortData(labelMap(), PortType::LabelMap) }, + }; + QVERIFY(!colorMapSource(&node, onlyLabels)); + auto fresh = std::make_shared(smallImage(100)); + inheritOutputMetadata(&node, onlyLabels, + { { "volume", PortData(fresh, PortType::Volume) } }); + QVERIFY(!fresh->hasColorMap()); + } + + // Image Math: the primary input's colors, though "second_dataset" + // sorts first + void outputsTakeThePrimaryInputsColors() + { + TwoInputNode node("second_dataset"); + auto primary = coloredVolume(1.0, 0.0, 0.0); + auto second = coloredVolume(0.0, 0.0, 1.0); + QMap inputs{ + { "volume", PortData(primary, PortType::Volume) }, + { "second_dataset", PortData(second, PortType::Volume) }, + }; + QCOMPARE(colorMapSource(&node, inputs), primary); + + // A primary input with no color map yet gives way to the other + auto bare = std::make_shared(smallImage(100)); + inputs["volume"] = PortData(bare, PortType::Volume); + QCOMPARE(colorMapSource(&node, inputs), second); + } + + // The FFT's output is a spectrum: a color map tuned for the data it + // came from means nothing there, so its description opts out. + void operatorsCanDeclineInheritance() + { + auto primary = coloredVolume(1.0, 0.0, 0.0); + QMap inputs{ + { "volume", PortData(primary, PortType::Volume) }, + }; + + LegacyPythonTransform fft; + fft.setJSONDescription( + R"({"name": "FFT_AbsLog", "inheritColorMap": false, "parameters": []})"); + QVERIFY(!fft.inheritsColorMap()); + QVERIFY(!colorMapSource(&fft, inputs)); + + LegacyPythonTransform blur; + blur.setJSONDescription(R"({"name": "GaussianFilter", "parameters": []})"); + QVERIFY(blur.inheritsColorMap()); + QCOMPARE(colorMapSource(&blur, inputs), primary); + } +}; + +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/TiltSeriesSinogramTest.cxx b/tests/cxx/TiltSeriesSinogramTest.cxx new file mode 100644 index 000000000..33035c2e8 --- /dev/null +++ b/tests/cxx/TiltSeriesSinogramTest.cxx @@ -0,0 +1,106 @@ +/* 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 "TomographyTiltSeries.h" + +#include + +using namespace tomviz; + +namespace { + +// A wider-than-tall tilt series (x > y), the shape that #2308 crashed on. +constexpr int kX = 300; +constexpr int kY = 200; +constexpr int kZ = 4; +constexpr int kNray = 64; + +vtkSmartPointer makeWideTiltSeries() +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(kX, kY, kZ); + image->AllocateScalars(VTK_UNSIGNED_SHORT, 1); + auto* ptr = static_cast(image->GetScalarPointer()); + for (int z = 0; z < kZ; ++z) { + for (int y = 0; y < kY; ++y) { + for (int x = 0; x < kX; ++x) { + ptr[(z * kY + y) * kX + x] = + static_cast((x + 7 * y + 1000 * z) % 65535); + } + } + } + return image; +} + +} // namespace + +// With a vertical tilt axis the slice index runs along y, so a slice seeded +// from the x dimension (0.75 * 300 = 225 > 200) used to read past the end +// of the tilt series. It must now clamp to the last valid y slice. +TEST(TiltSeriesSinogram, verticalTiltAxisClampsSliceToYDim) +{ + auto image = makeWideTiltSeries(); + std::vector outOfRange(kNray * kZ, -1.0f); + std::vector lastValid(kNray * kZ, -2.0f); + + TomographyTiltSeries::getSinogram(image, 225, outOfRange.data(), kNray, + 0.0, /*tiltAxis=*/1); + TomographyTiltSeries::getSinogram(image, kY - 1, lastValid.data(), kNray, + 0.0, /*tiltAxis=*/1); + + EXPECT_EQ(outOfRange, lastValid); +} + +TEST(TiltSeriesSinogram, horizontalTiltAxisClampsSliceToXDim) +{ + auto image = makeWideTiltSeries(); + std::vector outOfRange(kNray * kZ, -1.0f); + std::vector lastValid(kNray * kZ, -2.0f); + + TomographyTiltSeries::getSinogram(image, kX + 50, outOfRange.data(), kNray, + 0.0, /*tiltAxis=*/0); + TomographyTiltSeries::getSinogram(image, kX - 1, lastValid.data(), kNray, + 0.0, /*tiltAxis=*/0); + + EXPECT_EQ(outOfRange, lastValid); +} + +TEST(TiltSeriesSinogram, negativeSliceClampsToZero) +{ + auto image = makeWideTiltSeries(); + std::vector negative(kNray * kZ, -1.0f); + std::vector zero(kNray * kZ, -2.0f); + + TomographyTiltSeries::getSinogram(image, -5, negative.data(), kNray, 0.0, + /*tiltAxis=*/1); + TomographyTiltSeries::getSinogram(image, 0, zero.data(), kNray, 0.0, + /*tiltAxis=*/1); + + EXPECT_EQ(negative, zero); +} + +// In-range slices still gather the expected samples: with no axis shift the +// ray at the center of the sinogram lands on the middle of the tilt axis. +TEST(TiltSeriesSinogram, inRangeSliceMatchesSource) +{ + auto image = makeWideTiltSeries(); + std::vector sino(kNray * kZ, 0.0f); + const int slice = 150; // along y for a vertical tilt axis + TomographyTiltSeries::getSinogram(image, slice, sino.data(), kNray, 0.0, + /*tiltAxis=*/1); + + // Ray Nray/2 has rayCoord 0 -> index1 = xDim/2 with weight1 = 0, so the + // sample is exactly the pixel at x = xDim/2 + 1 (index2, weight2 = 1). + auto* ptr = static_cast(image->GetScalarPointer()); + for (int z = 0; z < kZ; ++z) { + const float expected = ptr[(z * kY + slice) * kX + kX / 2 + 1]; + EXPECT_FLOAT_EQ(sino[z * kNray + kNray / 2], expected) << "tilt " << z; + } +} 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/UtilitiesTest.cxx b/tests/cxx/UtilitiesTest.cxx index 81f0b6267..8a7b25e57 100644 --- a/tests/cxx/UtilitiesTest.cxx +++ b/tests/cxx/UtilitiesTest.cxx @@ -10,6 +10,11 @@ #include "TomvizTest.h" #include "Utilities.h" +#include +#include +#include +#include + using namespace tomviz; class UtilitiesTest : public ::testing::Test @@ -118,3 +123,103 @@ TEST_F(UtilitiesTest, table_to_csv_single_row) ASSERT_STREQ(lines[0].toLatin1().constData(), "x,y"); ASSERT_STREQ(lines[1].toLatin1().constData(), "42,99"); } + +namespace { + +// Restores an environment variable to whatever the process had. +struct ScopedEnv +{ + explicit ScopedEnv(const char* variable) + : name(variable), had(qEnvironmentVariableIsSet(variable)), + old(qgetenv(variable)) + { + } + ~ScopedEnv() + { + if (had) { + qputenv(name, old); + } else { + qunsetenv(name); + } + } + const char* name; + bool had; + QByteArray old; +}; + +constexpr const char* kCustomTransformsPath = "TOMVIZ_CUSTOM_TRANSFORMS_PATH"; +constexpr const char* kUserDirectory = "TOMVIZ_USER_DIRECTORY"; + +} // namespace + +TEST_F(UtilitiesTest, user_data_path_follows_env_override) +{ + ScopedEnv guard(kUserDirectory); + QTemporaryDir root; + ASSERT_TRUE(root.isValid()); + // Nested and not yet existing: the override is created like the default. + const QString wanted = root.path() + "/nested/user-dir"; + qputenv(kUserDirectory, wanted.toLocal8Bit()); + + EXPECT_EQ(userDataPath(), QDir::cleanPath(wanted)); + EXPECT_TRUE(QFileInfo(wanted).isDir()); + EXPECT_EQ(userTemplatesPath(), QDir::cleanPath(wanted) + "/templates"); +} + +TEST_F(UtilitiesTest, user_data_path_defaults_to_tomviz_under_home) +{ + ScopedEnv guard(kUserDirectory); + qunsetenv(kUserDirectory); + + const QString path = userDataPath(); + EXPECT_TRUE(path.endsWith("/tomviz")) << path.toStdString(); + EXPECT_TRUE(QFileInfo(path).isDir()); +} + +TEST_F(UtilitiesTest, custom_operator_search_paths_follow_env_override) +{ + ScopedEnv guard(kCustomTransformsPath); + QTemporaryDir first; + QTemporaryDir second; + ASSERT_TRUE(first.isValid() && second.isValid()); + const QString missing = first.path() + "/does-not-exist"; + + const QString value = QStringList{ first.path(), missing, second.path() } + .join(QDir::listSeparator()); + qputenv(kCustomTransformsPath, value.toLocal8Bit()); + + const QStringList paths = customOperatorSearchPaths(); + const QStringList expected{ QDir::cleanPath(first.path()), + QDir::cleanPath(second.path()) }; + EXPECT_EQ(paths, expected); +} + +TEST_F(UtilitiesTest, custom_operator_search_paths_default_to_existing_dirs) +{ + ScopedEnv guard(kCustomTransformsPath); + qunsetenv(kCustomTransformsPath); + QTemporaryDir unrelated; + + const QStringList paths = customOperatorSearchPaths(); + EXPECT_FALSE(paths.contains(QDir::cleanPath(unrelated.path()))); + for (const QString& path : paths) { + EXPECT_TRUE(QFileInfo(path).isDir()) << path.toStdString(); + } +} + +TEST_F(UtilitiesTest, custom_operator_search_paths_keep_the_dot_tomviz_dir) +{ + ScopedEnv guard(kCustomTransformsPath); + qunsetenv(kCustomTransformsPath); + const QString home = + QStandardPaths::standardLocations(QStandardPaths::HomeLocation).first(); + const QString legacy = QDir::cleanPath(QDir(home).filePath(".tomviz")); + + const QStringList paths = customOperatorSearchPaths(); + EXPECT_EQ(paths.contains(legacy), QFileInfo(legacy).isDir()) + << legacy.toStdString(); + // The environment override replaces the default locations entirely. + QTemporaryDir only; + qputenv(kCustomTransformsPath, only.path().toLocal8Bit()); + EXPECT_FALSE(customOperatorSearchPaths().contains(legacy)); +} 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..25b2037eb 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -1,17 +1,29 @@ 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(boundary) +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) +add_python_test(rotate3d) +add_python_test(clear_volume) +add_python_test(fourier_operators) +add_python_test(segmentation_operators) +add_python_test(remove_labels) +add_python_test(morphology_operators) +add_python_test(periodic_sources) +add_python_test(pyxrf_scan_metadata) +add_python_test(simulated_live) +add_python_test(batch_example) diff --git a/tests/python/batch_example_test.py b/tests/python/batch_example_test.py new file mode 100644 index 000000000..cf58b9031 --- /dev/null +++ b/tests/python/batch_example_test.py @@ -0,0 +1,49 @@ +"""The batch-processing example in examples/batch really runs: one +tomviz-pipeline call over every generated input, one label map each.""" +import importlib.util +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from tomviz_pipeline.cli import main +from tomviz_pipeline.io import load_dataset +from tomviz_pipeline.writers import writer_for + +EXAMPLE = Path(__file__).parents[2] / 'examples' / 'batch' / 'make_example.py' + + +def _make_example(): + spec = importlib.util.spec_from_file_location('make_example', EXAMPLE) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_batch_example_labels_every_input(tmp_path): + # The example's leaf is a label map, which tomviz-pipeline only + # writes from 3.1.7 on; an older package writes nothing for it. + if writer_for('LabelMap') is None: + pytest.skip('the installed tomviz-pipeline has no LabelMap writer ' + '(needs tomviz-pipeline >= 3.1.7)') + make_example = _make_example() + state_path, inputs, spheres = make_example.build(tmp_path, count=3, + size=24) + assert len(inputs) == 3 + + result = CliRunner().invoke(main, [ + '-s', str(state_path), + '-o', str(tmp_path / 'results'), + '--input', str(tmp_path / 'inputs' / '*.emd'), + '-p', 'tqdm', + ]) + assert result.exit_code == 0, result.output + + for index, expected in enumerate(spheres): + written = list((tmp_path / 'results' / f'run_{index}').glob('*.emd')) + assert len(written) == 1, written + assert written[0].name == '4_Connected_Components__volume.emd' + labels = load_dataset(written[0]).active_scalars + # Connected Components numbers the spheres 1..N, so the largest + # label is the number of spheres that were placed. + assert int(labels.max()) == expected diff --git a/tests/python/boundary_test.py b/tests/python/boundary_test.py new file mode 100644 index 000000000..2223247f8 --- /dev/null +++ b/tests/python/boundary_test.py @@ -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". +############################################################################### +"""Tests for tomviz._boundary — the VTK <-> tomviz_pipeline numpy +conversion shim used by the in-app Python script boundary.""" + +import copy + +import numpy as np +import pytest + +vtk = pytest.importorskip('vtk') + +from vtkmodules.util import numpy_support as np_s # noqa: E402 + +from tomviz import _boundary # noqa: E402 +from tomviz_pipeline.dataset import Dataset, LegacyDataset # noqa: E402 +from tomviz_pipeline.table import Table # noqa: E402 +from tomviz_pipeline.molecule import Molecule # noqa: E402 +from tomviz.utils import apply_to_each_array # noqa: E402 + + +def _make_image(dims=(3, 4, 5), arrays=(('Scalars', 2.0),)): + img = vtk.vtkImageData() + img.SetDimensions(*dims) + img.SetSpacing(1.0, 2.0, 3.0) + for name, value in arrays: + flat = np.full(dims, value, dtype=np.float32).ravel(order='F') + va = np_s.numpy_to_vtk(flat, deep=1) + va.SetName(name) + img.GetPointData().AddArray(va) + img.GetPointData().SetActiveScalars(arrays[0][0]) + return img + + +def test_wrap_exposes_views_and_metadata(): + img = _make_image() + ds = _boundary.wrap_vtk_image(img, legacy=True) + assert isinstance(ds, LegacyDataset) + assert ds.active_scalars.shape == (3, 4, 5) + assert ds.spacing == (1.0, 2.0, 3.0) + # In-place mutation of the view lands in the VTK buffer without a + # flush (it aliases the same memory). + ds.active_scalars[0, 0, 0] = 42.0 + back = np_s.vtk_to_numpy(img.GetPointData().GetScalars()) + assert back[0] == 42.0 + + +def test_flush_replaced_array_and_metadata(): + img = _make_image() + ds = _boundary.wrap_vtk_image(img) + ds.active_scalars = np.zeros((2, 2, 2), dtype=np.float32) + 7.0 + ds.tilt_angles = np.array([0.0, 1.0]) + _boundary.flush_dataset(ds) + assert tuple(img.GetDimensions()) == (2, 2, 2) + assert np_s.vtk_to_numpy(img.GetPointData().GetScalars())[0] == 7.0 + ta = np_s.vtk_to_numpy(img.GetFieldData().GetArray('tilt_angles')) + assert list(ta) == [0.0, 1.0] + + +def test_flush_replaced_multi_component_array_keeps_its_components(): + # An RGB stack is one array of three components per voxel. Replacing + # it has to come back as such, with the components interleaved the + # way VTK stores them, not as one long single-component array. + dims = (3, 4, 5) + comps = 3 + n = dims[0] * dims[1] * dims[2] + img = vtk.vtkImageData() + img.SetDimensions(*dims) + tuples = np.arange(n * comps, dtype=np.float32).reshape(n, comps) + va = np_s.numpy_to_vtk(tuples, deep=1) + va.SetName('RGB') + img.GetPointData().SetScalars(va) + + ds = _boundary.wrap_vtk_image(img) + view = ds.active_scalars + assert view.shape == dims + (comps,) + x, y, z = 1, 2, 3 + t = x + dims[0] * (y + dims[1] * z) + assert view[x, y, z, 1] == tuples[t, 1] + + ds.active_scalars = view * 2 + _boundary.flush_dataset(ds) + out = img.GetPointData().GetScalars() + assert out.GetNumberOfComponents() == comps + assert out.GetNumberOfTuples() == n + flushed = np_s.vtk_to_numpy(out) + assert np.array_equal(flushed, tuples * 2) + # And the views over the new buffer read the same way again + assert _boundary.wrap_vtk_image(img).active_scalars[x, y, z, 1] == \ + 2 * tuples[t, 1] + + +def test_deepcopy_does_not_clone_backing_vtk(): + """Regression: a deep-copied wrapped dataset must NOT carry the VTK + backing image. Otherwise copy.deepcopy (used by + apply_to_each_array) clones vtkImageData on the pipeline worker + thread, creating/destroying VTK objects during Python GC and + corrupting the heap (in-app SIGSEGV on the first multi-array + transform).""" + img = _make_image(arrays=(('Pt_L', 1.0), ('Zn_K', 2.0))) + ds = _boundary.wrap_vtk_image(img, legacy=True) + assert _boundary._get_backing(ds) is not None + + ds2 = copy.deepcopy(ds) + # No backing carried over — the copy is independent. + assert _boundary._get_backing(ds2) is None + # And its arrays are owned deep copies, not views over VTK memory. + assert ds2.arrays['Pt_L'].flags.owndata + + +def test_apply_to_each_array_over_wrapped_multi_array_dataset(): + """The exact in-app crash scenario, at the Python level: a + multi-array wrapped dataset run through an @apply_to_each_array + operator. Must complete and mutate every array, without cloning the + backing image during the per-array deepcopies.""" + img = _make_image(arrays=(('Pt_L', 1.0), ('Zn_K', 2.0), ('Fe_K', 3.0))) + ds = _boundary.wrap_vtk_image(img, legacy=True) + + @apply_to_each_array + def add_ten(dataset): + dataset.active_scalars = dataset.active_scalars + 10.0 + + add_ten(ds) + _boundary.flush_dataset(ds) + + pd = img.GetPointData() + assert pd.GetNumberOfArrays() == 3 + for name, base in (('Pt_L', 1.0), ('Zn_K', 2.0), ('Fe_K', 3.0)): + arr = np_s.vtk_to_numpy(pd.GetArray(name)) + np.testing.assert_allclose(arr, base + 10.0) + + +def test_payload_to_vtk_dataset_table_molecule(): + ds = Dataset({'A': np.arange(8, dtype=np.float32).reshape(2, 2, 2)}, 'A') + ds.spacing = (2.0, 2.0, 2.0) + img = _boundary.payload_to_vtk(ds) + assert img.IsA('vtkImageData') + assert tuple(img.GetDimensions()) == (2, 2, 2) + + table = Table() + table.set_column('x', np.array([1.0, 2.0], dtype=np.float32)) + vt = _boundary.payload_to_vtk(table) + assert vt.IsA('vtkTable') + assert vt.GetNumberOfColumns() == 1 + + mol = Molecule([6, 1], [[0, 0, 0], [1, 0, 0]], bonds=[[0, 1]]) + vm = _boundary.payload_to_vtk(mol) + assert vm.IsA('vtkMolecule') + assert vm.GetNumberOfAtoms() == 2 + + # VTK objects pass through untouched. + assert _boundary.payload_to_vtk(vm) is vm + + +def test_vtk_table_and_molecule_to_library(): + vt = vtk.vtkTable() + col = vtk.vtkFloatArray() + col.SetName('radius') + col.SetNumberOfValues(2) + col.SetValue(0, 1.5) + col.SetValue(1, 2.5) + vt.AddColumn(col) + table = _boundary.vtk_table_to_table(vt) + assert isinstance(table, Table) + np.testing.assert_allclose(table.column('radius'), [1.5, 2.5]) + + vm = vtk.vtkMolecule() + vm.AppendAtom(6, 0.0, 0.0, 0.0) + vm.AppendAtom(1, 1.0, 0.0, 0.0) + vm.AppendBond(0, 1, 1) + mol = _boundary.vtk_molecule_to_molecule(vm) + assert isinstance(mol, Molecule) + assert mol.num_atoms == 2 + assert mol.num_bonds == 1 diff --git a/tests/python/clear_volume_test.py b/tests/python/clear_volume_test.py new file mode 100644 index 000000000..9f4441c4c --- /dev/null +++ b/tests/python/clear_volume_test.py @@ -0,0 +1,32 @@ +import numpy as np + +from tomviz_pipeline import PortData +from tomviz_pipeline.dataset import Dataset +from tomviz_pipeline.nodes.transforms.legacy_python import ( + LegacyPythonTransform, +) + +from utils import OPERATOR_PATH + + +def _run_clear_volume(arr, **arguments): + t = LegacyPythonTransform() + t.deserialize({ + 'description': (OPERATOR_PATH / 'ClearVolume.json').read_text(), + 'script': (OPERATOR_PATH / 'ClearVolume.py').read_text(), + 'arguments': arguments, + }) + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + result = t.transform({'volume': PortData(ds, 'ImageData')}) + return result[t.output_ports()[0].name].payload.active_scalars + + +def test_clear_volume_fills_the_box_and_keeps_the_shape(): + arr = np.ones((6, 5, 4)) + out = _run_clear_volume(arr, XRANGE=[1, 3], YRANGE=[0, 5], + ZRANGE=[2, 4], fill_value=-1.0) + assert out.shape == arr.shape + assert np.all(out[1:3, :, 2:4] == -1.0) + assert np.all(out[0] == 1.0) and np.all(out[3:] == 1.0) + assert np.all(out[1:3, :, :2] == 1.0) diff --git a/tests/python/conftest.py b/tests/python/conftest.py index e3548905e..473c5e3c5 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_pipeline.io.emd import load_dataset +from tomviz_pipeline.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..cfbe66a34 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_pipeline.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..2d7c10ef0 100644 --- a/tests/python/deconvolution_denoise_test.py +++ b/tests/python/deconvolution_denoise_test.py @@ -2,9 +2,9 @@ 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 +from tomviz_pipeline.dataset import Dataset def deep_copy_dataset(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/fourier_operators_test.py b/tests/python/fourier_operators_test.py new file mode 100644 index 000000000..5f749ca15 --- /dev/null +++ b/tests/python/fourier_operators_test.py @@ -0,0 +1,273 @@ +import numpy as np +import pytest + +from tomviz_pipeline import PortData +from tomviz_pipeline.dataset import Dataset +from tomviz_pipeline.nodes.transforms.legacy_python import ( + LegacyPythonTransform, +) +from tomviz_pipeline.nodes.transforms.python_transform import ( + PythonTransform, +) + +from utils import OPERATOR_PATH + + +# Schema-v2 operators (kernel classes) run through PythonTransform +V2_OPERATORS = {'FourierPeakMask'} + + +def _run_operator_raw(name, inputs, **arguments): + t = PythonTransform() if name in V2_OPERATORS else LegacyPythonTransform() + t.deserialize({ + 'description': (OPERATOR_PATH / f'{name}.json').read_text(), + 'script': (OPERATOR_PATH / f'{name}.py').read_text(), + 'arguments': arguments, + }) + ports = {} + for port, arr in inputs.items(): + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, + 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + ports[port] = PortData(ds, 'ImageData') + return t, t.transform(ports) + + +def _run_operator(name, inputs, **arguments): + t, result = _run_operator_raw(name, inputs, **arguments) + return result[t.output_ports()[0].name].payload.active_scalars + + +def _run_filter(arr, **arguments): + return _run_operator('FourierFilter', {'volume': arr}, **arguments) + + +def _run_peak_mask(arr, **arguments): + # Typed centers unless a test opts into auto-detection + arguments.setdefault('auto_detect', False) + return _run_operator('FourierPeakMask', {'volume': arr}, **arguments) + + +def _run_fourier_mask(data, mask, **arguments): + return _run_operator('FourierMask', {'volume': data, 'mask': mask}, + **arguments) + + +def _run_image_math(a, b, **arguments): + return _run_operator('ImageMath', {'volume': a, 'second_dataset': b}, + **arguments) + + +N = 32 + + +def _wave(k): + # A cosine with integer frequency vector k: two spectral deltas at + # the fftshift-ed DC bin +/- k. + x, y, z = np.mgrid[0:N, 0:N, 0:N].astype(np.float64) + return np.cos(2 * np.pi * (k[0] * x + k[1] * y + k[2] * z) / N) + + +def _corr(a, b): + a = a - a.mean() + b = b - b.mean() + return float((a * b).sum() / np.sqrt((a * a).sum() * (b * b).sum())) + + +def test_low_pass_keeps_low_and_removes_high(): + low = _wave((2, 0, 0)) + high = _wave((0, 12, 0)) + out = _run_filter(low + high, filter_type=0, cutoff=6.0, width=0.0) + assert np.abs(out - low).max() < 1e-6 + # A soft edge between the two frequencies separates them just as well + out = _run_filter(low + high, filter_type=0, cutoff=6.0, width=2.0) + assert _corr(out, low) > 0.99 + # NaNs in the input must not poison the transform + noisy = low.copy() + noisy[0, 0, 0] = np.nan + assert np.isfinite(_run_filter(noisy, filter_type=0, cutoff=6.0, + width=0.0)).all() + + +def test_high_pass_is_the_complement(): + data = _wave((2, 0, 0)) + _wave((0, 12, 0)) + 3.0 + lp = _run_filter(data, filter_type=0, cutoff=6.0, width=0.0) + hp = _run_filter(data, filter_type=1, cutoff=6.0, width=0.0) + assert np.abs((lp + hp) - data).max() < 1e-5 + + +def test_band_pass_keeps_only_the_shell(): + inner = _wave((2, 0, 0)) + shell = _wave((0, 12, 0)) + outer = _wave((15, 8, 0)) + for profile in range(4): # Gaussian, Hann, Rect, Erf + out = _run_filter(inner + shell + outer, filter_type=2, cutoff=12.0, + width=3.0, band_profile=profile) + assert _corr(out, shell) > 0.98, f'profile {profile}' + + +def test_2d_per_slice_mode_leaves_axis_variation_alone(): + # A signal varying only along z is each slice's DC: a 2D low pass + # over x-y slices must keep it exactly. + along_z = _wave((0, 0, 5)) + out = _run_filter(along_z + _wave((12, 0, 0)), filter_type=0, cutoff=4.0, + width=0.0, dimensionality=1, slice_axis=2) + assert np.abs(out - along_z).max() < 1e-6 + + +def _peak_index(k): + return tuple(N // 2 + c for c in k) + + +def test_peak_mask_isolates_one_component(): + k1, k2 = (5, 0, 0), (0, 9, 3) + comp1, comp2 = _wave(k1), _wave(k2) + center = ', '.join(str(c) for c in _peak_index(k1)) + out = _run_peak_mask(comp1 + comp2, centers=center, radius=3.0, + sigma=0.5) + assert _corr(out, comp1) > 0.99 + assert abs(_corr(out, comp2)) < 0.05 + + # Without the Friedel mate only half the amplitude survives + one_sided = _run_peak_mask(comp1, centers=center, radius=3.0, sigma=0.5, + include_friedel_mates=False) + both = _run_peak_mask(comp1, centers=center, radius=3.0, sigma=0.5) + assert 0.4 < one_sided.std() / both.std() < 0.6 + + +def test_peak_mask_centers_from_csv(tmp_path): + k1, k2 = (5, 0, 0), (0, 9, 3) + csv = tmp_path / 'centers.csv' + csv.write_text('x,y,z\n' + ','.join(str(c) for c in _peak_index(k1)) + + '\n') + out = _run_peak_mask(_wave(k1) + _wave(k2), centers_file=str(csv), + radius=3.0, sigma=0.5) + assert _corr(out, _wave(k1)) > 0.99 + + +def test_peak_mask_wraps_friedel_mates_on_the_nyquist_plane(): + # k_x = N/2 sits at index 0 after fftshift, and its mate wraps back to + # index 0 rather than N + k = (N // 2, 5, 0) + wave = _wave(k) + peak = ', '.join(str(c % N) for c in _peak_index(k)) + out = _run_peak_mask(wave, centers=peak, radius=3.0, sigma=0.5) + assert _corr(out, wave) > 0.99 + assert 0.9 < out.std() / wave.std() < 1.1 + + # Auto-detection keeps one peak of the pair + t, _ = _run_operator_raw('FourierPeakMask', {'volume': wave}, + auto_detect=True, threshold=0.8, + min_peak_size=1, radius=3.0, sigma=0.5) + assert len(t.parameters['centers'].split(';')) == 1 + + +def test_peak_mask_rejects_bad_centers(): + # A raising operator surfaces as an empty (failed) result + _, result = _run_operator_raw('FourierPeakMask', + {'volume': _wave((5, 0, 0))}, + auto_detect=False) + assert result == {} + _, result = _run_operator_raw('FourierPeakMask', + {'volume': _wave((5, 0, 0))}, + auto_detect=False, centers='999, 0, 0') + assert result == {} + + +def test_peak_mask_auto_detects_peaks_and_writes_them_back(): + k1, k2 = (5, 0, 0), (0, 9, 3) + strong, weak = 2.0 * _wave(k1), 0.5 * _wave(k2) + data = strong + weak + 1.0 # the DC term must be skipped + t, result = _run_operator_raw('FourierPeakMask', {'volume': data}, + auto_detect=True, threshold=0.8, + exclude_center_radius=3.0, + min_peak_size=1, radius=2.0, sigma=0.5) + out = result[t.output_ports()[0].name].payload.active_scalars + # Both components survive, the DC offset does not + assert _corr(out, strong + weak) > 0.99 + assert abs(out.mean()) < 1e-3 + # The peaks found land in the centers parameter, strongest first, + # one per Friedel pair + centers = t.parameters['centers'] + found = [tuple(int(v) for v in c.split(',')) for c in centers.split(';')] + assert len(found) == 2 + assert found[0] in (_peak_index(k1), _peak_index((-5, 0, 0))) + assert found[1] in (_peak_index(k2), _peak_index((0, -9, -3))) + + # Max Peaks keeps only the strongest pair + out = _run_peak_mask(data, auto_detect=True, threshold=0.8, + exclude_center_radius=3.0, min_peak_size=1, + max_peaks=1, radius=2.0, sigma=0.5) + assert _corr(out, strong) > 0.99 + assert abs(_corr(out, weak)) < 0.05 + + # Nothing above the threshold fails the run instead of returning zeros + _, result = _run_operator_raw('FourierPeakMask', {'volume': data}, + auto_detect=True, threshold=1.0) + assert result == {} + + +def test_image_math_operations(): + a = np.full((4, 4, 4), 6.0) + b = np.full((4, 4, 4), 2.0) + assert np.allclose(_run_image_math(a, b, operation=0), 4.0) + assert np.allclose(_run_image_math(a, b, operation=1), 8.0) + assert np.allclose(_run_image_math(a, b, operation=2), 12.0) + assert np.allclose(_run_image_math(a, b, operation=3), 3.0) + # Normalizing to unit mean first makes the difference vanish + assert np.allclose( + _run_image_math(a, b, operation=0, normalize_first=True), 0.0) + + +def test_image_math_guards(): + a = np.full((4, 4, 4), 6.0) + # Division by zero yields zero, not inf + b = np.zeros((4, 4, 4)) + b[0, 0, 0] = 2.0 + out = _run_image_math(a, b, operation=3) + assert out[0, 0, 0] == 3.0 and np.count_nonzero(out) == 1 + # Shape mismatches fail the run + _, result = _run_operator_raw( + 'ImageMath', {'volume': a, 'second_dataset': np.zeros((4, 4, 5))}) + assert result == {} + + +def test_image_math_resamples_a_smaller_second_dataset(): + a = np.full((8, 8, 8), 5.0) + b = np.full((4, 4, 4), 2.0) + with pytest.raises(Exception): + _run_image_math(a, b, operation=1) + out = _run_image_math(a, b, operation=1, resample_to_match=True) + assert out.shape == a.shape + assert np.allclose(out, 7.0) + + +def test_fourier_mask_keeps_the_selected_component(): + # Two periodicities along x; a mask around one peak keeps only it + n = 32 + x = np.arange(n)[:, None, None] + data = (np.cos(2 * np.pi * 4 * x / n) + + np.cos(2 * np.pi * 10 * x / n)) * np.ones((n, n, n)) + mask = np.zeros((n, n, n), dtype=np.uint8) + mask[n // 2 + 4, n // 2, n // 2] = 1 # +4 cycles; the mate is added + out = _run_fourier_mask(data, mask, edge_softness=0.0) + expected = np.cos(2 * np.pi * 4 * x / n) * np.ones((n, n, n)) + assert np.allclose(out, expected, atol=1e-3) + inverted = _run_fourier_mask(data, mask, edge_softness=0.0, invert=True) + assert np.allclose(inverted, data - expected, atol=1e-3) + + +def test_fourier_mask_rejects_a_mismatched_or_empty_mask(): + data = np.ones((8, 8, 8)) + with pytest.raises(Exception): + _run_fourier_mask(data, np.ones((4, 4, 4), dtype=np.uint8)) + with pytest.raises(Exception): + _run_fourier_mask(data, np.zeros((8, 8, 8), dtype=np.uint8)) + + +def test_image_math_pads_an_off_by_one_second_dataset(): + a = np.full((8, 8, 8), 5.0) + b = np.full((8, 9, 7), 2.0) + out = _run_image_math(a, b, operation=1, resample_to_match=True) + assert out.shape == a.shape + assert np.allclose(out, 7.0) 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/morphology_operators_test.py b/tests/python/morphology_operators_test.py new file mode 100644 index 000000000..5ca764b76 --- /dev/null +++ b/tests/python/morphology_operators_test.py @@ -0,0 +1,220 @@ +import numpy as np +import pytest + +from tomviz_pipeline import PortData +from tomviz_pipeline.dataset import Dataset +from tomviz_pipeline.nodes.transforms.legacy_python import ( + LegacyPythonTransform, +) + +from utils import OPERATOR_PATH + + +def _run(name, arr, port_type='LabelMap', spacing=(1.0, 1.0, 1.0), + **arguments): + t = LegacyPythonTransform() + t.deserialize({ + 'description': (OPERATOR_PATH / f'{name}.json').read_text(), + 'script': (OPERATOR_PATH / f'{name}.py').read_text(), + 'arguments': arguments, + }) + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, 'ImageScalars') + ds.spacing = spacing + result = t.transform({'volume': PortData(ds, port_type)}) + return result[t.output_ports()[0].name].payload.active_scalars + + +def _cube(size=12, lo=4, hi=8): + arr = np.zeros((size, size, size), dtype=np.uint8) + arr[lo:hi, lo:hi, lo:hi] = 1 + return arr + + +# --- Dilate / Erode --- + +def test_box_dilation_grows_by_the_radius(): + out = _run('BinaryDilate', _cube(), structuring_element_id=0, radius=1) + assert out.dtype == np.uint8 + assert out.sum() == 6 ** 3 + assert np.all(out[3:9, 3:9, 3:9] == 1) + + +def test_ball_dilation_is_smaller_than_box_dilation(): + box = _run('BinaryDilate', _cube(), structuring_element_id=0, radius=2) + ball = _run('BinaryDilate', _cube(), structuring_element_id=1, radius=2) + cross = _run('BinaryDilate', _cube(), structuring_element_id=2, radius=2) + assert cross.sum() < ball.sum() < box.sum() + # The ball rounds the corners off + assert box[2, 2, 2] == 1 and ball[2, 2, 2] == 0 + + +def test_erosion_shrinks_and_removes_thin_parts(): + arr = _cube() + arr[2, 6, 6] = 1 # an isolated voxel + out = _run('BinaryErode', arr, radius=1) + assert out.sum() == 2 ** 3 + assert np.all(out[5:7, 5:7, 5:7] == 1) + assert out[2, 6, 6] == 0 + + +def test_objects_touching_the_edge_keep_their_edge(): + arr = np.zeros((8, 8, 8), dtype=np.uint8) + arr[0:4, 2:6, 2:6] = 1 + out = _run('BinaryErode', arr, radius=1) + # Eroded from the free faces only, as ITK does by default + assert np.all(out[0:3, 3:5, 3:5] == 1) + assert out.sum() == 3 * 2 * 2 + + +def test_other_labels_are_overwritten_only_where_reached(): + arr = _cube() + arr[0, 0, 0] = 7 + arr[8, 8, 8] = 7 # touches the object, so the dilation takes it + out = _run('BinaryDilate', arr, radius=1, object_label=1, + background_label=0) + assert out[0, 0, 0] == 7 and out[8, 8, 8] == 1 + out = _run('BinaryErode', arr, radius=1, object_label=7, + background_label=0) + # Label 7 voxels are isolated, so they erode away; label 1 untouched + assert out[0, 0, 0] == 0 and out[8, 8, 8] == 0 + assert np.array_equal(out[4:8, 4:8, 4:8], np.ones((4, 4, 4))) + + +# --- Open / Close --- + +def test_opening_removes_specks_and_keeps_the_body(): + arr = _cube() + arr[1, 1, 1] = 1 + out = _run('BinaryOpen', arr, radius=1) + assert out[1, 1, 1] == 0 + assert np.array_equal(out, _cube()) + + +def test_closing_fills_a_hole(): + arr = _cube(size=14, lo=2, hi=12) + arr[7, 7, 7] = 0 + out = _run('BinaryClose', arr, radius=1) + assert out[7, 7, 7] == 1 + assert np.array_equal(out, _cube(size=14, lo=2, hi=12)) + + +# --- ITK equivalence --- + +@pytest.mark.parametrize('shape', [0, 1, 2]) +def test_dilate_and_erode_match_itk_when_available(shape): + itk = pytest.importorskip('itk') + rng = np.random.default_rng(3) + arr = (rng.random((14, 13, 12)) < 0.3).astype(np.uint8) + arr[rng.random(arr.shape) < 0.05] = 2 # a third label, left alone + + kernel_type = itk.FlatStructuringElement[3] + kernel = [kernel_type.Box, kernel_type.Ball, kernel_type.Cross][shape](2) + + def reference(filter_class, value_setter): + image = itk.GetImageViewFromArray(np.ascontiguousarray(arr)) + f = filter_class[type(image), type(image), kernel_type].New() + value_setter(f) + f.SetBackgroundValue(0) + f.SetKernel(kernel) + f.SetInput(image) + f.Update() + return itk.GetArrayFromImage(f.GetOutput()) + + dilated = _run('BinaryDilate', arr, structuring_element_id=shape, + radius=2) + assert np.array_equal( + dilated, reference(itk.BinaryDilateImageFilter, + lambda f: f.SetDilateValue(1))) + eroded = _run('BinaryErode', arr, structuring_element_id=shape, radius=2) + assert np.array_equal( + eroded, reference(itk.BinaryErodeImageFilter, + lambda f: f.SetErodeValue(1))) + + # Opening and closing are the two composed, in either order + def compose(first, first_value, second, second_value, data): + image = itk.GetImageViewFromArray(np.ascontiguousarray(data)) + a = first[type(image), type(image), kernel_type].New() + first_value(a) + a.SetBackgroundValue(0) + a.SetKernel(kernel) + a.SetInput(image) + b = second[type(image), type(image), kernel_type].New() + second_value(b) + b.SetBackgroundValue(0) + b.SetKernel(kernel) + b.SetInput(a.GetOutput()) + b.Update() + return itk.GetArrayFromImage(b.GetOutput()) + + opened = _run('BinaryOpen', arr, structuring_element_id=shape, radius=2) + assert np.array_equal( + opened, compose(itk.BinaryErodeImageFilter, + lambda f: f.SetErodeValue(1), + itk.BinaryDilateImageFilter, + lambda f: f.SetDilateValue(1), arr)) + closed = _run('BinaryClose', arr, structuring_element_id=shape, radius=2) + assert np.array_equal( + closed, compose(itk.BinaryDilateImageFilter, + lambda f: f.SetDilateValue(1), + itk.BinaryErodeImageFilter, + lambda f: f.SetErodeValue(1), arr)) + + +# --- Unsharp Mask --- + +def _blurry_step(): + x = np.linspace(-3, 3, 24) + step = 1.0 / (1.0 + np.exp(-2 * x)) + return np.broadcast_to(step, (8, 8, 24)).astype(np.float32).copy() + + +def test_unsharp_mask_sharpens_an_edge_and_amount_zero_is_identity(): + arr = _blurry_step() + same = _run('UnsharpMask', arr, 'ImageData', amount=0.0, sigma=1.0) + assert np.allclose(same, arr, atol=1e-6) + sharp = _run('UnsharpMask', arr, 'ImageData', amount=1.0, sigma=1.0) + assert sharp.dtype == np.float32 + # Steeper at the edge, with over- and undershoot either side + assert sharp[4, 4, 13] - sharp[4, 4, 10] > arr[4, 4, 13] - arr[4, 4, 10] + assert sharp.max() > arr.max() and sharp.min() < arr.min() + + +def test_unsharp_mask_threshold_leaves_small_differences_alone(): + arr = _blurry_step() + out = _run('UnsharpMask', arr, 'ImageData', amount=1.0, sigma=1.0, + threshold=1000.0) + assert np.allclose(out, arr, atol=1e-6) + + +def test_unsharp_mask_clamps_integer_data(): + arr = (_blurry_step() * 255).astype(np.uint8) + out = _run('UnsharpMask', arr, 'ImageData', amount=2.0, sigma=1.5) + assert out.dtype == np.uint8 + assert out.max() == 255 and out.min() == 0 + + +def test_unsharp_mask_sigma_is_in_physical_units(): + arr = _blurry_step() + fine = _run('UnsharpMask', arr, 'ImageData', amount=1.0, sigma=1.0, + spacing=(1.0, 1.0, 1.0)) + coarse = _run('UnsharpMask', arr, 'ImageData', amount=1.0, sigma=1.0, + spacing=(2.0, 2.0, 2.0)) + # Twice the spacing halves the blur in voxels, so less sharpening + assert np.abs(coarse - arr).max() < np.abs(fine - arr).max() + + +def test_unsharp_mask_agrees_with_itk_when_available(): + itk = pytest.importorskip('itk') + arr = _blurry_step() + out = _run('UnsharpMask', arr, 'ImageData', amount=0.8, sigma=2.0) + + image = itk.GetImageViewFromArray(np.ascontiguousarray(arr)) + f = itk.UnsharpMaskImageFilter.New(Input=image) + f.SetAmount(0.8) + f.SetThreshold(0.0) + f.SetSigma(2.0) + f.Update() + reference = itk.GetArrayFromImage(f.GetOutput()) + # ITK blurs with a recursive approximation of the Gaussian; the two + # agree to within a few percent of the sharpening they add + assert np.abs(out - reference).max() < 0.05 * np.abs(out - arr).max() diff --git a/tests/python/multi_array_test.py b/tests/python/multi_array_test.py index b9ed035f0..17f64eb89 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_pipeline.dataset import LegacyDataset as Dataset def test_multi_arrays(hxn_xrf_example_dataset: Dataset): diff --git a/tests/python/normalize_test.py b/tests/python/normalize_test.py index 5b0bc379c..a152f7a9c 100644 --- a/tests/python/normalize_test.py +++ b/tests/python/normalize_test.py @@ -3,7 +3,7 @@ from utils import load_operator_module -from tomviz.external_dataset import Dataset +from tomviz_pipeline.dataset import Dataset def _make_dataset(data): diff --git a/tests/python/operator_test.py b/tests/python/operator_test.py index a599615d3..cf3e29c28 100644 --- a/tests/python/operator_test.py +++ b/tests/python/operator_test.py @@ -3,23 +3,17 @@ import pytest import unittest -from unittest import mock # Add fixtures to sys.path sys.path.append(os.path.join(os.path.dirname(__file__), 'fixtures')) # noqa import simple import two -import cancelable import function -import tomviz -# Mock out the wrapping as the library requires symbols in tomviz -tomviz._wrapping = mock.MagicMock() -sys.modules['tomviz._wrapping'] = mock.MagicMock() from tomviz._internal import find_operator_class # noqa from tomviz._internal import find_transform_from_module # noqa -from tomviz._internal import find_transform_function, is_cancelable # noqa +from tomviz._internal import find_transform_function # noqa from tomviz._internal import find_operators # noqa @@ -47,30 +41,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) - - -def test_is_cancelable(): - # Nope - assert not is_cancelable(simple) - # Nope - assert not is_cancelable(function) - # Yes - assert is_cancelable(cancelable) - - with pytest.raises(Exception): - is_cancelable(unittest) + find_transform_function(unittest) def test_find_operators(): @@ -78,38 +60,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/periodic_sources_test.py b/tests/python/periodic_sources_test.py new file mode 100644 index 000000000..755af57d4 --- /dev/null +++ b/tests/python/periodic_sources_test.py @@ -0,0 +1,225 @@ +"""The ptycho and pyxrf sources absorb newly arrived scans into their +own parameters (self.set_parameter) during the auto-execute check.""" +import importlib.util +import json +from pathlib import Path + +import numpy as np + +from utils import OPERATOR_PATH + +SIMULATION_PATH = Path(__file__).parents[1] / 'simulation' + + +def _load_module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _load_source(name): + module = _load_module(OPERATOR_PATH / f'{name}.py', name) + spec = json.loads((OPERATOR_PATH / f'{name}.json').read_text()) + return module, {p['name']: p for p in spec['parameters']} + + +def _make_kernel(cls, spec): + # The host injects the declared spec before calling the hook, which + # turns set_parameter validation on. + kernel = cls() + kernel._parameter_spec = spec + return kernel + + +def _ptycho_sim(): + return _load_module(SIMULATION_PATH / 'simulate_ptycho_stream.py', + 'ptycho_sim') + + +def _ptycho_params(root, sids, versions, angles, use=None): + use = use if use is not None else [True] * len(sids) + ui_state = { + 'filter_sids_string': '', + 'csv_file': '', + 'full_sid_list': list(sids), + 'full_version_list': list(versions), + 'full_use_list': list(use), + } + used = [i for i, u in enumerate(use) if u] + return { + 'ptycho_dir': str(root), + 'output_info_file': '', + 'rotate_datasets': True, + 'sid_list': json.dumps([sids[i] for i in used]), + 'version_list': json.dumps([versions[i] for i in used]), + 'angle_list': json.dumps([angles[i] for i in used]), + 'ui_state': json.dumps(ui_state), + } + + +def test_ptycho_absorbs_new_scans(tmp_path): + sim = _ptycho_sim() + mod, spec = _load_source('PtychoSource') + rng = np.random.default_rng(0) + + sim.write_scan(tmp_path, 50001, -60.0, sim.BASE_SHAPE, rng) + sim.write_scan(tmp_path, 50002, -58.0, sim.BASE_SHAPE, rng) + + kernel = _make_kernel(mod.PtychoSource, spec) + # Scan 50002 was deselected by the user; that choice must survive. + params = _ptycho_params(tmp_path, [50001, 50002], ['t1', 't1'], + [-60.0, -58.0], use=[True, False]) + + # First check records the baseline + assert kernel.should_auto_execute(**params) is False + assert kernel._parameter_updates == {} + + sim.write_scan(tmp_path, 50003, -56.0, sim.BASE_SHAPE, rng) + assert kernel.should_auto_execute(**params) is True + + updates = kernel._parameter_updates + assert json.loads(updates['sid_list']) == [50001, 50003] + assert json.loads(updates['version_list']) == ['t1', 't1'] + assert abs(json.loads(updates['angle_list'])[1] + 56.0) < 1e-6 + + ui_state = json.loads(updates['ui_state']) + assert ui_state['full_sid_list'] == [50001, 50002, 50003] + # 50002 stays deselected; the new scan arrives selected + assert ui_state['full_use_list'] == [True, False, True] + + # A quiet directory answers no and writes nothing + kernel._parameter_updates = {} + assert kernel.should_auto_execute(**params) is False + assert kernel._parameter_updates == {} + + +def test_ptycho_ignores_incomplete_scans(tmp_path): + sim = _ptycho_sim() + mod, spec = _load_source('PtychoSource') + rng = np.random.default_rng(0) + + sim.write_scan(tmp_path, 50001, -60.0, sim.BASE_SHAPE, rng) + kernel = _make_kernel(mod.PtychoSource, spec) + params = _ptycho_params(tmp_path, [50001], ['t1'], [-60.0]) + assert kernel.should_auto_execute(**params) is False + + # Object written but no probe yet: re-run, but don't absorb + recon = tmp_path / 'S50002' / 't1' / 'recon_data' + recon.mkdir(parents=True) + (recon / '50002_t1.txt').write_text('angle = -58.0\n') + np.save(recon / 'recon_50002_t1_object_ave.npy', + np.zeros((4, 4), np.complex64)) + assert kernel.should_auto_execute(**params) is True + assert 'sid_list' not in kernel._parameter_updates + + # The probe arrives: now it is absorbed + np.save(recon / 'recon_50002_t1_probe_ave.npy', + np.zeros((4, 4), np.complex64)) + assert kernel.should_auto_execute(**params) is True + assert json.loads(kernel._parameter_updates['sid_list']) == \ + [50001, 50002] + + +def test_ptycho_absorbs_a_scan_whose_config_lands_last(tmp_path): + sim = _ptycho_sim() + mod, spec = _load_source('PtychoSource') + rng = np.random.default_rng(0) + + sim.write_scan(tmp_path, 50001, -60.0, sim.BASE_SHAPE, rng) + kernel = _make_kernel(mod.PtychoSource, spec) + params = _ptycho_params(tmp_path, [50001], ['t1'], [-60.0]) + assert kernel.should_auto_execute(**params) is False + + # Both arrays first: a re-run, but nothing to absorb without an angle + recon = tmp_path / 'S50002' / 't1' / 'recon_data' + recon.mkdir(parents=True) + np.save(recon / 'recon_50002_t1_object_ave.npy', + np.zeros((4, 4), np.complex64)) + np.save(recon / 'recon_50002_t1_probe_ave.npy', + np.zeros((4, 4), np.complex64)) + assert kernel.should_auto_execute(**params) is True + assert 'sid_list' not in kernel._parameter_updates + + # The config alone arriving completes the scan and must be noticed + (recon / '50002_t1.txt').write_text('angle = -58.0\n') + assert kernel.should_auto_execute(**params) is True + assert json.loads(kernel._parameter_updates['sid_list']) == \ + [50001, 50002] + + +def test_ptycho_produce_includes_absorbed_scans(tmp_path): + sim = _ptycho_sim() + mod, spec = _load_source('PtychoSource') + rng = np.random.default_rng(0) + + sim.write_scan(tmp_path, 50001, -60.0, sim.BASE_SHAPE, rng) + sim.write_scan(tmp_path, 50002, -58.0, sim.BASE_SHAPE, rng) + kernel = _make_kernel(mod.PtychoSource, spec) + params = _ptycho_params(tmp_path, [50001, 50002], ['t1', 't1'], + [-60.0, -58.0]) + assert kernel.should_auto_execute(**params) is False + + sim.write_scan(tmp_path, 50003, -56.0, sim.BASE_SHAPE, rng) + assert kernel.should_auto_execute(**params) is True + + # The host installs the write-backs before the run + params.update(kernel._parameter_updates) + outputs = kernel.produce(**params) + assert sorted(outputs['object'].scan_ids.tolist()) == \ + [50001, 50002, 50003] + + +def test_ptycho_write_back_through_real_node(tmp_path): + """The full harvest chain with the real tomviz_pipeline node.""" + from tomviz_pipeline.nodes.sources.python_source import PythonSource + + sim = _ptycho_sim() + rng = np.random.default_rng(0) + sim.write_scan(tmp_path, 50001, -60.0, sim.BASE_SHAPE, rng) + + node = PythonSource() + assert node.deserialize({ + 'description': (OPERATOR_PATH / 'PtychoSource.json').read_text(), + 'script': (OPERATOR_PATH / 'PtychoSource.py').read_text(), + 'arguments': _ptycho_params(tmp_path, [50001], ['t1'], [-60.0]), + }) + assert node.query_should_auto_execute() is False + + sim.write_scan(tmp_path, 50002, -58.0, sim.BASE_SHAPE, rng) + assert node.query_should_auto_execute() is True + assert json.loads(node.parameters['sid_list']) == [50001, 50002] + assert json.loads(node.parameters['ui_state'])['full_sid_list'] == \ + [50001, 50002] + + +def _touch_scans(root, sids): + for sid in sids: + (root / f'scan2D_{sid}.h5').write_bytes(b'') + + +def test_grown_scan_range_grammar(tmp_path): + mod, _ = _load_source('PyXRFSource') + grow = mod._grown_scan_range + + _touch_scans(tmp_path, [100, 101, 102, 103, 105]) + assert grow('100:102', tmp_path) == '100:105' + assert grow('100:102:2', tmp_path) == '100:105:2' + assert grow('100', tmp_path) == '100, 101:105' + # Nothing beyond the range, or no usable range: no growth + assert grow('100:200', tmp_path) is None + assert grow('', tmp_path) is None + assert grow('junk', tmp_path) is None + + +def test_pyxrf_grows_range_on_new_scans(tmp_path): + mod, spec = _load_source('PyXRFSource') + _touch_scans(tmp_path, [100, 101]) + + kernel = _make_kernel(mod.PyXRFSource, spec) + params = {'working_directory': str(tmp_path), 'scan_range': '100:101'} + assert kernel.should_auto_execute(**params) is False + + _touch_scans(tmp_path, [102]) + assert kernel.should_auto_execute(**params) is True + assert kernel._parameter_updates == {'scan_range': '100:102'} diff --git a/tests/python/pystackreg_test.py b/tests/python/pystackreg_test.py index a2abf72ff..196458998 100644 --- a/tests/python/pystackreg_test.py +++ b/tests/python/pystackreg_test.py @@ -2,7 +2,7 @@ from utils import load_operator_module -from tomviz.external_dataset import Dataset +from tomviz_pipeline.dataset import Dataset def test_pystackreg(hxn_xrf_example_dataset: Dataset, diff --git a/tests/python/pyxrf_scan_metadata_test.py b/tests/python/pyxrf_scan_metadata_test.py new file mode 100644 index 000000000..d89cd088e --- /dev/null +++ b/tests/python/pyxrf_scan_metadata_test.py @@ -0,0 +1,34 @@ +"""The PyXRF dialog's scan table: which scans a range lists.""" +import importlib.util +from pathlib import Path + +import h5py +import pytest + +# Loaded from its file: importing the tomviz.pyxrf package pulls in the +# PyXRF stack, which this module does not need +_path = (Path(__file__).parents[2] / 'tomviz' / 'python' / 'tomviz' / + 'pyxrf' / 'scan_metadata.py') +_spec = importlib.util.spec_from_file_location('scan_metadata', _path) +scan_metadata = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(scan_metadata) + + +# Only an empty range lists the scans in the directory; a range that +# names no scans (a typo, say) lists nothing rather than every file +@pytest.mark.parametrize('scan_range, expected', [ + ('', [1, 2]), + (' ', [1, 2]), + ('abc', []), + ('5:1', []), +]) +def test_only_an_empty_range_lists_the_scans_present(tmp_path, scan_range, + expected): + for scan_id in (1, 2): + with h5py.File(tmp_path / f'scan2D_{scan_id}.h5', 'w') as f: + md = f.create_group('xrfmap/scan_metadata') + md.attrs['scan_id'] = scan_id + md.attrs['param_theta'] = 0.0 + + rows = scan_metadata.read_scan_metadata(str(tmp_path), scan_range) + assert [row['scan_id'] for row in rows] == expected 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/remove_arrays_test.py b/tests/python/remove_arrays_test.py index 8bcd1066b..caee978c8 100644 --- a/tests/python/remove_arrays_test.py +++ b/tests/python/remove_arrays_test.py @@ -2,7 +2,7 @@ from utils import load_operator_module -from tomviz.external_dataset import Dataset +from tomviz_pipeline.dataset import Dataset def _make_dataset(num_arrays=3, shape=(10, 10, 5)): diff --git a/tests/python/remove_labels_test.py b/tests/python/remove_labels_test.py new file mode 100644 index 000000000..97b4190bd --- /dev/null +++ b/tests/python/remove_labels_test.py @@ -0,0 +1,74 @@ +import numpy as np +import pytest + +from tomviz_pipeline import PortData +from tomviz_pipeline.dataset import Dataset +from tomviz_pipeline.nodes.transforms.legacy_python import ( + LegacyPythonTransform, +) + +from utils import OPERATOR_PATH + + +def _remove(arr, **arguments): + t = LegacyPythonTransform() + t.deserialize({ + 'description': (OPERATOR_PATH / 'RemoveLabels.json').read_text(), + 'script': (OPERATOR_PATH / 'RemoveLabels.py').read_text(), + 'arguments': arguments, + }) + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + result = t.transform({'volume': PortData(ds, 'LabelMap')}) + return result[t.output_ports()[0].name].payload.active_scalars + + +def _labels(): + # Labels 0..7, one per z slice + return np.broadcast_to(np.arange(8, dtype=np.uint8), (3, 3, 8)).copy() + + +def test_listed_labels_go_to_the_background(): + out = _remove(_labels(), labels='2, 5') + assert out.dtype == np.uint8 + assert sorted(np.unique(out)) == [0, 1, 3, 4, 6, 7] + assert np.all(out[:, :, 2] == 0) and np.all(out[:, :, 5] == 0) + assert np.all(out[:, :, 3] == 3) + + +def test_ranges_and_loose_separators_are_accepted(): + out = _remove(_labels(), labels='1-3; 6 7') + assert sorted(np.unique(out)) == [0, 4, 5] + out = _remove(_labels(), labels='3 - 1') + assert sorted(np.unique(out)) == [0, 4, 5, 6, 7] + + +def test_an_empty_list_changes_nothing(): + arr = _labels() + assert np.array_equal(_remove(arr, labels=''), arr) + assert np.array_equal(_remove(arr, labels=' , '), arr) + + +def test_unreadable_entries_are_refused(): + with pytest.raises(Exception): + _remove(_labels(), labels='2, two') + + +def test_renumbering_closes_the_gaps_in_order(): + out = _remove(_labels(), labels='1, 4', renumber=True) + # 2, 3, 5, 6, 7 become 1..5 + assert sorted(np.unique(out)) == [0, 1, 2, 3, 4, 5] + assert np.all(out[:, :, 2] == 1) and np.all(out[:, :, 3] == 2) + assert np.all(out[:, :, 5] == 3) and np.all(out[:, :, 7] == 5) + + +def test_renumbering_narrows_the_label_type(): + arr = _labels().astype(np.uint16) * 1000 + out = _remove(arr, labels='7000', renumber=True) + assert out.dtype == np.uint8 + assert out.max() == 6 + + +def test_float_data_is_refused(): + with pytest.raises(Exception): + _remove(_labels().astype(np.float32), labels='1') diff --git a/tests/python/rotate3d_test.py b/tests/python/rotate3d_test.py new file mode 100644 index 000000000..6f2b9d5a9 --- /dev/null +++ b/tests/python/rotate3d_test.py @@ -0,0 +1,66 @@ +import numpy as np + +from tomviz_pipeline import PortData +from tomviz_pipeline.dataset import Dataset +from tomviz_pipeline.nodes.transforms.legacy_python import ( + LegacyPythonTransform, +) + +from utils import OPERATOR_PATH + + +def _run_rotate(arr, **arguments): + """Run the real Rotate operator from tomviz/python/ over `arr`.""" + t = LegacyPythonTransform() + t.deserialize({ + 'description': (OPERATOR_PATH / 'Rotate3D.json').read_text(), + 'script': (OPERATOR_PATH / 'Rotate3D.py').read_text(), + 'arguments': arguments, + }) + ds = Dataset({'ImageScalars': arr}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + result = t.transform({'volume': PortData(ds, 'ImageData')}) + return result[t.output_ports()[0].name].payload.active_scalars + + +def _slab_with_marker(): + # tomviz arrays are (nx, ny, nz). Put a marker near the top in y. + arr = np.zeros((64, 64, 20), dtype=np.float32, order='F') + arr[8:56, 8:56, 4:16] = 1.0 + arr[28:36, 48:54, 8:12] = 5.0 + return arr + + +def _marker_center(arr): + return np.argwhere(arr > 3.0).mean(axis=0) + + +def test_rotate_in_plane_without_expand_keeps_shape(): + """An alignment rotation about Z should turn the data within the image + plane, leave the dimensions alone, and leave the marker at the top.""" + arr = _slab_with_marker() + before = _marker_center(arr) + out = _run_rotate(arr, rotation_angle=3.0, rotation_axis=2, expand=False) + + assert out.shape == arr.shape + after = _marker_center(out) + # Still near the top in y, and still in the same slices. + assert after[1] > arr.shape[1] * 0.6 + assert abs(after[2] - before[2]) < 1.0 + + +def test_rotate_expand_pads_the_perpendicular_axis(): + """Expanding grows the box to hold the rotated volume and zero-pads the + corners, so the leading slices of the grown axis are nearly empty. This + is why a small rotation can appear to blank the start of a dataset.""" + arr = _slab_with_marker() + out = _run_rotate(arr, rotation_angle=3.0, rotation_axis=0, expand=True) + + # Rotating about x tips y into z, so z grows by about ny * sin(3 deg). + assert out.shape[2] > arr.shape[2] + filled = [(out[:, :, k] > 0.01).mean() for k in range(out.shape[2])] + assert filled[0] < 0.5 * filled[out.shape[2] // 2] + + # Turning expansion off is what avoids that. + kept = _run_rotate(arr, rotation_angle=3.0, rotation_axis=0, expand=False) + assert kept.shape == arr.shape 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/segmentation_operators_test.py b/tests/python/segmentation_operators_test.py new file mode 100644 index 000000000..38d82b5b2 --- /dev/null +++ b/tests/python/segmentation_operators_test.py @@ -0,0 +1,188 @@ +import numpy as np +import pytest + +from tomviz_pipeline import PortData +from tomviz_pipeline.dataset import Dataset +from tomviz_pipeline.nodes.transforms.legacy_python import ( + LegacyPythonTransform, +) + +from utils import OPERATOR_PATH + + +def _run(name, arr, port_type='ImageData', **arguments): + t = LegacyPythonTransform() + t.deserialize({ + 'description': (OPERATOR_PATH / f'{name}.json').read_text(), + 'script': (OPERATOR_PATH / f'{name}.py').read_text(), + 'arguments': arguments, + }) + ds = Dataset({'ImageScalars': np.asfortranarray(arr)}, 'ImageScalars') + ds.spacing = (1.0, 1.0, 1.0) + result = t.transform({'volume': PortData(ds, port_type)}) + return result[t.output_ports()[0].name].payload.active_scalars + + +def _threshold(arr, **arguments): + return _run('BinaryThreshold', arr, **arguments) + + +def _components(arr, **arguments): + return _run('ConnectedComponents', arr, 'LabelMap', **arguments) + + +# --- Binary Threshold --- + +def test_threshold_keeps_the_closed_interval_as_uint8(): + arr = np.arange(27, dtype=np.float32).reshape(3, 3, 3) + out = _threshold(arr, lower_threshold=5.0, upper_threshold=9.0) + assert out.dtype == np.uint8 + assert out.shape == arr.shape + expected = ((arr >= 5) & (arr <= 9)).astype(np.uint8) + assert np.array_equal(out, expected) + + +def test_threshold_rounds_fractional_thresholds_inward_for_integers(): + arr = np.arange(27, dtype=np.int16).reshape(3, 3, 3) + out = _threshold(arr, lower_threshold=4.2, upper_threshold=8.9) + # 4.2 rounds up to 5 and 8.9 rounds down to 8 + expected = ((arr >= 5) & (arr <= 8)).astype(np.uint8) + assert np.array_equal(out, expected) + + +def test_threshold_refuses_an_empty_integer_interval(): + arr = np.arange(27, dtype=np.uint8).reshape(3, 3, 3) + with pytest.raises(Exception): + _threshold(arr, lower_threshold=4.2, upper_threshold=4.8) + + +def test_threshold_sends_nan_to_the_background(): + arr = np.ones((2, 2, 2), dtype=np.float32) + arr[0, 0, 0] = np.nan + out = _threshold(arr, lower_threshold=0.0, upper_threshold=2.0) + assert out[0, 0, 0] == 0 + assert out.sum() == 7 + + +def test_threshold_matches_itk_when_available(): + itk = pytest.importorskip('itk') + rng = np.random.default_rng(1) + arr = (rng.random((12, 11, 10)) * 255).astype(np.float32) + out = _threshold(arr, lower_threshold=40.0, upper_threshold=200.0) + + image = itk.GetImageViewFromArray(np.ascontiguousarray(arr)) + f = itk.BinaryThresholdImageFilter[type(image), itk.Image.UC3].New() + f.SetLowerThreshold(40.0) + f.SetUpperThreshold(200.0) + f.SetInsideValue(1) + f.SetOutsideValue(0) + f.SetInput(image) + f.Update() + reference = itk.GetArrayFromImage(f.GetOutput()) + assert np.array_equal(out, reference) + + +# --- Connected Components --- + +def _two_blobs(): + arr = np.zeros((10, 10, 10), dtype=np.uint8) + arr[1:3, 1:3, 1:3] = 1 # 8 voxels + arr[5:9, 5:9, 5:9] = 1 # 64 voxels + return arr + + +def test_components_number_by_size_smallest_first(): + out = _components(_two_blobs()) + assert out.dtype == np.uint8 + assert out.max() == 2 + assert np.all(out[1:3, 1:3, 1:3] == 1) + assert np.all(out[5:9, 5:9, 5:9] == 2) + assert out.sum() == 8 * 1 + 64 * 2 + + +def test_components_treat_every_non_background_value_as_foreground(): + arr = _two_blobs() + arr[1:3, 1:3, 1:3] = 7 + out = _components(arr) + assert out.max() == 2 + # A different background value flips what is foreground + out = _components(arr, background_value=7) + assert out.max() == 1 + assert out[1, 1, 1] == 0 and out[0, 0, 0] == 1 + + +def test_components_connectivity_decides_whether_corners_touch(): + arr = np.zeros((4, 4, 4), dtype=np.uint8) + arr[1, 1, 1] = 1 + arr[2, 2, 2] = 1 # touches the first only at a corner + assert _components(arr, connectivity=1).max() == 2 + assert _components(arr, connectivity=2).max() == 2 + assert _components(arr, connectivity=3).max() == 1 + arr[2, 2, 1] = 1 # bridges the two along an edge + assert _components(arr, connectivity=1).max() == 2 + assert _components(arr, connectivity=2).max() == 1 + + +def test_components_drop_specks_below_the_minimum_size(): + arr = _two_blobs() + arr[0, 9, 9] = 1 # a single stray voxel + out = _components(arr) + assert out.max() == 3 and out[0, 9, 9] == 1 + out = _components(arr, min_size=2) + assert out.max() == 2 and out[0, 9, 9] == 0 + out = _components(arr, min_size=9) + assert out.max() == 1 and np.all(out[1:3, 1:3, 1:3] == 0) + assert np.all(out[5:9, 5:9, 5:9] == 1) + + +def test_components_widen_the_label_type_when_needed(): + # A lattice of isolated voxels: 5 * 6 * 10 = 300 components + arr = np.zeros((10, 12, 20), dtype=np.uint8) + arr[::2, ::2, ::2] = 1 + out = _components(arr) + assert out.max() == 300 + assert out.dtype == np.uint16 + + +def test_components_accept_a_float_mask(): + arr = _two_blobs().astype(np.float32) + out = _components(arr) + assert out.max() == 2 + + +def test_components_of_an_empty_volume_are_all_background(): + out = _components(np.zeros((3, 3, 3), dtype=np.uint8)) + assert out.dtype == np.uint8 + assert not out.any() + + +def test_components_partition_matches_itk_when_available(): + itk = pytest.importorskip('itk') + rng = np.random.default_rng(2) + arr = (rng.random((16, 15, 14)) < 0.3).astype(np.uint8) + out = _components(arr) + + image = itk.GetImageViewFromArray(np.ascontiguousarray( + arr.astype(np.uint16))) + T = type(image) + f = itk.ConnectedComponentImageFilter[T, T].New() + f.SetInput(image) + f.SetBackgroundValue(0) + r = itk.RelabelComponentImageFilter[T, T].New() + r.SetInput(f.GetOutput()) + r.SortByObjectSizeOn() + r.Update() + reference = itk.GetArrayFromImage(r.GetOutput()) + + # Same foreground and the same number of components + assert np.array_equal(out != 0, reference != 0) + assert out.max() == reference.max() + # Every component is the same set of voxels in both labelings + ours = {frozenset(np.flatnonzero(out == label)) + for label in range(1, out.max() + 1)} + theirs = {frozenset(np.flatnonzero(reference == label)) + for label in range(1, reference.max() + 1)} + assert ours == theirs + # Ordering by size, largest label = largest component + sizes = np.bincount(out.ravel())[1:] + assert np.all(np.diff(sizes) >= 0) diff --git a/tests/python/shift_rotation_center_test.py b/tests/python/shift_rotation_center_test.py index c7993f866..a298a6ae6 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_pipeline.dataset import LegacyDataset as Dataset try: import tomopy diff --git a/tests/python/simulated_live_test.py b/tests/python/simulated_live_test.py new file mode 100644 index 000000000..83d01910f --- /dev/null +++ b/tests/python/simulated_live_test.py @@ -0,0 +1,140 @@ +"""The Simulated Live Acquisition sample source delivers projections as +wall-clock time passes and asks for a re-run only when new ones exist.""" +import importlib.util +import json + +import numpy as np + +from utils import OPERATOR_PATH + +NAME = 'SimulatedLiveAcquisition' + + +def _load(): + spec = importlib.util.spec_from_file_location(NAME, + OPERATOR_PATH / f'{NAME}.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + description = json.loads((OPERATOR_PATH / f'{NAME}.json').read_text()) + return module, description + + +def _defaults(description, **overrides): + params = {p['name']: p['default'] for p in description['parameters']} + params.update(overrides) + return params + + +def _kernel(): + from tomviz_pipeline._internal import OperatorWrapper + + module, description = _load() + kernel = getattr(module, NAME)() + # What the runtime injects before calling the user's methods. + kernel._operator_wrapper = OperatorWrapper() + kernel._parameter_spec = {p['name']: p + for p in description['parameters']} + # A long period, so the tests move the clock instead of sleeping. + params = _defaults(description, size=16, num_projections=4, + seconds_per_projection=10.0) + return kernel, params + + +def _run(kernel, params): + """produce(), then install its write-backs the way the host does.""" + kernel._parameter_updates = {} + series = kernel.produce(**params)['tilt_series'] + params.update(kernel._parameter_updates) + return series + + +def _age(kernel, seconds): + kernel.state['started_at'] -= seconds + + +def test_first_run_starts_the_scan_with_one_projection(): + kernel, params = _kernel() + + # Nothing is running yet: the first run of produce starts the clock. + assert kernel.should_auto_execute(**params) is False + + series = _run(kernel, params) + assert series.active_scalars.shape[2] == 1 + assert series.tilt_angles.shape == (1,) + assert series.tilt_angles[0] == params['start_angle'] + assert params['acquired'] == 1 + + # The projection is delivered already, so no re-run is due. + assert kernel.should_auto_execute(**params) is False + + +def test_new_projections_request_a_rerun_and_arrive_in_order(): + kernel, params = _kernel() + _run(kernel, params) + + # Two periods later two more projections have been recorded. + _age(kernel, 25.0) + assert kernel.should_auto_execute(**params) is True + + series = _run(kernel, params) + assert series.active_scalars.shape[2] == 3 + assert params['acquired'] == 3 + expected = np.linspace(params['start_angle'], params['end_angle'], + params['num_projections'])[:3] + assert np.allclose(series.tilt_angles, expected) + # Every projection is of the same object, so their sums agree. + sums = series.active_scalars.sum(axis=(0, 1)) + assert np.allclose(sums, sums[0], rtol=0.05) + + +def test_a_loaded_scan_starts_again(): + """A state file keeps the acquired count but not self.state, so a + scan that was under way when it was saved starts over.""" + kernel, params = _kernel() + params['acquired'] = 3 + assert kernel.should_auto_execute(**params) is True + series = _run(kernel, params) + assert series.active_scalars.shape[2] == 1 + assert params['acquired'] == 1 + + +def test_a_finished_scan_stops_asking(): + kernel, params = _kernel() + _run(kernel, params) + + _age(kernel, 1000.0) + assert kernel.should_auto_execute(**params) is True + series = _run(kernel, params) + assert series.active_scalars.shape[2] == params['num_projections'] + + _age(kernel, 1000.0) + assert kernel.should_auto_execute(**params) is False + + +def test_real_node_round_trip(): + """The hook and the write-backs work through the tomviz_pipeline + node, the way the application and the CLI drive them.""" + from tomviz_pipeline.nodes.sources.python_source import PythonSource + + _, description = _load() + node = PythonSource() + assert node.deserialize({ + 'description': (OPERATOR_PATH / f'{NAME}.json').read_text(), + 'script': (OPERATOR_PATH / f'{NAME}.py').read_text(), + 'arguments': _defaults(description, size=16, num_projections=3, + seconds_per_projection=10.0), + }) + # A tilt series, so the reconstruction operators accept it + assert node.output_port('tilt_series').port_type == 'TiltSeries' + assert node.query_should_auto_execute() is False + assert node.execute() is True + assert node.parameters['acquired'] == 1 + assert node.query_should_auto_execute() is False + + node.user_state['started_at'] -= 100.0 + assert node.query_should_auto_execute() is True + assert node.execute() is True + assert node.parameters['acquired'] == 3 + data = node.output_port('tilt_series').data().payload + assert data.active_scalars.shape[2] == 3 + assert node.query_should_auto_execute() is False diff --git a/tests/python/tilt_axis_shift_test.py b/tests/python/tilt_axis_shift_test.py index 1d2eef16a..307e3a9e8 100644 --- a/tests/python/tilt_axis_shift_test.py +++ b/tests/python/tilt_axis_shift_test.py @@ -2,7 +2,7 @@ from utils import load_operator_class, load_operator_module -from tomviz.external_dataset import Dataset +from tomviz_pipeline.dataset import Dataset def test_tilt_axis_shift(hxn_xrf_example_dataset: Dataset, diff --git a/tests/python/utils.py b/tests/python/utils.py index 880d5e6e2..589388183 100644 --- a/tests/python/utils.py +++ b/tests/python/utils.py @@ -1,15 +1,18 @@ from pathlib import Path from types import ModuleType from typing import Callable +import http.client 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,25 +59,69 @@ def load_operator_class(operator_module: ModuleType) -> Operator | None: return operator -def download_file(url: str, destination: str): - filename = Path('downloaded_file') - if filename.exists(): - filename.unlink() - +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_to(url: str, filename: Path): + # A read timeout matters more than it looks: without one, a stalled + # connection hangs the test until CTest's own timeout kills it, so a + # blip on the data server turns into a 25 minute failure. + timeout = 60 + block_size = 1024 * 1024 last_progress = -1 - def download_progress_hook(block_num, block_size, total_size): - nonlocal last_progress - downloaded = block_num * block_size - downloaded_mb = downloaded / 1024 / 1024 - total_size_mb = total_size / 1024 / 1024 - progress = int((downloaded / total_size) * 100) if total_size > 0 else 0 - if progress > last_progress: - last_progress = progress - print(f"\rDownload Progress: {progress}% ({downloaded_mb:.2f}/{total_size_mb:.2f} MB)") + with urllib.request.urlopen(url, timeout=timeout) as response: + total_size = int(response.headers.get('Content-Length') or 0) + downloaded = 0 + with open(filename, 'wb') as wf: + while True: + block = response.read(block_size) + if not block: + break + wf.write(block) + downloaded += len(block) + downloaded_mb = downloaded / 1024 / 1024 + total_size_mb = total_size / 1024 / 1024 + progress = (int((downloaded / total_size) * 100) + if total_size > 0 else 0) + if progress > last_progress: + last_progress = progress + print(f"\rDownload Progress: {progress}% " + f"({downloaded_mb:.2f}/{total_size_mb:.2f} MB)") + + if total_size > 0 and downloaded != total_size: + raise http.client.IncompleteRead(b'', total_size - downloaded) + + +def download_file(url: str, destination: str): + filename = Path('downloaded_file') 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): + if filename.exists(): + filename.unlink() + + try: + _download_to(url, filename) + break + except (urllib.error.URLError, OSError, + http.client.HTTPException) 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/tests/python/xcorr_test.py b/tests/python/xcorr_test.py index c56ab2851..13c99ad66 100644 --- a/tests/python/xcorr_test.py +++ b/tests/python/xcorr_test.py @@ -2,7 +2,7 @@ from utils import load_operator_class, load_operator_module -from tomviz.external_dataset import Dataset +from tomviz_pipeline.dataset import Dataset def test_xcorr(hxn_xrf_example_dataset: Dataset, diff --git a/tests/simulation/simulate_ptycho_stream.py b/tests/simulation/simulate_ptycho_stream.py new file mode 100644 index 000000000..bd91cf17c --- /dev/null +++ b/tests/simulation/simulate_ptycho_stream.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Simulate a ptychography reconstruction stream. + +Every ``--interval`` seconds a new scan's reconstruction appears in the +output directory using the layout the tomviz ptycho source reads: + + S//recon_data/recon___object_ave.npy + S//recon_data/recon___probe_ave.npy + +Point a tomviz Ptycho source's directory here and enable Periodic +Execution on the node; each new scan changes the directory fingerprint +and triggers a re-run. The scan IDs and angles the widget needs are +printed as they are emitted (and written to ``angles.txt``). + +Some scans are emitted with a slightly different object shape to +exercise the stack-padding path. + +Example: + python simulate_ptycho_stream.py /tmp/ptycho-sim --interval 5 --count 20 +""" +from __future__ import annotations + +import argparse +import time +from pathlib import Path + +import numpy as np + +VERSION = 't1' +BASE_SHAPE = (140, 150) +PROBE_SHAPE = (32, 32) + + +def _object(theta_deg: float, shape: tuple[int, int], + rng: np.random.Generator) -> np.ndarray: + h, w = shape + y, x = np.mgrid[0:h, 0:w] + cx = w / 2 + (w / 6) * np.cos(np.radians(theta_deg)) + amp = 1.0 - 0.5 * np.exp(-(((x - cx) / 12) ** 2 + ((y - h / 2) / 15) ** 2)) + phase = 0.8 * np.exp(-(((x - cx) / 10) ** 2 + ((y - h / 2) / 12) ** 2)) + phase += rng.normal(0, 0.01, shape) + return (amp * np.exp(1j * phase)).astype(np.complex64) + + +def write_scan(root: Path, sid: int, theta: float, + shape: tuple[int, int], rng: np.random.Generator) -> None: + recon_dir = root / f'S{sid}' / VERSION / 'recon_data' + recon_dir.mkdir(parents=True, exist_ok=True) + base = f'recon_{sid}_{VERSION}' + + obj = _object(theta, shape, rng) + probe = (rng.normal(size=PROBE_SHAPE) + + 1j * rng.normal(size=PROBE_SHAPE)).astype(np.complex64) + + # The per-scan config file real reconstructions carry: tomviz reads + # the tilt angle (and pixel sizes) from it, both in the dialog and + # when absorbing new scans during periodic execution. + config = recon_dir / f'{sid}_{VERSION}.txt' + config.write_text( + f'angle = {theta}\n' + 'lambda_nm = 0.1\n' + 'z_m = 2.0\n' + f'nx = {shape[1]}\n' + f'ny = {shape[0]}\n' + 'ccd_pixel_um = 55\n') + + # Write to temp names then rename, so a reader never sees a + # half-written array. The temp name keeps the .npy suffix because + # np.save appends one to any other extension. + for name, array in ((f'{base}_object_ave.npy', obj), + (f'{base}_probe_ave.npy', probe)): + tmp = recon_dir / ('.partial_' + name) + np.save(tmp, array) + tmp.replace(recon_dir / name) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', help='output directory') + parser.add_argument('--interval', type=float, default=5.0, + help='seconds between scans (default 5)') + parser.add_argument('--count', type=int, default=20, + help='number of scans to emit (default 20)') + parser.add_argument('--start-sid', type=int, default=50001) + parser.add_argument('--seed', type=int, default=0) + args = parser.parse_args() + + root = Path(args.directory) + root.mkdir(parents=True, exist_ok=True) + rng = np.random.default_rng(args.seed) + + thetas = np.linspace(-60, 60, args.count) + angles_file = root / 'angles.txt' + with open(angles_file, 'w') as f: + f.write('# sid version angle\n') + + for i in range(args.count): + sid = args.start_sid + i + shape = BASE_SHAPE + # Every fourth scan reconstructs with a slightly different crop. + if i % 4 == 3: + shape = (BASE_SHAPE[0] - 5, BASE_SHAPE[1] + 4) + write_scan(root, sid, float(thetas[i]), shape, rng) + with open(angles_file, 'a') as f: + f.write(f'{sid} {VERSION} {thetas[i]:.3f}\n') + print(f'[{i + 1}/{args.count}] scan {sid} at ' + f'theta={thetas[i]:.1f} shape={shape}', flush=True) + if i + 1 < args.count: + time.sleep(args.interval) + print(f'done; sid/angle list in {angles_file}') + + +if __name__ == '__main__': + main() diff --git a/tests/simulation/simulate_pyxrf_stream.py b/tests/simulation/simulate_pyxrf_stream.py new file mode 100644 index 000000000..c2bb45dc5 --- /dev/null +++ b/tests/simulation/simulate_pyxrf_stream.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Simulate a PyXRF acquisition: periodically add scan files to a directory. + +Every ``--interval`` seconds a new ``scan2D_.h5`` appears in the +output directory with the ``xrfmap`` layout the PyXRF tooling reads, and +``tomo.h5`` in the same directory is reassembled to include the new +projection. Point a tomviz PyXRF source's working directory here (leave +the scan range empty so it reads tomo.h5 directly), enable Periodic +Execution on the node, and the volume should grow as scans "arrive". + +Some scans are emitted with a slightly different pixel shape to exercise +the auto-padding path. + +Example: + python simulate_pyxrf_stream.py /tmp/pyxrf-sim --interval 5 --count 30 +""" +from __future__ import annotations + +import argparse +import time +from pathlib import Path + +import h5py +import numpy as np + +ELEMENTS = ['Fe_K', 'Ni_K', 'Ca_K'] +BASE_SHAPE = (48, 64) # rows, columns + + +def _phantom(theta_deg: float, shape: tuple[int, int], + rng: np.random.Generator) -> np.ndarray: + """A fake projection: two blobs whose separation follows the angle.""" + h, w = shape + y, x = np.mgrid[0:h, 0:w] + cx = w / 2 + (w / 4) * np.cos(np.radians(theta_deg)) + cy = h / 2 + blob1 = np.exp(-(((x - cx) / 6) ** 2 + ((y - cy) / 8) ** 2)) + cx2 = w / 2 - (w / 5) * np.cos(np.radians(theta_deg)) + blob2 = 0.6 * np.exp(-(((x - cx2) / 4) ** 2 + ((y - cy - 6) / 5) ** 2)) + return blob1 + blob2 + rng.normal(0, 0.01, shape) + + +def write_scan(directory: Path, sid: int, theta: float, + shape: tuple[int, int], rng: np.random.Generator) -> None: + h, w = shape + path = directory / f'scan2D_{sid}.h5' + # Write to a temp name then rename, so a reader never sees a + # half-written file (matches rebuild_tomo and the ptycho simulator). + tmp_path = directory / f'.partial_scan2D_{sid}.h5' + with h5py.File(tmp_path, 'w') as f: + md = f.create_group('xrfmap/scan_metadata') + md.attrs['scan_id'] = sid + md.attrs['scan_uid'] = f'sim-{sid:08d}' + md.attrs['scan_time_start'] = time.strftime( + '%Y-%m-%dT%H:%M:%S+00:00', time.gmtime()) + md.attrs['param_theta'] = theta + md.attrs['param_theta_units'] = 'deg' + md.attrs['scan_exit_status'] = 'success' + # x_start, x_stop, num_x, y_start, y_stop, num_y + md.attrs['param_input'] = [0.0, w * 0.1, w, 0.0, h * 0.1, h] + + fit = np.stack([ + (i + 1) * _phantom(theta, shape, rng) for i in range(len(ELEMENTS)) + ]) + detsum = f.create_group('xrfmap/detsum') + detsum.create_dataset('xrf_fit', data=fit) + detsum.create_dataset( + 'xrf_fit_name', data=np.array([e.encode() for e in ELEMENTS])) + + pos = np.stack([np.mgrid[0:h, 0:w][0].astype(float), + np.mgrid[0:h, 0:w][1].astype(float)]) + f.create_group('xrfmap/positions').create_dataset('pos', data=pos) + + scalers = f.create_group('xrfmap/scalers') + scalers.create_dataset('name', data=np.array([b'sclr1_ch4'])) + scalers.create_dataset( + 'val', data=np.ones((h, w, 1))) + + tmp_path.replace(path) + + +def rebuild_tomo(directory: Path) -> None: + """Assemble tomo.h5 from every scan present, padding to the max shape.""" + scans = [] + for path in sorted(directory.glob('scan2D_*.h5')): + with h5py.File(path, 'r') as f: + theta = float(f['xrfmap/scan_metadata'].attrs['param_theta']) + fit = f['xrfmap/detsum/xrf_fit'][()] + scans.append((theta, fit)) + scans.sort(key=lambda item: item[0]) + + hmax = max(fit.shape[1] for _, fit in scans) + wmax = max(fit.shape[2] for _, fit in scans) + data = np.zeros((len(scans), scans[0][1].shape[0], hmax, wmax)) + for i, (_, fit) in enumerate(scans): + pad = ((0, 0), (hmax - fit.shape[1], 0), (wmax - fit.shape[2], 0)) + data[i] = np.pad(fit, pad) + + # Write to a temp name then replace, so a reader never sees a + # half-written file. + tmp = directory / 'tomo.h5.partial' + with h5py.File(tmp, 'w') as f: + f.create_dataset('reconstruction/fitting/data', data=data) + f.create_dataset( + 'reconstruction/fitting/elements', + data=np.array([e.encode() for e in ELEMENTS])) + f.create_dataset( + 'exchange/theta', data=np.array([t for t, _ in scans])) + tmp.replace(directory / 'tomo.h5') + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', help='output directory') + parser.add_argument('--interval', type=float, default=5.0, + help='seconds between scans (default 5)') + parser.add_argument('--count', type=int, default=37, + help='number of scans to emit (default 37)') + parser.add_argument('--start-sid', type=int, default=100001) + parser.add_argument('--seed', type=int, default=0) + args = parser.parse_args() + + directory = Path(args.directory) + directory.mkdir(parents=True, exist_ok=True) + rng = np.random.default_rng(args.seed) + + thetas = np.linspace(-90, 90, args.count) + for i in range(args.count): + sid = args.start_sid + i + shape = BASE_SHAPE + # Every fifth scan arrives with fewer pixels, like the real + # beamline occasionally produces. + if i % 5 == 4: + shape = (BASE_SHAPE[0] - 2, BASE_SHAPE[1] - 3) + write_scan(directory, sid, float(thetas[i]), shape, rng) + rebuild_tomo(directory) + print(f'[{i + 1}/{args.count}] scan {sid} at ' + f'theta={thetas[i]:.1f} shape={shape}', flush=True) + if i + 1 < args.count: + time.sleep(args.interval) + print('done') + + +if __name__ == '__main__': + main() diff --git a/thirdparty/pybind11 b/thirdparty/pybind11 index ed5057ded..d03662f09 160000 --- a/thirdparty/pybind11 +++ b/thirdparty/pybind11 @@ -1 +1 @@ -Subproject commit ed5057ded698e305210269dafa57574ecf964483 +Subproject commit d03662f0984f652b60e7ddce53d3868002275197 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..f004b6ce5 100644 --- a/tomviz/ActiveObjects.h +++ b/tomviz/ActiveObjects.h @@ -5,13 +5,8 @@ #define tomvizActiveObjects_h #include -#include -#include "DataSource.h" -#include "Module.h" -#include "MoleculeSource.h" -#include "Operator.h" -#include "OperatorResult.h" +#include class pqRenderView; class pqTimeKeeper; @@ -21,7 +16,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 +43,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 +80,72 @@ 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 as the mouse moves over a slice, with the voxel index and + /// value under the cursor. + void mouseOverVoxel(const vtkVector3i& ijk, double value); + + /// Fired whenever the active pipeline changes. + void activePipelineChanged(pipeline::Pipeline*); + + /// Fired whenever the active node changes. + void activeNodeChanged(pipeline::Node*); - /// Fired to set image viewer mode - void setImageViewerMode(bool b); + /// Fired whenever the active output port changes. + void activePortChanged(pipeline::OutputPort*); - /// Fired when the interaction data source was fixed. - void interactionDataSourceFixed(DataSource*); + /// 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..f81d4803d --- /dev/null +++ b/tomviz/AddPythonSourceReaction.cxx @@ -0,0 +1,88 @@ +/* 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); + + // A description can ask for periodic execution from the start, for + // sources that exist to be polled. The setting is the node's from + // here on, so the Execution tab shows and edits it as usual. + const QJsonObject description = + QJsonDocument::fromJson(m_json.toUtf8()).object(); + if (description.contains(QStringLiteral("autoExecute"))) { + const QJsonObject autoExec = + description.value(QStringLiteral("autoExecute")).toObject(); + source->setAutoExecuteIntervalSeconds( + autoExec.value(QStringLiteral("intervalSeconds")) + .toInt(source->autoExecuteIntervalSeconds())); + source->setAutoExecuteEnabled( + autoExec.value(QStringLiteral("enabled")).toBool(false)); + } + + // 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 = description.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/AddRenderViewContextMenuBehavior.cxx b/tomviz/AddRenderViewContextMenuBehavior.cxx index 0485743a0..bcc2ff170 100644 --- a/tomviz/AddRenderViewContextMenuBehavior.cxx +++ b/tomviz/AddRenderViewContextMenuBehavior.cxx @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -18,11 +19,56 @@ #include #include #include +#include #include #include #include namespace tomviz { + +namespace { + +// ParaView's pqRenderView::linkToOtherView() connects QWidget::close, a +// slot, as if it were a signal, which warns "signal not found" on every +// link and never frees the widget. Show the widget here instead, and free +// it once it hides. +class LinkViewWidget : public pqLinkViewWidget +{ +public: + using pqLinkViewWidget::pqLinkViewWidget; + +protected: + bool event(QEvent* e) override + { + bool handled = pqLinkViewWidget::event(e); + if (e->type() == QEvent::Hide) { + deleteLater(); + } + return handled; + } +}; + +class CameraLinkReaction : public pqCameraLinkReaction +{ +public: + using pqCameraLinkReaction::pqCameraLinkReaction; + +protected: + void onTriggered() override + { + auto* view = + qobject_cast(pqActiveObjects::instance().activeView()); + if (!view) { + return; + } + auto* widget = new LinkViewWidget(view); + widget->move(view->widget()->mapToGlobal(QPoint(2, 2))); + widget->show(); + } +}; + +} // namespace + AddRenderViewContextMenuBehavior::AddRenderViewContextMenuBehavior(QObject* p) : QObject(p) { @@ -38,7 +84,7 @@ AddRenderViewContextMenuBehavior::AddRenderViewContextMenuBehavior(QObject* p) m_menu->addSeparator(); // Support camera linking/unlinking - new pqCameraLinkReaction(m_menu->addAction("Add Camera Link...") + new CameraLinkReaction(m_menu->addAction("Add Camera Link...") << pqSetName("actionToolsAddCameraLink")); new pqManageLinksReaction(m_menu->addAction("Manage Camera Links...") << pqSetName("actionToolsManageCameraLinks")); 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..b2aeeca15 100644 --- a/tomviz/AnimationHelperDialog.cxx +++ b/tomviz/AnimationHelperDialog.cxx @@ -2,59 +2,422 @@ It is released under the 3-Clause BSD License, see "LICENSE". */ #include "AnimationHelperDialog.h" + +#include "animations/AnimationSceneGuard.h" #include "ui_AnimationHelperDialog.h" #include "ActiveObjects.h" -#include "ContourAnimation.h" -#include "ModuleManager.h" -#include "SliceAnimation.h" +#include "CameraAnimation.h" +#include "CameraViewpoints.h" +#include "AnimatableProperties.h" +#include "ModuleAnimations.h" +#include "MovieExportDialog.h" +#include "RecordedAnimations.h" +#include "ScalarOpacityAnimation.h" +#include "SceneSnapshot.h" #include "Utilities.h" +#include "pipeline/Pipeline.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/SourceNode.h" +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/VolumeSink.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 #include #include +#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; +} + +// Big enough to tell two framings of the same volume apart, small +// enough that a dozen of them in a state file is not worth noticing. +const QSize thumbnailSize(128, 96); + +// A sparkline of an opacity curve. Two captured curves are hard to tell +// apart from their point counts, and the whole animation is the difference +// between them, so draw the shape. +QPixmap curvePreview(vtkPiecewiseFunction* curve, const double range[2], + const QSize& size, const QPalette& palette) +{ + QPixmap preview(size); + preview.fill(Qt::transparent); + if (!curve || curve->GetSize() == 0 || !(range[1] > range[0])) { + return preview; + } + + QPainterPath path; + for (int x = 0; x < size.width(); ++x) { + const double value = + range[0] + (range[1] - range[0]) * x / (size.width() - 1.0); + const double opacity = qBound(0.0, curve->GetValue(value), 1.0); + const QPointF point(x, (1.0 - opacity) * (size.height() - 3) + 1.5); + if (x == 0) { + path.moveTo(point); + } else { + path.lineTo(point); + } + } + + QPainter painter(&preview); + painter.setRenderHint(QPainter::Antialiasing); + // Text color rather than highlight: the sparkline has to stay visible + // when its row is selected and painted with the highlight color. + painter.setPen(QPen(palette.windowText().color(), 1.5)); + painter.drawPath(path); + return preview; +} + +// A PNG of what the view currently shows, or empty if it cannot be read. +QByteArray captureThumbnail(pqRenderView* renderView) +{ + auto* proxy = renderView ? renderView->getRenderViewProxy() : nullptr; + if (!proxy) { + return {}; + } + + // CaptureWindow hands back a reference that is ours to release. + vtkSmartPointer image; + image.TakeReference(proxy->CaptureWindow(1)); + if (!image) { + return {}; + } + + QImage captured; + if (!pqImageUtil::fromImageData(image, captured) || captured.isNull()) { + return {}; + } + + QByteArray png; + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + captured.scaled(thumbnailSize, Qt::KeepAspectRatio, Qt::SmoothTransformation) + .save(&buffer, "PNG"); + return png; +} + +} // anonymous namespace + class AnimationHelperDialog::Internal : public QObject { public: Ui::AnimationHelperDialog ui; + // Whether Add/Update Viewpoint also records the module state + QCheckBox* recordScene = nullptr; + // Caption shown in the view while the path is at the selected viewpoint + QLineEdit* viewpointLabel = nullptr; + // Where every caption is centered, as fractions of the view from its + // lower-left corner + QDoubleSpinBox* captionX = nullptr; + QDoubleSpinBox* captionY = nullptr; + // The orbit at the selected viewpoint + QCheckBox* viewpointOrbit = nullptr; + QSpinBox* orbitTurns = nullptr; + QComboBox* orbitDirection = nullptr; + QSpinBox* orbitFrames = nullptr; + QLabel* orbitFramesTitle = nullptr; + QCheckBox* orbitEased = nullptr; pqPropertyLinks pqLinks; QPointer parent; - QList> moduleAnimations; + vtkWeakPointer linkedScene; + + // Opacity curves captured but not yet added, keyed by the volume they + // were captured for so switching modules never shows another volume's + // captures. Entries are dropped when their node leaves the pipeline. + QHash>> stagedCurves; + + // What the range page is currently configured for, so pipeline + // executions only rebuild it (and clobber user-entered values) when the + // data-derived bounds actually moved. + QPointer configuredNode; + // What each row of the animation list puts into the controls above + // when it is selected, in row order. + QList> animationRowSelectors; + QString configuredProperty; + double configuredLo = 0.0; + double configuredHi = 0.0; + // The range caption: a slice or clip that turns custom is measured in + // a different unit and offers a differently named property. + QString configuredLabel; 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(); - + auto* labelRow = new QHBoxLayout; + auto* labelTitle = new QLabel("Label:", parent); + viewpointLabel = new QLineEdit(parent); + viewpointLabel->setPlaceholderText( + "Caption shown transitioning to the next viewpoint"); + viewpointLabel->setToolTip( + "Text drawn in the 3D view, and in exported movies, while the camera " + "is at this viewpoint (including its orbit) and on its way to the " + "next one."); + labelTitle->setBuddy(viewpointLabel); + labelRow->addWidget(labelTitle); + labelRow->addWidget(viewpointLabel, 1); + ui.cameraLayout->insertLayout( + ui.cameraLayout->indexOf(ui.segmentLayout) + 1, labelRow); + connect(viewpointLabel, &QLineEdit::editingFinished, this, + &Internal::labelChanged); + + auto* positionRow = new QHBoxLayout; + auto* positionTitle = new QLabel("Label position:", parent); + positionTitle->setToolTip( + "Where the captions are centered in the 3D view and in exported " + "movies, as a fraction of the view's width and height measured from " + "the lower-left corner. 0.5, 0.5 is the middle of the view; the " + "default, 0.5, 0.05, is the bottom middle. One position serves " + "every viewpoint."); + positionRow->addWidget(positionTitle); + auto addAxis = [&](const QString& axis) { + auto* box = new QDoubleSpinBox(parent); + box->setRange(0.0, 1.0); + box->setSingleStep(0.01); + box->setDecimals(2); + // Commit once editing is done: a value committed per keystroke is + // reflected straight back into the box, which reformats the text + // under the user and swallows the digit they were about to type + box->setKeyboardTracking(false); + box->setToolTip(positionTitle->toolTip()); + auto* axisLabel = new QLabel(axis, parent); + axisLabel->setBuddy(box); + positionRow->addWidget(axisLabel); + positionRow->addWidget(box); + return box; + }; + captionX = addAxis("x"); + captionY = addAxis("y"); + positionRow->addStretch(1); + ui.cameraLayout->insertLayout(ui.cameraLayout->indexOf(labelRow) + 1, + positionRow); + refreshCaptionPosition(); + auto positionEdited = [this]() { + CameraViewpoints::instance().setCaptionPosition(captionX->value(), + captionY->value()); + }; + connect(captionX, qOverload(&QDoubleSpinBox::valueChanged), this, + positionEdited); + connect(captionY, qOverload(&QDoubleSpinBox::valueChanged), this, + positionEdited); + // A loaded state file moves it from outside the dialog + connect(&CameraViewpoints::instance(), + &CameraViewpoints::captionPositionChanged, this, + &Internal::refreshCaptionPosition); + + // The orbit at the viewpoint, above the leg controls: what happens + // on arriving here comes before what happens on leaving. + auto* orbitRow = new QHBoxLayout; + viewpointOrbit = new QCheckBox("Add orbit", parent); + viewpointOrbit->setObjectName("viewpointOrbit"); + viewpointOrbit->setToolTip( + "Once the camera reaches this viewpoint, swing it around the focal " + "point before it moves on: full turns, in this direction, lasting " + "this long relative to the legs of the path. The last viewpoint can " + "orbit too, to end on a spin, and a single viewpoint that orbits is " + "an animation on its own: what Play makes of the current view when " + "nothing else is set up."); + orbitTurns = new QSpinBox(parent); + orbitTurns->setObjectName("orbitTurns"); + orbitTurns->setRange(1, 10); + orbitTurns->setSuffix(" turn(s)"); + // Typed values commit once editing is done: every change rebuilds + // the viewpoint list, which would otherwise reset the text mid-edit + orbitTurns->setKeyboardTracking(false); + orbitTurns->setToolTip("Full turns around the focal point."); + orbitDirection = new QComboBox(parent); + orbitDirection->setObjectName("orbitDirection"); + orbitDirection->addItems({ "Counterclockwise", "Clockwise" }); + orbitDirection->setToolTip( + "Which way the camera goes round, as seen from above (looking down " + "the view's up direction)."); + orbitFramesTitle = new QLabel("lasting", parent); + orbitFrames = new QSpinBox(parent); + orbitFrames->setObjectName("orbitFrames"); + // Never zero: an orbit of no length would be a checkbox that does + // nothing + orbitFrames->setRange(1, 100000); + orbitFrames->setSingleStep(10); + orbitFrames->setSuffix(" frames"); + orbitFrames->setValue(120); + orbitFrames->setKeyboardTracking(false); + orbitFrames->setToolTip( + "Frames the orbit takes, on top of the legs."); + orbitFramesTitle->setBuddy(orbitFrames); + orbitEased = new QCheckBox("Ease In/Out", parent); + orbitEased->setObjectName("orbitEased"); + orbitEased->setToolTip( + "Start the orbit slowly and slow it to a stop at the end. Leave off " + "for a spin at constant speed, which loops without a pause."); + orbitRow->addWidget(viewpointOrbit); + orbitRow->addWidget(orbitTurns); + orbitRow->addWidget(orbitDirection); + orbitRow->addWidget(orbitFramesTitle); + orbitRow->addWidget(orbitFrames); + orbitRow->addWidget(orbitEased); + orbitRow->addStretch(1); + // The details only appear once there is an orbit to describe + for (auto* detail : orbitDetails()) { + detail->setVisible(false); + } + ui.cameraLayout->insertLayout(ui.cameraLayout->indexOf(ui.segmentLayout), + orbitRow); + connect(viewpointOrbit, &QCheckBox::toggled, this, &Internal::orbitChanged); + connect(orbitTurns, qOverload(&QSpinBox::valueChanged), this, + &Internal::orbitChanged); + connect(orbitDirection, qOverload(&QComboBox::currentIndexChanged), + this, &Internal::orbitChanged); + connect(orbitFrames, qOverload(&QSpinBox::valueChanged), this, + &Internal::orbitChanged); + connect(orbitEased, &QCheckBox::toggled, this, &Internal::orbitChanged); + + recordScene = new QCheckBox("Record module state with viewpoints", parent); + recordScene->setToolTip( + "Also save the state of every visualization with each viewpoint: " + "which are visible, their opacity or opacity curve, volume " + "solidity, where slice and clip planes sit, iso values, threshold " + "ranges, hidden labels, and any volume cut-out or exploded view. Whatever differs between two " + "viewpoints is listed under Visualizations, marked recorded, and " + "plays between them. A module added after a viewpoint was saved " + "counts as hidden there until that viewpoint is updated."); + recordScene->setChecked(true); + ui.cameraLayout->addWidget(recordScene); + + ui.viewpointList->setIconSize(QSize(96, 72)); + ui.viewpointList->setDragDropMode(QAbstractItemView::InternalMove); + ui.viewpointList->setDefaultDropAction(Qt::MoveAction); + ui.viewpointList->setSelectionMode(QAbstractItemView::SingleSelection); + ui.viewpointList->setContextMenuPolicy(Qt::CustomContextMenu); + ui.viewpointList->setEditTriggers(QAbstractItemView::NoEditTriggers); + + ui.keyframeList->setIconSize(QSize(96, 28)); + + // The viewpoint and leg controls start out matching the (empty) + // selection, like the ones the .ui file starts disabled + updateSegmentControls(); updateGui(); setupConnections(); } void setupConnections() { - // Camera animations - connect(ui.clearCameraAnimations, &QPushButton::clicked, this, - &Internal::clearCameraAnimations); - connect(ui.createCameraOrbit, &QPushButton::clicked, this, - &Internal::createCameraOrbitInternal); + // No control arms the camera path: it flies whenever there is one + // (see CameraViewpoints::syncFlight), and Clear All Animations is + // how it stops. + ui.clearAllAnimations->setToolTip( + "Remove every animation: the viewpoints and their path, the module " + "state recorded with them, the visualization animations, and the " + "time series playback."); + + // Camera viewpoints + connect(ui.addViewpoint, &QPushButton::clicked, this, + &Internal::addViewpoint); + connect(ui.updateViewpoint, &QPushButton::clicked, this, + &Internal::updateViewpoint); + connect(ui.removeViewpoint, &QPushButton::clicked, this, + &Internal::removeViewpoint); + connect(ui.viewpointList, &QListWidget::currentRowChanged, this, + [this]() { + updateSegmentControls(); + updateEnableStates(); + }); + connect(ui.viewpointList, &QListWidget::itemDoubleClicked, this, + &Internal::goToViewpoint); + connect(ui.viewpointList, &QListWidget::customContextMenuRequested, this, + &Internal::showViewpointMenu); + connect(ui.viewpointList, &QListWidget::itemChanged, this, + &Internal::commitViewpointRename); + // Drag-and-drop reorder. The list has already moved its row when this + // fires; defer the model sync so the rebuild it triggers does not run + // inside the view's own drop handling. + connect(ui.viewpointList->model(), &QAbstractItemModel::rowsMoved, this, + [this](const QModelIndex&, int start, int, const QModelIndex&, + int destination) { + int to = destination > start ? destination - 1 : destination; + QTimer::singleShot(0, this, [this, start, to]() { + CameraViewpoints::instance().move(start, to); + ui.viewpointList->setCurrentRow(to); + }); + }); + connect(ui.segmentDuration, QOverload::of(&QSpinBox::valueChanged), + this, &Internal::segmentChanged); + connect(ui.segmentEased, &QCheckBox::toggled, this, + &Internal::segmentChanged); + // The list is shared with the state file, so it can change while the + // dialog is open. Everything showing viewpoint names follows it. + connect(&CameraViewpoints::instance(), &CameraViewpoints::changed, this, + [this]() { + // The flight and the playback follow the change on their + // own (AnimationSceneGuard); this is the display. + refreshViewpoints(); + refreshSegmentOptions(); + refreshKeyframeRows(); + refreshAnimationList(); + }); // Time series connect(&activeObjects(), @@ -64,46 +427,158 @@ class AnimationHelperDialog::Internal : public QObject &activeObjects(), &ActiveObjects::enableTimeSeriesAnimations); connect(ui.enableTimeSeriesAnimations, &QCheckBox::toggled, this, [this](bool b) { + interruptAnimationPlayback(); updateEnableStates(); if (b) { play(); } }); - // 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); + // Sinks re-cache their scalar range / slice count on every + // consume, but the authoring row only re-reads them on selection + // changes. Track executions so the ranges follow the data. + connect(pip, &pipeline::Pipeline::executionFinished, this, + &Internal::refreshModuleRanges); + } + + // Visualization animation authoring 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); + connect(ui.animatedProperty, + QOverload::of(&QComboBox::currentIndexChanged), this, + [this]() { + configurePropertyPage(); + updateEnableStates(); + }); + connect(ui.captureCurve, &QPushButton::clicked, this, + &Internal::captureCurve); + connect(ui.clearCurve, &QPushButton::clicked, this, + &Internal::clearCurve); + connect(ui.keyframeList, &QListWidget::currentRowChanged, this, + [this]() { updateEnableStates(); }); + connect(ui.keyframeList, &QListWidget::itemDoubleClicked, this, + &Internal::loadCurveIntoEditor); connect(ui.addModuleAnimation, &QPushButton::clicked, this, &Internal::addModuleAnimation); - connect(ui.clearModuleAnimations, &QPushButton::clicked, this, - &Internal::clearModuleAnimations); + // The registry can change from outside the dialog too, e.g. when a + // state file loads a whole set at once. + // Selecting a row shows that animation in the controls above, so it + // can be read off and, with Add, adjusted + connect(ui.animationList, &QListWidget::currentRowChanged, this, + &Internal::selectAnimationRow); + connect(ui.animationList, &QListWidget::itemClicked, this, + [this](QListWidgetItem* item) { + selectAnimationRow(ui.animationList->row(item)); + }); + connect(&ModuleAnimations::instance(), &ModuleAnimations::changed, this, + [this]() { + refreshAnimationList(); + // The keyframe list shows the recorded curves, which are + // rebuilt whenever the list changes + refreshKeyframeRows(); + updateEnableStates(); + }); // All animations - pqLinks.addPropertyLink(ui.numberOfFrames, "value", - SIGNAL(valueChanged(int)), scene()->getProxy(), - scene()->getProxy()->GetProperty("NumberOfFrames"), - 0); - connect(ui.numberOfFrames, QOverload::of(&QSpinBox::valueChanged), - this, &Internal::numberOfFramesModified); + linkToScene(); + // React to user edits of the frame count only. Connecting to the spin + // box's valueChanged directly would also fire when the property link + // syncs the widget from the scene (e.g. after a relink, or when a time + // series load sets the frame count), and numberOfFramesModified would + // then clobber a freshly set "Snap To TimeSteps" play mode. + // qtWidgetChanged is only emitted for Qt-originated changes. + connect(&pqLinks, &pqPropertyLinks::qtWidgetChanged, this, + &Internal::numberOfFramesModified); + // Loading a state file resets the ParaView session and replaces the + // animation scene; rebind the frame count link when that happens while + // the dialog is open (showEvent covers the closed case). + connect(pqPVApplicationCore::instance()->animationManager(), + &pqAnimationManager::activeSceneChanged, this, + &Internal::linkToScene); connect(ui.exportMovie, &QPushButton::clicked, this, &Internal::exportMovie); connect(ui.clearAllAnimations, &QPushButton::clicked, this, &Internal::clearAllAnimations); } - void play() { scene()->getProxy()->InvokeCommand("Play"); } + // Bind the frame count spin box to the current animation scene. Loading + // a state file resets the ParaView session, which destroys the scene and + // creates a new one, so the link built when the dialog was first opened + // can be pointing at a dead proxy. The dialog is created once and only + // hidden on close, so re-check the scene every time it is shown. + void linkToScene() + { + auto* sceneProxy = scene() ? scene()->getProxy() : nullptr; + if (!sceneProxy || sceneProxy == linkedScene) { + return; + } + + pqLinks.removeAllPropertyLinks(); + pqLinks.addPropertyLink(ui.numberOfFrames, "value", + SIGNAL(valueChanged(int)), sceneProxy, + sceneProxy->GetProperty("NumberOfFrames"), 0); + linkedScene = sceneProxy; + } + + void refresh() + { + linkToScene(); + updateGui(); + refreshViewpoints(); + refreshSegmentOptions(); + refreshAnimationList(); + refreshModuleRanges(); + } + + void play() + { + auto* animationScene = scene(); + if (!animationScene) { + return; + } + auto* proxy = animationScene->getProxy(); + + // Play() runs a blocking loop that pumps events, so a mode switch + // made mid-playback executes inside that loop, and re-invoking Play + // there is a hard vtkAnimationPlayer error ("Cannot play during an + // active playback"). Stop() only raises a flag the loop honors once + // control unwinds, so the restart has to be deferred; if the timer + // fires while the old loop is still winding down, play() simply + // defers again. + auto* sceneObject = + vtkSMAnimationScene::SafeDownCast(proxy->GetClientSideObject()); + if (sceneObject && sceneObject->GetInPlay()) { + proxy->InvokeCommand("Stop"); + QTimer::singleShot(0, this, [this]() { play(); }); + return; + } + + proxy->InvokeCommand("Play"); + } + + // The active view isn't necessarily a render view: a Plot module makes + // an XYChartView active. Fall back to the first render view so the orbit + // still ends up somewhere the user can see it. + pqRenderView* renderViewForOrbit() + { + if (auto* renderView = activeObjects().activePqRenderView()) { + return renderView; + } + + auto* smModel = pqApplicationCore::instance()->getServerManagerModel(); + auto renderViews = smModel->findItems(); + return renderViews.isEmpty() ? nullptr : renderViews.first(); + } void updateGui() { @@ -116,101 +591,446 @@ class AnimationHelperDialog::Internal : public QObject void updateEnableStates() { - bool hasCameraAnimations = false; - for (auto* cue : scene()->getCues()) { - if (cue->getSMName().startsWith("CameraAnimationCue")) { - hasCameraAnimations = true; - break; - } - } + bool hasCameraAnimations = CameraViewpoints::instance().isFlying(); 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 = ui.enableTimeSeriesAnimations->isChecked() && hasTimeSeries; - - bool hasDataSourceOptions = ui.selectedDataSource->count() != 0; - bool hasModuleOptions = ui.selectedModule->count() != 0; - bool moduleSelected = selectedModule() != nullptr; - bool hasModuleAnimations = !moduleAnimations.empty(); + // The checkbox means nothing without a series loaded, so it does not + // take up footer space until one is. + ui.enableTimeSeriesAnimations->setVisible(hasTimeSeries); + + int selectedViewpoint = ui.viewpointList->currentRow(); + bool viewpointSelected = selectedViewpoint >= 0; + + ui.addViewpoint->setEnabled(renderViewForOrbit() != nullptr); + ui.updateViewpoint->setEnabled(viewpointSelected); + ui.removeViewpoint->setEnabled(viewpointSelected); + + auto* node = selectedSink(); + bool hasModuleAnimations = !ModuleAnimations::instance().isEmpty(); + ui.selectedDataSource->setEnabled(ui.selectedDataSource->count() != 0); + ui.selectedModule->setEnabled(ui.selectedModule->count() != 0); + ui.animatedProperty->setEnabled(ui.animatedProperty->count() != 0); + + QString property = selectedProperty(); + bool curveProperty = property == "curve"; + int stagedCount = 0; + if (node) { + const int rows = keyframeRowCount(); + for (int anchor : stagedCurves.value(node).keys()) { + if (anchor < rows) { + ++stagedCount; + } + } + } + bool curveSelected = ui.keyframeList->currentRow() >= 0; + bool curveStaged = + node && curveSelected && + stagedCurves.value(node).contains(ui.keyframeList->currentRow()); + + ui.captureCurve->setEnabled(curveProperty && curveSelected && + liveOpacityCurve() != nullptr); + ui.clearCurve->setEnabled(curveStaged); + // A morph needs two curves to move between; everything else just + // needs a visualization. + ui.addModuleAnimation->setEnabled(node && + (!curveProperty || stagedCount >= 2)); + + // With a path the frame count is what its legs and orbits add up to, + // so the box gives way to the total; without one it is the count. + auto& viewpoints = CameraViewpoints::instance(); + const bool pathSetsFrames = viewpoints.isPath(); + ui.numberOfFrames->setVisible(!pathSetsFrames); + ui.numberOfFramesLabel->setText( + pathSetsFrames ? tr("Total: %1 frames").arg(viewpoints.totalFrames()) + : tr("Number of Frames:")); + ui.numberOfFramesLabel->setToolTip( + pathSetsFrames ? tr("The legs and orbits of the camera path added up.") + : QString()); bool hasAnyAnimations = hasCameraAnimations || timeSeriesEnabled || hasModuleAnimations; - - ui.clearCameraAnimations->setEnabled(hasCameraAnimations); - ui.enableTimeSeriesAnimations->setEnabled(hasTimeSeries); - ui.addModuleAnimation->setEnabled(moduleSelected); - ui.selectedDataSource->setEnabled(hasDataSourceOptions); - ui.selectedModule->setEnabled(hasModuleOptions); - ui.clearModuleAnimations->setEnabled(hasModuleAnimations); ui.exportMovie->setEnabled(hasAnyAnimations); - ui.clearAllAnimations->setEnabled(hasAnyAnimations); + // A lone viewpoint is not yet an animation, but it is something to + // clear + ui.clearAllAnimations->setEnabled(hasAnyAnimations || + CameraViewpoints::instance().size() > 0); + } + + // Camera viewpoints + struct CameraContext + { + pqRenderView* view = nullptr; + vtkSMRenderViewProxy* proxy = nullptr; + vtkCamera* camera = nullptr; + }; + + // The render view and camera the viewpoint actions operate on. All + // three are present or the camera is null. + CameraContext cameraContext() + { + CameraContext context; + context.view = renderViewForOrbit(); + context.proxy = + context.view ? context.view->getRenderViewProxy() : nullptr; + context.camera = context.proxy ? context.proxy->GetActiveCamera() : nullptr; + return context; } - // Camera - void clearCameraAnimations() + void refreshViewpoints() { - clearCameraCues(); + auto& viewpoints = CameraViewpoints::instance(); + + QSignalBlocker blocked(ui.viewpointList); + int previousRow = ui.viewpointList->currentRow(); + ui.viewpointList->clear(); + for (int i = 0; i < viewpoints.size(); ++i) { + auto name = viewpoints.at(i).name; + if (name.isEmpty()) { + name = QString("Viewpoint %1").arg(i + 1); + } + auto* item = new QListWidgetItem(name); + QPixmap thumbnail; + if (thumbnail.loadFromData(viewpoints.at(i).thumbnail, "PNG")) { + item->setIcon(QIcon(thumbnail)); + } + const int turns = viewpoints.at(i).orbitTurns; + if (turns != 0) { + item->setToolTip(QString("Orbits %1 full turn(s) %2 here") + .arg(std::abs(turns)) + .arg(turns > 0 ? "counterclockwise" : "clockwise")); + } + ui.viewpointList->addItem(item); + } + if (previousRow >= 0 && previousRow < viewpoints.size()) { + ui.viewpointList->setCurrentRow(previousRow); + } + + updateSegmentControls(); updateEnableStates(); } - void createCameraOrbitInternal() + void showViewpointMenu(const QPoint& pos) { - auto* renderView = activeObjects().activePqRenderView(); + auto* item = ui.viewpointList->itemAt(pos); + if (!item) { + return; + } + // The menu runs its own event loop, during which the list can be + // rebuilt (a playback stopping, a state file), so hold the row, not + // the item, and look the item up again afterwards. + const int row = ui.viewpointList->row(item); + ui.viewpointList->setCurrentRow(row); + + QMenu menu; + auto* rename = menu.addAction("Rename"); + auto* goTo = menu.addAction("Go To"); + auto* update = menu.addAction("Update From Current View"); + auto* remove = menu.addAction("Remove"); + auto* chosen = menu.exec(ui.viewpointList->mapToGlobal(pos)); + item = ui.viewpointList->item(row); + if (!chosen || !item) { + return; + } + if (chosen == rename) { + item->setFlags(item->flags() | Qt::ItemIsEditable); + ui.viewpointList->editItem(item); + } else if (chosen == goTo) { + goToViewpoint(); + } else if (chosen == update) { + updateViewpoint(); + } else if (chosen == remove) { + removeViewpoint(); + } + } - // Remove all previous camera cues, and create the orbit - clearCameraCues(renderView->getRenderViewProxy()); - createCameraOrbit(renderView->getRenderViewProxy()); + // itemChanged fires from inside the list widget's own edit commit, + // and replacing the viewpoint rebuilds the list, which would delete + // the item Qt is still working with: the crash on the next right + // click. So take the row and the text now, and apply them once the + // widget is done. It also fires for the editable flag Rename sets + // just before opening the editor, when the text is unchanged: that + // must not rebuild the list either, or the editor is gone before the + // user has typed a letter. + void commitViewpointRename(QListWidgetItem* item) + { + const int row = ui.viewpointList->row(item); + const auto name = item->text().trimmed(); + QTimer::singleShot(0, this, [this, row, name]() { + auto& viewpoints = CameraViewpoints::instance(); + if (row < 0 || row >= viewpoints.size()) { + return; + } + auto viewpoint = viewpoints.at(row); + if (name == viewpoint.name) { + return; + } + if (name.isEmpty()) { + // Rejected edit; put the old name back. + refreshViewpoints(); + return; + } + viewpoint.name = name; + viewpoints.replace(row, viewpoint); + }); + } - updateEnableStates(); - play(); + // The duration and easing belong to the leg leaving the selected + // viewpoint, so the last one in the list has nothing to edit. + void updateSegmentControls() + { + auto& viewpoints = CameraViewpoints::instance(); + int row = ui.viewpointList->currentRow(); + bool hasSegment = row >= 0 && row < viewpoints.size() - 1; + + QSignalBlocker blockedDuration(ui.segmentDuration); + QSignalBlocker blockedEased(ui.segmentEased); + QSignalBlocker blockedOrbit(viewpointOrbit); + QSignalBlocker blockedTurns(orbitTurns); + QSignalBlocker blockedDirection(orbitDirection); + QSignalBlocker blockedOrbitFrames(orbitFrames); + QSignalBlocker blockedOrbitEased(orbitEased); + QSignalBlocker blockedLabel(viewpointLabel); + if (hasSegment) { + ui.segmentDuration->setValue(viewpoints.at(row).legFrames); + ui.segmentEased->setChecked(viewpoints.at(row).eased); + } + bool hasViewpoint = row >= 0 && row < viewpoints.size(); + bool orbiting = false; + if (hasViewpoint) { + const int turns = viewpoints.at(row).orbitTurns; + orbiting = turns != 0; + orbitTurns->setValue(orbiting ? std::abs(turns) : 1); + orbitDirection->setCurrentIndex(turns < 0 ? 1 : 0); + orbitFrames->setValue(viewpoints.at(row).orbitFrames); + orbitEased->setChecked(viewpoints.at(row).orbitEased); + } + viewpointOrbit->setChecked(orbiting); + viewpointLabel->setText(hasViewpoint ? viewpoints.at(row).label + : QString()); + viewpointLabel->setEnabled(hasViewpoint); + + ui.segmentDurationLabel->setEnabled(hasSegment); + ui.segmentDuration->setEnabled(hasSegment); + ui.segmentEased->setEnabled(hasSegment); + viewpointOrbit->setEnabled(hasViewpoint); + for (auto* detail : orbitDetails()) { + detail->setVisible(hasViewpoint && orbiting); + } + } + + // The orbit controls other than the checkbox that shows them + QList orbitDetails() const + { + return { orbitTurns, orbitDirection, orbitFramesTitle, orbitFrames, + orbitEased }; } - QStringList moduleTabTexts() + void orbitChanged() { - QStringList types; - for (int i = 0; i < ui.modulesTabWidget->count(); ++i) { - types.append(ui.modulesTabWidget->tabText(i)); + auto& viewpoints = CameraViewpoints::instance(); + int row = ui.viewpointList->currentRow(); + if (row < 0 || row >= viewpoints.size()) { + return; } - return types; + + auto viewpoint = viewpoints.at(row); + const int turns = orbitTurns->value(); + viewpoint.orbitTurns = + viewpointOrbit->isChecked() + ? (orbitDirection->currentIndex() == 0 ? turns : -turns) + : 0; + viewpoint.orbitFrames = orbitFrames->value(); + viewpoint.orbitEased = orbitEased->isChecked(); + viewpoints.replace(row, viewpoint); } - QStringList allowedModuleTypes() + void addViewpoint() { - // This is based upon tab texts - return moduleTabTexts(); + auto context = cameraContext(); + if (!context.camera) { + return; + } + + auto& viewpoints = CameraViewpoints::instance(); + Viewpoint viewpoint; + viewpoint.readFrom(context.camera); + viewpoint.thumbnail = captureThumbnail(context.view); + viewpoint.name = viewpoints.nextDefaultName(); + if (recordScene->isChecked()) { + viewpoint.scene = SceneSnapshot::capture(pipeline()); + } + + viewpoints.append(viewpoint); + ui.viewpointList->setCurrentRow(viewpoints.size() - 1); + } + + void updateViewpoint() + { + auto& viewpoints = CameraViewpoints::instance(); + int row = ui.viewpointList->currentRow(); + auto context = cameraContext(); + if (row < 0 || row >= viewpoints.size() || !context.camera) { + return; + } + + // Re-frame the viewpoint but leave its place in the path alone. + auto viewpoint = viewpoints.at(row); + viewpoint.readFrom(context.camera); + viewpoint.thumbnail = captureThumbnail(context.view); + viewpoint.scene = recordScene->isChecked() + ? SceneSnapshot::capture(pipeline()) + : SceneSnapshot(); + viewpoints.replace(row, viewpoint); } - // Data sources + void goToViewpoint() + { + auto& viewpoints = CameraViewpoints::instance(); + int row = ui.viewpointList->currentRow(); + auto context = cameraContext(); + if (row < 0 || row >= viewpoints.size() || !context.camera) { + return; + } + + // The flight would put the camera back on the path at its next + // tick; stopped where it is, the next Play resumes from there. + interruptAnimationPlayback(/*rewind=*/false); + viewpoints.at(row).applyTo(context.camera); + // Modules some viewpoint recorded but this one lacks were not on + // screen here; ones no viewpoint knows are left as they are. + auto known = RecordedAnimations::instance().recordedNodeIds(); + viewpoints.at(row).scene.apply(pipeline(), &known); + if (auto* renderer = context.proxy->GetRenderer()) { + renderer->ResetCameraClippingRange(); + } + context.view->render(); + } + + void removeViewpoint() + { + CameraViewpoints::instance().removeAt(ui.viewpointList->currentRow()); + } + + void refreshCaptionPosition() + { + const auto position = CameraViewpoints::instance().captionPosition(); + QSignalBlocker blockX(captionX); + QSignalBlocker blockY(captionY); + captionX->setValue(position[0]); + captionY->setValue(position[1]); + } + + void labelChanged() + { + auto& viewpoints = CameraViewpoints::instance(); + int row = ui.viewpointList->currentRow(); + if (row < 0 || row >= viewpoints.size()) { + return; + } + auto viewpoint = viewpoints.at(row); + if (viewpoint.label == viewpointLabel->text()) { + return; + } + viewpoint.label = viewpointLabel->text(); + viewpoints.replace(row, viewpoint); + } + + void segmentChanged() + { + auto& viewpoints = CameraViewpoints::instance(); + int row = ui.viewpointList->currentRow(); + if (row < 0 || row >= viewpoints.size()) { + return; + } + + auto viewpoint = viewpoints.at(row); + viewpoint.legFrames = ui.segmentDuration->value(); + viewpoint.eased = ui.segmentEased->isChecked(); + viewpoints.replace(row, viewpoint); + } + + // The legs a visualization animation can be bound to: the whole + // timeline, or one hop of the camera path. + void refreshSegmentOptions() + { + QSignalBlocker blocked(ui.animationSegment); + // An empty combo has no current data, and asking an invalid variant + // for an int gives 0, which is the first leg rather than "no leg". + auto current = ui.animationSegment->currentData(); + int previous = current.isValid() ? current.toInt() : -1; + + ui.animationSegment->clear(); + ui.animationSegment->addItem("Whole animation", -1); + + auto& viewpoints = CameraViewpoints::instance(); + for (int i = 0; i + 1 < viewpoints.size(); ++i) { + ui.animationSegment->addItem(segmentLabel(i), i); + } + + int index = ui.animationSegment->findData(previous); + ui.animationSegment->setCurrentIndex(index < 0 ? 0 : index); + } + + QString segmentLabel(int segment) + { + auto& viewpoints = CameraViewpoints::instance(); + if (segment < 0 || segment + 1 >= viewpoints.size()) { + // The leg's viewpoints are gone; all that is left is which leg it + // was. + return QString("viewpoints %1 to %2").arg(segment + 1).arg(segment + 2); + } + return viewpoints.at(segment).name + " to " + + viewpoints.at(segment + 1).name; + } + + // 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; + } - 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]; + QStringList usedLabels; + int idx = 0; + for (auto* node : pip->nodes()) { + auto* source = qobject_cast(node); + if (!source) { + continue; + } - QVariant data; - data.setValue(dataSource); - ui.selectedDataSource->addItem(label, data); + auto label = source->label(); + if (label.isEmpty()) { + label = "Source"; + } - if (dataSource == previouslySelected) { - previouslySelectedIndex = i; + auto uniqueLabel = label; + int n = 1; + while (usedLabels.contains(uniqueLabel)) { + uniqueLabel = label + " " + QString::number(++n); } + usedLabels.append(uniqueLabel); + + ui.selectedDataSource->addItem( + uniqueLabel, QVariant::fromValue(static_cast(source))); + + if (source == previouslySelected) { + previouslySelectedIndex = idx; + } + ++idx; } if (previouslySelectedIndex != -1) { @@ -222,241 +1042,808 @@ 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) { + selectedModuleChanged(); return; } - auto& manager = moduleManager(); + QList sinks; + for (auto* node : pip->nodes()) { + if (hasAnimatableProperties(node) || + ScalarOpacityAnimation::supports(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"; + } else if (qobject_cast(sink)) { + label = "Clip"; + } else if (qobject_cast(sink)) { + label = "Volume"; } - 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; } } if (previouslySelectedIndex != -1) { ui.selectedModule->setCurrentIndex(previouslySelectedIndex); - } else { - selectedModuleChanged(); } - - updateEnableStates(); + selectedModuleChanged(); } - 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()); + populateProperties(); + configurePropertyPage(); + updateEnableStates(); + } + + // Which properties of the selected visualization can be animated. + void populateProperties() + { + QSignalBlocker blocked(ui.animatedProperty); + auto previous = ui.animatedProperty->currentData().toString(); + + ui.animatedProperty->clear(); + + auto* node = selectedSink(); + // The curve morph has its own page; every swept property comes from + // the table + if (ScalarOpacityAnimation::supports(node)) { + ui.animatedProperty->addItem("Opacity curve", "curve"); + } + for (const auto& property : animatableProperties()) { + if (node && property.applies(node)) { + ui.animatedProperty->addItem(property.label(node), property.id); + } } - ui.modulesTabWidget->setCurrentIndex(tabIndex); + int index = ui.animatedProperty->findData(previous); + ui.animatedProperty->setCurrentIndex(index < 0 ? 0 : index); + } - if (qobject_cast(module)) { - setupContourTab(); - } else if (qobject_cast(module)) { - setupSliceTab(); + QString selectedProperty() + { + return ui.animatedProperty->currentData().toString(); + } + + void configurePropertyPage() + { + auto* node = selectedSink(); + QString property = selectedProperty(); + + bool curveProperty = property == "curve"; + ui.propertyStack->setCurrentWidget(curveProperty ? ui.curvePage + : ui.rangePage); + // A curve morph is keyed to the viewpoints themselves, so there is no + // separate leg to pick. + ui.animationSegmentLabel->setEnabled(!curveProperty); + ui.animationSegment->setEnabled(!curveProperty); + + if (curveProperty) { + seedStagedCurves(node); + refreshKeyframeRows(); + return; } - updateEnableStates(); + configureRange(node, property); } - void onDataSourceAdded() + void configureRange(pipeline::Node* node, const QString& property) { - // 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(); + configuredNode = node; + configuredProperty = property; + + PropertyRange range; + if (const auto* animatable = animatableProperty(property)) { + range = animatable->range(node); + } + const QString label = range.label; + const int decimals = range.decimals; + const double lo = range.lo; + const double hi = range.hi; + const double startDefault = range.start; + const double stopDefault = range.stop; + configuredLabel = label; + + // A slice or clip that turns custom, or back, is measured in a + // different unit and offers a differently named property. This + // reconfigures on every reselection and data update, so drop the + // previous connection first or they accumulate. + auto follow = [this](auto* sink) { + using SinkT = std::remove_pointer_t; + disconnect(sink, &SinkT::directionChanged, this, nullptr); + connect(sink, &SinkT::directionChanged, this, [this, sink]() { + if (sink != this->selectedSink()) { + disconnect(sink, nullptr, this, nullptr); + return; + } + this->populateProperties(); + this->configurePropertyPage(); + }); + }; + if (auto* slice = qobject_cast(node)) { + follow(slice); + } else if (auto* clip = qobject_cast(node)) { + follow(clip); + } else if (auto* volume = qobject_cast(node)) { + // The exploded offset's bound follows the slab count and the + // direction, which the panel can change while this is showing + disconnect(volume, &pipeline::VolumeSink::explodedChanged, this, + nullptr); + connect(volume, &pipeline::VolumeSink::explodedChanged, this, + [this, volume]() { + if (volume != this->selectedSink()) { + disconnect(volume, &pipeline::VolumeSink::explodedChanged, + this, nullptr); + return; + } + this->refreshModuleRanges(); + }); + } + + configuredLo = lo; + configuredHi = hi; + + ui.rangeLabel->setText(label); + for (auto* spin : { ui.rangeStart, ui.rangeStop }) { + QSignalBlocker blocked(spin); + spin->setDecimals(decimals); + spin->setRange(lo, hi); + } + ui.rangeStart->setValue(startDefault); + ui.rangeStop->setValue(stopDefault); } - void onDataSourceRemoved() + // Re-read the selected module's data-dependent bounds after the + // pipeline produces new data. Only rebuild when they actually changed, + // so user-entered start/stop values survive executions that don't + // affect this module. + void refreshModuleRanges() { - updateDataSourceOptions(); - updateEnableStates(); + auto* node = selectedSink(); + QString property = selectedProperty(); + if (!node || property.isEmpty() || property == "curve") { + return; + } + + if (node != configuredNode || property != configuredProperty) { + configurePropertyPage(); + return; + } + + const auto* animatable = animatableProperty(property); + if (!animatable) { + return; + } + const PropertyRange range = animatable->range(node); + if (range.label != configuredLabel) { + // A plane that changed orientation is measured in a different unit + // entirely, and offers a differently named property. + populateProperties(); + configurePropertyPage(); + return; + } + if (range.lo != configuredLo || range.hi != configuredHi) { + configureRange(node, property); + } + } + + // Opacity curve keyframes + // + // The curve the histogram editor is showing for the selected volume, + // which is the sink's own if it has been detached and the data's + // otherwise. + vtkPiecewiseFunction* liveOpacityCurve() + { + auto* sink = qobject_cast(selectedSink()); + if (!sink) { + return nullptr; + } + auto* proxy = sink->opacityMap(); + return proxy ? vtkPiecewiseFunction::SafeDownCast( + proxy->GetClientSideObject()) + : nullptr; + } + + // If nothing is staged for this volume but an authored morph exists, + // start from its keyframes so editing is a round trip rather than a + // recapture. + void seedStagedCurves(pipeline::Node* node) + { + if (!node || !stagedCurves.value(node).isEmpty()) { + return; + } + + for (auto* animation : ModuleAnimations::instance().animations()) { + auto* morph = qobject_cast(animation); + // Only a morph authored here: the recorded one belongs to the + // viewpoints and is edited by updating them + if (!morph || morph->recorded() || morph->baseNode != node) { + continue; + } + for (const auto& keyframe : morph->keyframes()) { + auto copy = vtkSmartPointer::New(); + copy->DeepCopy(keyframe.curve); + stagedCurves[node].insert(keyframe.anchor, copy); + } + return; + } + } + + // The curves the viewpoints recorded for this volume, keyed by + // viewpoint, or an empty map when nothing was recorded or an authored + // morph has taken the curve over. + QMap> recordedCurves( + pipeline::Node* node) + { + QMap> curves; + for (auto* animation : ModuleAnimations::instance().animations()) { + auto* morph = qobject_cast(animation); + if (!morph || !morph->recorded() || morph->baseNode != node) { + continue; + } + for (const auto& keyframe : morph->keyframes()) { + curves.insert(keyframe.anchor, keyframe.curve); + } + } + return curves; } - void setupContourTab() + // The first edit to a volume's keyframes starts from what the + // viewpoints recorded, so the recorded morph can be adjusted rather + // than recaptured anchor by anchor. Adding the result authors a morph + // that takes the curve over from the recorded one. + void seedStagedFromRecorded(pipeline::Node* node) { - auto* module = qobject_cast(selectedModule()); - double range[2]; - module->isoRange(range); + if (!node || !stagedCurves.value(node).isEmpty()) { + return; + } + auto recorded = recordedCurves(node); + for (auto it = recorded.constBegin(); it != recorded.constEnd(); ++it) { + auto copy = vtkSmartPointer::New(); + copy->DeepCopy(it.value()); + stagedCurves[node].insert(it.key(), copy); + } + } - ui.contourStart->setMinimum(range[0]); - ui.contourStart->setMaximum(range[1]); - ui.contourStop->setMinimum(range[0]); - ui.contourStop->setMaximum(range[1]); + static bool curveIsAllZero(vtkPiecewiseFunction* curve) + { + double node[4]; + for (int i = 0; curve && i < curve->GetSize(); ++i) { + curve->GetNodeValue(i, node); + if (node[1] > 0.0) { + return false; + } + } + return true; + } - // 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]); + // One row per viewpoint, or a plain start and end when there is no + // camera path to key against. + int keyframeRowCount() + { + return std::max(2, CameraViewpoints::instance().size()); } - void setupSliceTab() + void refreshKeyframeRows() { - auto* module = qobject_cast(selectedModule()); - double max = module->maxSlice(); + if (selectedProperty() != "curve") { + return; + } - ui.sliceStart->setMinimum(0); - ui.sliceStart->setMaximum(max); - ui.sliceStop->setMinimum(0); - ui.sliceStop->setMaximum(max); + QSignalBlocker blocked(ui.keyframeList); + int previousRow = ui.keyframeList->currentRow(); + ui.keyframeList->clear(); - // Default to the full range - ui.sliceStart->setValue(0); - ui.sliceStop->setValue(max); + auto* node = selectedSink(); + auto* volume = qobject_cast(node); + if (!volume) { + return; + } - // 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; + // Both previews and the blend read the curves over the volume's own + // window; drawn over their own ranges, a narrow spike and a + // full-width ramp look identical. + double range[2] = { 0.0, 1.0 }; + auto volumeData = volume->volumeData(); + if (volumeData && volumeData->isValid()) { + auto volumeRange = volumeData->colorMapRange(); + range[0] = volumeRange[0]; + range[1] = volumeRange[1]; + } + + auto& viewpoints = CameraViewpoints::instance(); + auto staged = stagedCurves.value(node); + // Until the user edits, the list shows what the viewpoints recorded + // for this volume, so the recorded morph is something to look at + // rather than take on faith. + const bool showingRecorded = staged.isEmpty(); + auto recorded = showingRecorded ? recordedCurves(node) : decltype(staged)(); + int rows = keyframeRowCount(); + for (int anchor = 0; anchor < rows; ++anchor) { + QString label; + if (viewpoints.size() >= 2) { + label = "At " + viewpoints.at(anchor).name; + } else { + label = anchor == 0 ? "At start" : "At end"; } - // Update the slice tab min/max - this->setupSliceTab(); + auto* item = new QListWidgetItem(); + if (staged.contains(anchor)) { + item->setText(label); + item->setIcon(QIcon(curvePreview(staged.value(anchor), range, + ui.keyframeList->iconSize(), + parent->palette()))); + } else if (recorded.contains(anchor)) { + auto curve = recorded.value(anchor); + item->setText(label + (curveIsAllZero(curve) ? " (recorded: hidden)" + : " (recorded)")); + item->setToolTip("Recorded with the viewpoint. Update the viewpoint " + "to change it, or capture over it here to start a " + "morph of your own from the recorded curves."); + item->setIcon(QIcon(curvePreview(curve, range, + ui.keyframeList->iconSize(), + parent->palette()))); + } else { + item->setText(label + " (not captured)"); + // A blank icon keeps the labels aligned with the captured rows. + QPixmap blank(ui.keyframeList->iconSize()); + blank.fill(Qt::transparent); + item->setIcon(QIcon(blank)); + } + ui.keyframeList->addItem(item); + } + + if (previousRow >= 0 && previousRow < rows) { + ui.keyframeList->setCurrentRow(previousRow); + } + } + + void captureCurve() + { + auto* node = selectedSink(); + auto* live = liveOpacityCurve(); + int anchor = ui.keyframeList->currentRow(); + if (!node || !live || anchor < 0) { + return; + } + + seedStagedFromRecorded(node); + auto copy = vtkSmartPointer::New(); + copy->DeepCopy(live); + // The histogram editor parks nodes at the ends of the data range + // purely so the chart looks right. Saving them would make a captured + // curve's point count depend on what a panel happened to be doing, and + // the point count decides whether two curves can be blended point by + // point. + removePlaceholderNodes(copy); + + stagedCurves[node].insert(anchor, copy); + refreshKeyframeRows(); + updateEnableStates(); + } + + // Put a captured curve back into the histogram editor, so it can be + // looked at and edited. The counterpart of double clicking a viewpoint + // to move the camera there. + void loadCurveIntoEditor(QListWidgetItem* item) + { + auto* node = selectedSink(); + auto* live = liveOpacityCurve(); + int anchor = ui.keyframeList->row(item); + if (!node || !live || anchor < 0) { + return; + } + + auto captured = stagedCurves.value(node).value(anchor); + if (!captured) { + return; + } + + live->DeepCopy(captured); + + // Captures strip the nodes the editor parks at the ends of the data + // range, so put them back or its chart stops spanning the range. + auto* volume = qobject_cast(node); + auto volumeData = volume ? volume->volumeData() : nullptr; + if (volumeData && volumeData->isValid()) { + auto colorRange = volumeData->colorMapRange(); + double range[2] = { colorRange[0], colorRange[1] }; + addPlaceholderNodes(live, range); + } + + // The editor watches this function and redraws, re-renders and + // mirrors the change into its proxy off the one event. Render + // anyway, in case the editor is currently showing another data + // source and nothing else would. + live->Modified(); + activeObjects().renderAllViews(); + } + + void clearCurve() + { + auto* node = selectedSink(); + int anchor = ui.keyframeList->currentRow(); + if (!node) { + return; + } + + seedStagedFromRecorded(node); + stagedCurves[node].remove(anchor); + refreshKeyframeRows(); + updateEnableStates(); + } + + void onNodeAdded(pipeline::Node*) + { + QTimer::singleShot(0, this, [this]() { + updateDataSourceOptions(); + updateModuleOptions(); }); } + void onNodeRemoved(pipeline::Node* node) + { + stagedCurves.remove(node); + updateDataSourceOptions(); + updateModuleOptions(); + updateEnableStates(); + } + + // Adding an animation 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(); - moduleAnimations.removeAt(i); - --i; + QString property = selectedProperty(); + auto segmentData = ui.animationSegment->currentData(); + int segment = property == "curve" + ? -1 + : (segmentData.isValid() ? segmentData.toInt() : -1); + + ModuleAnimation* animation = buildAnimation(node, property); + if (!animation) { + return; + } + animation->segment = segment; + + // One animation per visualization, property and leg: adding the same + // thing again replaces it, while the same property on another leg is + // a second animation. + // Recorded animations are not replaced here: they step aside from + // the legs an authored one runs on by themselves. + for (auto* existing : ModuleAnimations::instance().animations()) { + if (!existing->recorded() && existing->baseNode == node && + existing->type() == animation->type() && + (property == "curve" || existing->segment == segment)) { + ModuleAnimations::instance().remove(existing); } } - if (qobject_cast(module)) { - addContourAnimation(); - } else if (qobject_cast(module)) { - addSliceAnimation(); - } else { - qDebug() << "Unknown module type: " << module; - } + ModuleAnimations::instance().add(animation); + ensureAnimationFrames(); + // Same reason as the camera path: put the module at its starting + // value rather than leaving it wherever it was until the clock + // first moves. + animation->onTimeChanged(); updateEnableStates(); play(); } - void addContourAnimation() + ModuleAnimation* buildAnimation(pipeline::Node* node, + const QString& property) { - auto start = ui.contourStart->value(); - auto stop = ui.contourStop->value(); - auto* module = qobject_cast(selectedModule()); - moduleAnimations.append(new ContourAnimation(module, start, stop)); + double start = ui.rangeStart->value(); + double stop = ui.rangeStop->value(); + + if (const auto* animatable = animatableProperty(property)) { + return animatable->make(node, start, stop); + } + if (property == "curve") { + auto* volume = qobject_cast(node); + if (volume && ScalarOpacityAnimation::supports(node)) { + QList keyframes; + auto staged = stagedCurves.value(node); + const int rows = keyframeRowCount(); + for (auto it = staged.constBegin(); it != staged.constEnd(); ++it) { + // Curves captured against viewpoints that have since been + // removed are kept in case those viewpoints come back, but + // they are not part of what the list is currently offering. + if (it.key() >= rows) { + continue; + } + OpacityKeyframe keyframe; + keyframe.anchor = it.key(); + keyframe.curve = it.value(); + keyframes.append(keyframe); + } + if (keyframes.size() >= 2) { + return new ScalarOpacityAnimation(volume, keyframes); + } + } + } + + return nullptr; } - void addSliceAnimation() + // One row of the animation list, with its remove button. The removal + // is deferred because the button lives in the row it deletes. + void addAnimationRow(const QString& text, const QString& tooltip, + const QString& removeTooltip, + std::function remove, + std::function select) { - auto start = ui.sliceStart->value(); - auto stop = ui.sliceStop->value(); - auto* module = qobject_cast(selectedModule()); - moduleAnimations.append(new SliceAnimation(module, start, stop)); + animationRowSelectors.append(select); + auto* row = new QWidget(); + auto* layout = new QHBoxLayout(row); + layout->setContentsMargins(4, 1, 4, 1); + auto* label = new QLabel(text, row); + label->setToolTip(tooltip); + layout->addWidget(label); + layout->addStretch(); + auto* removeButton = new QToolButton(row); + removeButton->setText("x"); + removeButton->setAutoRaise(true); + removeButton->setToolTip(removeTooltip); + layout->addWidget(removeButton); + connect(removeButton, &QToolButton::clicked, this, [remove]() { + QTimer::singleShot(0, remove); + }); + + auto* item = new QListWidgetItem(); + item->setSizeHint(row->sizeHint()); + ui.animationList->addItem(item); + ui.animationList->setItemWidget(item, row); } - void clearModuleAnimations() + QString nodeLabel(pipeline::Node* node) { - for (auto animation : moduleAnimations) { - animation->deleteLater(); + QString label = node ? node->label() : QString(); + return label.isEmpty() ? QString("Visualization") : label; + } + + // The list of everything that will animate, one row per animation, so + // authoring is visible state rather than something to remember. The + // animations authored here come first; then, marked as such, every + // change the camera viewpoints recorded. + void refreshAnimationList() + { + QSignalBlocker blocked(ui.animationList); + ui.animationList->clear(); + animationRowSelectors.clear(); + + for (auto* animation : ModuleAnimations::instance().animations()) { + if (animation->recorded()) { + continue; + } + QString text = + nodeLabel(animation->baseNode) + ": " + animation->describeParameters(); + if (animation->segment >= 0) { + text += ", during " + segmentLabel(animation->segment); + } + QPointer target(animation); + addAnimationRow( + text, QString(), "Remove this animation", + [target]() { + if (target) { + ModuleAnimations::instance().remove(target); + } + }, + [this, target]() { + if (target) { + showAnimationInControls(target); + } + }); + } + + auto& viewpoints = CameraViewpoints::instance(); + auto* pip = pipeline(); + for (const auto& change : RecordedAnimations::instance().changes(pip)) { + auto* node = pip ? pip->nodeById(change.nodeId) : nullptr; + const QString from = viewpoints.at(change.fromAnchor).name; + const QString to = viewpoints.at(change.toAnchor).name; + QString text = nodeLabel(node) + ": " + change.description + ", " + + from + " to " + to + " (recorded)"; + QString tooltip = + "Recorded with the viewpoints: this is how the visualization " + "differs between " + from + " and " + to + ". Update " + to + + " from the view to change it."; + QString removeTooltip = "Keep it as it is at " + from + + " on this leg (edits " + to + ")"; + addAnimationRow( + text, tooltip, removeTooltip, + [change, pip]() { RecordedAnimations::instance().remove(change, pip); }, + [this, change, node]() { + // A stretch over blank viewpoints is several legs; the combo + // takes one, so offer the first and let the user pick another + showInControls(node, + change.controlProperty.isEmpty() + ? change.property + : change.controlProperty, + change.startValue, change.stopValue, + change.fromAnchor); + }); + } + } + + void selectAnimationRow(int row) + { + if (row >= 0 && row < animationRowSelectors.size() && + animationRowSelectors[row]) { + animationRowSelectors[row](); } + } - moduleAnimations.clear(); + // Put an authored animation into the controls: its visualization, + // property, values and leg. Adding again then replaces it. + void showAnimationInControls(ModuleAnimation* animation) + { + auto* node = animation ? animation->baseNode.data() : nullptr; + if (!node) { + return; + } + std::optional start; + std::optional stop; + QString property = animation->type(); + double from = 0.0; + double to = 0.0; + if (const auto* animatable = animatablePropertyOf(animation, from, to)) { + property = animatable->id; + start = from; + stop = to; + } else if (qobject_cast(animation)) { + property = "curve"; + } + showInControls(node, property, start, stop, animation->segment); + } + // Select @a node's data source, the node, and @a property in the + // authoring controls, then the values and the leg. Properties the + // controls cannot author (visibility, hidden labels) select the + // visualization and leave the property as it was. + void showInControls(pipeline::Node* node, const QString& property, + std::optional start, std::optional stop, + int segment) + { + if (!node) { + return; + } + auto* source = findUpstreamSource(node); + int sourceIndex = ui.selectedDataSource->findData( + QVariant::fromValue(static_cast(source))); + if (sourceIndex >= 0 && sourceIndex != ui.selectedDataSource->currentIndex()) { + QSignalBlocker blocked(ui.selectedDataSource); + ui.selectedDataSource->setCurrentIndex(sourceIndex); + updateModuleOptions(); + } + int moduleIndex = ui.selectedModule->findData( + QVariant::fromValue(static_cast(node))); + if (moduleIndex < 0) { + return; + } + if (moduleIndex != ui.selectedModule->currentIndex()) { + QSignalBlocker blocked(ui.selectedModule); + ui.selectedModule->setCurrentIndex(moduleIndex); + selectedModuleChanged(); + } + int propertyIndex = ui.animatedProperty->findData(property); + if (propertyIndex >= 0 && + propertyIndex != ui.animatedProperty->currentIndex()) { + QSignalBlocker blocked(ui.animatedProperty); + ui.animatedProperty->setCurrentIndex(propertyIndex); + configurePropertyPage(); + } + if (propertyIndex >= 0 && property != "curve") { + if (start) { + ui.rangeStart->setValue(*start); + } + if (stop) { + ui.rangeStop->setValue(*stop); + } + int segmentIndex = ui.animationSegment->findData(segment); + if (segmentIndex >= 0) { + ui.animationSegment->setCurrentIndex(segmentIndex); + } + } updateEnableStates(); } // 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. + interruptAnimationPlayback(); pqSMAdaptor::setEnumerationProperty( scene()->getProxy()->GetProperty("PlayMode"), "Sequence"); + // qtWidgetChanged is emitted after the property link has copied and + // flushed the frame count, so the play mode needs its own push to + // reach the animation player. + scene()->getProxy()->UpdateVTKObjects(); } - void exportMovie() { pqSaveAnimationReaction::saveAnimation(); } + void exportMovie() { MovieExportDialog::exportMovie(parent); } void clearAllAnimations() { - clearCameraAnimations(); - if (ui.enableTimeSeriesAnimations->isEnabled()) { + auto& viewpoints = CameraViewpoints::instance(); + // The viewpoints are the camera animation, so they go with the rest; + // clearing them stops the flight. + viewpoints.clear(); + if (ui.enableTimeSeriesAnimations->isVisible()) { ui.enableTimeSeriesAnimations->setChecked(false); } - clearModuleAnimations(); + ModuleAnimations::instance().clear(); updateEnableStates(); } ActiveObjects& activeObjects() { return ActiveObjects::instance(); } - ModuleManager& moduleManager() { return ModuleManager::instance(); } + pipeline::Pipeline* pipeline() + { + return activeObjects().pipeline(); + } pqAnimationScene* scene() { @@ -469,8 +1856,17 @@ 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; +void AnimationHelperDialog::showEvent(QShowEvent* e) +{ + QDialog::showEvent(e); + m_internal->refresh(); +} + } // namespace tomviz diff --git a/tomviz/AnimationHelperDialog.h b/tomviz/AnimationHelperDialog.h index 871d7db23..51894ae4a 100644 --- a/tomviz/AnimationHelperDialog.h +++ b/tomviz/AnimationHelperDialog.h @@ -17,6 +17,9 @@ class AnimationHelperDialog : public QDialog explicit AnimationHelperDialog(QWidget* parent); ~AnimationHelperDialog() override; +protected: + void showEvent(QShowEvent* e) override; + private: Q_DISABLE_COPY(AnimationHelperDialog) diff --git a/tomviz/AnimationHelperDialog.ui b/tomviz/AnimationHelperDialog.ui index fbe57cf4e..894bde1f6 100644 --- a/tomviz/AnimationHelperDialog.ui +++ b/tomviz/AnimationHelperDialog.ui @@ -6,348 +6,415 @@ 0 0 - 699 - 449 + 540 + 700 Animation Helper - - - - - Number of Frames: - - - numberOfFrames - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - false - - - 1 - - - 10000000 - - - - - - - Clear All Animations - - + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + VCR Toolbar + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + - - - - VCR Toolbar - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - + - 2 + 0 Camera - - - - - <html><head/><body><p>Create a camera orbit animation using the current camera settings.<br/></p><p>The orbit will start at the current camera position and move to the right around the camera focal point.<br/></p><p>Note that if any other camera animations exist for this render view, they will be removed.</p></body></html> - + + + - Create Camera Orbit + Viewpoints: + + + viewpointList - - - - Clear Camera Animations + + + + <html><head/><body><p>Camera positions the animation flies through, in order.</p><p><br/></p><p>Drag to reorder. Double click a viewpoint to move the camera to it. Right click for more, including rename.</p></body></html> - - - - Qt::Vertical - - - - 20 - 40 - - - + + + + + + Save the current camera position as a new viewpoint. + + + Add Current View + + + + + + + false + + + Replace the selected viewpoint with the current camera position. + + + Update From View + + + + + + + false + + + Remove + + + + + + + Qt::Horizontal + + + + 10 + 10 + + + + + + + + + + + + Leg to next: + + + segmentDuration + + + + + + + false + + + false + + + <html><head/><body><p>Frames the leg from this viewpoint to the next one takes.</p><p><br/></p><p>The animation is as long as its legs and orbits add up to, so a longer leg here never shortens another.</p></body></html> + + + frames + + + 0 + + + 100000 + + + 10 + + + 100 + + + + + + + false + + + Slow the camera down as it leaves this viewpoint and again as it arrives at the next one. + + + Ease In/Out + + + true + + + + - + - Time Series + Visualizations - + - - - <html><head/><body><p>Uncheck to disable time series animations.</p><p><br/></p><p>This setting only applies if there is a data source with time series animations.</p></body></html> - + - Enable Time Series Animations? + Data Source: - - true + + selectedDataSource - - - - Qt::Vertical - - - - 20 - 40 - + + + + <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> - + - - - - - Modules - - - + - Module: + Visualization: selectedModule - - + + + + <html><head/><body><p>Select a visualization to animate.</p><p><br/></p><p>Currently supported: Contour, Slice, Clip, Threshold, Segment, Volume, Label Map</p></body></html> + + + + + + + Animate: + + + animatedProperty + + + + + + + <html><head/><body><p>Which property of this visualization to animate.</p></body></html> + + + + + - 2 + 0 - - - None - - - - - Contour - - - - - - - 0 - 0 - - - - - - - - contourStop - - - - - + + + + Range: - contourStart + rangeStart - - + + - <html><head/><body><p>Start ISO value</p></body></html> + Value at the start of the animation. - 8 + 2 - -1000000.000000000000000 + -100000.000000000000000 - 10000000.000000000000000 + 100000.000000000000000 + + + + + + + + 0 + 0 + + + + - - - + + - <html><head/><body><p>Stop ISO value</p></body></html> + Value at the end of the animation. - 8 + 2 - -1000000.000000000000000 + -100000.000000000000000 - 10000000.000000000000000 + 100000.000000000000000 - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - Slice - - - - + + + + - Range: - - - - - - - 0 + Curve keyframes: - - 100000.000000000000000 + + keyframeList - - - - 0 - - - 100000.000000000000000 + + + + <html><head/><body><p>The opacity curve the volume shows as it arrives at each viewpoint. The volume morphs smoothly between the captured curves.</p><p><br/></p><p>Shape the curve in the histogram editor, select a viewpoint here, and capture it. Viewpoints without a captured curve are skipped.</p><p><br/></p><p>Double click a captured curve to load it back into the histogram editor.</p></body></html> - - - - - 0 - 0 - - - - - - - + + + + + + false + + + Save the curve currently in the histogram editor for the selected viewpoint. + + + Capture Current Curve + + + + + + + false + + + Clear + + + + + + + Qt::Horizontal + + + + 10 + 10 + + + + + - - + + - Data Source: + Run during: - selectedDataSource + animationSegment + + + + + + + <html><head/><body><p>When this animation runs.</p><p><br/></p><p>Pick a leg of the camera path to have the visualization do its whole change while the camera flies that leg, and hold still either side of it. Over the whole animation, it changes from start to stop across the entire timeline.</p></body></html> - + 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 this animation to the list below. Adding again with the same visualization, property and timing replaces the old one.</p></body></html> - Add Module Animation + Add Animation - - - - false - + + - Clear Module Animations + 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> + + animationList - - + + - <html><head/><body><p>Select a module for animation.</p><p><br/></p><p>Current supported list:<br/></p><p>Contour</p></body></html> + Every visualization animation that will play, with a button to remove each one. @@ -355,42 +422,73 @@ - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 5 - - - + + + + + + <html><head/><body><p>Step the loaded time series while the animation plays.</p></body></html> + + + Play time series + + + true + + + + + + + Qt::Horizontal + + + + 10 + 10 + + + + + + + + Number of Frames: + + + numberOfFrames + + + + + + + false + + + 1 + + + 100000 + + + + - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + Export Movie + + + + Clear All Animations + + + @@ -402,16 +500,24 @@ AnimationTypeTabWidget - createCameraOrbit - clearCameraAnimations - enableTimeSeriesAnimations + viewpointList + addViewpoint + updateViewpoint + removeViewpoint + segmentDuration + segmentEased selectedDataSource selectedModule - modulesTabWidget - contourStart - contourStop + animatedProperty + rangeStart + rangeStop + keyframeList + captureCurve + clearCurve + animationSegment addModuleAnimation - clearModuleAnimations + animationList + enableTimeSeriesAnimations numberOfFrames exportMovie clearAllAnimations diff --git a/tomviz/AnimationSerializer.cxx b/tomviz/AnimationSerializer.cxx new file mode 100644 index 000000000..71a0f0b6d --- /dev/null +++ b/tomviz/AnimationSerializer.cxx @@ -0,0 +1,94 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "AnimationSerializer.h" + +#include "ActiveObjects.h" +#include "Utilities.h" +#include "animations/CameraViewpoints.h" +#include "animations/ModuleAnimations.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace tomviz { + +namespace { + +vtkSMProxy* animationSceneProxy() +{ + auto* core = pqPVApplicationCore::instance(); + auto* manager = core ? core->animationManager() : nullptr; + auto* scene = manager ? manager->getActiveScene() : nullptr; + return scene ? scene->getProxy() : nullptr; +} + +} // anonymous namespace + +void AnimationSerializer::save(QJsonObject& doc) +{ + auto animation = CameraViewpoints::instance().serialize(); + + auto modules = + ModuleAnimations::instance().serialize(ActiveObjects::instance().pipeline()); + animation["modules"] = modules["modules"]; + + if (auto* scene = animationSceneProxy()) { + animation["numberOfFrames"] = + vtkSMPropertyHelper(scene, "NumberOfFrames").GetAsInt(); + } + + doc["animation"] = animation; +} + +void AnimationSerializer::restore(const QJsonObject& doc, + pipeline::Pipeline* pipeline) +{ + auto animation = doc["animation"].toObject(); + + auto& viewpoints = CameraViewpoints::instance(); + if (!viewpoints.deserialize(animation)) { + viewpoints.clear(); + } + + ModuleAnimations::instance().deserialize(animation, pipeline); + + auto* renderView = ActiveObjects::instance().activePqRenderView(); + auto* viewProxy = renderView ? renderView->getRenderViewProxy() : nullptr; + auto* camera = viewProxy ? viewProxy->GetActiveCamera() : nullptr; + // Files from before orbits were viewpoints flagged a ParaView orbit + // cue instead; the same spin is now a viewpoint that orbits. + if (animation["cameraOrbit"].toBool() && viewpoints.size() == 0 && + camera) { + Viewpoint viewpoint; + viewpoint.readFrom(camera); + viewpoint.name = "Camera Orbit"; + viewpoint.orbitTurns = 1; + viewpoints.append(viewpoint); + } + // The camera flies whenever there is a path. + viewpoints.stopFlight(); + viewpoints.syncFlight(renderView); + + // Loading a state file builds a fresh animation scene, which the view + // restore leaves on the frame count used for newly loaded data. A file + // that recorded its own count knows better; one that did not keeps it. + // With a path the count is what its legs and orbits add up to; the + // saved total only applies without one. + int numberOfFrames = animation["numberOfFrames"].toInt(0); + if (viewpoints.isPath()) { + setAnimationNumberOfFrames(viewpoints.totalFrames()); + } else if (numberOfFrames > 0) { + setAnimationNumberOfFrames(numberOfFrames); + } +} + +} // namespace tomviz diff --git a/tomviz/AnimationSerializer.h b/tomviz/AnimationSerializer.h new file mode 100644 index 000000000..bf5b1028b --- /dev/null +++ b/tomviz/AnimationSerializer.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 tomvizAnimationSerializer_h +#define tomvizAnimationSerializer_h + +#include + +namespace tomviz { + +namespace pipeline { +class Pipeline; +} + +/// Serialization helper for the top-level "animation" section of a +/// Tomviz state file: the frame count, the saved camera viewpoints and +/// the visualization animations. Both state formats go through here, in +/// the same spirit as ViewsLayoutsSerializer. +class AnimationSerializer +{ +public: + /// Write the animation section into @a doc. Safe only where + /// pqApplicationCore has been initialized. + static void save(QJsonObject& doc); + + /// Restore the animation section from @a doc. Visualization + /// animations name their nodes by the id the pipeline assigns, so + /// @a pipeline must already be loaded. A file with no animation + /// section leaves an empty animation state rather than whatever the + /// previous file put there. + static void restore(const QJsonObject& doc, pipeline::Pipeline* pipeline); +}; + +} // namespace tomviz + +#endif 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..a73b2d35d 100644 --- a/tomviz/Behaviors.cxx +++ b/tomviz/Behaviors.cxx @@ -5,12 +5,21 @@ #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 "SelectVolumeRangeWidget.h" +#include "LabelSelectionWidget.h" +#include "ThresholdRangeWidget.h" +#include "ShiftRotationCenterWidget.h" #include "RotateAlignWidget.h" +#include "CustomNodeWidgetRegistry.h" +#include "ClippingRangeBehavior.h" +#include "ImageViewerModeBehavior.h" #include "TimeSeriesLabel.h" +#include "TrackpadTouchFix.h" #include "ViewFrameActions.h" #include @@ -18,7 +27,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -87,8 +98,18 @@ Behaviors::Behaviors(QMainWindow* mainWindow) : QObject(mainWindow) new pqPersistentMainWindowStateBehavior(mainWindow); new tomviz::AddRenderViewContextMenuBehavior(this); + new tomviz::ImageViewerModeBehavior(this); + new tomviz::ClippingRangeBehavior(this); + + // ParaView's view widgets are QVTKOpenGLNativeWidget on macOS, so they need + // the same trackpad workaround our own 2D views get in QVTKGLWidget. + connect(pqApplicationCore::instance()->getServerManagerModel(), + &pqServerManagerModel::viewAdded, this, [](pqView* view) { + if (view) { + disableTrackpadTouchEvents(view->widget()); + } + }); - 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 +123,28 @@ 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( + "ThresholdRangeWidget", /*needsData=*/true); + registerCustomNodeWidget( + "LabelSelectionWidget", /*needsData=*/true); + registerCustomNodeWidget( + "SelectVolumeRangeWidget", /*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..1156d3e77 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); @@ -394,14 +403,19 @@ BrightnessContrastWidget::BrightnessContrastWidget( emit resetPressed(); }); + connect(ui.autoRegionButton, &QPushButton::pressed, this, [this]() { + m_internals->reset(); + emit autoRegionPressed(); + }); + updateGui(); } 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..1bbcf05c9 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(); @@ -37,6 +42,9 @@ class BrightnessContrastWidget : public QWidget signals: void autoPressed(); void resetPressed(); + /// Auto-contrast using only the voxels inside a region the user + /// picks, rather than the whole volume. + void autoRegionPressed(); private: class Internals; diff --git a/tomviz/BrightnessContrastWidget.ui b/tomviz/BrightnessContrastWidget.ui index 97af861bf..822088287 100644 --- a/tomviz/BrightnessContrastWidget.ui +++ b/tomviz/BrightnessContrastWidget.ui @@ -68,6 +68,16 @@ + + + + Auto-adjust the contrast using only the voxels inside a region you select, rather than the whole volume. + + + Auto (Selected Region)... + + + diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 6a88e0e2b..b3a813f57 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -15,17 +15,17 @@ 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 AnimationHelperDialog.h + AnimationSerializer.cxx + AnimationSerializer.h ArrayWranglerReaction.cxx ArrayWranglerReaction.h AxesReaction.cxx @@ -33,6 +33,8 @@ set(SOURCES Behaviors.cxx Behaviors.h BrightnessContrastWidget.cxx + OpacityPresetWidget.cxx + OpacityPresetWidget.h BrightnessContrastWidget.h CameraReaction.cxx CameraReaction.h @@ -46,14 +48,26 @@ set(SOURCES ColorMapSettingsWidget.h ComboTextEditor.cxx ComboTextEditor.h - ConformVolumeDialog.cxx - ConformVolumeDialog.h - ConformVolumeReaction.cxx - ConformVolumeReaction.h ConvertToFloatReaction.cxx ConvertToFloatReaction.h CropReaction.cxx CropReaction.h + CustomOperatorEditDialog.cxx + CustomOperatorEditDialog.h + CustomOperatorManagerDialog.cxx + CustomOperatorManagerDialog.h + SAM2SeedWidget.cxx + SAM2SeedWidget.h + SelectCylinderWidget.cxx + SelectCylinderWidget.h + SelectVolumeRangeWidget.cxx + SelectVolumeRangeWidget.h + ThresholdRangeWidget.cxx + ThresholdRangeWidget.h + LabelSelection.cxx + LabelSelection.h + LabelSelectionWidget.cxx + LabelSelectionWidget.h SelectVolumeWidget.cxx SelectVolumeWidget.h DataBroker.cxx @@ -70,30 +84,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 @@ -114,12 +114,12 @@ set(SOURCES Histogram2DWidget.cxx ImageStackDialog.h ImageStackDialog.cxx + ClippingRangeBehavior.cxx + ClippingRangeBehavior.h + ImageViewerModeBehavior.cxx + ImageViewerModeBehavior.h ImageStackModel.h ImageStackModel.cxx - InterfaceBuilder.h - InterfaceBuilder.cxx - InternalPythonHelper.h - InternalPythonHelper.cxx IntSliderWidget.cxx IntSliderWidget.h ListEditorWidget.cxx @@ -136,34 +136,16 @@ 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 + MovieExportDialog.cxx + MovieExportDialog.h + InteractiveTransformWidget.cxx + InteractiveTransformWidget.h + PortDataWriter.cxx + PortDataWriter.h PresetDialog.cxx PresetDialog.h PresetModel.cxx @@ -172,22 +154,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 +186,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 +207,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 +216,223 @@ set(SOURCES TomographyReconstruction.cxx TomographyTiltSeries.h TomographyTiltSeries.cxx + TrackpadTouchFix.h + TrackpadTouchFix.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/PythonEnvironmentCheck.cxx + pipeline/PythonEnvironmentCheck.h + pipeline/NodeExecutorFactory.cxx + pipeline/NodeExecutorFactory.h + pipeline/AutoExecuteController.cxx + pipeline/AutoExecuteController.h + pipeline/ProgressReader.cxx + pipeline/ProgressReader.h + pipeline/PythonNodeWrapper.cxx + pipeline/PythonNodeWrapper.h + pipeline/EnumOptions.cxx + pipeline/EnumOptions.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/data/LabelMapData.cxx + pipeline/data/LabelMapData.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/ExplodedGeometry.cxx + pipeline/sinks/ExplodedGeometry.h + pipeline/sinks/VolumeSink.cxx + pipeline/sinks/VolumeSink.h + pipeline/sinks/LabelMapSink.cxx + pipeline/sinks/LabelMapSink.h + pipeline/sinks/MultiVolumeCoordinator.cxx + pipeline/sinks/MultiVolumeCoordinator.h + pipeline/sinks/LabelMapSurface.cxx + pipeline/sinks/LabelMapSurface.h + pipeline/sinks/LabelTableWidget.cxx + pipeline/sinks/LabelTableWidget.h + pipeline/sinks/LightingPresetStore.cxx + pipeline/sinks/LightingPresetStore.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/NodeDefinitionValidator.cxx + pipeline/NodeDefinitionValidator.h + pipeline/NodeDefinitionEdits.cxx + pipeline/NodeDefinitionEdits.h + pipeline/NodeDefinitionFormWidget.cxx + pipeline/NodeDefinitionFormWidget.h + pipeline/NodeDefinitionWidget.cxx + pipeline/NodeDefinitionWidget.h + pipeline/PythonNodeEditorWidget.cxx + pipeline/PythonNodeEditorWidget.h + pipeline/PythonScriptEdit.cxx + pipeline/PythonScriptEdit.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 +458,6 @@ set(SOURCES vtkLengthScaleRepresentation.cxx vtkActiveScalarsProducer.h vtkActiveScalarsProducer.cxx - vtkNonOrthoImagePlaneWidget.cxx - vtkNonOrthoImagePlaneWidget.h vtkOMETiffReader.cxx vtkOMETiffReader.h vtkTriangleBar.cxx @@ -284,51 +472,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,88 +485,49 @@ 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 animations/ModuleAnimation.h + animations/ModuleAnimations.cxx + animations/ModuleAnimations.h + animations/OpacityInterpolation.cxx + animations/OpacityInterpolation.h + animations/ScalarOpacityAnimation.h + animations/CameraAnimation.h + animations/AnimationSceneGuard.cxx + animations/CameraViewpoints.cxx + animations/CameraViewpoints.h + animations/RecordedAnimations.cxx + animations/RecordedAnimations.h + animations/SceneSnapshot.cxx + animations/SceneSnapshot.h + animations/AnimatableProperties.cxx + animations/AnimatableProperties.h + animations/ClipAnimation.h animations/ContourAnimation.h + animations/CutOutAnimation.h + animations/ExplodedAnimation.h + animations/OpacityAnimation.h animations/SliceAnimation.h + animations/SolidityAnimation.h + animations/ThresholdAnimation.h + animations/TimeSeriesAnimation.h ) include_directories(${CMAKE_CURRENT_SOURCE_DIR}/animations) @@ -425,21 +535,30 @@ 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 CircleMask.py + CombineDatasets.py + FourierFilter.py + FourierPeakMask.py + FourierMask.py + ImageMath.py ConnectedComponents.py + RemoveLabels.py ClipEdges.py OtsuMultipleThreshold.py BinaryDilate.py @@ -491,20 +610,28 @@ set(python_files ClearVolume.py ConstantDataset.py RandomParticles.py + PtychoSource.py + PyXRFSource.py GenerateTiltSeries.py SetNegativeVoxelsToZero.py ShiftTiltSeriesRandomly.py STEM_probe.py + SimulatedLiveAcquisition.py InvertData.py Pad_Data.py BinVolumeByTwo.py BinTiltSeriesByTwo.py + DefaultCustomTransform.py + NewCustomTransform.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 @@ -519,6 +646,7 @@ set(python_files set(json_files AddPoissonNoise.json + NewCustomTransform.json AutoCenterOfMassTiltImageAlignment.json AutoCrossCorrelationTiltImageAlignment.json AutoTiltAxisRotationAlignment.json @@ -526,8 +654,15 @@ set(json_files PyStackRegImageAlignment.json BinaryThreshold.json CircleMask.json + CombineDatasets.json + FourierFilter.json + FourierPeakMask.json + FourierMask.json + ImageMath.json ConnectedComponents.json + RemoveLabels.json ClipEdges.json + ClearVolume.json OtsuMultipleThreshold.json BinaryDilate.json BinaryErode.json @@ -545,6 +680,7 @@ set(json_files Pad_Data.json Resample.json DeleteSlices.json + FFT_AbsLog.json GaussianFilter.json GaussianFilterTiltSeries.json PeronaMalikAnisotropicDiffusion.json @@ -565,8 +701,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 +716,13 @@ 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 + SimulatedLiveAcquisition.json ) set(resource_files @@ -608,6 +754,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} @@ -634,20 +795,22 @@ foreach(file ${template_files}) COMPONENT runtime) endforeach() +# The generic pipeline engine lives in the external tomviz-pipeline +# package (import name tomviz_pipeline), installed alongside this +# package's Python dependencies. Only the app-side layer is staged here. set(tomviz_python_modules __init__.py + _boundary.py _internal.py + _pipeline_requirement.py dataset.py - executor.py - external_dataset.py fix_pdb.py + nodes.py operators.py - internal_dataset.py internal_utils.py itkutils.py utils.py web.py - modules.py ) file(MAKE_DIRECTORY "${tomviz_python_binary_dir}/tomviz") @@ -694,7 +857,7 @@ endforeach() set(tomviz_python_pyxrf_modules __init__.py ic_names.py - load_output.py + scan_metadata.py sids.py ) @@ -717,15 +880,6 @@ foreach(file ${tomviz_python_ptycho_modules}) "${tomviz_python_binary_dir}/tomviz/ptycho/${file}") endforeach() -file(GLOB tomviz_python_state_modules "${tomviz_SOURCE_DIR}/tomviz/python/tomviz/state/*.py") -file(MAKE_DIRECTORY "${tomviz_python_binary_dir}/tomviz/state") -foreach(file_ ${tomviz_python_state_modules}) - get_filename_component(file ${file_} NAME) - execute_process(COMMAND ${CMAKE_COMMAND} -E ${script_cmd} - "${tomviz_SOURCE_DIR}/tomviz/python/tomviz/state/${file}" - "${tomviz_python_binary_dir}/tomviz/state/${file}") -endforeach() - # Autodiscover python reader/writers added to tomviz/python/tomviz/io/formats file(GLOB tomviz_python_io_formats "${tomviz_SOURCE_DIR}/tomviz/python/tomviz/io/formats/*.py") file(MAKE_DIRECTORY "${tomviz_python_binary_dir}/tomviz/io/formats") @@ -745,10 +899,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 +917,27 @@ 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) + +# The tomviz-pipeline version this app depends on lives in +# python/tomviz/_pipeline_requirement.py (also read by setup.py) and is +# surfaced to the C++ external-environment check via tomvizConfig.h. +set(_pipeline_requirement_py + "${CMAKE_CURRENT_SOURCE_DIR}/python/tomviz/_pipeline_requirement.py") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${_pipeline_requirement_py}") +file(STRINGS "${_pipeline_requirement_py}" _pipeline_requirement_line + REGEX "^TOMVIZ_PIPELINE_MIN_VERSION[ ]*=[ ]*['\"]") +string(REGEX REPLACE + "^TOMVIZ_PIPELINE_MIN_VERSION[ ]*=[ ]*['\"]([^'\"]+)['\"].*$" "\\1" + tomviz_pipeline_min_version "${_pipeline_requirement_line}") +if(NOT tomviz_pipeline_min_version MATCHES "^[0-9]+\\.[0-9]+\\.[0-9]+") + message(FATAL_ERROR + "Could not read TOMVIZ_PIPELINE_MIN_VERSION from ${_pipeline_requirement_py}") +endif() +message(STATUS "Required tomviz-pipeline version: ${tomviz_pipeline_min_version}") configure_file(tomvizConfig.h.in tomvizConfig.h @ONLY) configure_file(tomvizPythonConfig.h.in tomvizPythonConfig.h @ONLY) @@ -767,6 +946,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 +955,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. @@ -786,12 +976,15 @@ target_link_libraries(tomvizlib tomvizcore ParaView::pqApplicationComponents ParaView::pqPython + ParaView::RemotingAnimation ParaView::RemotingExport ParaView::RemotingViews VTK::jsoncpp VTK::pugixml VTK::tiff VTK::CommonColor + VTK::CommonCore + VTK::CommonDataModel VTK::DomainsChemistry VTK::IOChemistry VTK::hdf5 @@ -799,11 +992,28 @@ target_link_libraries(tomvizlib VTK::RenderingVolume VTK::RenderingVolumeOpenGL2 Qt6::Network + Qt6::Widgets PRIVATE Qt6::Core5Compat - Python3::Python) + 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) +if(APPLE AND TOMVIZ_MACOSX_BUNDLE) set_target_properties(tomviz PROPERTIES MACOSX_BUNDLE_INFO_PLIST ${CMAKE_CURRENT_SOURCE_DIR}/tomviz.plist.in) @@ -817,7 +1027,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..005ac22d8 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,11 @@ #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/sinks/VolumeSink.h" +#include "pipeline/data/VolumeData.h" namespace tomviz { @@ -69,7 +66,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 +84,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())); } @@ -142,6 +139,18 @@ CentralWidget::CentralWidget(QWidget* parentObject, Qt::WindowFlags wflags) m_ui->gradientOpacityWidget->hide(); connect(m_ui->histogramWidget, &HistogramWidget::opacityChanged, m_ui->histogram2DWidget, &Histogram2DWidget::updateTransfer2D); + // An opacity morph animation installs an override curve on the sink + // (see ScalarOpacityAnimation); scrubbing can leave it installed with + // no playback-ended event to clear it. Editing the histogram is the + // user taking the controls back, so drop the override or the edit + // renders no change. + connect(m_ui->histogramWidget, &HistogramWidget::opacityChanged, this, + [this]() { + if (auto* volumeSink = + qobject_cast(m_activeSink.data())) { + volumeSink->setAnimatedScalarOpacity(nullptr); + } + }); auto& histogramMgr = HistogramManager::instance(); connect(&histogramMgr, &HistogramManager::histogramReady, this, @@ -151,11 +160,10 @@ 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); - - m_ui->imageViewerSlider->setVisible(false); } CentralWidget::~CentralWidget() @@ -166,127 +174,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(); } @@ -302,46 +193,6 @@ void CentralWidget::onColorLegendToggled(bool visibility) } } -void CentralWidget::setImageViewerMode(bool enabled) -{ - // First, 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); -} - void CentralWidget::onColorMapDataSourceChanged() { // This starts/restarts the internal timer so that several events occurring @@ -352,14 +203,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 +252,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 +273,89 @@ void CentralWidget::setHistogramTable(vtkTable* table) "image_pops"); } -void CentralWidget::onTransferModeChanged(const int mode) +void CentralWidget::setActiveSinkNode(pipeline::LegacyModuleSink* sink) { - if (!m_activeModule) { + // 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; } - 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; + // 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) +{ + // Disconnect previous sink + if (m_activeSink) { + disconnect(m_activeSink, &pipeline::LegacyModuleSink::colorMapChanged, + this, nullptr); + m_activeSink = nullptr; } - m_ui->swTransferMode->setCurrentIndex(index); + 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; + } + + // 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); + } + + // 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..2b7f656d7 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,44 @@ 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/CentralWidget.ui b/tomviz/CentralWidget.ui index 957245e33..53d04b33c 100644 --- a/tomviz/CentralWidget.ui +++ b/tomviz/CentralWidget.ui @@ -252,9 +252,6 @@ - - - @@ -282,12 +279,6 @@
Histogram2DWidget.h
1 - - tomviz::IntSliderWidget - QWidget -
IntSliderWidget.h
- 1 -
diff --git a/tomviz/ClippingRangeBehavior.cxx b/tomviz/ClippingRangeBehavior.cxx new file mode 100644 index 000000000..3f63d4ce7 --- /dev/null +++ b/tomviz/ClippingRangeBehavior.cxx @@ -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". */ + +#include "ClippingRangeBehavior.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace tomviz { + +namespace { + +// Weak, so a renderer that outlives its view never calls into a dead one +class SynchronizeBounds : public vtkCommand +{ +public: + static SynchronizeBounds* New() { return new SynchronizeBounds; } + + vtkWeakPointer View; + + void Execute(vtkObject*, unsigned long, void*) override + { + // Recomputes the bounds from the visible props and resets the + // clipping range to them, before the renderer applies the camera + if (this->View) { + this->View->SynchronizeGeometryBounds(); + } + } +}; + +} // namespace + +ClippingRangeBehavior::ClippingRangeBehavior(QObject* parent) + : QObject(parent) +{ + auto* model = pqApplicationCore::instance()->getServerManagerModel(); + for (auto* view : model->findItems()) { + watchView(view); + } + connect(model, &pqServerManagerModel::viewAdded, this, + &ClippingRangeBehavior::watchView); +} + +void ClippingRangeBehavior::watchView(pqView* view) +{ + if (auto* renderView = qobject_cast(view)) { + watch(renderView->getViewProxy()); + } +} + +void ClippingRangeBehavior::watch(vtkSMViewProxy* view) +{ + auto* pvView = + view ? vtkPVRenderView::SafeDownCast(view->GetClientSideView()) : nullptr; + auto* renderer = pvView ? pvView->GetRenderer() : nullptr; + if (!renderer) { + return; + } + vtkNew command; + command->View = pvView; + renderer->AddObserver(vtkCommand::StartEvent, command); +} + +} // namespace tomviz diff --git a/tomviz/ClippingRangeBehavior.h b/tomviz/ClippingRangeBehavior.h new file mode 100644 index 000000000..c0bb6a507 --- /dev/null +++ b/tomviz/ClippingRangeBehavior.h @@ -0,0 +1,42 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizClippingRangeBehavior_h +#define tomvizClippingRangeBehavior_h + +#include + +class pqView; +class vtkSMViewProxy; + +namespace tomviz { + +/// Keeps every render view's camera clipping range fitted to what it +/// actually shows. +/// +/// ParaView clamps the clipping range to the view's cached scene bounds +/// on every render (vtkPVRenderView::Render) and whenever anything resets +/// it. That cache is only refreshed by a view update, which ParaView +/// triggers for its own representations, but tomviz visualizations add +/// their props to the renderer directly, so data that grows or moves +/// (a live source, a transform, a new dataset) kept being cut by stale +/// near and far planes. This refreshes the cache at the start of every +/// render of every render view. +class ClippingRangeBehavior : public QObject +{ + Q_OBJECT + +public: + explicit ClippingRangeBehavior(QObject* parent = nullptr); + + /// Refresh @a view's bounds before each of its renders. Not a render + /// view: nothing happens. + static void watch(vtkSMViewProxy* view); + +private: + void watchView(pqView* view); +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/CloneDataReaction.cxx b/tomviz/CloneDataReaction.cxx index 417b7e146..3f913278b 100644 --- a/tomviz/CloneDataReaction.cxx +++ b/tomviz/CloneDataReaction.cxx @@ -4,10 +4,24 @@ #include "CloneDataReaction.h" #include "ActiveObjects.h" -#include "DataSource.h" #include "LoadDataReaction.h" +#include "MainWindow.h" #include "Utilities.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineUtils.h" +#include "pipeline/PortData.h" +#include "pipeline/PortType.h" +#include "pipeline/PortUtils.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/LabelMapData.h" +#include "pipeline/data/VolumeData.h" + +#include +#include + +#include #include namespace tomviz { @@ -19,8 +33,48 @@ 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; + } + + // Clone the selected dataset: the source feeding whatever the user + // picked. Only with no selection at all does the first source in the + // pipeline stand in. + auto& active = ActiveObjects::instance(); + auto* sourcePort = pipeline::feedingSourcePort(active.activeNode()); + if (!sourcePort) { + // A selected port, or the tip left by the last selection + sourcePort = pipeline::feedingSourcePort(active.activeTipOutputPort()); + } + if (!sourcePort) { + for (auto* node : pip->nodes()) { + auto* src = qobject_cast(node); + if (src && !src->outputPorts().isEmpty()) { + sourcePort = src->outputPorts().first(); + break; + } + } + } + + if (!sourcePort) { + return nullptr; + } + auto* activeSource = sourcePort->node(); + auto vol = pipeline::getOutputData( + activeSource, sourcePort->name()); + if (!vol || !vol->imageData()) { return nullptr; } @@ -37,19 +91,35 @@ 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); + // Deep-copy the vtkImageData + vtkNew clonedImage; + clonedImage->DeepCopy(vol->imageData()); + + auto* newSource = new pipeline::SourceNode(); + newSource->setLabel(activeSource->label() + " (clone)"); + // Keep the port's type, so a label map stays one (with its label + // table: names, colors, visibility). Only a generic ImageData port is + // narrowed, as loading does, since it would disable every operator + // declaring Volume or TiltSeries. + auto dataType = sourcePort->type(); + if (dataType == pipeline::PortType::ImageData) { + dataType = vol->hasTiltAngles() ? pipeline::PortType::TiltSeries + : pipeline::PortType::Volume; + } + auto newVol = pipeline::makeVolumeData(clonedImage, dataType); + if (auto labels = pipeline::labelMapData(newVol)) { + if (auto original = pipeline::labelMapData(vol)) { + labels->adoptLabelsFrom(*original); } } - return newClone; + newVol->setLabel(newSource->label()); + newSource->addOutput("volume", dataType); + newSource->setOutputData("volume", pipeline::PortData(newVol, dataType)); + + 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..f893c863a 100644 --- a/tomviz/ColorMap.cxx +++ b/tomviz/ColorMap.cxx @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -16,9 +17,118 @@ #include #include +#include +#include + namespace tomviz { +QColor segmentationLabelColor(int index) +{ + static const double goldenAngle = 137.508; + double hue = std::fmod(index * goldenAngle, 360.0) / 360.0; + double sat = 0.65 + 0.35 * ((index % 3) / 2.0); + double brightness = 0.75 + 0.25 * ((index + 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; + } + + return QColor::fromRgbF(r + m, g + m, b + m); +} + +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(); + } + + QJsonArray colors; + int idx = 0; + const int lastIdx = static_cast(uniqueValues.size()) - 1; + for (double val : uniqueValues) { + // By value, as LabelTable::reconcile colors, so a label keeps its + // color whichever path built the map + QColor color = segmentationLabelColor(static_cast(std::lround(val))); + + // 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(color.redF()); + colors.append(color.greenF()); + colors.append(color.blueF()); + } + ++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 +246,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..4300c42b5 100644 --- a/tomviz/ColorMap.h +++ b/tomviz/ColorMap.h @@ -6,13 +6,43 @@ #include +#include #include +#include #include +class vtkDataArray; class vtkSMProxy; namespace tomviz { +/// The @a index'th color of the segmentation palette: golden-angle hue +/// spacing with saturation and brightness cycling on short periods so +/// that neighboring indices stay distinguishable. The sequence repeats +/// once the hues wrap onto earlier ones, so very large label counts do +/// see duplicate colors. +QColor segmentationLabelColor(int index); + +/// 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..cfd1dcf0b 100644 --- a/tomviz/ComputeHistogram.h +++ b/tomviz/ComputeHistogram.h @@ -7,19 +7,99 @@ #include #include #include +#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; + } } } @@ -31,9 +111,13 @@ void calcHistogram(T*, const vtkIdType, uint64_t*) } /** Single component unsigned char covering 0 -> 255 range. */ -void calcHistogram(unsigned char* values, const vtkIdType numTuples, - uint64_t* pops) +// inline: unlike its neighbours this overload is not a template, so +// without it the header cannot be included in more than one translation +// unit. +inline 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 +132,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; } @@ -204,6 +293,66 @@ void Calculate2DHistogram(T* values, const int* dim, const int numComp, } } +/** + * Estimate the value below which @a fraction of the finite values (or + * magnitudes, for multi-component arrays) lie, from a fine histogram + * over @a range. Linear interpolation inside the bin holding the + * percentile keeps the estimate smooth for coarse integer data. + * + * With @a excludeMinimum the values equal to range[0] are left out of + * the count. A reconstruction is padded with its minimum (usually zero) + * wherever there is nothing, and that pile can be most of the volume, + * which drags every percentile down to the noise just above it. + */ +template +double ComputePercentile(const T* values, const vtkIdType numTuples, + const vtkIdType numComponents, const double range[2], + const double fraction, + const bool excludeMinimum = false) +{ + constexpr int bins = 4096; + if (numTuples <= 0 || !(range[1] > range[0])) { + return range[0]; + } + const double inv = bins / (range[1] - range[0]); + std::vector pops(bins, 0); + uint64_t total = 0; + for (vtkIdType j = 0; j < numTuples; ++j) { + double value; + if (numComponents == 1) { + value = static_cast(values[j]); + } else { + double squaredSum = 0.0; + for (vtkIdType c = 0; c < numComponents; ++c) { + double v = static_cast(values[j * numComponents + c]); + squaredSum += v * v; + } + value = std::sqrt(squaredSum); + } + if (!vtkMath::IsFinite(value) || (excludeMinimum && value == range[0])) { + continue; + } + int idx = static_cast((value - range[0]) * inv); + idx = std::min(std::max(idx, 0), bins - 1); + ++pops[idx]; + ++total; + } + if (total == 0) { + return range[0]; + } + + const double target = std::min(std::max(fraction, 0.0), 1.0) * total; + uint64_t below = 0; + for (int i = 0; i < bins; ++i) { + if (below + pops[i] >= target) { + double within = pops[i] > 0 ? (target - below) / pops[i] : 0.0; + return range[0] + (i + within) / inv; + } + below += pops[i]; + } + return range[1]; +} + } // namespace tomviz #endif 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/CustomOperatorEditDialog.cxx b/tomviz/CustomOperatorEditDialog.cxx new file mode 100644 index 000000000..bd7a80787 --- /dev/null +++ b/tomviz/CustomOperatorEditDialog.cxx @@ -0,0 +1,359 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ +#include "CustomOperatorEditDialog.h" + +#include "Utilities.h" +#include "pipeline/NodeDefinitionValidator.h" +#include "pipeline/NodeDefinitionWidget.h" +#include "pipeline/PythonNodeEditorWidget.h" +#include "pipeline/PythonScriptEdit.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +namespace { + +/// Read @a path as UTF-8 into @a text. On failure @a error says why and +/// @a text is left alone. +bool readTextFile(const QString& path, QString* text, QString* error) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + *error = QObject::tr("Could not read \"%1\":\n%2") + .arg(QDir::toNativeSeparators(path), file.errorString()); + return false; + } + *text = QString::fromUtf8(file.readAll()); + return true; +} + +bool isStemCharacter(QChar c) +{ + return (c.unicode() < 128 && c.isLetterOrNumber()) || + c == QLatin1Char('_') || c == QLatin1Char('-'); +} + +} // namespace + +QString sanitizedOperatorStem(const QString& text) +{ + QString stem; + for (QChar c : text) { + if (isStemCharacter(c)) { + stem.append(c); + } + } + return stem; +} + +QString uniqueOperatorStem(const QString& directory, const QString& base, + int* number) +{ + QString stem = sanitizedOperatorStem(base); + if (stem.isEmpty()) { + stem = QStringLiteral("CustomTransform"); + } + const QDir dir(directory); + auto taken = [&dir](const QString& candidate) { + return QFile::exists(dir.filePath(candidate + QStringLiteral(".py"))) || + QFile::exists(dir.filePath(candidate + QStringLiteral(".json"))); + }; + int n = 1; + QString candidate = stem; + while (taken(candidate)) { + ++n; + candidate = stem + QString::number(n); + } + if (number) { + *number = n; + } + return candidate; +} + +QString withDescriptionIdentity(const QString& description, + const QString& name, const QString& label) +{ + const QJsonDocument doc = QJsonDocument::fromJson(description.toUtf8()); + if (!doc.isObject()) { + return description; + } + QJsonObject obj = doc.object(); + if (!name.isEmpty() && obj.contains(QStringLiteral("name"))) { + obj[QStringLiteral("name")] = name; + } + if (!label.isEmpty() && obj.contains(QStringLiteral("label"))) { + obj[QStringLiteral("label")] = label; + } + return QString::fromUtf8(QJsonDocument(obj).toJson()); +} + +QString markDescriptionAsCopy(const QString& description, int number) +{ + const QJsonDocument doc = QJsonDocument::fromJson(description.toUtf8()); + if (!doc.isObject()) { + return description; + } + const QJsonObject obj = doc.object(); + QString name = obj.value(QStringLiteral("name")).toString(); + if (!name.isEmpty()) { + name += QStringLiteral("Copy"); + if (number > 1) { + name += QString::number(number); + } + } + QString label = obj.value(QStringLiteral("label")).toString(); + if (!label.isEmpty()) { + label += number > 1 ? QStringLiteral(" (copy %1)").arg(number) + : QStringLiteral(" (copy)"); + } + return withDescriptionIdentity(description, name, label); +} + +using pipeline::DefinitionTarget; +using pipeline::NodeDefinitionWidget; +using pipeline::PythonScriptEdit; + +CustomOperatorEditDialog::CustomOperatorEditDialog( + const QString& label, const QString& scriptPath, + const QString& descriptionPath, QWidget* parent) + : QDialog(parent), m_backed(true), m_scriptPath(scriptPath), + m_descriptionPath( + descriptionPath.isEmpty() + ? pipeline::PythonNodeEditorWidget::descriptionPathFor(scriptPath) + : descriptionPath) +{ + setWindowTitle(tr("Edit Custom Node - %1").arg(label)); + + QString script; + if (!readTextFile(m_scriptPath, &script, &m_loadError)) { + return; + } + // No description file is a valid state (a script-only operator); an + // unreadable one is not, since saving would replace it. + QString description; + if (QFile::exists(m_descriptionPath) && + !readTextFile(m_descriptionPath, &description, &m_loadError)) { + return; + } + build(script, description); +} + +CustomOperatorEditDialog::CustomOperatorEditDialog( + const CustomOperatorDraft& draft, QWidget* parent) + : QDialog(parent), m_backed(false), + m_scriptPath( + QDir(draft.directory).filePath(draft.stem + QStringLiteral(".py"))), + m_descriptionPath( + QDir(draft.directory).filePath(draft.stem + QStringLiteral(".json"))) +{ + setWindowTitle(tr("New Custom Node")); + build(draft.script, draft.description); +} + +void CustomOperatorEditDialog::build(const QString& script, + const QString& description) +{ + // Like the node editor: float above the main window rather than slip + // behind it while the user consults the pipeline. + floatAboveMainWindow(this); + + auto* layout = new QVBoxLayout(this); + + // File name: one stem for both files, renamed (or created) together. + const QString directory = + QDir::toNativeSeparators(QFileInfo(m_scriptPath).absolutePath()); + auto* nameRow = new QHBoxLayout; + nameRow->addWidget(new QLabel(tr("File name"), this)); + m_stemEdit = new QLineEdit(currentStem(), this); + m_stemEdit->setObjectName(QStringLiteral("operatorStemEdit")); + // Letters, digits, underscore and hyphen: a name no filesystem, shell + // or menu scan trips over. Typing anything else is simply refused. + m_stemEdit->setValidator(new QRegularExpressionValidator( + QRegularExpression(QStringLiteral("[A-Za-z0-9_-]+")), m_stemEdit)); + m_stemEdit->setToolTip( + m_backed ? tr("Both files are renamed on save. They live in %1.") + .arg(directory) + : tr("Both files are created on save, in %1.").arg(directory)); + nameRow->addWidget(m_stemEdit, 1); + auto* suffixes = new QLabel(tr(".py and .json"), this); + suffixes->setStyleSheet("QLabel { color: palette(mid); }"); + nameRow->addWidget(suffixes); + layout->addLayout(nameRow); + + m_stemIssue = new QLabel(this); + m_stemIssue->setObjectName(QStringLiteral("operatorStemIssue")); + m_stemIssue->setWordWrap(true); + m_stemIssue->hide(); + layout->addWidget(m_stemIssue); + + m_tabs = new QTabWidget(this); + // Shape and schema only seed the form's initial rendering; as a file + // the description is free to change either. + m_definition = new NodeDefinitionWidget( + description, pipeline::definitionShape(description), + pipeline::definitionSchema(description), DefinitionTarget::File, + m_tabs); + m_tabs->addTab(m_definition, tr("Definition")); + m_script = new PythonScriptEdit(m_tabs); + m_script->setPlainText(script); + m_tabs->addTab(m_script, tr("Script")); + layout->addWidget(m_tabs, 1); + + m_buttons = new QDialogButtonBox( + QDialogButtonBox::Save | QDialogButtonBox::Cancel, this); + layout->addWidget(m_buttons); + connect(m_buttons, &QDialogButtonBox::accepted, this, + &CustomOperatorEditDialog::save); + connect(m_buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(m_definition, &NodeDefinitionWidget::validityChanged, this, + &CustomOperatorEditDialog::updateSaveEnabled); + connect(m_stemEdit, &QLineEdit::textChanged, this, + &CustomOperatorEditDialog::updateSaveEnabled); + updateSaveEnabled(); + + resize(760, 600); +} + +QString CustomOperatorEditDialog::currentStem() const +{ + // completeBaseName() so "my.operator.py" keeps its dots, the same way + // descriptionPathFor() pairs the files. + return QFileInfo(m_scriptPath).completeBaseName(); +} + +QString CustomOperatorEditDialog::stemIssue() const +{ + const QString stem = m_stemEdit->text(); + if (stem.isEmpty() || !m_stemEdit->hasAcceptableInput()) { + return tr("The file name may contain letters, digits, \"_\" and \"-\" " + "only."); + } + if (m_backed && stem == currentStem()) { + return QString(); + } + + // Anything already at the target is another operator, except this + // operator's own file under another spelling of its case. + const QDir dir = QFileInfo(m_scriptPath).dir(); + auto taken = [this, &dir, &stem](const QString& suffix, + const QString& own) { + const QString target = dir.filePath(stem + suffix); + return QFile::exists(target) && + (!m_backed || target.compare(own, Qt::CaseInsensitive) != 0); + }; + if (taken(QStringLiteral(".py"), m_scriptPath) || + taken(QStringLiteral(".json"), m_descriptionPath)) { + return tr("An operator named \"%1\" already exists in this directory.") + .arg(stem); + } + return QString(); +} + +void CustomOperatorEditDialog::updateSaveEnabled() +{ + const QString issue = stemIssue(); + m_stemIssue->setText( + QStringLiteral("%1").arg(issue.toHtmlEscaped())); + m_stemIssue->setVisible(!issue.isEmpty()); + m_buttons->button(QDialogButtonBox::Save) + ->setEnabled(m_definition->isValid() && issue.isEmpty()); +} + +bool CustomOperatorEditDialog::renameTo(const QString& stem) +{ + const QDir dir = QFileInfo(m_scriptPath).dir(); + const QString newScript = dir.filePath(stem + QStringLiteral(".py")); + const QString newDescription = dir.filePath(stem + QStringLiteral(".json")); + + // QFile::rename never overwrites, and knows how to change only the + // case of a name on a case-insensitive filesystem. + auto move = [this](const QString& from, const QString& to) { + if (QFile::rename(from, to)) { + return true; + } + QMessageBox::warning(this, tr("Rename Custom Node"), + tr("Could not rename \"%1\" to \"%2\".") + .arg(QDir::toNativeSeparators(from), + QDir::toNativeSeparators(to))); + return false; + }; + if (!move(m_scriptPath, newScript)) { + return false; + } + if (QFile::exists(m_descriptionPath) && + !move(m_descriptionPath, newDescription)) { + // Keep the pair together: put the script back where it was. + QFile::rename(newScript, m_scriptPath); + return false; + } + m_scriptPath = newScript; + m_descriptionPath = newDescription; + return true; +} + +void CustomOperatorEditDialog::save() +{ + // Flush so a keystroke typed a moment ago can't slip past validation. + m_definition->flushPendingValidation(); + if (!m_definition->isValid()) { + QMessageBox::warning( + this, tr("Invalid node definition"), + tr("The description in the Definition tab has errors, so it can't be " + "saved. Fix the problems listed there, or revert the edit.")); + return; + } + // The live check keeps Save disabled on a bad name; this is the guard + // against a file that appeared since. + const QString issue = stemIssue(); + if (!issue.isEmpty()) { + QMessageBox::warning(this, tr("Save Custom Node"), issue); + return; + } + + const QString stem = m_stemEdit->text(); + if (m_backed) { + // Rename before writing, so a case-only change on a case-insensitive + // filesystem never writes and then deletes the same file. + if (stem != currentStem() && !renameTo(stem)) { + return; + } + } else { + const QDir dir = QFileInfo(m_scriptPath).dir(); + m_scriptPath = dir.filePath(stem + QStringLiteral(".py")); + m_descriptionPath = dir.filePath(stem + QStringLiteral(".json")); + } + + if (!writeTextFile(this, m_scriptPath, m_script->toPlainText())) { + return; + } + // From here the operator is on disk, so a retry after a failure below + // is a plain re-save rather than a draft colliding with its own file. + m_backed = true; + // The validator refuses to blank out a description that exists, so an + // empty one here means the operator never had a file and stays without. + const QString description = m_definition->definitionText(); + if (!description.trimmed().isEmpty() && + !writeTextFile(this, m_descriptionPath, description)) { + return; + } + + emit saved(); + accept(); +} + +} // namespace tomviz diff --git a/tomviz/CustomOperatorEditDialog.h b/tomviz/CustomOperatorEditDialog.h new file mode 100644 index 000000000..2f5e990e0 --- /dev/null +++ b/tomviz/CustomOperatorEditDialog.h @@ -0,0 +1,130 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ +#ifndef tomvizCustomOperatorEditDialog_h +#define tomvizCustomOperatorEditDialog_h + +#include +#include + +class QDialogButtonBox; +class QLabel; +class QLineEdit; +class QTabWidget; + +namespace tomviz { + +namespace pipeline { +class NodeDefinitionWidget; +class PythonScriptEdit; +} // namespace pipeline + +/// A custom operator that exists only in memory until it is saved as +/// /.py and .json: what "Create New", "Clone" and +/// "Save as Custom Transform" hand to the dialog. +struct CustomOperatorDraft +{ + QString directory; + QString stem; + QString script; + QString description; +}; + +/// @a text reduced to the characters an operator file stem may contain: +/// letters, digits, "_" and "-". +QString sanitizedOperatorStem(const QString& text); + +/// A stem no operator in @a directory uses yet: @a base (sanitized, or +/// "CustomTransform" if nothing is left of it), else base2, base3, ... +/// The number picked (1 for the bare base) is written to @a number when +/// given, so a caller can mark the name and label the same way. +QString uniqueOperatorStem(const QString& directory, const QString& base, + int* number = nullptr); + +/// @a description with "name" and "label" replaced, when present in the +/// text and non-empty themselves. Text that is not a JSON object comes +/// back untouched. +QString withDescriptionIdentity(const QString& description, + const QString& name, const QString& label); + +/// @a description marked as copy number @a number of its original: the +/// name gains "Copy" (then "Copy2", ...), the label " (copy)" (then +/// " (copy 2)", ...). Keys the description lacks are left out, so an +/// operator labelled by its file name is told apart by the stem alone. +QString markDescriptionAsCopy(const QString& description, int number); + +/// Edits the two files that define a custom operator in the user's +/// directory, in place: a "Definition" tab for the JSON description and +/// a "Script" tab for the Python source. Both tabs are the same widgets +/// the Python node editor uses, with the definition edited as a file +/// rather than as a live node, so schema, shape and ports may change. +/// +/// A file name field above the tabs names both files. The name is +/// restricted to letters, digits, "_" and "-", and may not collide with +/// another operator in the directory; Save stays disabled while it does. +/// For an existing operator a changed name renames the files on save. +/// +/// Save writes the script, and the description unless it is empty (a +/// script-only operator stays that way until a description is typed). +/// Nothing touches the disk before Save; Cancel discards the edit. A +/// dialog opened on a CustomOperatorDraft has no files at all until then. +class CustomOperatorEditDialog : public QDialog +{ + Q_OBJECT + +public: + /// Edit an operator that is on disk. @a descriptionPath may be empty + /// when the operator has no JSON file yet; the conventional path + /// beside the script is used then. The files are read here: when that + /// fails, loadError() says why and the dialog is not usable. + CustomOperatorEditDialog(const QString& label, const QString& scriptPath, + const QString& descriptionPath, + QWidget* parent = nullptr); + + /// Start from @a draft; the files come into being on the first Save. + explicit CustomOperatorEditDialog(const CustomOperatorDraft& draft, + QWidget* parent = nullptr); + + /// Empty when both files were read (or the description is absent). + QString loadError() const { return m_loadError; } + + /// False for a draft that has not been saved yet. + bool isBacked() const { return m_backed; } + + /// Current locations: where a draft will land, or where the files are. + /// They move when a save renames the files. + QString scriptPath() const { return m_scriptPath; } + QString descriptionPath() const { return m_descriptionPath; } + +signals: + /// Emitted once both files are on disk, just before the dialog accepts. + void saved(); + +private: + Q_DISABLE_COPY(CustomOperatorEditDialog) + + void build(const QString& script, const QString& description); + void save(); + void updateSaveEnabled(); + /// The stem the paths currently carry. + QString currentStem() const; + /// Why the typed file name can't be used, or empty when it can. + QString stemIssue() const; + /// Move both files to @a stem, reporting failure to the user. + bool renameTo(const QString& stem); + + bool m_backed = false; + QString m_scriptPath; + QString m_descriptionPath; + QString m_loadError; + + QLineEdit* m_stemEdit = nullptr; + QLabel* m_stemIssue = nullptr; + QTabWidget* m_tabs = nullptr; + pipeline::NodeDefinitionWidget* m_definition = nullptr; + pipeline::PythonScriptEdit* m_script = nullptr; + QDialogButtonBox* m_buttons = nullptr; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/CustomOperatorManagerDialog.cxx b/tomviz/CustomOperatorManagerDialog.cxx new file mode 100644 index 000000000..9de17df01 --- /dev/null +++ b/tomviz/CustomOperatorManagerDialog.cxx @@ -0,0 +1,260 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ +#include "CustomOperatorManagerDialog.h" + +#include "Utilities.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +namespace { + +enum Column +{ + NameColumn, + PathColumn, + StatusColumn, + EditColumn, + DeleteColumn, + CloneColumn, + OpenColumn, + ColumnCount +}; + +/// A label that shows as much of a long path as fits, eliding the middle +/// so both the root and the leaf stay readable; the full path is the +/// tooltip. Its width is decided by the layout, never by the text. +class ElidedPathLabel : public QLabel +{ +public: + ElidedPathLabel(const QString& path, QWidget* parent) + : QLabel(parent), m_path(path) + { + setToolTip(path); + setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + setStyleSheet("QLabel { color: palette(mid); }"); + } + +protected: + void resizeEvent(QResizeEvent* event) override + { + QLabel::resizeEvent(event); + setText(fontMetrics().elidedText(m_path, Qt::ElideMiddle, + contentsRect().width())); + } + +private: + QString m_path; +}; + +} // namespace + +CustomOperatorManagerDialog::CustomOperatorManagerDialog(Provider provider, + QWidget* parent) + : QDialog(parent), m_provider(std::move(provider)) +{ + setWindowTitle(tr("Manage Custom Transforms")); + // Like the node editor: float above the main window rather than slip + // behind it while the user consults the pipeline. + floatAboveMainWindow(this); + + auto* layout = new QVBoxLayout(this); + + m_scroll = new QScrollArea(this); + m_scroll->setObjectName(QStringLiteral("customOperatorList")); + m_scroll->setWidgetResizable(true); + m_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + layout->addWidget(m_scroll, 1); + + m_emptyHint = new QLabel( + tr("No custom transforms yet. Create a new one, or turn a node into " + "one from its editor with \"Save as Custom Transform\"."), + this); + m_emptyHint->setObjectName(QStringLiteral("customOperatorEmptyHint")); + m_emptyHint->setWordWrap(true); + m_emptyHint->setStyleSheet("QLabel { color: palette(mid); }"); + layout->addWidget(m_emptyHint); + + auto* bottom = new QHBoxLayout; + layout->addLayout(bottom); + auto* createButton = new QPushButton(tr("Create New..."), this); + createButton->setObjectName(QStringLiteral("createCustomOperator")); + connect(createButton, &QPushButton::clicked, this, + &CustomOperatorManagerDialog::createRequested); + bottom->addWidget(createButton); + auto* refreshButton = new QPushButton(tr("Refresh"), this); + refreshButton->setObjectName(QStringLiteral("refreshCustomOperators")); + refreshButton->setToolTip( + tr("Rescan the directories, for changes made outside tomviz")); + connect(refreshButton, &QPushButton::clicked, this, + &CustomOperatorManagerDialog::refresh); + bottom->addWidget(refreshButton); + bottom->addStretch(); + auto* closeButton = new QPushButton(tr("Close"), this); + closeButton->setDefault(true); + connect(closeButton, &QPushButton::clicked, this, &QDialog::reject); + bottom->addWidget(closeButton); + + resize(760, 420); + refresh(); +} + +void CustomOperatorManagerDialog::refresh() +{ + m_operators = m_provider ? m_provider() : std::vector(); + + // Rebuilt from scratch each time; the scroll area deletes the old one. + auto* content = new QWidget(m_scroll); + auto* grid = new QGridLayout(content); + grid->setColumnStretch(PathColumn, 1); + grid->setHorizontalSpacing(12); + + int row = 0; + for (auto type : { OperatorDescription::Type::Source, + OperatorDescription::Type::Transform }) { + // Legacy transforms are transforms as far as the user is concerned. + const bool sources = type == OperatorDescription::Type::Source; + bool any = false; + for (const auto& op : m_operators) { + if ((op.type == OperatorDescription::Type::Source) != sources) { + continue; + } + if (!any) { + addSection(grid, row, sources ? tr("Sources") : tr("Transforms")); + any = true; + } + addOperator(grid, row, op); + } + } + grid->setRowStretch(row, 1); + m_scroll->setWidget(content); + + m_emptyHint->setVisible(m_operators.empty()); +} + +void CustomOperatorManagerDialog::addSection(QGridLayout* grid, int& row, + const QString& title) +{ + auto* header = new QLabel(title, grid->parentWidget()); + header->setObjectName(QStringLiteral("customOperatorSection")); + header->setStyleSheet("QLabel { font-weight: bold; }"); + if (row > 0) { + header->setContentsMargins(0, 12, 0, 0); + } + grid->addWidget(header, row++, 0, 1, ColumnCount); +} + +void CustomOperatorManagerDialog::addOperator(QGridLayout* grid, int& row, + const OperatorDescription& op) +{ + QWidget* parent = grid->parentWidget(); + // Every widget of the row carries the label, so a test (or a handler) + // can tell the rows apart without a container widget per row. + auto tag = [&op](QWidget* widget) { + widget->setProperty("operatorLabel", op.label); + }; + + auto* name = new QLabel(op.label, parent); + name->setObjectName(QStringLiteral("customOperatorName")); + name->setToolTip(QDir::toNativeSeparators(op.pythonPath)); + name->setEnabled(op.valid); + tag(name); + grid->addWidget(name, row, NameColumn); + + const QString directory = + QDir::toNativeSeparators(QFileInfo(op.pythonPath).absolutePath()); + auto* path = new ElidedPathLabel(directory, parent); + path->setObjectName(QStringLiteral("customOperatorPath")); + tag(path); + grid->addWidget(path, row, PathColumn); + + if (!op.valid) { + auto* status = new QLabel(tr("broken"), parent); + status->setObjectName(QStringLiteral("customOperatorStatus")); + status->setStyleSheet("QLabel { color: palette(mid); }"); + status->setToolTip(op.loadError.isEmpty() + ? tr("The file does not contain a valid operator " + "definition.") + : op.loadError); + tag(status); + grid->addWidget(status, row, StatusColumn); + } + + // Same rule as everywhere: only the user's own files are touched in + // place; anything else is copied into the user's directory first. + const QString whyNot = + op.userOwned ? QString() + : tr("Only nodes in your tomviz directory can be changed. " + "Clone this one to get your own copy."); + // Icon buttons with the verb as tooltip; a disabled one explains + // itself instead. A missing icon resource falls back to the verb as + // text rather than a blank button. + const int iconSize = + parent->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, parent); + auto makeButton = [&](const QString& iconResource, const QString& text, + const char* objectName, int column, bool enabled) { + auto* button = new QToolButton(parent); + button->setObjectName(QString::fromLatin1(objectName)); + button->setAutoRaise(true); + button->setEnabled(enabled); + button->setToolTip(enabled ? text : whyNot); + QIcon icon(iconResource); + if (icon.pixmap(QSize(iconSize, iconSize)).isNull()) { + button->setText(text); + button->setToolButtonStyle(Qt::ToolButtonTextOnly); + } else { + button->setIcon(icon); + button->setIconSize(QSize(iconSize, iconSize)); + } + tag(button); + grid->addWidget(button, row, column); + return button; + }; + auto* edit = makeButton(QStringLiteral(":/icons/edit.png"), tr("Edit"), + "editCustomOperator", EditColumn, op.userOwned); + auto* remove = + makeButton(QStringLiteral(":/QtWidgets/Icons/pqDelete.svg"), tr("Delete"), + "deleteCustomOperator", DeleteColumn, op.userOwned); + auto* clone = makeButton(QStringLiteral(":/icons/clone.svg"), tr("Clone"), + "cloneCustomOperator", CloneColumn, true); + auto* open = + makeButton(QStringLiteral(":/icons/folder.svg"), + tr("Open containing folder"), "openCustomOperatorDirectory", + OpenColumn, true); + + // A handler may refresh this dialog, which destroys these buttons and + // with them the closures below, so each hands out a copy that lives on + // its own stack frame for the duration of the emit. + connect(edit, &QToolButton::clicked, this, [this, op]() { + const OperatorDescription copy = op; + emit editRequested(copy); + }); + connect(remove, &QToolButton::clicked, this, [this, op]() { + const OperatorDescription copy = op; + emit deleteRequested(copy); + }); + connect(clone, &QToolButton::clicked, this, [this, op]() { + const OperatorDescription copy = op; + emit cloneRequested(copy); + }); + connect(open, &QToolButton::clicked, this, [this, op]() { + const OperatorDescription copy = op; + emit openDirectoryRequested(copy); + }); + + ++row; +} + +} // namespace tomviz diff --git a/tomviz/CustomOperatorManagerDialog.h b/tomviz/CustomOperatorManagerDialog.h new file mode 100644 index 000000000..39d17763c --- /dev/null +++ b/tomviz/CustomOperatorManagerDialog.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 tomvizCustomOperatorManagerDialog_h +#define tomvizCustomOperatorManagerDialog_h + +#include "PythonUtilities.h" + +#include + +#include +#include + +class QGridLayout; +class QLabel; +class QScrollArea; + +namespace tomviz { + +/// Lists the custom operators the Custom Transforms menu shows, sources +/// first and transforms below, one row each: label, where its files +/// live, and its own Edit, Delete, Clone and open-folder buttons. Clone +/// and open-folder work for every entry; Edit and Delete only for files +/// in the user's own directory, and are disabled with a reason +/// otherwise. Broken definitions are listed too, with their error, since +/// fixing or removing them is the point. +/// +/// The dialog only asks and reports: the list comes from a provider the +/// host supplies, and each button emits a request the host acts on. The +/// host calls refresh() once something changed on disk. +class CustomOperatorManagerDialog : public QDialog +{ + Q_OBJECT + +public: + using Provider = std::function()>; + + explicit CustomOperatorManagerDialog(Provider provider, + QWidget* parent = nullptr); + + /// Re-query the provider and rebuild the list. + void refresh(); + +signals: + void createRequested(); + void cloneRequested(const OperatorDescription& op); + void editRequested(const OperatorDescription& op); + void deleteRequested(const OperatorDescription& op); + /// Show the directory holding the operator's files. + void openDirectoryRequested(const OperatorDescription& op); + +private: + Q_DISABLE_COPY(CustomOperatorManagerDialog) + + void addSection(QGridLayout* grid, int& row, const QString& title); + void addOperator(QGridLayout* grid, int& row, const OperatorDescription& op); + + Provider m_provider; + std::vector m_operators; + + QScrollArea* m_scroll = nullptr; + QLabel* m_emptyHint = nullptr; +}; + +} // namespace tomviz + +#endif 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..2f1338171 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,187 +34,232 @@ 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 combineDatasetsAction = dataManagement->addAction("Combine Datasets"); 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"); + auto manualManipulationAction = + volumeManip->addAction("Manual Manipulation"); + 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"); + auto registrationAction = volumeManip->addAction("Registration"); + + // === 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"); + // "Fourier" and "FFT" both find it in the operator search + auto fftAbsLogAction = mathOps->addAction("Fast Fourier Transform (FFT)"); + auto fourierFilterAction = mathOps->addAction("Fourier Filter"); + auto fourierPeakMaskAction = mathOps->addAction("Fourier Peak Mask"); + auto fourierMaskAction = mathOps->addAction("Fourier Mask"); + auto imageMathAction = mathOps->addAction("Image Math"); + + // === 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( + combineDatasetsAction, "Combine Datasets", + readInPythonScript("CombineDatasets"), + readInJSONDescription("CombineDatasets")); new AddPythonTransformReaction( reinterpretSignedToUnignedAction, "Reinterpret Signed to Unsigned", readInPythonScript("ReinterpretSignedToUnsigned")); 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")); - new AddPythonTransformReaction(clearAction, "Clear Volume", - readInPythonScript("ClearVolume")); + readInPythonScript("Rotate3D"), + readInJSONDescription("Rotate3D")); + new AddPythonTransformReaction(clearAction, "Clear Subvolume", + readInPythonScript("ClearVolume"), + readInJSONDescription("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)", - readInPythonScript("FFT_AbsLog")); + new AddPythonTransformReaction(fftAbsLogAction, "FFT", + readInPythonScript("FFT_AbsLog"), + readInJSONDescription("FFT_AbsLog")); + new AddPythonTransformReaction(fourierFilterAction, "Fourier Filter", + readInPythonScript("FourierFilter"), + readInJSONDescription("FourierFilter")); + new AddPythonTransformReaction(fourierPeakMaskAction, "Fourier Peak Mask", + readInPythonScript("FourierPeakMask"), + readInJSONDescription("FourierPeakMask")); + new AddPythonTransformReaction(fourierMaskAction, "Fourier Mask", + readInPythonScript("FourierMask"), + readInJSONDescription("FourierMask")); + new AddPythonTransformReaction(imageMathAction, "Image Math", + readInPythonScript("ImageMath"), + readInJSONDescription("ImageMath")); new AddPythonTransformReaction(gradientMagnitudeSobelAction, "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 +268,108 @@ 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"); + auto removeLabelsAction = thresholding->addAction("Remove Labels"); + + // === 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(removeLabelsAction, "Remove Labels", + readInPythonScript("RemoveLabels"), + readInJSONDescription("RemoveLabels")); 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..9908f39cf --- /dev/null +++ b/tomviz/FileReader.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 "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 / .nxs — DataExchange / FXI / generic HDF5. NeXus + // files are HDF5 underneath, so they go through the same readers. + if (suffix == "hspy" || suffix == "h5" || suffix == "nxs") { + 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..b07b6f5d2 --- /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 / .nxs -> 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..e71e42f70 100644 --- a/tomviz/HistogramWidget.cxx +++ b/tomviz/HistogramWidget.cxx @@ -5,15 +5,23 @@ #include "ActiveObjects.h" #include "BrightnessContrastWidget.h" +#include "OpacityPresetWidget.h" #include "ColorMap.h" #include "ColorMapSettingsWidget.h" -#include "DataSource.h" +#include "ComputeHistogram.h" #include "DoubleSliderWidget.h" -#include "ModuleContour.h" -#include "ModuleManager.h" #include "PresetDialog.h" #include "QVTKGLWidget.h" +#include "SelectVolumeWidget.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" @@ -23,10 +31,14 @@ #include #include #include +#include +#include #include #include +#include #include #include +#include #include #include @@ -48,8 +60,13 @@ #include #include #include +#include +#include #include #include +#include + +#include #include #include @@ -145,16 +162,28 @@ HistogramWidget::HistogramWidget(QWidget* parent) &HistogramWidget::onBrightnessAndContrastClicked); vLayout->addWidget(button); + button = new QToolButton; + m_opacityPresetButton = button; + button->setIcon(QIcon(":/icons/gradient_opacity.png")); + button->setToolTip("Opacity presets (Gaussian, linear, cutoff)"); + button->setEnabled(false); + connect(button, &QToolButton::clicked, this, + &HistogramWidget::onOpacityPresetsClicked); + vLayout->addWidget(button); + vLayout->addStretch(1); 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 +227,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 +271,6 @@ void HistogramWidget::updateLUTProxy() void HistogramWidget::updateColorMapDialogs() { - auto* ds = ActiveObjects::instance().activeDataSource(); auto* lut = m_LUT.Get(); if (m_colorMapSettingsWidget) { @@ -248,10 +279,16 @@ void HistogramWidget::updateColorMapDialogs() } if (m_brightnessContrastWidget) { - m_brightnessContrastWidget->setDataSource(ds); + m_brightnessContrastWidget->setVolumeData(m_volumeData); m_brightnessContrastWidget->setLut(lut); m_brightnessContrastWidget->updateGui(); } + + if (m_opacityPresetWidget) { + m_opacityPresetWidget->setVolumeData(m_volumeData); + m_opacityPresetWidget->setLut(lut); + m_opacityPresetWidget->updateGui(); + } } void HistogramWidget::setInputData(vtkTable* table, const char* x_, @@ -289,7 +326,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,19 +339,54 @@ 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() +{ + // One ModifiedEvent per inserted point is the norm (each AddPoint + // re-sorts and fires), and everything below is a render of every + // view plus a copy of the whole function into its proxy. Coalesce + // to one pass per event-loop turn, as onColorFunctionChanged does, + // so a function rebuilt from thousands of points (a label map's two + // per label) costs one render rather than thousands. + if (m_opacityFunctionUpdatePending) { + return; + } + m_opacityFunctionUpdatePending = true; + QTimer::singleShot(0, this, [this]() { + m_opacityFunctionUpdatePending = false; + syncScalarOpacityFunction(); + }); +} + +void HistogramWidget::syncScalarOpacityFunction() { // Update rendered views of the data. ActiveObjects::instance().renderAllViews(); @@ -322,9 +394,16 @@ void HistogramWidget::onScalarOpacityFunctionChanged() // Update the histogram m_histogramView->GetRenderWindow()->Render(); + updateOpacityProxy(); + + emit opacityChanged(); +} + +void HistogramWidget::updateOpacityProxy() +{ // Update the scalar opacity function proxy as it does not update its // internal state when the VTK object changes. - if (!m_LUTProxy) { + if (!m_LUTProxy || !m_scalarOpacityFunction) { return; } @@ -334,22 +413,18 @@ void HistogramWidget::onScalarOpacityFunctionChanged() return; } - vtkSMPropertyHelper pointsHelper(opacityMapProxy, "Points"); auto opacityMapObject = opacityMapProxy->GetClientSideObject(); auto pwf = vtkPiecewiseFunction::SafeDownCast(opacityMapObject); if (pwf) { - pointsHelper.SetNumberOfElements(4 * pwf->GetSize()); - for (int i = 0; i < pwf->GetSize(); ++i) { - double value[4]; - pwf->GetNodeValue(i, value); - pointsHelper.Set(4 * i + 0, value[0]); - pointsHelper.Set(4 * i + 1, value[1]); - pointsHelper.Set(4 * i + 2, value[2]); - pointsHelper.Set(4 * i + 3, value[3]); + const int n = pwf->GetSize(); + std::vector points(4 * n); + for (int i = 0; i < n; ++i) { + pwf->GetNodeValue(i, points.data() + 4 * i); } + // Recorded, not pushed: the function is the object we just read. + recordProxyValues(opacityMapProxy, "Points", points.data(), + static_cast(points.size())); } - - emit opacityChanged(); } void HistogramWidget::onCurrentPointEditEvent() @@ -371,40 +446,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) { - QList contours = - ModuleManager::instance().findModules( - activeDataSource, view); - if (contours.size() == 0) { - auto res = createContourDialog(isoValue); - if (!res) { - return; + auto* tipPort = ao.activeTipOutputPort(); + if (tipPort) { + for (auto* link : tipPort->links()) { + auto* sink = + qobject_cast(link->to()->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); + + // Priority 3: any ContourSink in the pipeline. + if (!contour) { + for (auto* node : pip->nodes()) { + auto* sink = qobject_cast(node); + if (sink) { + contour = sink; + break; + } + } + } + + // 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 +532,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 +556,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 +597,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 +615,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 +741,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 +796,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,14 +812,17 @@ 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(); connect(widget, &BrightnessContrastWidget::autoPressed, this, QOverload<>::of(&HistogramWidget::autoAdjustContrast)); + connect(widget, &BrightnessContrastWidget::autoRegionPressed, this, + &HistogramWidget::autoAdjustContrastForSelectedRegion); connect(widget, &BrightnessContrastWidget::resetPressed, this, QOverload<>::of(&HistogramWidget::resetRange)); @@ -705,20 +832,34 @@ void HistogramWidget::onBrightnessAndContrastClicked() connect(&dialog, &QDialog::finished, &dialog, &QDialog::deleteLater); } +void HistogramWidget::onOpacityPresetsClicked() +{ + if (m_opacityPresetDialog) { + m_opacityPresetDialog->raise(); + return; + } + + m_opacityPresetDialog = new QDialog(this); + auto& dialog = *m_opacityPresetDialog; + dialog.setLayout(new QVBoxLayout); + dialog.setWindowTitle("Opacity Presets"); + dialog.resize(500, 200); + + m_opacityPresetWidget = new OpacityPresetWidget(m_volumeData, m_LUT, this); + dialog.layout()->addWidget(m_opacityPresetWidget); + dialog.show(); + connect(&dialog, &QDialog::finished, &dialog, &QDialog::deleteLater); +} + 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 = @@ -732,13 +873,138 @@ void HistogramWidget::autoAdjustContrast() autoAdjustContrast(histogram, extents, imageData); } +void HistogramWidget::autoAdjustContrastForSelectedRegion() +{ + if (!m_volumeData || !m_volumeData->isValid()) { + return; + } + + auto* imageData = m_volumeData->imageData(); + if (!imageData) { + return; + } + + // One selector at a time: a second press would put a second box + // widget in the render view. + if (m_autoContrastRegionDialog) { + m_autoContrastRegionDialog->raise(); + m_autoContrastRegionDialog->activateWindow(); + return; + } + + double origin[3], spacing[3], displayPosition[3] = { 0, 0, 0 }; + int extent[6]; + imageData->GetOrigin(origin); + imageData->GetSpacing(spacing); + imageData->GetExtent(extent); + + // Modeless: the selector puts a box widget in the render view, which + // the user has to reach past the dialog to drag. + auto* dialog = new QDialog(this); + m_autoContrastRegionDialog = dialog; + dialog->setWindowTitle("Auto Contrast Region"); + dialog->setAttribute(Qt::WA_DeleteOnClose); + + auto* selector = new SelectVolumeWidget(origin, spacing, extent, extent, + displayPosition, dialog); + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Apply | QDialogButtonBox::Close, Qt::Horizontal, dialog); + + auto* layout = new QVBoxLayout(dialog); + layout->addWidget(new QLabel( + "Drag the box in the 3D view to choose the region the contrast should " + "be computed from, then click Apply.", dialog)); + layout->addWidget(selector); + layout->addWidget(buttons); + + connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, this, + [this, selector]() { + int selected[6]; + selector->getExtentOfSelection(selected); + autoAdjustContrastForExtent(selected); + }); + connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::close); + + dialog->show(); +} + +void HistogramWidget::autoAdjustContrastForExtent(const int extent[6]) +{ + if (!m_volumeData || !m_volumeData->isValid() || !m_LUT) { + return; + } + + auto* imageData = m_volumeData->imageData(); + if (!imageData) { + return; + } + + vtkNew extractor; + extractor->SetInputData(imageData); + int voi[6] = { extent[0], extent[1], extent[2], + extent[3], extent[4], extent[5] }; + extractor->SetVOI(voi); + extractor->Update(); + + auto* region = extractor->GetOutput(); + auto* array = region ? region->GetPointData()->GetScalars() : nullptr; + if (!array || array->GetNumberOfTuples() < 1) { + return; + } + + // Bin the region the way HistogramManager bins whole images; its + // cache is keyed on whole images, so transient sub-regions bin here. + double minmax[2] = { 0.0, 0.0 }; + switch (array->GetDataType()) { + vtkTemplateMacro(ComputeFiniteRange( + reinterpret_cast(array->GetVoidPointer(0)), + array->GetNumberOfTuples(), array->GetNumberOfComponents(), + /*useMagnitude=*/true, minmax)); + default: + return; + } + if (minmax[0] == minmax[1]) { + minmax[1] = minmax[0] + 1.0; + } + + double inc = (minmax[1] - minmax[0]) / (kHistogramBins - 1); + double halfInc = inc / 2.0; + + vtkNew extentsArray; + extentsArray->SetName("image_extents"); + extentsArray->SetNumberOfTuples(kHistogramBins); + double binMin = minmax[0] + halfInc; + for (int j = 0; j < kHistogramBins; ++j) { + extentsArray->SetValue(j, binMin + j * inc); + } + + vtkNew populations; + populations->SetName("image_pops"); + populations->SetNumberOfTuples(kHistogramBins); + auto* pops = static_cast(populations->GetVoidPointer(0)); + std::fill(pops, pops + kHistogramBins, 0); + + int invalid = 0; + switch (array->GetDataType()) { + vtkTemplateMacro(CalculateHistogram( + reinterpret_cast(array->GetVoidPointer(0)), + array->GetNumberOfTuples(), array->GetNumberOfComponents(), minmax[0], + minmax[1], pops, 1.0 / inc, invalid)); + default: + return; + } + + // The region's own dimensions set the ImageJ-style thresholds + autoAdjustContrast(populations, extentsArray, region); +} + void HistogramWidget::autoAdjustContrast(vtkDataArray* histogram, vtkDataArray* extents, 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 +1034,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 +1048,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 +1060,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 +1112,28 @@ 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(); - - // 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); + auto* sbProxy = + m_LUTProxy && view ? getScalarBarRepresentation(view) : nullptr; + bool enable = sbProxy != nullptr; + + QSignalBlocker blocker1(m_colorLegendToolButton); + QSignalBlocker blocker2(m_colorMapSettingsButton); + QSignalBlocker blocker3(m_savePresetButton); + QSignalBlocker blocker4(m_brightnessAndContrastButton); + QSignalBlocker blocker5(m_opacityPresetButton); + m_colorLegendToolButton->setEnabled(enable); + m_colorMapSettingsButton->setEnabled(enable); + m_savePresetButton->setEnabled(enable); + m_brightnessAndContrastButton->setEnabled(enable); + m_opacityPresetButton->setEnabled(enable); + if (enable) { + m_colorLegendToolButton->setChecked( + vtkSMPropertyHelper(sbProxy, "Visibility").GetAsInt() == 1); } } @@ -859,6 +1153,16 @@ 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 needs the same: onScalarOpacityFunctionChanged mirrors + // client-side changes into its property only on the next event-loop + // turn, so without this each rescale compressed the window further. + updateLUTProxy(); + updateOpacityProxy(); vtkSMTransferFunctionProxy::RescaleTransferFunction(lutProxy, min, max); vtkSMTransferFunctionProxy::RescaleTransferFunction(opacityMap, min, max); addPlaceholderNodes(); @@ -874,16 +1178,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..d42882680 100644 --- a/tomviz/HistogramWidget.h +++ b/tomviz/HistogramWidget.h @@ -10,6 +10,8 @@ #include #include +#include + class vtkChartHistogramColorOpacityEditor; class vtkContextView; class vtkDataArray; @@ -27,7 +29,13 @@ class vtkSMProxy; namespace tomviz { +namespace pipeline { +class VolumeData; +using VolumeDataPtr = std::shared_ptr; +} // namespace pipeline + class BrightnessContrastWidget; +class OpacityPresetWidget; class ColorMapSettingsWidget; class PresetDialog; class QVTKGLWidget; @@ -42,6 +50,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 +74,8 @@ public slots: void onPresetClicked(); void onSaveToPresetClicked(); void onBrightnessAndContrastClicked(); + void onOpacityPresetsClicked(); + void onCreateSegmentationColormapClicked(); void applyCurrentPreset(); void updateUI(); @@ -75,6 +86,9 @@ public slots: private: void renderViews(); + /// The deferred body of onScalarOpacityFunctionChanged: render, and + /// copy the function's points into its proxy. + void syncScalarOpacityFunction(); void rescaleTransferFunction(vtkSMProxy* lutProxy, double min, double max); bool createContourDialog(double& isoValue); void showPresetDialog(const QJsonObject& newPreset); @@ -86,11 +100,17 @@ public slots: // need to update the proxy with these modifications, or any operations // with the proxy will over-write what the user has done. void updateLUTProxy(); + void updateOpacityProxy(); // Auto contrast functions void autoAdjustContrast(); void autoAdjustContrast(vtkDataArray* histogram, vtkDataArray* extents, vtkImageData* imageData); + /// Open a box selector in the render view and auto-adjust the + /// contrast from the voxels inside it. + void autoAdjustContrastForSelectedRegion(); + /// Auto-adjust the contrast using only @a extent (in voxel indices). + void autoAdjustContrastForExtent(const int extent[6]); void resetAutoContrastState(); // Add placeholder nodes to make the color bar and opacity editor look nicer @@ -104,6 +124,7 @@ public slots: QToolButton* m_colorMapSettingsButton; QToolButton* m_savePresetButton; QToolButton* m_brightnessAndContrastButton; + QToolButton* m_opacityPresetButton; vtkWeakPointer m_LUT; vtkWeakPointer m_scalarOpacityFunction; @@ -118,12 +139,25 @@ public slots: QPointer m_brightnessContrastDialog; QPointer m_brightnessContrastWidget; + QPointer m_opacityPresetDialog; + QPointer m_opacityPresetWidget; + QPointer m_autoContrastRegionDialog; + + 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; + bool m_opacityFunctionUpdatePending = 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..4add837b6 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,16 +388,11 @@ QList ImageStackDialog::getStackSummary() const return m_summary; } -DataSource::DataSourceType ImageStackDialog::getStackType() const +pipeline::PortType ImageStackDialog::getStackType() const { return m_stackType; } -bool ImageStackDialog::getImageViewerMode() const -{ - return m_ui->imageViewerMode->isChecked(); -} - void ImageStackDialog::onImageToggled(int row, bool value) { m_summary[row].selected = value; @@ -407,10 +401,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..c0274a192 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,12 +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; - bool getImageViewerMode() const; + pipeline::PortType getStackType() const; public slots: void onOpenFileClick(); @@ -43,7 +42,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 +52,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/ImageStackDialog.ui b/tomviz/ImageStackDialog.ui index 447512d64..d64d6ab11 100644 --- a/tomviz/ImageStackDialog.ui +++ b/tomviz/ImageStackDialog.ui @@ -51,16 +51,6 @@
- - - - <html><head/><body><p>After the data is loaded, enable image viewer mode.</p></body></html> - - - Image Viewer Mode - - - @@ -209,7 +199,6 @@ stackTypeCombo - imageViewerMode openFolder openFile tableView 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/ImageViewerModeBehavior.cxx b/tomviz/ImageViewerModeBehavior.cxx new file mode 100644 index 000000000..688099590 --- /dev/null +++ b/tomviz/ImageViewerModeBehavior.cxx @@ -0,0 +1,429 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ImageViewerModeBehavior.h" + +#include "ActiveObjects.h" +#include "pipeline/InputPort.h" +#include "pipeline/Link.h" +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/PipelineUtils.h" +#include "pipeline/PortType.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/sinks/SliceSink.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace tomviz { + +using pipeline::LegacyModuleSink; +using pipeline::OutputPort; +using pipeline::SliceSink; + +/// What entering 2D changed in a view, so leaving can put it back. +struct ImageViewerModeBehavior::State +{ + vtkSmartPointer camera; + QList> hiddenSinks; + QPointer slice; + bool sliceWasVisible = true; + bool sliceShowedArrow = true; +}; + +namespace { + +/// The port upstream of a sink, looking through the SinkGroupNode a +/// sink usually hangs off. +OutputPort* feedingPort(pipeline::SinkNode* sink) +{ + if (sink->inputPorts().isEmpty()) { + return nullptr; + } + auto* link = sink->inputPorts()[0]->link(); + if (!link) { + return nullptr; + } + auto* from = link->from(); + auto* group = qobject_cast(from->node()); + if (!group) { + return from; + } + int idx = group->outputPorts().indexOf(from); + if (idx < 0 || idx >= group->inputPorts().size()) { + return from; + } + auto* groupLink = group->inputPorts()[idx]->link(); + return groupLink ? groupLink->from() : nullptr; +} + +/// With several slices in the view, the one fed by the tip port; with +/// one, that one. Falls back to a visible slice, then the first. +SliceSink* chooseSlice(const QList& slices, OutputPort* tipPort) +{ + if (slices.isEmpty()) { + return nullptr; + } + if (tipPort) { + for (auto* slice : slices) { + if (feedingPort(slice) == tipPort) { + return slice; + } + } + } + for (auto* slice : slices) { + if (slice->visibility()) { + return slice; + } + } + return slices.first(); +} + +/// A new slice in the view, hanging off the tip port; nullptr when there +/// is no tip port, it carries no volume, or the view can't host it. +SliceSink* createSlice(pipeline::Pipeline* pip, vtkSMRenderViewProxy* proxy, + OutputPort* tipPort) +{ + if (!tipPort) { + return nullptr; + } + auto* slice = new SliceSink(); + slice->setLabel("Slice"); + auto* input = slice->inputPorts()[0]; + if (!pipeline::isPortTypeCompatible(tipPort->type(), + input->acceptedTypes()) || + !slice->initialize(proxy)) { + delete slice; + return nullptr; + } + pip->addNode(slice); + pip->createLink(pipeline::sinkAttachPort(pip, tipPort, input), input); + pip->executeWhenIdle(); + return slice; +} + +bool volumeBounds(const pipeline::PortData& data, double bounds[6]) +{ + if (!data.isValid() || !pipeline::isVolumeType(data.type())) { + return false; + } + auto vol = data.value(); + if (!vol || !vol->isValid()) { + return false; + } + vol->imageData()->GetBounds(bounds); + return true; +} + +/// Bounds of what the slice shows: its own last data, else what feeds +/// it, else the tip port (a slice created just now has consumed nothing +/// yet, and its group's passthrough is empty until the pipeline runs). +bool sliceBounds(SliceSink* slice, OutputPort* tipPort, double bounds[6]) +{ + auto vol = slice->volumeData(); + if (vol && vol->isValid()) { + vol->imageData()->GetBounds(bounds); + return true; + } + if (auto* port = feedingPort(slice)) { + if (volumeBounds(port->data(), bounds)) { + return true; + } + } + return tipPort && volumeBounds(tipPort->data(), bounds); +} + +/// Where the camera looks and which way is up for a slice, as close to +/// the current camera as the slice allows: the camera looks down the +/// slice normal from the side it is already on, and up is the in-plane +/// axis nearest its current up. When the camera gives no hint (it looks +/// edge-on, or its up is the normal) the defaults are x right and y up +/// for XY, x right and z up for XZ, y right and z up for YZ. Returns the +/// axis the camera looks along and sets @a upAxis to the one pointing +/// up, or -1 for both with a custom plane. +int lookAlong(SliceSink* slice, vtkCamera* camera, double look[3], + double up[3], int& upAxis) +{ + double current[3], currentUp[3]; + camera->GetDirectionOfProjection(current); + camera->GetViewUp(currentUp); + const double hint = 1e-6; + + int axis = -1; + double normal[3] = { 0, 0, 0 }; + switch (slice->direction()) { + case SliceSink::XY: + axis = 2, upAxis = 1, normal[2] = -1; + break; + case SliceSink::XZ: + axis = 1, upAxis = 2, normal[1] = 1; + break; + case SliceSink::YZ: + axis = 0, upAxis = 2, normal[0] = -1; + break; + default: + upAxis = -1; + slice->planeNormal(normal); + if (vtkMath::Normalize(normal) == 0.0) { + normal[0] = 0, normal[1] = 0, normal[2] = 1; + } + vtkMath::MultiplyScalar(normal, -1.0); + break; + } + + // The side the camera is on + double sign = vtkMath::Dot(normal, current) < -hint ? -1.0 : 1.0; + for (int i = 0; i < 3; ++i) { + look[i] = sign * normal[i]; + } + + if (axis >= 0) { + // The in-plane axis, either way round, nearest the current up + double best = hint; + int bestAxis = -1; + for (int i = 0; i < 3; ++i) { + if (i != axis && std::abs(currentUp[i]) > best) { + best = std::abs(currentUp[i]); + bestAxis = i; + } + } + up[0] = up[1] = up[2] = 0; + if (bestAxis >= 0) { + upAxis = bestAxis; + up[upAxis] = currentUp[upAxis] < 0 ? -1 : 1; + } else { + up[upAxis] = 1; + } + return axis; + } + + // Custom plane: the current up, flattened onto the plane + double along = vtkMath::Dot(currentUp, look); + for (int i = 0; i < 3; ++i) { + up[i] = currentUp[i] - along * look[i]; + } + if (vtkMath::Normalize(up) < hint) { + // Any up vector not parallel to the normal will do. + if (std::abs(look[2]) < 0.9) { + up[0] = 0, up[1] = 0, up[2] = 1; + } else { + up[0] = 0, up[1] = 1, up[2] = 0; + } + } + return -1; +} + +/// Tighten the parallel scale so the slice fills the viewport along its +/// narrower dimension, instead of ResetCamera's bounding-sphere fit. +void fitParallelScale(vtkSMRenderViewProxy* proxy, const double bounds[6], + int axis, int upAxis) +{ + int* size = proxy->GetRenderWindow()->GetSize(); + double w = size[0]; + double h = size[1]; + if (w <= 0 || h <= 0) { + return; + } + + double lengths[3] = { bounds[1] - bounds[0], bounds[3] - bounds[2], + bounds[5] - bounds[4] }; + double bw = lengths[3 - axis - upAxis]; + double bh = lengths[upAxis]; + if (bw <= 0 || bh <= 0) { + return; + } + + double viewAspect = w / h; + double boundsAspect = bw / bh; + double scale = viewAspect >= boundsAspect ? bh / 2 : bw / 2 / viewAspect; + proxy->GetActiveCamera()->SetParallelScale(scale); +} + +void aimCamera(vtkSMRenderViewProxy* proxy, SliceSink* slice, + OutputPort* tipPort) +{ + double look[3], up[3]; + int upAxis = -1; + int axis = lookAlong(slice, proxy->GetActiveCamera(), look, up, upAxis); + proxy->ResetActiveCameraToDirection(look[0], look[1], look[2], up[0], + up[1], up[2]); + double bounds[6]; + if (sliceBounds(slice, tipPort, bounds)) { + proxy->ResetCamera(bounds); + if (axis >= 0) { + fitParallelScale(proxy, bounds, axis, upAxis); + } + } else { + proxy->ResetCamera(); + } +} + +} // namespace + +ImageViewerModeBehavior::ImageViewerModeBehavior(QObject* parentObject) + : QObject(parentObject) +{ + auto* smmodel = pqApplicationCore::instance()->getServerManagerModel(); + for (auto* view : smmodel->findItems()) { + watch(view); + } + connect(smmodel, &pqServerManagerModel::viewAdded, this, + &ImageViewerModeBehavior::watch); + connect(smmodel, &pqServerManagerModel::preViewRemoved, this, + [this](pqView* view) { + m_states.erase(qobject_cast(view)); + }); +} + +ImageViewerModeBehavior::~ImageViewerModeBehavior() = default; + +bool ImageViewerModeBehavior::isActive(pqRenderView* view) const +{ + return m_states.count(view) > 0; +} + +SliceSink* ImageViewerModeBehavior::slice(pqRenderView* view) const +{ + auto it = m_states.find(view); + return it == m_states.end() ? nullptr : it->second->slice.data(); +} + +void ImageViewerModeBehavior::watch(pqView* view) +{ + auto* renderView = qobject_cast(view); + if (!renderView) { + return; + } + // pqRenderView emits this for every change of the InteractionMode + // property, whoever made it: the toolbar toggle, the View menu, or a + // loaded state. + connect(renderView, qOverload(&pqRenderView::updateInteractionMode), + this, [this, renderView](int mode) { + onInteractionModeChanged(renderView, mode); + }); +} + +void ImageViewerModeBehavior::onInteractionModeChanged(pqRenderView* view, + int mode) +{ + // Selection and zoom-to-box are transient and hand back to the mode + // they interrupted, so only 2D and 3D themselves count. + if (mode == vtkPVRenderView::INTERACTION_MODE_2D) { + enter(view); + } else if (mode == vtkPVRenderView::INTERACTION_MODE_3D) { + leave(view); + } +} + +void ImageViewerModeBehavior::enter(pqRenderView* view) +{ + if (isActive(view)) { + return; + } + auto* pip = ActiveObjects::instance().pipeline(); + auto* proxy = view->getRenderViewProxy(); + if (!pip || !proxy) { + return; + } + + QList others; + QList slices; + for (auto* node : pip->nodes()) { + auto* sink = qobject_cast(node); + if (!sink || sink->view() != proxy) { + continue; + } + if (auto* sliceSink = qobject_cast(sink)) { + slices.append(sliceSink); + } else { + others.append(sink); + } + } + + // Nothing selected yet (a dataset just loaded, say): the first + // branch's tip, as ActiveObjects itself falls back to. + auto* tipPort = ActiveObjects::instance().activeTipOutputPort(); + if (!tipPort) { + tipPort = pipeline::findTipOutputPort(pip, nullptr); + } + auto* slice = chooseSlice(slices, tipPort); + bool created = false; + if (!slice) { + slice = createSlice(pip, proxy, tipPort); + if (!slice) { + // Nothing to look at: leave the view as it is, just with 2D + // interaction. + return; + } + created = true; + } + + auto state = std::make_unique(); + state->camera = vtkSmartPointer::New(); + // DeepCopy: the snapshot must not share matrices with the live camera + state->camera->DeepCopy(proxy->GetActiveCamera()); + for (auto* sink : others) { + if (sink->visibility()) { + state->hiddenSinks.append(sink); + sink->setVisibility(false); + } + } + state->slice = slice; + state->sliceWasVisible = !created && slice->visibility(); + state->sliceShowedArrow = slice->showArrow(); + slice->setVisibility(true); + // The plane-moving arrow points at the camera here and its drag + // would fight the 2D pan. + slice->setShowArrow(false); + + aimCamera(proxy, slice, tipPort); + m_states[view] = std::move(state); + view->render(); +} + +void ImageViewerModeBehavior::leave(pqRenderView* view) +{ + auto it = m_states.find(view); + if (it == m_states.end()) { + return; + } + std::unique_ptr state = std::move(it->second); + m_states.erase(it); + + for (const auto& sink : state->hiddenSinks) { + if (sink) { + sink->setVisibility(true); + } + } + if (state->slice) { + state->slice->setShowArrow(state->sliceShowedArrow); + // A slice created for the viewer stays in the pipeline, hidden, so + // the next 2D toggle finds it where the user left it. + if (!state->sliceWasVisible) { + state->slice->setVisibility(false); + } + } + if (auto* proxy = view->getRenderViewProxy()) { + proxy->GetActiveCamera()->DeepCopy(state->camera); + } + view->render(); +} + +} // namespace tomviz diff --git a/tomviz/ImageViewerModeBehavior.h b/tomviz/ImageViewerModeBehavior.h new file mode 100644 index 000000000..7828898b0 --- /dev/null +++ b/tomviz/ImageViewerModeBehavior.h @@ -0,0 +1,65 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizImageViewerModeBehavior_h +#define tomvizImageViewerModeBehavior_h + +#include + +#include +#include + +class pqRenderView; +class pqView; + +namespace tomviz { + +namespace pipeline { +class SliceSink; +} + +/// Turns a render view into a 2D image viewer while its interaction mode +/// is 2D, and puts it back when the mode returns to 3D. +/// +/// Hangs off the 2D/3D toggle in every render view's toolbar, or any +/// other change of the view's InteractionMode property. On entering 2D +/// every non-slice visualization in that view is hidden, one slice in +/// the view is chosen (the one fed by the active tip output port when +/// there are several, a new one on the tip port when there is none) and +/// the camera is aimed straight down its normal. On returning to 3D the +/// hidden visualizations, the slice this behavior showed or created, and +/// the camera (position, direction and up) are restored. +/// +/// Everything is per view: a split layout can hold a 2D view next to a +/// 3D one, and views showing other datasets are left alone. +class ImageViewerModeBehavior : public QObject +{ + Q_OBJECT + +public: + explicit ImageViewerModeBehavior(QObject* parent = nullptr); + ~ImageViewerModeBehavior() override; + + /// Whether @a view is currently set up as an image viewer. + bool isActive(pqRenderView* view) const; + + /// The slice the image viewer in @a view looks at, or nullptr when the + /// view is not an image viewer (or the slice was deleted since). + pipeline::SliceSink* slice(pqRenderView* view) const; + +private: + struct State; + + void watch(pqView* view); + void onInteractionModeChanged(pqRenderView* view, int mode); + void enter(pqRenderView* view); + void leave(pqRenderView* view); + + std::map> m_states; + + Q_DISABLE_COPY(ImageViewerModeBehavior) +}; + +} // namespace tomviz + +#endif 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.cxx b/tomviz/InterfaceBuilder.cxx deleted file mode 100644 index 87dda0bff..000000000 --- a/tomviz/InterfaceBuilder.cxx +++ /dev/null @@ -1,1415 +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 "InterfaceBuilder.h" - -#include "ActiveObjects.h" -#include "DataSource.h" -#include "DoubleSpinBox.h" -#include "ModuleManager.h" -#include "SpinBox.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 - -using tomviz::DataSource; - -Q_DECLARE_METATYPE(DataSource*) - -namespace { - -// Templated query of QJsonValue type -template -bool isType(const QJsonValue&) -{ - return false; -} - -template <> -bool isType(const QJsonValue& value) -{ - return value.isDouble(); -} - -template <> -bool isType(const QJsonValue& value) -{ - return value.isDouble(); -} - -// Templated accessor of QJsonValue value -template -T getAs(const QJsonValue&) -{ - return 0; -} - -template <> -int getAs(const QJsonValue& value) -{ - int iValue = 0; - try { - iValue = value.toInt(); - } catch (...) { - qCritical() << "Could not get int from QJsonValue"; - } - return iValue; -} - -template <> -double getAs(const QJsonValue& value) -{ - double dValue = 0.0; - try { - dValue = value.toDouble(); - } catch (...) { - qCritical() << "Could not get double from QJsonValue"; - } - return dValue; -} - -template <> -QString getAs(const QJsonValue& value) -{ - QString strValue; - try { - strValue = value.toString(); - } catch (...) { - qCritical() << "Could not get QString from QJsonValue"; - } - return strValue; -} - -// Templated generation of numeric editing widgets. -template -QWidget* getNumericWidget(T, T, T, int, T) -{ - return nullptr; -} - -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); - } - spinBox->setMinimum(rangeMin); - spinBox->setMaximum(rangeMax); - spinBox->setValue(defaultValue); - return spinBox; -} - -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); - } - spinBox->setMinimum(rangeMin); - spinBox->setMaximum(rangeMax); - spinBox->setValue(defaultValue); - return spinBox; -} - -template -bool isWidgetType(const QWidget* widget) { - return qobject_cast(widget) != nullptr; -} - -bool isWidgetNumeric(const QWidget* widget) { - return isWidgetType(widget) || isWidgetType(widget); -} - -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) { - return &QCheckBox::toggled; - } else if constexpr (std::is_same_v) { - return QOverload::of(&QDoubleSpinBox::valueChanged); - } else if constexpr (std::is_same_v) { - return QOverload::of(&QSpinBox::valueChanged); - } else if constexpr (std::is_same_v) { - return &QLineEdit::textChanged; - } -} - -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) { - return w->isChecked(); - } else if constexpr (std::is_same_v) { - return w->value(); - } else if constexpr (std::is_same_v) { - return w->value(); - } else if constexpr (std::is_same_v) { - return w->text(); - } -} - -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()); - if (!labelValue.isUndefined()) { - label->setText(labelValue.toString()); - } - layout->addWidget(label, row, 0, 1, 1); - - bool defaultValue = false; - if (parameterNode.contains("default")) { - QJsonValueRef defaultNode = parameterNode["default"]; - if (defaultNode.isBool()) { - defaultValue = defaultNode.toBool(); - } - } - QCheckBox* checkBox = new QCheckBox(); - checkBox->setObjectName(nameValue.toString()); - checkBox->setCheckState(defaultValue ? Qt::Checked : Qt::Unchecked); - label->setBuddy(checkBox); - layout->addWidget(checkBox, row, 1, 1, 1); -} - -template -void addNumericWidget(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; - } - - QLabel* label = new QLabel(nameValue.toString()); - if (!labelValue.isUndefined()) { - label->setText(labelValue.toString()); - } - layout->addWidget(label, row, 0, 1, 1); - - std::vector defaultValues; - if (parameterNode.contains("default")) { - QJsonValueRef defaultNode = parameterNode["default"]; - if (isType(defaultNode)) { - defaultValues.push_back(getAs(defaultNode)); - } else if (defaultNode.isArray()) { - QJsonArray defaultArray = defaultNode.toArray(); - for (QJsonObject::size_type i = 0; i < defaultArray.size(); ++i) { - 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 (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) { - minValues[i] = getAs(minArray[i]); - } - } - } - - 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) { - maxValues[i] = getAs(maxArray[i]); - } - } - } - - int precision = -1; - if (parameterNode.contains("precision")) { - QJsonValueRef precNode = parameterNode["precision"]; - if (isType(precNode)) { - precision = getAs(precNode); - } - } - T step = -1; - if (parameterNode.contains("step")) { - QJsonValueRef stepNode = parameterNode["step"]; - if (isType(stepNode)) { - step = getAs(stepNode); - } - } - - QHBoxLayout* horizontalLayout = new QHBoxLayout(); - horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* horizontalWidget = new QWidget; - horizontalWidget->setLayout(horizontalLayout); - label->setBuddy(horizontalWidget); - layout->addWidget(horizontalWidget, row, 1, 1, 1); - - 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')); - } - - QWidget* spinBox = getNumericWidget(defaultValues[i], minValues[i], - maxValues[i], precision, step); - spinBox->setObjectName(name); - horizontalLayout->addWidget(spinBox); - } -} - -void addEnumerationWidget(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()); - if (!labelValue.isUndefined()) { - label->setText(labelValue.toString()); - } - layout->addWidget(label, row, 0, 1, 1); - - QComboBox* comboBox = new QComboBox(); - comboBox->setObjectName(nameValue.toString()); - label->setBuddy(comboBox); - QJsonValueRef optionsNode = parameterNode["options"]; - if (!optionsNode.isUndefined()) { - QJsonArray optionsArray = optionsNode.toArray(); - for (QJsonObject::size_type i = 0; i < optionsArray.size(); ++i) { - QJsonObject optionNode = optionsArray[i].toObject(); - QString optionName = optionNode.keys()[0]; - 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); - } - } - } - - layout->addWidget(comboBox, row, 1, 1, 1); -} - -void addXYZHeaderWidget(QGridLayout* layout, int row, const QJsonValue&) -{ - QHBoxLayout* horizontalLayout = new QHBoxLayout; - horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* horizontalWidget = new QWidget; - horizontalWidget->setLayout(horizontalLayout); - layout->addWidget(horizontalWidget, row, 1, 1, 1); - - QLabel* xLabel = new QLabel("X"); - xLabel->setAlignment(Qt::AlignCenter); - horizontalLayout->addWidget(xLabel); - QLabel* yLabel = new QLabel("Y"); - yLabel->setAlignment(Qt::AlignCenter); - horizontalLayout->addWidget(yLabel); - QLabel* zLabel = new QLabel("Z"); - zLabel->setAlignment(Qt::AlignCenter); - horizontalLayout->addWidget(zLabel); -} - -void addPathWidget(QGridLayout* layout, int row, QJsonObject& pathNode) -{ - QHBoxLayout* horizontalLayout = new QHBoxLayout; - horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* 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()); - 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 ) - 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); - } - - horizontalLayout->addWidget(pathField); - auto filter = pathNode["filter"].toString(); - - QPushButton* 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(); - } - } - - // 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); - } - - // If a path was selected update the line edit. - if (!path.isNull()) { - pathField->setText(path); - } - }); -} - -void addStringWidget(QGridLayout* layout, int row, QJsonObject& pathNode) -{ - QHBoxLayout* horizontalLayout = new QHBoxLayout; - horizontalLayout->setContentsMargins(0, 0, 0, 0); - QWidget* 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()); - 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 ) - stringField->setProperty("type", type); - stringField->setObjectName(nameValue.toString()); - stringField->setMinimumWidth(500); - label->setBuddy(stringField); - horizontalLayout->addWidget(stringField); - - QJsonValueRef defaultNode = pathNode["default"]; - if (!defaultNode.isUndefined() && defaultNode.isString()) { - auto defaultValue = getAs(defaultNode); - stringField->setText(defaultValue); - } -} - -void addDatasetWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) -{ - QJsonValueRef nameValue = parameterNode["name"]; - QJsonValueRef labelValue = parameterNode["label"]; - auto defaultId = parameterNode.value("default").toString(); - - if (nameValue.isUndefined()) { - QJsonDocument document(parameterNode); - qWarning() << QString("Parameter %1 has no name. Skipping.") - .arg(document.toJson().data()); - return; - } - - QLabel* labelWidget = new QLabel(nameValue.toString()); - if (!labelValue.isUndefined()) { - labelWidget->setText(labelValue.toString()); - } - layout->addWidget(labelWidget, row, 0, 1, 1); - - 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; - } - - 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; - } - - QString name = nameValue.toString(); - - QLabel* label = new QLabel(name); - if (!labelValue.isUndefined()) { - label->setText(labelValue.toString()); - } - layout->addWidget(label, row, 0, 1, 1); - - // Container widget - QWidget* container = new QWidget(); - container->setObjectName(name); - container->setProperty("type", "select_scalars"); - label->setBuddy(container); - - QVBoxLayout* 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(); - comboBox->setObjectName(name + "_combo"); - QStandardItemModel* 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); - } - - // 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; - } - } - applyAllCheckBox->setChecked(showApplyAll && allSelected); - comboBox->setEnabled(!applyAllCheckBox->isChecked()); - } - - // Auto-hide when only one scalar - if (scalars.size() <= 1) { - label->setVisible(false); - container->setVisible(false); - } - } - - vLayout->addWidget(comboBox); - - // Toggle combo box enabled state based on checkbox - QObject::connect(applyAllCheckBox, &QCheckBox::toggled, - [comboBox](bool checked) { - comboBox->setEnabled(!checked); - }); - - // 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. - class ComboEventFilter : public QObject - { - public: - ComboEventFilter(QComboBox* combo, QObject* parent) - : QObject(parent), m_combo(combo) {} - bool eventFilter(QObject* obj, QEvent* event) override - { - if (event->type() == QEvent::MouseButtonPress) { - m_pressedOnViewport = true; - return true; // Consume press to keep popup open - } - if (event->type() == QEvent::MouseButtonRelease) { - if (!m_pressedOnViewport) { - return true; // No matching press — consume without toggling - } - 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()); - if (model) { - auto* item = model->itemFromIndex(index); - if (item && (item->flags() & Qt::ItemIsUserCheckable)) { - auto state = item->checkState() == Qt::Checked - ? Qt::Unchecked : Qt::Checked; - item->setCheckState(state); - } - } - } - return true; // Consume the event to keep popup open - } - return QObject::eventFilter(obj, event); - } - private: - QComboBox* m_combo; - bool m_pressedOnViewport = false; - }; - - auto* filter = new ComboEventFilter(comboBox, comboBox); - comboBox->view()->viewport()->installEventFilter(filter); - - layout->addWidget(container, row, 1, 1, 1); -} - -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) -{} - -void InterfaceBuilder::setJSONDescription(const QString& description) -{ - setJSONDescription(QJsonDocument::fromJson(description.toLatin1())); -} - -void InterfaceBuilder::setJSONDescription(const QJsonDocument& description) -{ - if (!description.isObject()) { - qCritical() << "Failed to parse operator JSON"; - qCritical() << m_json; - m_json = QJsonDocument(); - } else { - m_json = description; - } -} - -QLayout* InterfaceBuilder::buildParameterInterface(QGridLayout* layout, - QJsonArray& parameters, - const QString& tag) const -{ - 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; - } - - QString typeString = typeValue.toString(); - - // 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()); - } - - parameterObject["default"] = QJsonValue::fromVariant(parameterValue); - } - } - - 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); - } - } - - setupEnableAndVisibleStates(layout->parentWidget(), parameters); - - return layout; -} - -void InterfaceBuilder::setupEnableAndVisibleStates( - const QObject* parent, - QJsonArray& parameters) const -{ - setupEnableStates(parent, parameters, true); - setupEnableStates(parent, parameters, false); -} - -QLayout* InterfaceBuilder::buildInterface() const -{ - 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; - } - - for (auto* child : parent->findChildren()) { - if (child->buddy() == 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) -{ - // The generic one, for bools and strings, can only do `==` and `!=`. - if (comparator == "==") { - return value == ref; - } else if (comparator == "!=") { - return value != ref; - } - - 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; - } - - return false; -} - -template -bool compare(const T* widget, const QVariant& compareValue, - const QString& comparator) -{ - 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); - } - - return false; -} - -// Represents a single condition clause like "algorithm == 'mlem'" -struct EnableCondition -{ - QWidget* refWidget = nullptr; - QString comparator; - QVariant compareValue; -}; - -// Evaluate a single condition by delegating to the typed compare() function -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; -} - -// 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) -{ - for (auto& andGroup : orGroups) { - bool groupResult = true; - for (auto& cond : andGroup) { - if (!evaluateCondition(cond)) { - groupResult = false; - break; - } - } - if (groupResult) { - return true; - } - } - return false; -} - -static void connectWidgetChanged(QWidget* refWidget, QWidget* target, - std::function func) -{ - 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(); - } -} - -void InterfaceBuilder::setupEnableStates(const QObject* parent, - QJsonArray& parameters, - bool visible) const -{ - static const QStringList validComparators = { - "==", "!=", ">", ">=", "<", "<=" - }; - - QJsonObject::size_type numParameters = parameters.size(); - for (QJsonObject::size_type i = 0; i < numParameters; ++i) { - QJsonValueRef parameterNode = parameters[i]; - QJsonObject parameterObject = parameterNode.toObject(); - - QString text = visible ? "visible_if" : "enable_if"; - QString enableIfValue = parameterObject[text].toString(""); - if (enableIfValue.isEmpty()) { - continue; - } - - 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); - - QList> orGroups; - bool parseError = false; - - for (auto& orPart : orParts) { - 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; - } - - auto refWidgetName = tokens[0]; - auto comparator = tokens[1]; - auto compareValue = tokens[2]; - - 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; - } - - EnableCondition cond; - cond.refWidget = refWidget; - cond.comparator = comparator; - cond.compareValue = compareValue; - andGroup.append(cond); - } - - if (parseError) { - break; - } - orGroups.append(andGroup); - } - - if (parseError) { - continue; - } - - 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) { - if (connectedWidgets.contains(cond.refWidget)) { - continue; - } - connectedWidgets.insert(cond.refWidget); - connectWidgetChanged(cond.refWidget, widget, evalFunc); - } - } - - // Evaluate once for the initial state - evalFunc(); - } -} - -} // namespace tomviz 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/InternalPythonHelper.cxx b/tomviz/InternalPythonHelper.cxx deleted file mode 100644 index 36a34ba91..000000000 --- a/tomviz/InternalPythonHelper.cxx +++ /dev/null @@ -1,81 +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 "InternalPythonHelper.h" - -#include - -namespace tomviz { - -class InternalPythonHelper::Internal -{ -public: - Python::Module operatorModule; - Python::Module internalModule; - Python::Function deleteModuleFunction; - - void loadInternalModules() - { - Python::initialize(); - - { - Python python; - operatorModule = python.import("tomviz.utils"); - if (!operatorModule.isValid()) { - qCritical() << "Failed to import tomviz.utils module."; - } - - internalModule = python.import("tomviz._internal"); - if (!internalModule.isValid()) { - qCritical() << "Failed to import tomviz._internal module."; - } - - deleteModuleFunction = internalModule.findFunction("delete_module"); - if (!deleteModuleFunction.isValid()) { - qCritical() << "Unable to locate delete_module."; - } - } - } - - Python::Module loadModule(const QString& script, const QString& name) - { - Python::Module result; - { - Python python; - QString label = name + ".py"; - - result = python.import(script, label, name); - if (!result.isValid()) { - qCritical("Failed to load module."); - return result; - } - - // Delete the module from sys.module so we don't reuse it - Python::Tuple delArgs(1); - Python::Object pyName(name); - delArgs.set(0, pyName); - auto delResult = deleteModuleFunction.call(delArgs); - if (!delResult.isValid()) { - qCritical("An error occurred deleting module."); - } - } - - return result; - } -}; - -InternalPythonHelper::InternalPythonHelper() -{ - m_internal.reset(new Internal); - m_internal->loadInternalModules(); -} - -InternalPythonHelper::~InternalPythonHelper() = default; - -Python::Module InternalPythonHelper::loadModule(const QString& script, - const QString& name) -{ - return m_internal->loadModule(script, name); -} - -} // namespace tomviz diff --git a/tomviz/InternalPythonHelper.h b/tomviz/InternalPythonHelper.h deleted file mode 100644 index 00648d68d..000000000 --- a/tomviz/InternalPythonHelper.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 tomvizInternalPythonHelper_h -#define tomvizInternalPythonHelper_h - -#include - -#include "PythonUtilities.h" - -namespace tomviz { - -class InternalPythonHelper -{ -public: - InternalPythonHelper(); - ~InternalPythonHelper(); - - Python::Module loadModule(const QString& script, - const QString& name = "tomviz_auto_generated"); - - class Internal; - QScopedPointer m_internal; -}; - -} // namespace tomviz - -#endif // tomvizInternalPythonHelper_h diff --git a/tomviz/LabelSelection.cxx b/tomviz/LabelSelection.cxx new file mode 100644 index 000000000..0c4874d2e --- /dev/null +++ b/tomviz/LabelSelection.cxx @@ -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". */ + +#include "LabelSelection.h" + +#include +#include + +#include +#include + +namespace tomviz { + +QVector parseLabelList(const QString& text) +{ + static const QRegularExpression separators("[,;\\s]+"); + static const QRegularExpression spacedRun("(\\d+)\\s*-\\s*(\\d+)"); + static const QRegularExpression run("^(\\d+)-(\\d+)$"); + QVector labels; + // "3 - 5" is one run, so close it up before splitting on whitespace + QString tidy = text; + tidy.replace(spacedRun, "\\1-\\2"); + for (const auto& token : tidy.split(separators, Qt::SkipEmptyParts)) { + auto match = run.match(token); + if (match.hasMatch()) { + double first = match.captured(1).toDouble(); + double last = match.captured(2).toDouble(); + if (first > last) { + std::swap(first, last); + } + for (double v = first; v <= last; v += 1.0) { + labels.append(v); + } + continue; + } + bool ok = false; + double value = token.toDouble(&ok); + if (ok) { + labels.append(value); + } + } + std::sort(labels.begin(), labels.end()); + labels.erase(std::unique(labels.begin(), labels.end()), labels.end()); + return labels; +} + +QString formatLabelList(QVector labels) +{ + std::sort(labels.begin(), labels.end()); + labels.erase(std::unique(labels.begin(), labels.end()), labels.end()); + + auto number = [](double v) { + return v == std::floor(v) ? QString::number(static_cast(v)) + : QString::number(v); + }; + QStringList parts; + int i = 0; + while (i < labels.size()) { + int j = i; + // Extend over consecutive integers; a run of three or more is worth + // writing as a range. + while (j + 1 < labels.size() && labels[j] == std::floor(labels[j]) && + labels[j + 1] == labels[j] + 1.0) { + ++j; + } + if (j - i >= 2) { + parts.append(number(labels[i]) + "-" + number(labels[j])); + } else { + for (int k = i; k <= j; ++k) { + parts.append(number(labels[k])); + } + } + i = j + 1; + } + return parts.join(", "); +} + +} // namespace tomviz diff --git a/tomviz/LabelSelection.h b/tomviz/LabelSelection.h new file mode 100644 index 000000000..cc686f2ea --- /dev/null +++ b/tomviz/LabelSelection.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 tomvizLabelSelection_h +#define tomvizLabelSelection_h + +#include +#include + +namespace tomviz { + +/// The label values named by a list such as "3, 5, 10-14": commas, +/// semicolons and whitespace separate entries, and "first-last" is a +/// run. Sorted ascending with duplicates dropped; entries that are not +/// numbers are ignored. The Remove Labels operator reads the same +/// format. +QVector parseLabelList(const QString& text); + +/// The list form of @a labels, "3, 5, 10-14", with runs of consecutive +/// integers folded into ranges. +QString formatLabelList(QVector labels); + +} // namespace tomviz + +#endif diff --git a/tomviz/LabelSelectionWidget.cxx b/tomviz/LabelSelectionWidget.cxx new file mode 100644 index 000000000..7df15f149 --- /dev/null +++ b/tomviz/LabelSelectionWidget.cxx @@ -0,0 +1,267 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "LabelSelectionWidget.h" + +#include "LabelSelection.h" +#include "pipeline/data/LabelMapData.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace tomviz { + +namespace { + +enum Column +{ + RemoveColumn = 0, + ColorColumn, + ValueColumn, + NameColumn, + CountColumn, + ColumnCount +}; + +/// The label table of the operator's input: the port's own when it is +/// typed as a label map, otherwise a scan of the volume's values. +pipeline::LabelMapDataPtr inputLabels( + const QMap& inputs) +{ + auto it = inputs.constFind(QStringLiteral("volume")); + if (it == inputs.constEnd()) { + return nullptr; + } + pipeline::VolumeDataPtr volume; + try { + volume = it.value().value(); + } catch (...) { + return nullptr; + } + if (!volume || !volume->isValid()) { + return nullptr; + } + auto labels = pipeline::labelMapData(volume); + if (!labels) { + labels = std::make_shared( + vtkSmartPointer(volume->imageData())); + } + labels->refreshLabels(); + return labels; +} + +} // namespace + +LabelSelectionWidget::LabelSelectionWidget( + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent) +{ + // wireParameterBindings finds this control by the parameter's name + setObjectName(QStringLiteral("labels")); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(new QLabel(tr("Labels to Remove"), this)); + + m_table = new QTableWidget(0, ColumnCount, this); + m_table->setHorizontalHeaderLabels( + { QString(), tr("Color"), tr("Value"), tr("Name"), tr("Voxels") }); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->verticalHeader()->setVisible(false); + auto* header = m_table->horizontalHeader(); + header->setStretchLastSection(false); + header->setSectionResizeMode(RemoveColumn, QHeaderView::ResizeToContents); + header->setSectionResizeMode(ColorColumn, QHeaderView::Fixed); + header->resizeSection(ColorColumn, 48); + header->setSectionResizeMode(ValueColumn, QHeaderView::ResizeToContents); + header->setSectionResizeMode(NameColumn, QHeaderView::Stretch); + header->setSectionResizeMode(CountColumn, QHeaderView::ResizeToContents); + m_table->setMinimumHeight(180); + layout->addWidget(m_table); + + auto labels = inputLabels(inputs); + if (labels && labels->labelsSupported()) { + for (const auto& entry : labels->labels().entries()) { + // The background is what the others are removed into + if (entry.value == 0.0) { + continue; + } + int row = m_table->rowCount(); + m_table->insertRow(row); + auto* remove = new QTableWidgetItem; + remove->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | + Qt::ItemIsUserCheckable); + remove->setCheckState(Qt::Unchecked); + remove->setData(Qt::UserRole, entry.value); + m_table->setItem(row, RemoveColumn, remove); + auto* color = new QTableWidgetItem; + color->setBackground(entry.color); + m_table->setItem(row, ColorColumn, color); + auto* value = new QTableWidgetItem(QString::number(entry.value)); + value->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_table->setItem(row, ValueColumn, value); + m_table->setItem(row, NameColumn, + new QTableWidgetItem(entry.displayName())); + auto* count = new QTableWidgetItem( + QLocale().toString(qlonglong(entry.voxelCount))); + count->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_table->setItem(row, CountColumn, count); + } + } + + auto* buttons = new QHBoxLayout; + auto* all = new QPushButton(tr("All"), this); + auto* none = new QPushButton(tr("None"), this); + auto* invert = new QPushButton(tr("Invert"), this); + buttons->addWidget(all); + buttons->addWidget(none); + buttons->addWidget(invert); + buttons->addStretch(); + layout->addLayout(buttons); + connect(all, &QPushButton::clicked, this, [this]() { setAllChecked(true); }); + connect(none, &QPushButton::clicked, this, + [this]() { setAllChecked(false); }); + connect(invert, &QPushButton::clicked, this, + &LabelSelectionWidget::invertChecked); + + m_summary = new QLabel(this); + m_summary->setWordWrap(true); + layout->addWidget(m_summary); + + m_renumber = new QCheckBox(tr("Renumber remaining labels"), this); + m_renumber->setToolTip( + tr("Number the labels that remain 1, 2, 3... in ascending order of " + "their old values. Off, they keep their values so names and colors " + "in a Label Map visualization still line up.")); + layout->addWidget(m_renumber); + + connect(m_table, &QTableWidget::itemChanged, this, + [this](QTableWidgetItem* item) { + if (item && item->column() == RemoveColumn) { + updateSummary(); + emit selectionChanged(); + } + }); + + if (m_table->rowCount() == 0) { + m_summary->setText(labels && !labels->labelsSupported() + ? tr("The active scalars are floating point, so " + "they have no labels to list.") + : tr("The input holds no labels yet.")); + } else { + updateSummary(); + } +} + +QVector LabelSelectionWidget::selectedLabels() const +{ + QVector labels = m_unlisted; + for (int row = 0; row < m_table->rowCount(); ++row) { + auto* item = m_table->item(row, RemoveColumn); + if (item && item->checkState() == Qt::Checked) { + labels.append(item->data(Qt::UserRole).toDouble()); + } + } + std::sort(labels.begin(), labels.end()); + return labels; +} + +void LabelSelectionWidget::setSelectedLabels(const QVector& labels) +{ + QSignalBlocker blocker(m_table); + QVector listed; + for (int row = 0; row < m_table->rowCount(); ++row) { + auto* item = m_table->item(row, RemoveColumn); + if (!item) { + continue; + } + double value = item->data(Qt::UserRole).toDouble(); + listed.append(value); + item->setCheckState(labels.contains(value) ? Qt::Checked : Qt::Unchecked); + } + m_unlisted.clear(); + for (double value : labels) { + if (!listed.contains(value)) { + m_unlisted.append(value); + } + } + updateSummary(); +} + +void LabelSelectionWidget::setAllChecked(bool checked) +{ + { + QSignalBlocker blocker(m_table); + for (int row = 0; row < m_table->rowCount(); ++row) { + if (auto* item = m_table->item(row, RemoveColumn)) { + item->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + } + } + } + updateSummary(); + emit selectionChanged(); +} + +void LabelSelectionWidget::invertChecked() +{ + { + QSignalBlocker blocker(m_table); + for (int row = 0; row < m_table->rowCount(); ++row) { + if (auto* item = m_table->item(row, RemoveColumn)) { + item->setCheckState(item->checkState() == Qt::Checked + ? Qt::Unchecked + : Qt::Checked); + } + } + } + updateSummary(); + emit selectionChanged(); +} + +void LabelSelectionWidget::updateSummary() +{ + if (m_table->rowCount() == 0) { + return; + } + int checked = 0; + for (int row = 0; row < m_table->rowCount(); ++row) { + auto* item = m_table->item(row, RemoveColumn); + if (item && item->checkState() == Qt::Checked) { + ++checked; + } + } + m_summary->setText(tr("Removing %1 of %2 labels") + .arg(checked) + .arg(m_table->rowCount())); +} + +void LabelSelectionWidget::getValues(QMap& map) +{ + map["labels"] = formatLabelList(selectedLabels()); + map["renumber"] = m_renumber->isChecked(); +} + +void LabelSelectionWidget::setValues(const QMap& map) +{ + if (map.contains("labels")) { + setSelectedLabels(parseLabelList(map["labels"].toString())); + } + if (map.contains("renumber")) { + m_renumber->setChecked(map["renumber"].toBool()); + } +} + +} // namespace tomviz diff --git a/tomviz/LabelSelectionWidget.h b/tomviz/LabelSelectionWidget.h new file mode 100644 index 000000000..61408bfcb --- /dev/null +++ b/tomviz/LabelSelectionWidget.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 tomvizLabelSelectionWidget_h +#define tomvizLabelSelectionWidget_h + +#include "CustomPythonNodeWidget.h" +#include "PortData.h" + +#include +#include + +class QCheckBox; +class QLabel; +class QTableWidget; + +namespace tomviz { + +/// The Remove Labels operator's editor: the labels present in the input, +/// each with a box to tick for removal. Named after the operator's +/// "labels" parameter so a "bindToSink" declaration can link the ticks +/// to the hidden labels of a Label Map visualization on the same data. +class LabelSelectionWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + LabelSelectionWidget(const QMap& inputs, + QWidget* parent = nullptr); + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + + /// The labels ticked for removal, ascending. Labels named in the + /// parameter but absent from the input are kept too, so a saved + /// selection survives the data changing under it. + QVector selectedLabels() const; + /// Tick exactly @a labels. Does not emit selectionChanged(). + void setSelectedLabels(const QVector& labels); + +signals: + /// The user changed which labels are ticked. + void selectionChanged(); + +private: + void setAllChecked(bool checked); + void invertChecked(); + void updateSummary(); + + QTableWidget* m_table = nullptr; + QCheckBox* m_renumber = nullptr; + QLabel* m_summary = nullptr; + /// Labels from the parameter that the input does not hold; carried + /// along invisibly rather than dropped. + QVector m_unlisted; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/LoadDataReaction.cxx b/tomviz/LoadDataReaction.cxx index d040453b8..f84083a7f 100644 --- a/tomviz/LoadDataReaction.cxx +++ b/tomviz/LoadDataReaction.cxx @@ -5,26 +5,37 @@ #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 "animations/TimeSeriesAnimation.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 +52,20 @@ #include #include -#include #include #include #include #include #include #include -#include +#include #include #include #include #include #include +#include #include @@ -82,7 +93,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 +108,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 +137,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 *.nxs *.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 *.nxs)" << "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 +218,64 @@ 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); + // Drive step switching from the animation clock. Self-owned: it + // deletes itself when the source node goes away. + new TimeSeriesAnimation(sources[0]); - // 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()); + // 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(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 +284,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 +293,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"); + } } - } 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; + // DataExchange / FXI auxiliary frames. + if (result.darkData) { + source->setProperty( + "darkData", QVariant::fromValue( + static_cast(result.darkData.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.whiteData) { + source->setProperty( + "whiteData", + QVariant::fromValue( + static_cast(result.whiteData.GetPointer()))); } - } 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.readerProperties.isEmpty()) { + source->setProperty( + "readerProperties", + QVariant::fromValue(result.readerProperties.toVariantMap())); } - // 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 (!result.tvh5NodePath.isEmpty()) { + source->setProperty("tvh5NodePath", result.tvh5NodePath); + } + // 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 +447,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 +480,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 +543,75 @@ 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) { + // Give the animation enough frames to show motion once the first + // execution completes. The opening spin itself is made when Play is + // pressed with nothing else set up (see AnimationSceneGuard), from + // whatever the user is looking at then. + auto conn = std::make_shared(); + *conn = QObject::connect( + pip, &pipeline::Pipeline::executionFinished, pip, [conn]() { + QObject::disconnect(*conn); + tomviz::setAnimationNumberOfFrames(200); + }); + } + + // 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 +637,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 +663,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 +686,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..c56c7ffa4 100644 --- a/tomviz/LoadStackReaction.cxx +++ b/tomviz/LoadStackReaction.cxx @@ -3,14 +3,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 +32,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 +64,14 @@ DataSource* LoadStackReaction::execStackDialog(ImageStackDialog& dialog) if (fNames.size() < 1) { return nullptr; } - DataSource* dataSource = LoadDataReaction::loadData(fNames); - DataSource::DataSourceType stackType = dialog.getStackType(); - bool imageViewerMode = dialog.getImageViewerMode(); - if (stackType == DataSource::DataSourceType::TiltSeries) { - auto op = new SetTiltAnglesOperator; + auto* source = LoadDataReaction::loadData(fNames); + if (!source) { + return nullptr; + } + + pipeline::PortType stackType = dialog.getStackType(); + 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 +79,24 @@ 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); - }); - } else { - 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"); + } } - return dataSource; + return source; } else { return nullptr; } @@ -102,17 +114,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..9092fff5d 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -8,87 +8,120 @@ #include #include #include -#include #include #include #include #include #include #include +#include +#include #include +#include +#include +#include +#include +#include + #include "AboutDialog.h" #include "AcquisitionWidget.h" #include "ActiveObjects.h" #include "AddAlignReaction.h" #include "AddPythonTransformReaction.h" +#include "CustomOperatorEditDialog.h" +#include "CustomOperatorManagerDialog.h" #include "AnimationHelperDialog.h" +#include "animations/AnimationSceneGuard.h" +#include "animations/RecordedAnimations.h" #include "AxesReaction.h" #include "Behaviors.h" #include "CameraReaction.h" +#include "ColorMap.h" #include "Connection.h" #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/AutoExecuteController.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 "MovieExportDialog.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 #include #include #include -#include namespace { QString getAutosaveFile() @@ -102,24 +135,53 @@ 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); + } + }); + // Also when only released ports are left, so the dialog can say why + // they cannot be saved + const auto scope = SaveDataDialog::Scope::AllPorts; + action->setEnabled( + enabled && + (!SaveDataDialog::candidatePorts(target, scope).isEmpty() || + !SaveDataDialog::releasedPorts(target, scope).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 +195,33 @@ 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, its content container and the strip 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. The strip has to be listed explicitly because it is + // custom-painted and never fills its own background — leave it out and it + // alone goes grey. + setStyleSheet(styleSheet() + + QStringLiteral( + "QMainWindow::separator { background: palette(mid);" + " width: 1px; height: 1px; }" + "#pipelineScroll, #pipelineScrollContainer, #pipelineStrip" + " { background: white; }")); + setAcceptDrops(true); + // Several child widgets accept drops by default (line edits, the + // message/python text docks, item views) and claim a file drag only + // to refuse it, leaving dead zones where drag-and-drop silently does + // nothing. Filter file drags application-wide so dropping data works + // anywhere over this window. + qApp->installEventFilter(this); // Force full messages to be shown m_ui->outputWidget->showFullMessages(true); m_timer = new QTimer(this); @@ -158,13 +247,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 +273,388 @@ 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->setObjectName("pipelineStrip"); + 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::mouseOverVoxel, this, + &MainWindow::onMouseOverVoxel); + 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 +678,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 +691,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 +703,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,210 +717,251 @@ 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]() { + // Custom operators can change on disk without the menu ever having + // been opened; a rescan only lists the directories and parses the + // JSON descriptions, cheap enough here. + registerCustomOperators(findCustomOperators()); + 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); + // The same dialog as the Animation Helper's Export Movie button, which + // can write MP4 (ParaView's own exporter cannot in our packages) + connect(m_ui->actionSaveMovie, &QAction::triggered, this, + [this]() { MovieExportDialog::exportMovie(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); - auto* viewMenuManager = new ViewMenuManager(this, m_ui->menuView); - connect(viewMenuManager, &ViewMenuManager::imageViewerModeToggled, this, - &MainWindow::setImageViewerMode); + new ViewMenuManager(this, m_ui->menuView); QMenu* sampleDataMenu = new QMenu("Sample Data", this); m_ui->menubar->insertMenu(m_ui->menuHelp->menuAction(), sampleDataMenu); @@ -503,27 +969,52 @@ 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); + // 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(); + QAction* liveAcquisitionAction = + sampleDataMenu->addAction("Simulated Live Acquisition"); + new AddPythonSourceReaction( + liveAcquisitionAction, readInPythonScript("SimulatedLiveAcquisition"), + readInJSONDescription("SimulatedLiveAcquisition")); sampleDataMenu->addSeparator(); -#endif + 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 +1034,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); }); @@ -556,14 +1044,12 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) connect(m_ui->actionAnimationHelper, &QAction::triggered, this, [this]() { 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); + // The Animation menu gathers what the .ui spreads over File, View and + // a dock: the helper, the panel with the timeline, and the export. + auto* animationPanel = m_ui->dockWidgetAnimation->toggleViewAction(); + animationPanel->setText(tr("Animation Panel")); + m_ui->menuAnimation->insertAction(m_ui->actionSaveMovie, animationPanel); + m_ui->menuAnimation->insertSeparator(m_ui->actionSaveMovie); // Prepopulate the previously seen python readers/writers // This operation is fast since it fetches the readers description @@ -571,59 +1057,49 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) 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(); - auto pythonFuture = QtConcurrent::run(initPython); - pythonWatcher->setFuture(pythonFuture); + // 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. - // Add plugin dock widgets when a plugin is loaded + // 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. 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 +1110,6 @@ std::vector MainWindow::initPython() { Python::initialize(); Connection::registerType(); - RegexGroupSubstitution::registerType(); auto operators = findCustomOperators(); FileFormatManager::instance().registerPythonReaders(); FileFormatManager::instance().registerPythonWriters(); @@ -737,115 +1212,84 @@ 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); -} - -void MainWindow::moduleChanged(Module*) -{ - m_ui->propertiesPanelStackedWidget->setCurrentWidget( - m_ui->modulePropertiesScrollArea); -} +// Panel switching is now handled by onNodeSelected() / onPortSelected() -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() +bool MainWindow::eventFilter(QObject* watched, QEvent* event) { - 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; - } - - // 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); + // Route local-file drags over any child of this window to the data + // loading handlers above. Without this, whichever drop-accepting + // child sits under the cursor claims the drag and refuses the files. + // Other windows (e.g. the image stack dialog, which has its own drop + // handling) are left alone, as are drags carrying no local files. + auto type = event->type(); + if (type == QEvent::DragEnter || type == QEvent::DragMove || + type == QEvent::Drop) { + auto* widget = qobject_cast(watched); + if (widget && widget != this && widget->window() == this) { + auto* dropEvt = static_cast(event); + const auto* mime = dropEvt->mimeData(); + bool hasLocalFile = false; + if (mime && mime->hasUrls()) { + const auto urls = mime->urls(); + for (const auto& url : urls) { + if (url.isLocalFile()) { + hasLocalFile = true; + break; + } + } + } + if (hasLocalFile) { + if (type == QEvent::DragEnter) { + dragEnterEvent(static_cast(event)); + } else if (type == QEvent::DragMove) { + dropEvt->acceptProposedAction(); + } else { + dropEvent(dropEvt); + } + return true; } - - // Register custom operators again. - registerCustomOperators(findCustomOperators()); } } + return QMainWindow::eventFilter(watched, event); } -void MainWindow::showEvent(QShowEvent* e) +void MainWindow::dropEvent(QDropEvent* e) { - QMainWindow::showEvent(e); - if (m_isFirstShow) { - m_isFirstShow = false; - QTimer::singleShot(1, this, &MainWindow::onFirstWindowShow); + for (const auto& url : e->mimeData()->urls()) { + if (!url.isLocalFile()) { + continue; + } + QString path = url.toLocalFile(); + QString suffix = QFileInfo(path).suffix().toLower(); + if (suffix == "tvsm" || suffix == "tvh5") { + SaveLoadStateReaction::loadState(path); + } else { + LoadDataReaction::loadData(path); + } } + 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 +1300,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 +1311,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 +1374,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,86 +1484,133 @@ 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(); + // The search dialog keeps its own list; the actions it knew are about + // to be deleted, so it gets the new set below. + const QString searchCategory = m_customTransformsMenu->title(); + if (m_operatorSearchDialog) { + m_operatorSearchDialog->removeCategory(searchCategory); + } - 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; - } + auto* createAction = m_customTransformsMenu->addAction(tr("Create New...")); + connect(createAction, &QAction::triggered, this, + &MainWindow::createCustomOperator); + auto* manageAction = m_customTransformsMenu->addAction(tr("Manage...")); + connect(manageAction, &QAction::triggered, this, + &MainWindow::manageCustomOperators); + + 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); + } + } + + auto addEntry = [this, &searchCategory](const OperatorDescription& op) { + // Plain actions, like every other menu: editing, deleting and cloning + // live in the Manage dialog. A broken definition is listed disabled + // so it can be found and fixed there. + QAction* action = m_customTransformsMenu->addAction(op.label); + action->setEnabled(op.valid); + if (m_operatorSearchDialog) { + // Registered before the files are read: the search shows the + // action's live status tip, set below once the description is in. + m_operatorSearchDialog->addOperatorAction(action, searchCategory); + } + if (!op.valid) { + action->setStatusTip( + tr("This definition is broken; fix it under %1 > Manage...") + .arg(searchCategory)); + } + 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; - // Read the Python source - QFile pythonFile(op.pythonPath); - if (pythonFile.open(QIODevice::ReadOnly)) { - source = pythonFile.readAll(); + 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); } - } - 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); + if (op.type == OperatorDescription::Type::Source) { + new AddPythonSourceReaction(action, source, json); + } else { + new AddPythonTransformReaction(action, op.label, source, json); + } + // The transform reaction sets the status tip from the description; + // give sources the same so the search can show it. + if (action->statusTip().isEmpty()) { + action->setStatusTip(QJsonDocument::fromJson(json.toUtf8()) + .object() + .value(QStringLiteral("description")) + .toString()); } - path = QString("%1%2tomviz").arg(home).arg(QDir::separator()); - if (QFile(path).exists()) { - paths.append(path); + }; + + if (!sources.empty()) { + m_customTransformsMenu->addSection("Sources"); + for (const auto* op : sources) { + addEntry(*op); } } - // 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); + if (!transforms.empty()) { + m_customTransformsMenu->addSection("Transforms"); + for (const auto* op : transforms) { + addEntry(*op); } } +} +std::vector MainWindow::findCustomOperators() +{ std::vector operators; - foreach (QString path, paths) { + QSet scanned; + auto scan = [&operators, &scanned](const QString& path, bool userOwned) { + // The same directory can be named more than once (a symlink, or the + // user directory repeated in the environment); scan it once. + const QString canonical = QFileInfo(path).canonicalFilePath(); + if (canonical.isEmpty() || scanned.contains(canonical)) { + return; + } + scanned.insert(canonical); std::vector ops = tomviz::findCustomOperators(path); + for (auto& op : ops) { + op.userOwned = userOwned; + } operators.insert(operators.end(), ops.begin(), ops.end()); + }; + + // The user's own directory is always scanned, whatever the environment + // says: it is the one place tomviz edits and deletes operators in. + const QString userDir = userDataPath(); + if (!userDir.isEmpty()) { + scan(userDir, /*userOwned=*/true); + } + for (const QString& path : customOperatorSearchPaths()) { + scan(path, /*userOwned=*/false); } // Sort so we get a consistent order each time we load @@ -1123,62 +1622,202 @@ std::vector MainWindow::findCustomOperators() return operators; } -void MainWindow::setEnabledPythonConsole(bool enabled) +void MainWindow::deleteCustomOperator(const OperatorDescription& op) { - m_ui->dockWidgetPythonConsole->setEnabled(enabled); + QStringList files{ op.pythonPath }; + if (!op.jsonPath.isEmpty()) { + files.append(op.jsonPath); + } + + const auto answer = QMessageBox::question( + this, tr("Delete Custom Node"), + tr("Delete \"%1\"?\n\nThese files will be removed from disk:\n%2") + .arg(op.label, files.join('\n')), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + + // Nodes already in a pipeline hold their own copy of the script and + // description, so they are unaffected by the files going away. + QStringList failed; + for (const QString& file : files) { + if (QFile::exists(file) && !QFile::remove(file)) { + failed.append(file); + } + } + if (!failed.isEmpty()) { + QMessageBox::warning( + this, tr("Delete Custom Node"), + tr("These files could not be removed:\n%1").arg(failed.join('\n'))); + } + // The menu rescans the directories the next time it opens; the + // manager shows the change right away. + if (m_customOperatorManager) { + m_customOperatorManager->refresh(); + } } -void MainWindow::setImageViewerMode(bool enabled) +void MainWindow::showCustomOperatorDialog(CustomOperatorEditDialog* dialog) { - m_ui->centralWidget->setImageViewerMode(enabled); + if (!dialog->loadError().isEmpty()) { + QMessageBox::critical(this, tr("Custom Node"), dialog->loadError()); + delete dialog; + return; + } + // The manager, when open, lists what is on disk; a save changes that. + connect(dialog, &CustomOperatorEditDialog::saved, this, [this]() { + if (m_customOperatorManager) { + m_customOperatorManager->refresh(); + } + }); + // Modeless, like the node editor: the pipeline stays usable while a + // file is being edited. The menu rescans the files on its next open. + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); + dialog->raise(); + dialog->activateWindow(); } -void MainWindow::onMouseOverVoxel(const vtkVector3i& ijk, double v) +void MainWindow::editCustomOperator(const OperatorDescription& op) { + showCustomOperatorDialog( + new CustomOperatorEditDialog(op.label, op.pythonPath, op.jsonPath, this)); +} - statusBar()->showMessage( - QString("(%1, %2, %3) : %4").arg(ijk[0]).arg(ijk[1]).arg(ijk[2]).arg(v), - 5000); +void MainWindow::manageCustomOperators() +{ + if (!m_customOperatorManager) { + auto* dialog = new CustomOperatorManagerDialog( + []() { return findCustomOperators(); }, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + connect(dialog, &CustomOperatorManagerDialog::createRequested, this, + &MainWindow::createCustomOperator); + connect(dialog, &CustomOperatorManagerDialog::cloneRequested, this, + &MainWindow::cloneCustomOperator); + connect(dialog, &CustomOperatorManagerDialog::editRequested, this, + &MainWindow::editCustomOperator); + connect(dialog, &CustomOperatorManagerDialog::deleteRequested, this, + &MainWindow::deleteCustomOperator); + connect(dialog, &CustomOperatorManagerDialog::openDirectoryRequested, + this, &MainWindow::openCustomOperatorDirectory); + m_customOperatorManager = dialog; + } else { + m_customOperatorManager->refresh(); + } + m_customOperatorManager->show(); + m_customOperatorManager->raise(); + m_customOperatorManager->activateWindow(); } -void MainWindow::syncPythonToApp() +void MainWindow::openCustomOperatorDirectory(const OperatorDescription& op) { - Python python; - auto tomvizState = python.import("tomviz.state"); - if (!tomvizState.isValid()) { - qCritical() << "Failed to import tomviz.state"; - return; + QDesktopServices::openUrl( + QUrl::fromLocalFile(QFileInfo(op.pythonPath).absolutePath())); +} + +void MainWindow::createCustomOperator() +{ + const QString directory = userDataPath(); + if (directory.isEmpty()) { + return; // userDataPath() has already told the user why + } + // The shipped template, named so it doesn't collide with an earlier + // "new" operator still sitting under its default name. + int number = 1; + const QString stem = uniqueOperatorStem( + directory, QStringLiteral("NewCustomTransform"), &number); + const QString label = number > 1 + ? tr("New Custom Transform %1").arg(number) + : tr("New Custom Transform"); + CustomOperatorDraft draft; + draft.directory = directory; + draft.stem = stem; + draft.script = readInPythonScript("NewCustomTransform"); + draft.description = withDescriptionIdentity( + readInJSONDescription("NewCustomTransform"), stem, label); + showCustomOperatorDialog(new CustomOperatorEditDialog(draft, this)); +} + +void MainWindow::cloneCustomOperator(const OperatorDescription& op) +{ + const QString directory = userDataPath(); + if (directory.isEmpty()) { + return; // userDataPath() has already told the user why } - auto sync = tomvizState.findFunction("sync"); - if (!sync.isValid()) { - qCritical() << "Unable to locate sync."; + auto read = [this](const QString& path, QString* text) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::critical(this, tr("Clone Custom Node"), + tr("Could not read \"%1\":\n%2") + .arg(QDir::toNativeSeparators(path), + file.errorString())); + return false; + } + *text = QString::fromUtf8(file.readAll()); + return true; + }; + + CustomOperatorDraft draft; + draft.directory = directory; + if (!read(op.pythonPath, &draft.script)) { + return; + } + if (!op.jsonPath.isEmpty() && !read(op.jsonPath, &draft.description)) { return; } + // The copy is told apart from its original in both the file name and + // the menu, and from earlier copies by a number. + int number = 1; + draft.stem = uniqueOperatorStem( + directory, + QFileInfo(op.pythonPath).completeBaseName() + QStringLiteral("_copy"), + &number); + draft.description = markDescriptionAsCopy(draft.description, number); + showCustomOperatorDialog(new CustomOperatorEditDialog(draft, this)); +} + +void MainWindow::setEnabledPythonConsole(bool enabled) +{ + m_ui->dockWidgetPythonConsole->setEnabled(enabled); +} + +void MainWindow::onMouseOverVoxel(const vtkVector3i& ijk, double v) +{ - Python::Tuple args(0); - Python::Dict kwargs; - sync.call(args, kwargs); + statusBar()->showMessage( + QString("(%1, %2, %3) : %4").arg(ijk[0]).arg(ijk[1]).arg(ijk[2]).arg(v), + 5000); } void MainWindow::findPipelineTemplates() { m_pipelineTemplates->clear(); - // Look in 'share' directory for default templates - QDir provided (QApplication::applicationDirPath() + "/../share/tomviz/templates/"); - // Look for user created templates - QDir created (tomviz::userDataPath() + "/templates"); + // 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::userTemplatesPath())); + } - QList locations = { provided, created }; foreach (QDir dir, locations) { foreach (QFileInfo file, dir.entryInfoList()) { - QString menuName = file.completeBaseName().replace("_", " "); - if (file.isFile()) { + if (file.isFile() && file.suffix() == "tvsm") { + QString menuName = file.completeBaseName().replace("_", " "); QAction* action = m_pipelineTemplates->addAction(menuName); - new SaveLoadTemplateReaction(action, true, file.completeBaseName()); - if (!ModuleManager::instance().hasDataSources()) { - action->setEnabled(false); - } + new SaveLoadTemplateReaction(action, true, file.absoluteFilePath()); } } } @@ -1188,4 +1827,574 @@ void MainWindow::findPipelineTemplates() { connect(actionSaveTemplate, &QAction::triggered, this, &MainWindow::findPipelineTemplates); } +void MainWindow::initPipeline() +{ + auto* p = new pipeline::Pipeline(this); + p->setExecutor(new pipeline::ThreadedExecutor(p)); + // Owns the per-node periodic-execution timers; parented to the pipeline + // so its lifetime (and its worker thread's) tracks it exactly. + new pipeline::AutoExecuteController(p, p); + m_pipeline = p; + m_pipelineStrip->setPipeline(p); + m_pipelineControls->setPipeline(p); + ActiveObjects::instance().setPipeline(p); + // The animations recorded with the camera viewpoints follow the + // viewpoints and the authored animations from here on + RecordedAnimations::instance().install(); + // Rewinds a finished animation before it plays again; see the class. + new AnimationSceneGuard(this); + 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; + } + // A LabelMap's transfer functions carry one band per label, in + // data coordinates. Rescaling them to the data range is at best a + // no-op and, once the label set shrinks, actively wrong: the + // bands would be stretched onto values no label holds. + if (port->type() == pipeline::PortType::LabelMap) { + 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) +{ + 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) { + // Wrap in a scroll area so the dock can be dragged down small, the + // way the pipeline dock can. Added directly, the content widget's + // minimum height becomes the stacked widget's, which becomes the + // dock's floor — properties widgets are tall, so the splitter stops + // well before the pipeline's does. + // + // Content that already scrolls itself is added as-is. Wrapping it + // again would pull its pinned rows into a scrolling viewport — + // NodePropertiesPanel deliberately keeps Apply below its inner + // scroll area, and that has to stay put as the body scrolls. + bool scrollsItself = + qobject_cast(content) != nullptr || + content->property("providesOwnScrolling").toBool(); + if (scrollsItself) { + content->setParent(container); + layout->addWidget(content, 1); + } else { + auto* scroll = new QScrollArea(container); + scroll->setObjectName("propertiesScroll"); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + content->setParent(scroll); + scroll->setWidget(content); + // setWidget() turns autoFillBackground on, which would make these + // panels paint their own palette background where they used to + // inherit one. Has to follow setWidget() rather than precede it. + content->setAutoFillBackground(false); + layout->addWidget(scroll, 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(); +} + +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; + } + + // 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::onActivePortChanged(pipeline::OutputPort* port) +{ + m_pipelineStrip->setSelectedPort(port); + clearDynamicPropertiesWidget(); + if (!port) { + m_ui->propertiesPanelStackedWidget->setCurrentWidget(m_ui->empty); + return; + } + + 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); + } +} + +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: their colors come from + // their own label table, reconciled against the label set this + // execution produced. Skip rescale too — the per-label node positions + // are already in data-coordinate space and rescaling would shift them. + if (port->type() == pipeline::PortType::LabelMap) { + bool created = !vol->hasColorMap(); + pipeline::applyLabelMapColors(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; + } + } + } + } + // Pass-through node: shared color map, nothing to do. + if (vol == upstream) { + return false; + } + + bool created = false; + if (!vol->hasColorMap()) { + vol->initColorMap(); + // The same choice as the execution path (inheritOutputMetadata): the + // primary input's color map, never a label map's + const auto inputs = + node ? node->collectInputs() : QMap(); + if (auto source = pipeline::colorMapSource(node, inputs)) { + vol->copyColorMapFrom(*source); + } else { + // Nothing to inherit: tomviz's default preset, as loaded data gets, + // not ParaView's + ColorMap::instance().applyPreset(vol->colorMap()); + } + created = true; + } + // A time-series step switch replaces the image in place and announces + // it with the same signal new data uses. The range that matters spans + // the whole series and cannot move with the step, so rescaling here + // would only re-stretch the user's window on every frame of playback. + if (created || !vol->hasTimeSteps()) { + 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..1a303047e 100644 --- a/tomviz/MainWindow.h +++ b/tomviz/MainWindow.h @@ -8,6 +8,7 @@ #include +#include #include class QMenu; @@ -20,13 +21,24 @@ class MainWindow; namespace tomviz { class AboutDialog; -class DataPropertiesPanel; class DataSource; -class MoleculeSource; class Module; -class Operator; +class CustomOperatorEditDialog; +class CustomOperatorManagerDialog; 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 +50,18 @@ 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; + bool eventFilter(QObject* watched, QEvent* event) override; /// Check the system at runtime to see for an appropriate OpenGL version. bool checkOpenGL(); @@ -49,30 +70,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(); @@ -90,30 +99,70 @@ private slots: /// Load a custom pipeline template void findPipelineTemplates(); - void setImageViewerMode(bool enabled); - private: Q_DISABLE_COPY(MainWindow) /// Find and register any user defined operators static std::vector findCustomOperators(); void registerCustomOperators(std::vector operators); + /// Handlers for the Custom Transforms menu's "Create New..." and + /// "Manage..." entries and for the Manage dialog's buttons. + void createCustomOperator(); + void manageCustomOperators(); + void cloneCustomOperator(const OperatorDescription& op); + void deleteCustomOperator(const OperatorDescription& op); + void editCustomOperator(const OperatorDescription& op); + void openCustomOperatorDirectory(const OperatorDescription& op); + /// Show @a dialog modeless, or report why it could not load. + void showCustomOperatorDialog(CustomOperatorEditDialog* dialog); 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; + QPointer m_customOperatorManager; 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..b46c1607a 100644 --- a/tomviz/MainWindow.ui +++ b/tomviz/MainWindow.ui @@ -63,6 +63,9 @@ + + + @@ -79,9 +82,16 @@ true + + + + Animation + + + true + - - + @@ -110,20 +120,21 @@ Segmentation - + - Workflows + Sources - - + + - + + @@ -132,7 +143,7 @@ 250 - 300 + 50 @@ -156,22 +167,21 @@ 0 - - - true - - - QAbstractItemView::DragOnly - - - true - - - true - - - false - + + + + 0 + + + 0 + + + 0 + + + 0 + +
@@ -232,7 +242,7 @@ 250 - 300 + 50 @@ -263,111 +273,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 +315,6 @@ Ctrl+Q - - - &Passive Acquisition - - - Passive Acquisition - - &Save Data @@ -483,14 +380,6 @@ Export data to Web - - - Pipeline Settings - - - Pipeline Settings - - Acquisition @@ -512,6 +401,16 @@ Save State + + + Load Template + + + + + Save Template As + + &Data @@ -532,15 +431,15 @@ Animation Helper - + PyXRF - PyXRF Workflow + PyXRF Source - + Ptycho @@ -564,41 +463,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..1117d5221 100644 --- a/tomviz/ManualManipulationWidget.cxx +++ b/tomviz/ManualManipulationWidget.cxx @@ -5,108 +5,150 @@ #include "ui_ManualManipulationWidget.h" #include "ActiveObjects.h" -#include "DataSource.h" -#include "ModuleManager.h" -#include "OperatorPython.h" -#include "Utilities.h" +#include "InteractiveTransformWidget.h" -#include -#include -#include +#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/LegacyModuleSink.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 { -class ManualManipulationWidget::Internal : public QObject +using pipeline::OutputPort; +using pipeline::VolumeDataPtr; + +class ManualManipulationWidget::Internal { public: Ui::ManualManipulationWidget ui; - QPointer op; vtkSmartPointer image; - QPointer parent; - QPointer dataSource; - QPointer referenceData; + ManualManipulationWidget* parent; + + QPointer node; + QPointer pipeline; + + // The transform being edited. The same prop-style composition the box + // widget and VolumeSink use: x -> position + R(orientation) * S(scale) * x, + // rotations applied in vtkProp3D's Z-X-Y order. scale doubles as the + // output spacing, matching the operator's "scaling" parameter. + double position[3] = { 0, 0, 0 }; + double orientation[3] = { 0, 0, 0 }; + double scale[3] = { 1, 1, 1 }; + + // Live preview: the upstream volume we moved, and the state to put back. + VolumeDataPtr previewVolume; + double savedPosition[3] = { 0, 0, 0 }; + double savedOrientation[3] = { 0, 0, 0 }; + double savedSpacing[3] = { 1, 1, 1 }; + bool previewActive = false; + + // Reference data: ports offered in the combo (parallel to items 1..n), + // and the volume whose display position we borrowed for center-alignment. + QList> referencePorts; + VolumeDataPtr referenceVolume; + QPointer referencePort; 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) + // Red outline marking the volume's original (untransformed) bounds. + vtkSmartPointer outlineActor; + vtkWeakPointer outlineRenderer; + + Internal(vtkSmartPointer img, ManualManipulationWidget* p) + : image(img), parent(p) { - // Must call setupUi() before using p in any way ui.setupUi(p); - setParent(p); - - if (op->childDataSource() && !op->isEditing()) { - dataSource = op->childDataSource(); - } else if (op->dataSource()) { - dataSource = op->dataSource(); - } else { - dataSource = ActiveObjects::instance().activeDataSource(); - } - 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); + if (image) { + double spacing[3]; + image->GetSpacing(spacing); + for (int i = 0; i < 3; ++i) { + scale[i] = spacing[i]; + } } - // Make sure this is the active data source - ActiveObjects::instance().setActiveDataSource(dataSource); - fixInteractionDataSource(); + // Interaction needs input data to place the box against. + ui.interactionGroup->setEnabled(image != nullptr); - populateReferenceDataCheckBoxes(); - createOriginalOutline(); - setupConnections(); - enableAllInteraction(); updateGui(); + updateReferenceEnableStates(); + setupConnections(); + + // Match the old behavior: interaction is live as soon as the editor + // opens, so the user can immediately drag the volume into place. If + // another consumer already holds the box widget, this unchecks + // itself gracefully. + if (image) { + ui.interactTranslate->setChecked(true); + ui.interactRotate->setChecked(true); + ui.interactScale->setChecked(true); + updateTransformWidget(); + } } ~Internal() { - restoreReferenceDataPosition(); - unfixInteractionDataSource(); + restorePreview(); + restoreReferencePosition(); removeOriginalOutline(); - disableAllInteraction(); + + auto& itw = InteractiveTransformWidget::instance(); + if (itw.currentUser() == parent) { + itw.release(parent); + } } void setupConnections() { - 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); + auto& itw = InteractiveTransformWidget::instance(); + + QObject::connect(ui.interactTranslate, &QCheckBox::clicked, parent, + [this]() { updateTransformWidget(); }); + QObject::connect(ui.interactRotate, &QCheckBox::clicked, parent, + [this]() { updateTransformWidget(); }); + QObject::connect(ui.interactScale, &QCheckBox::clicked, parent, + [this]() { updateTransformWidget(); }); + + QObject::connect(&itw, &InteractiveTransformWidget::widgetReleased, + parent, [this, &itw]() { + if (itw.currentUser() != parent) { + ui.interactTranslate->setChecked(false); + ui.interactRotate->setChecked(false); + ui.interactScale->setChecked(false); + } + }); + + QObject::connect(&itw, &InteractiveTransformWidget::transformChanged, + parent, + [this](const double* pos, const double* orient, + const double* sc) { + onBoxTransformChanged(pos, orient, sc); + }); QList shiftWidgets = { ui.shiftX, ui.shiftY, ui.shiftZ }; QList rotateWidgets = { ui.rotateX, ui.rotateY, @@ -114,115 +156,99 @@ class ManualManipulationWidget::Internal : public QObject 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()); - }); + QObject::connect(shiftWidgets[axis], &QDoubleSpinBox::editingFinished, + parent, [this, axis, shiftWidgets]() { + setShiftValue(axis, shiftWidgets[axis]->value()); + }); + QObject::connect(rotateWidgets[axis], &QDoubleSpinBox::editingFinished, + parent, [this, axis, rotateWidgets]() { + setRotationValue(axis, rotateWidgets[axis]->value()); + }); + QObject::connect(scaleWidgets[axis], &QDoubleSpinBox::editingFinished, + parent, [this, axis, scaleWidgets]() { + setScalingValue(axis, scaleWidgets[axis]->value()); + }); } - connect(ui.selectedReferenceData, - QOverload::of(&QComboBox::currentIndexChanged), this, - &Internal::onSelectedReferenceDataChanged); - connect(ui.alignVoxelsWithReference, &QCheckBox::toggled, this, - &Internal::onAlignVoxelsWithReferenceToggled); + QObject::connect(ui.selectedReferenceData, + QOverload::of(&QComboBox::currentIndexChanged), + parent, [this]() { onSelectedReferenceDataChanged(); }); + QObject::connect(ui.alignVoxelsWithReference, &QCheckBox::toggled, parent, + [this]() { updateReferenceEnableStates(); }); } - QList scaling() + void setNodeContext(pipeline::Node* n, pipeline::Pipeline* pip) { - auto* scaling = dataSource->getSpacing(); - return { scaling[0], scaling[1], scaling[2] }; + node = n; + pipeline = pip; + populateReferenceCombo(); } - QList rotation() + OutputPort* upstreamPort() const { - const auto* orientation = dataSource->displayOrientation(); - return { orientation[0], orientation[1], orientation[2] }; + if (!node) { + return nullptr; + } + auto* in = node->inputPort(QStringLiteral("volume")); + if (!in || !in->link()) { + return nullptr; + } + return in->link()->from(); + } + + // --- Transform math (matches ManualManipulation.py's conventions) --- + + void computeCenter(double* center) const + { + const int* dims = image ? image->GetDimensions() : nullptr; + for (int i = 0; i < 3; ++i) { + center[i] = dims ? dims[i] * scale[i] / 2.0 : 0.0; + } } - QList shift() + // The physical displacement of the volume center. Rotation is about + // the prop origin, so the center is the natural anchor: the Python + // script rotates about the center and then applies this shift, which + // reproduces the prop transform exactly. + QList physicalShift() const { - // 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->Translate(position[0], position[1], position[2]); 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]; - } + double moved[3]; + t->TransformPoint(center, moved); - return { physicalShift[0], physicalShift[1], physicalShift[2] }; + return { moved[0] - center[0], moved[1] - center[1], + moved[2] - center[2] }; } - QList voxelShift() + QList voxelShift() const { - // 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 lengths[3]; - dataSource->getPhysicalDimensions(lengths); - const auto* dims = dataSource->imageData()->GetDimensions(); + auto phys = physicalShift(); 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(); + shifts[i] = (scale[i] > 0) + ? static_cast( + std::round(phys[i].toDouble() / scale[i])) + : 0; } - dataSource->setDisplayOrientation(rotation); - // Change the data positions so that the shift stays fixed - setShift(physicalShift); + return { shifts[0], shifts[1], shifts[2] }; } - void setShift(double* physicalShift) + // Solve for the box position that produces @a physShift at the center, + // keeping the current orientation. + void setPhysicalShift(const double* physShift) { 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]); @@ -230,462 +256,588 @@ class ManualManipulationWidget::Internal : public QObject 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]; + position[i] = physShift[i] - rotatedCenter[i] + center[i]; } - - dataSource->setDisplayPosition(newPosition); } - void setVoxelShift(int* shift) + void setVoxelShift(const 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 phys[3]; + for (int i = 0; i < 3; ++i) { + phys[i] = shift[i] * scale[i]; } - - setShift(physicalShift); + setPhysicalShift(phys); } - bool alignWithReference() { return ui.alignVoxelsWithReference->isChecked(); } - - void setAlignWithReference(bool b) + QList scaling() const { - ui.alignVoxelsWithReference->setChecked(b); + return { scale[0], scale[1], scale[2] }; } - QList referenceSpacing() + QList rotation() const { - QList ret; - QList widgets = { ui.referenceSpacingX, - ui.referenceSpacingY, - ui.referenceSpacingZ }; - for (auto* w : widgets) { - ret.append(w->value()); - } - return ret; + return { orientation[0], orientation[1], orientation[2] }; } - void setReferenceSpacing(const double* spacing) + // --- Spinbox handlers --- + + void setShiftValue(int axis, double value) { - QList widgets = { ui.referenceSpacingX, - ui.referenceSpacingY, - ui.referenceSpacingZ }; + auto prev = physicalShift(); + double phys[3]; for (int i = 0; i < 3; ++i) { - widgets[i]->setValue(spacing[i]); + phys[i] = prev[i].toDouble(); } + phys[axis] = value; + setPhysicalShift(phys); + syncBoxAndPreview(); + updateGui(); } - QList referenceShape() + void setRotationValue(int axis, double value) { - QList ret; - QList widgets = { ui.referenceShapeX, ui.referenceShapeY, - ui.referenceShapeZ }; - - for (auto* w : widgets) { - ret.append(w->value()); + // Keep the shift fixed while the orientation changes. + auto prev = physicalShift(); + double phys[3]; + for (int i = 0; i < 3; ++i) { + phys[i] = prev[i].toDouble(); } - - return ret; + orientation[axis] = value; + setPhysicalShift(phys); + syncBoxAndPreview(); + updateGui(); } - void setReferenceShape(const int* shape) + void setScalingValue(int axis, double value) { - QList widgets = { ui.referenceShapeX, ui.referenceShapeY, - ui.referenceShapeZ }; - for (int i = 0; i < 3; ++i) { - widgets[i]->setValue(shape[i]); - } + scale[axis] = value; + syncBoxAndPreview(); + updateGui(); } - void createOriginalOutline() + void updateGui() { - auto* vtkView = ActiveObjects::instance().activeView(); + auto shifts = physicalShift(); - 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); + QList shiftWidgets = { ui.shiftX, ui.shiftY, ui.shiftZ }; + QList rotateWidgets = { ui.rotateX, ui.rotateY, + ui.rotateZ }; + QList scaleWidgets = { ui.scaleX, ui.scaleY, ui.scaleZ }; - // Create the representation for it. - originalOutlineRepresentation = - pipelineController->Show(originalOutlineSource, 0, vtkView); + for (int i = 0; i < 3; ++i) { + shiftWidgets[i]->setValue(shifts[i].toDouble()); + rotateWidgets[i]->setValue(orientation[i]); + scaleWidgets[i]->setValue(scale[i]); + } + } - // Set the color - static const double color[3] = { 1, 0, 0 }; - vtkSMPropertyHelper(originalOutlineRepresentation, "DiffuseColor") - .Set(color, 3); - vtkSMPropertyHelper(originalOutlineRepresentation, "LineWidth").Set(5); + // --- Interactive box widget --- - originalOutlineRepresentation->UpdateVTKObjects(); + void updateTransformWidget() + { + auto& itw = InteractiveTransformWidget::instance(); + bool translate = ui.interactTranslate->isChecked(); + bool rotate = ui.interactRotate->isChecked(); + bool doScale = ui.interactScale->isChecked(); + bool anyEnabled = translate || rotate || doScale; - // Give the proxy a friendly name for the GUI/Python world. - if (auto p = convert(proxy)) { - p->rename("OriginalPositionOutline"); + if (!anyEnabled) { + if (itw.currentUser() == parent) { + itw.release(parent); + } + return; } - render(); - } - - void removeOriginalOutline() - { - if (originalOutlineRepresentation) { - pipelineController->UnRegisterProxy(originalOutlineRepresentation); - originalOutlineRepresentation = nullptr; + bool freshAcquire = (itw.currentUser() != parent); + if (freshAcquire && !itw.acquire(parent)) { + // Another consumer (e.g. the volume properties panel) holds it. + ui.interactTranslate->setChecked(false); + ui.interactRotate->setChecked(false); + ui.interactScale->setChecked(false); + return; } - if (originalOutlineSource) { - pipelineController->UnRegisterProxy(originalOutlineSource); - originalOutlineSource = nullptr; + if (freshAcquire) { + itw.setView(ActiveObjects::instance().activePqView()); + placeBox(); + showOriginalOutline(); } - render(); + itw.setTranslationEnabled(translate); + itw.setRotationEnabled(rotate); + itw.setScalingEnabled(doScale); } - void render() + void placeBox() { - auto* view = ActiveObjects::instance().activeView(); - if (!view) { + if (!image) { return; } - view->StillRender(); - } - void onDataSourcePropertiesChanged() - { - const double tol = 1.e-8; + auto& itw = InteractiveTransformWidget::instance(); + int ext[6]; + image->GetExtent(ext); 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(); + bounds[i] = static_cast(ext[i]); } - updateGui(); + itw.setBounds(bounds); + itw.setTransform(position, orientation, scale); } - void onBoundsChanged() + void onBoxTransformChanged(const double* pos, const double* orient, + const double* sc) { - double bounds[6]; - dataSource->getBounds(bounds); - - for (int i = 0; i < 6; ++i) { - cachedBounds[i] = bounds[i]; + auto& itw = InteractiveTransformWidget::instance(); + if (itw.currentUser() != parent) { + return; } - 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 }; - 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()); + position[i] = pos[i]; + orientation[i] = orient[i]; + scale[i] = sc[i]; } - } - void setScalingValue(int axis, double value) - { - double scales[3]; - dataSource->getSpacing(scales); - - scales[axis] = value; - setScaling(scales); + updateGui(); + applyPreview(); } - void setShiftValue(int axis, double value) + void syncBox() { - double shifts[3]; - auto previousShifts = shift(); - for (int i = 0; i < 3; ++i) { - shifts[i] = previousShifts[i].toDouble(); + auto& itw = InteractiveTransformWidget::instance(); + if (itw.currentUser() == parent) { + itw.setTransform(position, orientation, scale); } - - shifts[axis] = value; - setShift(shifts); } - void setRotationValue(int axis, double value) + void syncBoxAndPreview() { - double orientation[3]; - for (int i = 0; i < 3; ++i) { - orientation[i] = dataSource->displayOrientation()[i]; - } - orientation[axis] = value; - setRotation(orientation); + syncBox(); + applyPreview(); } - void enableAllInteraction() - { - ActiveObjects& activeObjects = ActiveObjects::instance(); + // --- Live preview on the upstream volume --- - activeObjects.enableTranslation(true); - activeObjects.enableRotation(true); - activeObjects.enableScaling(true); - } - - void disableAllInteraction() + void applyPreview() { - ActiveObjects& activeObjects = ActiveObjects::instance(); + auto* port = upstreamPort(); + if (!port || !port->hasData()) { + return; + } + auto vol = port->data().value(); + if (!vol || !vol->isValid()) { + return; + } - activeObjects.enableTranslation(false); - activeObjects.enableRotation(false); - activeObjects.enableScaling(false); - } + if (!previewActive || vol != previewVolume) { + // First touch of this volume: remember what to put back. + if (previewActive && vol != previewVolume) { + restorePreview(); + } + previewVolume = vol; + auto p = vol->displayPosition(); + auto o = vol->displayOrientation(); + auto s = vol->spacing(); + for (int i = 0; i < 3; ++i) { + savedPosition[i] = p[i]; + savedOrientation[i] = o[i]; + savedSpacing[i] = s[i]; + } + previewActive = true; + } - void fixInteractionDataSource() - { - ActiveObjects::instance().setFixedInteractionDataSource(dataSource); + vol->setDisplayPosition(position[0], position[1], position[2]); + vol->setDisplayOrientation(orientation[0], orientation[1], + orientation[2]); + vol->setSpacing(scale[0], scale[1], scale[2]); + emit port->metadataChanged(); + notifyPreviewConsumers(); } - void unfixInteractionDataSource() + // Spacing lives on the image itself, so a re-render alone shows it, + // but display position and orientation are applied by each sink to + // its own prop when its port reports metadata changes. A sink does + // not necessarily listen on the port we just mutated (it latches the + // connection to whatever port fed its first run, which a later + // insertion can change), so tell every sink showing this exact + // volume directly. + void notifyPreviewConsumers() { - ActiveObjects::instance().setFixedInteractionDataSource(nullptr); + if (!pipeline || !previewVolume) { + return; + } + for (auto* n : pipeline->nodes()) { + auto* sink = qobject_cast(n); + if (sink && sink->volumeData() == previewVolume) { + sink->onMetadataChanged(); + } + } } - void computeCenter(double* center, DataSource* ds = nullptr) + void restorePreview() { - if (!ds) { - ds = dataSource; + if (!previewActive) { + return; } + previewActive = false; - double lengths[3]; - ds->getPhysicalDimensions(lengths); + if (previewVolume && previewVolume->isValid()) { + previewVolume->setDisplayPosition(savedPosition[0], savedPosition[1], + savedPosition[2]); + previewVolume->setDisplayOrientation( + savedOrientation[0], savedOrientation[1], savedOrientation[2]); + previewVolume->setSpacing(savedSpacing[0], savedSpacing[1], + savedSpacing[2]); + } + notifyPreviewConsumers(); + previewVolume.reset(); - for (auto i = 0; i < 3; ++i) { - center[i] = lengths[i] / 2; + if (auto* port = upstreamPort()) { + emit port->metadataChanged(); } + render(); } - void updateInteractionCheckboxes() - { - ActiveObjects& activeObjects = ActiveObjects::instance(); + // --- Original-position outline --- - bool translate = activeObjects.translationEnabled(); - bool rotate = activeObjects.rotationEnabled(); - bool scale = activeObjects.scalingEnabled(); + void showOriginalOutline() + { + if (outlineActor || !image) { + return; + } - ui.interactTranslate->setChecked(translate); - ui.interactRotate->setChecked(rotate); - ui.interactScale->setChecked(scale); - } + auto* view = ActiveObjects::instance().activeView(); + if (!view) { + return; + } + auto* renderView = + vtkPVRenderView::SafeDownCast(view->GetClientSideView()); + if (!renderView || !renderView->GetRenderer()) { + return; + } - void populateReferenceDataCheckBoxes() - { - auto allDataSources = ModuleManager::instance().allDataSourcesDepthFirst(); + int ext[6]; + double spacing[3]; + image->GetExtent(ext); + // The preview mutates the image's spacing in place, so once a + // preview has started the original spacing lives in savedSpacing. + if (previewActive) { + for (int i = 0; i < 3; ++i) { + spacing[i] = savedSpacing[i]; + } + } else { + image->GetSpacing(spacing); + } - // Do not include this data source - allDataSources.removeAll(dataSource); + double bounds[6]; + for (int i = 0; i < 3; ++i) { + bounds[2 * i] = ext[2 * i] * spacing[i]; + bounds[2 * i + 1] = ext[2 * i + 1] * spacing[i]; + } - auto* cb = ui.selectedReferenceData; - cb->clear(); + vtkNew outline; + outline->SetBounds(bounds); + outline->Update(); - // Make the first item null - QVariant firstItemData; - firstItemData.setValue(static_cast(nullptr)); - cb->addItem("None", firstItemData); + vtkNew mapper; + mapper->SetInputConnection(outline->GetOutputPort()); - auto labels = ModuleManager::createUniqueLabels(allDataSources); - for (int i = 0; i < allDataSources.size(); ++i) { - auto* ds = allDataSources[i]; - auto label = labels[i]; + outlineActor = vtkSmartPointer::New(); + outlineActor->SetMapper(mapper); + outlineActor->GetProperty()->SetColor(1, 0, 0); + outlineActor->GetProperty()->SetLineWidth(5); + outlineActor->PickableOff(); - QVariant data; - data.setValue(ds); - cb->addItem(label, data); - } + outlineRenderer = renderView->GetRenderer(); + outlineRenderer->AddActor(outlineActor); + render(); } - DataSource* selectedReferenceData() const + void removeOriginalOutline() { - // We know we can convert to DataSource*, even for the nullptr - return ui.selectedReferenceData->currentData().value(); + if (outlineActor && outlineRenderer) { + outlineRenderer->RemoveActor(outlineActor); + } + outlineActor = nullptr; + outlineRenderer = nullptr; + render(); } - void onSelectedReferenceDataChanged() + void render() { - if (referenceData) { - restoreReferenceDataPosition(); + if (auto* view = ActiveObjects::instance().activePqView()) { + view->render(); } - - referenceData = selectedReferenceData(); - updateReferenceValues(); - saveReferenceDataPosition(); - alignReferenceDataPosition(); - updateReferenceEnableStates(); } - void onAlignVoxelsWithReferenceToggled() { updateReferenceEnableStates(); } + // --- Reference data --- - void updateReferenceValues() + void populateReferenceCombo() { - if (!referenceData) { - return; - } + auto* cb = ui.selectedReferenceData; + QSignalBlocker blocker(cb); - const double* spacing = referenceData->getSpacing(); - int* dimensions = referenceData->imageData()->GetDimensions(); + cb->clear(); + referencePorts.clear(); + cb->addItem(QStringLiteral("None")); - setReferenceSpacing(spacing); - setReferenceShape(dimensions); - } + if (!pipeline) { + return; + } - void updateReferenceEnableStates() - { - bool enableValuesWidget = alignWithReference() && referenceData == nullptr; - ui.referenceDataValuesWidget->setEnabled(enableValuesWidget); + auto* ourUpstream = upstreamPort(); + for (auto* n : pipeline->nodes()) { + if (n == node) { + continue; + } + auto ports = n->outputPorts(); + for (auto* port : ports) { + if (port == ourUpstream || !port->hasData() || + !pipeline::isVolumeType(port->type())) { + continue; + } + auto vol = port->data().value(); + if (!vol || !vol->isValid()) { + continue; + } + QString label = n->label(); + if (ports.size() > 1) { + label += QStringLiteral(" / ") + port->name(); + } + cb->addItem(label); + referencePorts.append(port); + } + } } - void saveReferenceDataPosition() + void onSelectedReferenceDataChanged() { - if (!referenceData) { - return; + restoreReferencePosition(); + + int index = ui.selectedReferenceData->currentIndex(); + OutputPort* port = + (index >= 1 && index <= referencePorts.size()) + ? referencePorts[index - 1].data() + : nullptr; + + if (port && port->hasData()) { + auto vol = port->data().value(); + if (vol && vol->isValid()) { + referenceVolume = vol; + referencePort = port; + + auto spacing = vol->spacing(); + auto dims = vol->dimensions(); + QList spacingWidgets = { ui.referenceSpacingX, + ui.referenceSpacingY, + ui.referenceSpacingZ }; + QList shapeWidgets = { ui.referenceShapeX, + ui.referenceShapeY, + ui.referenceShapeZ }; + for (int i = 0; i < 3; ++i) { + spacingWidgets[i]->setValue(spacing[i]); + shapeWidgets[i]->setValue(dims[i]); + } + + auto p = vol->displayPosition(); + for (int i = 0; i < 3; ++i) { + savedReferencePosition[i] = p[i]; + } + alignReferenceDataPosition(); + } } - referenceData->displayPosition(savedReferencePosition); + updateReferenceEnableStates(); } + // Center the reference volume on the volume being manipulated, so the + // user can align them visually. void alignReferenceDataPosition() { - if (!referenceData || !dataSource) { + if (!referenceVolume || !referenceVolume->isValid()) { return; } double center[3]; - computeCenter(center, dataSource); + computeCenter(center); - double referenceCenter[3]; - computeCenter(referenceCenter, referenceData); + auto refSpacing = referenceVolume->spacing(); + auto refDims = referenceVolume->dimensions(); - // Find the difference double newPosition[3]; for (int i = 0; i < 3; ++i) { - newPosition[i] = center[i] - referenceCenter[i]; + double refCenter = refDims[i] * refSpacing[i] / 2.0; + newPosition[i] = center[i] - refCenter; } - referenceData->setDisplayPosition(newPosition); + referenceVolume->setDisplayPosition(newPosition[0], newPosition[1], + newPosition[2]); + if (referencePort) { + emit referencePort->metadataChanged(); + } + render(); } - void restoreReferenceDataPosition() + void restoreReferencePosition() { - if (!referenceData) { + if (!referenceVolume) { return; } - referenceData->setDisplayPosition(savedReferencePosition); + if (referenceVolume->isValid()) { + referenceVolume->setDisplayPosition(savedReferencePosition[0], + savedReferencePosition[1], + savedReferencePosition[2]); + } + referenceVolume.reset(); + + if (referencePort) { + emit referencePort->metadataChanged(); + referencePort = nullptr; + } + render(); + } + + void updateReferenceEnableStates() + { + // Manual spacing/shape entry only makes sense when aligning without + // a selected reference dataset. + bool enableValuesWidget = + ui.alignVoxelsWithReference->isChecked() && !referenceVolume; + ui.referenceDataValuesWidget->setEnabled(enableValuesWidget); + } + + QList referenceSpacing() const + { + return { ui.referenceSpacingX->value(), ui.referenceSpacingY->value(), + ui.referenceSpacingZ->value() }; + } + + QList referenceShape() const + { + return { ui.referenceShapeX->value(), ui.referenceShapeY->value(), + ui.referenceShapeZ->value() }; } }; ManualManipulationWidget::ManualManipulationWidget( - Operator* op, vtkSmartPointer image, QWidget* p) - : CustomPythonOperatorWidget(p) + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent) { - m_internal.reset(new Internal(op, image, this)); + 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)); } -CustomPythonOperatorWidget* ManualManipulationWidget::New( - QWidget* p, Operator* op, vtkSmartPointer data) +ManualManipulationWidget::~ManualManipulationWidget() = default; + +void ManualManipulationWidget::setNodeContext(pipeline::Node* node, + pipeline::Pipeline* pipeline) { - return new ManualManipulationWidget(op, data, p); + m_internal->setNodeContext(node, pipeline); } -ManualManipulationWidget::~ManualManipulationWidget() = default; +void ManualManipulationWidget::writeSettings() +{ + // Apply is about to bake the transform into the data. Put the preview + // back so the result is not transformed twice, and let the reference + // volume return to where it was. + m_internal->restorePreview(); + m_internal->restoreReferencePosition(); +} 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", + m_internal->ui.alignVoxelsWithReference->isChecked()); map.insert("reference_spacing", m_internal->referenceSpacing()); map.insert("reference_shape", m_internal->referenceShape()); } void ManualManipulationWidget::setValues(const QMap& map) { + // Order matters: scaling feeds the center used by the shift solve, and + // rotation feeds the same solve, so restore scaling and rotation first. 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) { + for (int i = 0; i < 3; ++i) { + double v = array[i].toDouble(); + if (v > 0) { + m_internal->scale[i] = v; + } + } } - 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) { + for (int i = 0; i < 3; ++i) { + m_internal->orientation[i] = array[i].toDouble(); + } } - 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()); + m_internal->ui.alignVoxelsWithReference->setChecked( + 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(); + if (array.size() >= 3) { + QList widgets = { m_internal->ui.referenceSpacingX, + m_internal->ui.referenceSpacingY, + m_internal->ui.referenceSpacingZ }; + for (int i = 0; i < 3; ++i) { + widgets[i]->setValue(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(); + if (array.size() >= 3) { + QList widgets = { m_internal->ui.referenceShapeX, + m_internal->ui.referenceShapeY, + m_internal->ui.referenceShapeZ }; + for (int i = 0; i < 3; ++i) { + widgets[i]->setValue(array[i].toInt()); + } } - m_internal->setReferenceShape(referenceShape); } m_internal->updateGui(); + + // Loading values must not start a preview by itself: an already-applied + // node shows its baked output, and moving the upstream volume here would + // display the transform twice. Only refresh what's already live. + m_internal->syncBox(); + if (m_internal->previewActive) { + m_internal->applyPreview(); + } } } // namespace tomviz diff --git a/tomviz/ManualManipulationWidget.h b/tomviz/ManualManipulationWidget.h index 0b0c03c31..a97d87b61 100644 --- a/tomviz/ManualManipulationWidget.h +++ b/tomviz/ManualManipulationWidget.h @@ -4,37 +4,47 @@ #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; + /// Grabs the upstream output port (for the live drag preview) and the + /// pipeline (to offer other volume ports as reference data). + void setNodeContext(pipeline::Node* node, + pipeline::Pipeline* pipeline) override; + + /// Called on Apply, before the node re-executes: clears the preview + /// transform from the upstream volume so the baked result is not + /// double-transformed. + void writeSettings() 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/MovieExportDialog.cxx b/tomviz/MovieExportDialog.cxx new file mode 100644 index 000000000..fbea604e1 --- /dev/null +++ b/tomviz/MovieExportDialog.cxx @@ -0,0 +1,639 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "MovieExportDialog.h" + +#include "animations/AnimationSceneGuard.h" +#include "animations/ModuleAnimations.h" + +#include "ActiveObjects.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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +namespace { + +QString findFFmpegExecutable() +{ + QString name = "ffmpeg"; +#ifdef Q_OS_WIN + name = "ffmpeg.exe"; +#endif + // Prefer an ffmpeg shipped next to the tomviz executable (bundled + // installs and conda environments put both in the same bin dir). + QDir appDir(QCoreApplication::applicationDirPath()); + if (appDir.exists(name)) { + return appDir.absoluteFilePath(name); + } + QString found = QStandardPaths::findExecutable(name); +#ifdef Q_OS_MAC + if (found.isEmpty()) { + // Apps launched from Finder get a minimal PATH that excludes + // Homebrew and MacPorts. + found = QStandardPaths::findExecutable( + name, { "/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin" }); + } +#endif + return found; +} + +} // anonymous namespace + +class MovieExportDialog::Internal : public QObject +{ +public: + QPointer parent; + + QComboBox* format; + QLineEdit* fileName; + QComboBox* resolution; + QSpinBox* customWidth; + QSpinBox* customHeight; + QLabel* customTimes; + QSpinBox* frameRate; + QComboBox* quality; + QLabel* info; + + QString ffmpegPath; + + enum class Result + { + Success, + Canceled, + Error + }; + + Internal(MovieExportDialog* p) : QObject(p), parent(p) + { + p->setWindowTitle("Export Movie"); + ffmpegPath = findFFmpegExecutable(); + + auto* layout = new QVBoxLayout(p); + auto* form = new QFormLayout; + layout->addLayout(form); + + format = new QComboBox(p); + if (!ffmpegPath.isEmpty()) { + format->addItem("MP4 video (H.264)", QString("mp4")); + } + if (writerPrototype("OggTheora")) { + format->addItem("Ogg Theora video", QString("ogv")); + } + format->addItem("PNG image sequence", QString("png")); + form->addRow("Format:", format); + + auto* fileRow = new QHBoxLayout; + fileName = new QLineEdit(p); + fileName->setText(defaultFileName()); + auto* browse = new QPushButton("Browse...", p); + fileRow->addWidget(fileName); + fileRow->addWidget(browse); + form->addRow("File:", fileRow); + + auto* resolutionRow = new QHBoxLayout; + resolution = new QComboBox(p); + // QSize(0, 0) = keep the view's size; QSize(-1, -1) = custom. A + // default-constructed QSize is (-1, -1), so it can't mark "current". + resolution->addItem("Current view size", QSize(0, 0)); + resolution->addItem("1280 x 720", QSize(1280, 720)); + resolution->addItem("1920 x 1080", QSize(1920, 1080)); + resolution->addItem("2560 x 1440", QSize(2560, 1440)); + resolution->addItem("3840 x 2160", QSize(3840, 2160)); + resolution->addItem("Custom", QSize(-1, -1)); + customWidth = new QSpinBox(p); + customWidth->setRange(16, 8192); + customWidth->setValue(1920); + customHeight = new QSpinBox(p); + customHeight->setRange(16, 8192); + customHeight->setValue(1080); + resolutionRow->addWidget(resolution); + resolutionRow->addWidget(customWidth); + customTimes = new QLabel("x", p); + resolutionRow->addWidget(customTimes); + resolutionRow->addWidget(customHeight); + form->addRow("Resolution:", resolutionRow); + + frameRate = new QSpinBox(p); + frameRate->setRange(1, 120); + frameRate->setValue(30); + frameRate->setSuffix(" fps"); + form->addRow("Frame rate:", frameRate); + + quality = new QComboBox(p); + quality->addItem("High"); + quality->addItem("Medium"); + quality->addItem("Low"); + form->addRow("Quality:", quality); + + info = new QLabel(p); + layout->addWidget(info); + + if (ffmpegPath.isEmpty()) { + auto* hint = new QLabel( + "MP4 export requires the ffmpeg executable, which was not found.", p); + hint->setWordWrap(true); + layout->addWidget(hint); + } + + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, p); + buttons->button(QDialogButtonBox::Ok)->setText("Export"); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, p, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, p, &QDialog::reject); + connect(browse, &QPushButton::clicked, this, &Internal::browseForFile); + connect(format, QOverload::of(&QComboBox::currentIndexChanged), this, + &Internal::formatChanged); + connect(resolution, QOverload::of(&QComboBox::currentIndexChanged), + this, &Internal::updateEnableStates); + connect(frameRate, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::refreshInfo); + + updateEnableStates(); + refreshInfo(); + } + + QString currentFormat() { return format->currentData().toString(); } + + QString defaultFileName() + { + QString dir = + QStandardPaths::writableLocation(QStandardPaths::MoviesLocation); + if (dir.isEmpty()) { + dir = QDir::homePath(); + } + QString ext = + format->count() > 0 ? format->itemData(0).toString() : "png"; + return QDir(dir).absoluteFilePath("tomviz-movie." + ext); + } + + void formatChanged() + { + // Keep the file name's extension in sync with the format. + QFileInfo fi(fileName->text()); + if (!fi.fileName().isEmpty()) { + QString base = fi.completeBaseName(); + QString dir = fi.path(); + fileName->setText( + QDir(dir).absoluteFilePath(base + "." + currentFormat())); + } + updateEnableStates(); + } + + void updateEnableStates() + { + bool custom = resolution->currentData().toSize() == QSize(-1, -1); + customWidth->setVisible(custom); + customTimes->setVisible(custom); + customHeight->setVisible(custom); + + // An image sequence has no encoder settings. + bool isVideo = currentFormat() != "png"; + quality->setEnabled(isVideo); + frameRate->setEnabled(isVideo); + } + + void browseForFile() + { + QString fmt = currentFormat(); + QString filter; + if (fmt == "mp4") { + filter = "MP4 video (*.mp4)"; + } else if (fmt == "ogv") { + filter = "Ogg Theora video (*.ogv)"; + } else { + filter = "PNG images (*.png)"; + } + + QString picked = QFileDialog::getSaveFileName(parent, "Export Movie", + fileName->text(), filter); + if (picked.isEmpty()) { + return; + } + if (QFileInfo(picked).suffix().isEmpty()) { + picked += "." + fmt; + } + fileName->setText(picked); + } + + vtkSMSessionProxyManager* proxyManager() + { + auto* server = pqActiveObjects::instance().activeServer(); + return server ? server->proxyManager() : nullptr; + } + + vtkSMProxy* writerPrototype(const char* name) + { + auto* pxm = proxyManager(); + return pxm ? pxm->GetPrototypeProxy("animation_writers", name) : nullptr; + } + + pqAnimationScene* scene() + { + return pqPVApplicationCore::instance() + ->animationManager() + ->getActiveScene(); + } + + // The active view isn't necessarily a render view (a Plot module + // makes a chart view active); fall back to the first render view. + pqRenderView* renderViewForExport() + { + if (auto* renderView = ActiveObjects::instance().activePqRenderView()) { + return renderView; + } + auto* smModel = pqApplicationCore::instance()->getServerManagerModel(); + auto renderViews = smModel->findItems(); + return renderViews.isEmpty() ? nullptr : renderViews.first(); + } + + int sceneFrameCount() + { + auto* scenePq = scene(); + if (!scenePq) { + return 0; + } + auto* proxy = scenePq->getProxy(); + int playMode = vtkSMPropertyHelper(proxy, "PlayMode").GetAsInt(); + if (playMode == vtkCompositeAnimationPlayer::SNAP_TO_TIMESTEPS) { + auto* tk = ActiveObjects::instance().activeTimeKeeper(); + return tk ? static_cast(tk->getTimeSteps().size()) : 0; + } + return vtkSMPropertyHelper(proxy, "NumberOfFrames").GetAsInt(); + } + + void refreshInfo() + { + int frames = sceneFrameCount(); + double seconds = + frameRate->value() > 0 + ? static_cast(frames) / frameRate->value() + : 0; + info->setText(QString("%1 frames, %2 s at the current frame rate") + .arg(frames) + .arg(seconds, 0, 'f', 1)); + } + + int crf() + { + // Map the three quality steps onto sensible x264 CRF values: + // 18 is visually near-lossless, 23 is the x264 default, 28 is + // small-file territory. + switch (quality->currentIndex()) { + case 1: + return 23; + case 2: + return 28; + default: + return 18; + } + } + + int oggQuality() + { + // vtkOggTheoraWriter quality is 0 (low) to 2 (high). + switch (quality->currentIndex()) { + case 1: + return 1; + case 2: + return 0; + default: + return 2; + } + } + + Result doExport(QString& error) + { + QString fmt = currentFormat(); + QString target = fileName->text(); + + auto* pqview = renderViewForExport(); + if (!pqview) { + error = "No render view is available to export from."; + return Result::Error; + } + auto* scenePq = scene(); + if (!scenePq) { + error = "No animation scene is available."; + return Result::Error; + } + + auto* pxm = pqview->getServer()->proxyManager(); + vtkSmartPointer proxy; + proxy.TakeReference(pxm->NewProxy("misc", "SaveAnimation")); + auto* ahProxy = vtkSMSaveAnimationProxy::SafeDownCast(proxy.Get()); + if (!ahProxy) { + error = "Failed to create the SaveAnimation helper proxy."; + return Result::Error; + } + + // MP4 renders PNG frames into a temporary directory first; the + // other formats write the target file directly. + QTemporaryDir tempDir; + QString writeTarget = target; + if (fmt == "mp4") { + if (!tempDir.isValid()) { + error = "Failed to create a temporary directory for frames."; + return Result::Error; + } + writeTarget = tempDir.path() + "/frame.png"; + } + + vtkNew controller; + controller->PreInitializeProxy(ahProxy); + vtkSMPropertyHelper(ahProxy, "View").Set(pqview->getRenderViewProxy()); + vtkSMPropertyHelper(ahProxy, "AnimationScene").Set(scenePq->getProxy()); + ahProxy->UpdateDefaultsAndVisibilities(writeTarget.toUtf8().data()); + controller->PostInitializeProxy(ahProxy); + + int res[2]; + vtkSMPropertyHelper resolutionHelper(ahProxy, "ImageResolution"); + resolutionHelper.Get(res, 2); + QSize selected = resolution->currentData().toSize(); + if (selected == QSize(-1, -1)) { + selected = QSize(customWidth->value(), customHeight->value()); + } + if (!selected.isEmpty()) { + res[0] = selected.width(); + res[1] = selected.height(); + } + if (fmt == "mp4") { + // yuv420p subsampling needs even dimensions. + res[0] -= res[0] % 2; + res[1] -= res[1] % 2; + } + resolutionHelper.Set(res, 2); + + vtkSMPropertyHelper(ahProxy, "FrameRate").Set(frameRate->value()); + + // Writer-specific options live on the format proxies in the + // "Format" proxy-list domain; WriteAnimation picks the proxy + // matching the file extension from that same list. + auto* pld = + ahProxy->GetProperty("Format")->FindDomain(); + if (pld) { + if (auto* png = pld->FindProxy("animation_writers", "PNG")) { + // A fixed-width counter the ffmpeg input pattern can match. The + // suffix is a printf format (the writer's own default is ".%04d"); + // a brace-style one is not expanded, it is written literally, so + // every frame would land on the same file. + vtkSMPropertyHelper(png, "SuffixFormat", true).Set(".%06d"); + png->UpdateVTKObjects(); + } + if (fmt == "ogv") { + if (auto* ogv = pld->FindProxy("animation_writers", "OggTheora")) { + vtkSMPropertyHelper(ogv, "Quality", true).Set(oggQuality()); + ogv->UpdateVTKObjects(); + } + } + } + ahProxy->UpdateVTKObjects(); + + bool canceled = false; + { + pqAnimationProgressDialog progress( + fmt == "mp4" ? "Rendering frames..." : "Saving animation...", "Abort", + 0, 100, parent); + progress.setWindowTitle("Export Movie"); + progress.setAnimationScene(scenePq); + progress.show(); + + // pqProgressManager blocks interaction while progress events are + // pending; unblock so the dialog's abort button stays clickable + // (same dance as pqSaveAnimationReaction). + auto* pgm = pqApplicationCore::instance()->getProgressManager(); + const auto prev = pgm->unblockEvents(true); + // Let the render guard size every frame for the most expensive point + // of the animation. Tracking the cost faithfully would be sharper on + // average but would change sharpness between frames, which reads as + // the picture breathing once the frames are played back. + ModuleAnimations::instance().pinExportQuality(); + bool ok = ahProxy->WriteAnimation(writeTarget.toUtf8().data()); + ModuleAnimations::instance().releaseExportQuality(); + pgm->unblockEvents(prev); + + canceled = progress.wasCanceled(); + progress.hide(); + if (!canceled && !ok) { + error = "Failed to render the animation frames."; + return Result::Error; + } + } + if (canceled) { + return Result::Canceled; + } + + if (fmt != "mp4") { + return Result::Success; + } + + // Match the counter width exactly, so a naming mismatch is reported + // here rather than as an ffmpeg failure further down. + int numFrames = + QDir(tempDir.path()) + .entryList({ "frame.??????.png" }, QDir::Files) + .size(); + if (numFrames == 0) { + error = "No animation frames were rendered."; + return Result::Error; + } + + return encodeMp4(tempDir.path(), numFrames, target, error); + } + + Result encodeMp4(const QString& framesDir, int numFrames, + const QString& target, QString& error) + { + QStringList args; + args << "-y" << "-framerate" << QString::number(frameRate->value()) + << "-start_number" << "0" + << "-i" << framesDir + "/frame.%06d.png" + << "-c:v" << "libx264" + << "-preset" << "medium" + << "-crf" << QString::number(crf()) + << "-pix_fmt" << "yuv420p" + << "-movflags" << "+faststart" + << "-nostats" + << "-progress" << "pipe:1" + << target; + + QProgressDialog progress("Encoding movie...", "Abort", 0, numFrames, + parent); + progress.setWindowTitle("Export Movie"); + progress.setWindowModality(Qt::WindowModal); + progress.setMinimumDuration(0); + progress.setValue(0); + + QProcess process; + connect(&process, &QProcess::readyReadStandardOutput, this, + [&process, &progress, numFrames]() { + while (process.canReadLine()) { + QByteArray line = process.readLine().trimmed(); + if (line.startsWith("frame=")) { + bool ok = false; + int frame = line.mid(6).toInt(&ok); + if (ok) { + progress.setValue(qMin(frame, numFrames)); + } + } + } + }); + + bool canceled = false; + connect(&progress, &QProgressDialog::canceled, this, + [&process, &canceled]() { + canceled = true; + process.kill(); + }); + + QEventLoop loop; + connect(&process, + QOverload::of(&QProcess::finished), + &loop, &QEventLoop::quit); + + process.start(ffmpegPath, args); + if (!process.waitForStarted(5000)) { + error = QString("Failed to start ffmpeg at \"%1\".").arg(ffmpegPath); + return Result::Error; + } + // QProcess::finished is only delivered from the event loop, so it + // cannot fire between start() and exec() above; no missed-quit race. + loop.exec(); + progress.reset(); + + if (canceled) { + QFile::remove(target); + return Result::Canceled; + } + if (process.exitStatus() != QProcess::NormalExit || + process.exitCode() != 0) { + QString tail = + QString::fromLocal8Bit(process.readAllStandardError()).right(600); + error = "ffmpeg failed to encode the movie:\n" + tail; + QFile::remove(target); + return Result::Error; + } + return Result::Success; + } +}; + +MovieExportDialog::MovieExportDialog(QWidget* parent) + : QDialog(parent), m_internal(new Internal(this)) +{ +} + +MovieExportDialog::~MovieExportDialog() = default; + +void MovieExportDialog::showEvent(QShowEvent* e) +{ + QDialog::showEvent(e); + m_internal->refreshInfo(); +} + +void MovieExportDialog::exportMovie(QWidget* parent) +{ + auto* scene = + pqPVApplicationCore::instance()->animationManager()->getActiveScene(); + auto* proxy = scene ? scene->getProxy() : nullptr; + auto* object = + proxy ? vtkSMAnimationScene::SafeDownCast(proxy->GetClientSideObject()) + : nullptr; + if (object && object->GetInPlay()) { + interruptAnimationPlayback(/*rewind=*/false); + QObject* context = parent ? static_cast(parent) : qApp; + QTimer::singleShot(0, context, [parent]() { exportMovie(parent); }); + return; + } + MovieExportDialog dialog(parent); + dialog.exec(); +} + +void MovieExportDialog::accept() +{ + QString target = m_internal->fileName->text().trimmed(); + if (target.isEmpty()) { + QMessageBox::warning(this, "Export Movie", "Choose an output file."); + return; + } + QFileInfo fi(target); + if (!fi.dir().exists()) { + QMessageBox::warning(this, "Export Movie", + QString("The directory \"%1\" does not exist.") + .arg(fi.dir().absolutePath())); + return; + } + if (fi.exists()) { + auto answer = QMessageBox::question( + this, "Export Movie", + QString("\"%1\" already exists. Overwrite it?").arg(fi.fileName())); + if (answer != QMessageBox::Yes) { + return; + } + } + + QString error; + auto result = m_internal->doExport(error); + switch (result) { + case Internal::Result::Success: + QDialog::accept(); + break; + case Internal::Result::Canceled: + // The user aborted; leave the dialog open with their settings. + break; + case Internal::Result::Error: + QMessageBox::critical(this, "Export Movie", error); + break; + } +} + +} // namespace tomviz diff --git a/tomviz/MovieExportDialog.h b/tomviz/MovieExportDialog.h new file mode 100644 index 000000000..aafa336c6 --- /dev/null +++ b/tomviz/MovieExportDialog.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 tomvizMovieExportDialog_h +#define tomvizMovieExportDialog_h + +#include +#include + +namespace tomviz { + +/// Export the current animation to a movie file or image sequence. +/// MP4 (H.264) output renders PNG frames offscreen and encodes them +/// with the ffmpeg executable, so it does not depend on which movie +/// writers ParaView was built with. Ogg Theora and PNG sequences go +/// through ParaView's writers directly. +class MovieExportDialog : public QDialog +{ + Q_OBJECT + +public: + explicit MovieExportDialog(QWidget* parent = nullptr); + ~MovieExportDialog() override; + + /// Open the dialog, as the Export Movie actions do. The writer plays + /// the scene itself, which the player refuses during an active + /// playback, and the dialog is modal, so a playing scene is stopped + /// first and the dialog opens once the play loop has unwound. + static void exportMovie(QWidget* parent); + + void accept() override; + +protected: + void showEvent(QShowEvent* e) override; + +private: + class Internal; + QScopedPointer m_internal; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/OpacityPresetWidget.cxx b/tomviz/OpacityPresetWidget.cxx new file mode 100644 index 000000000..2425a0de4 --- /dev/null +++ b/tomviz/OpacityPresetWidget.cxx @@ -0,0 +1,325 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "OpacityPresetWidget.h" + +#include "DoubleSliderWidget.h" +#include "Utilities.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tomviz { + +class OpacityPresetWidget::Internals +{ +public: + enum Preset + { + Gaussian = 0, + Linear, + LinearWithCutoff + }; + + pipeline::VolumeDataPtr volumeData; + vtkWeakPointer lut; + // The curve and color range as they were when the dialog opened + vtkNew originalOpacity; + double originalColorRange[2] = { 0.0, 1.0 }; + double range[2] = { 0.0, 1.0 }; + + QComboBox* preset = nullptr; + QLabel* centerLabel = nullptr; + QLabel* widthLabel = nullptr; + DoubleSliderWidget* center = nullptr; + DoubleSliderWidget* width = nullptr; + QCheckBox* colorFollows = nullptr; + + vtkPiecewiseFunction* opacity() const + { + return lut ? lut->GetScalarOpacityFunction() : nullptr; + } + + void readRange() + { + if (volumeData) { + auto r = volumeData->scalarRange(); + range[0] = r[0]; + range[1] = r[1]; + } + if (!(range[1] > range[0])) { + range[1] = range[0] + 1.0; + } + } + + void snapshot() + { + if (auto* pwf = opacity()) { + originalOpacity->DeepCopy(pwf); + } + if (lut) { + lut->GetRange(originalColorRange); + } + } + + // Opacity of the current preset at x + double evaluate(double x) const + { + double c = center->value(); + double w = std::max(width->value(), 1e-9 * (range[1] - range[0])); + switch (preset->currentIndex()) { + case Gaussian: { + double t = (x - c) / w; + return std::exp(-0.5 * t * t); + } + case Linear: + return std::clamp((x - (c - 0.5 * w)) / w, 0.0, 1.0); + default: // LinearWithCutoff: transparent below the cutoff + return x < c ? 0.0 : std::clamp((x - c) / w, 0.0, 1.0); + } + } + + // Sample points where the shape bends, plus the range ends so the + // editor keeps spanning the whole data range. + std::vector samplePoints() const + { + double c = center->value(); + double w = std::max(width->value(), 1e-9 * (range[1] - range[0])); + std::vector xs{ range[0], range[1] }; + switch (preset->currentIndex()) { + case Gaussian: + for (int k = -6; k <= 6; ++k) { + xs.push_back(c + 0.5 * k * w); + } + break; + case Linear: + xs.push_back(c - 0.5 * w); + xs.push_back(c + 0.5 * w); + break; + default: + xs.push_back(c); + xs.push_back(c + w); + break; + } + std::vector inRange; + for (double x : xs) { + if (x >= range[0] && x <= range[1]) { + inRange.push_back(x); + } + } + std::sort(inRange.begin(), inRange.end()); + inRange.erase(std::unique(inRange.begin(), inRange.end()), + inRange.end()); + return inRange; + } + + void apply() + { + auto* pwf = opacity(); + if (!pwf) { + return; + } + pwf->RemoveAllPoints(); + for (double x : samplePoints()) { + pwf->AddPoint(x, evaluate(x)); + } + // One Modified() is enough: the histogram editor listens for it, + // re-renders, and mirrors the points onto the proxy. + pwf->Modified(); + + if (colorFollows->isChecked() && lut && + preset->currentIndex() == Gaussian) { + // The color map's upper end tracks the tail of the Gaussian, so + // the brightest color lands where the opacity fades out. + double tail = std::min(center->value() + 3.0 * width->value(), + range[1]); + double lo = std::min(originalColorRange[0], tail); + if (tail > lo) { + removePlaceholderNodes(lut); + rescaleNodes(lut.Get(), lo, tail); + addPlaceholderNodes(lut, range); + lut->Modified(); + } + } + } + + void restore() + { + if (auto* pwf = opacity()) { + pwf->DeepCopy(originalOpacity); + pwf->Modified(); + } + if (lut) { + removePlaceholderNodes(lut); + rescaleNodes(lut.Get(), originalColorRange[0], originalColorRange[1]); + addPlaceholderNodes(lut, range); + lut->Modified(); + } + } + + void relabel() + { + switch (preset->currentIndex()) { + case Gaussian: + centerLabel->setText("Center"); + widthLabel->setText("Width (sigma)"); + break; + case Linear: + centerLabel->setText("Ramp Center"); + widthLabel->setText("Ramp Width"); + break; + default: + centerLabel->setText("Cutoff"); + widthLabel->setText("Ramp Width"); + break; + } + colorFollows->setEnabled(preset->currentIndex() == Gaussian); + } + + void configureSliders() + { + double span = range[1] - range[0]; + for (auto* slider : { center, width }) { + slider->setMinimum(range[0]); + slider->setMaximum(range[1]); + } + width->setMinimum(span / 1000.0); + width->setMaximum(span); + } + + void setDefaults() + { + double span = range[1] - range[0]; + double mid = 0.5 * (range[0] + range[1]); + switch (preset->currentIndex()) { + case Gaussian: + center->setValue(mid); + width->setValue(span / 6.0); + break; + case Linear: + center->setValue(mid); + width->setValue(span); + break; + default: + center->setValue(range[0] + 0.2 * span); + width->setValue(0.8 * span); + break; + } + } +}; + +OpacityPresetWidget::OpacityPresetWidget( + pipeline::VolumeDataPtr volumeData, vtkDiscretizableColorTransferFunction* lut, + QWidget* parent) + : QWidget(parent), m_internals(new Internals) +{ + auto& in = *m_internals; + in.volumeData = volumeData; + in.lut = lut; + in.readRange(); + in.snapshot(); + + auto* layout = new QFormLayout(this); + in.preset = new QComboBox(this); + in.preset->addItems({ "Gaussian", "Linear", "Linear with cutoff" }); + layout->addRow("Shape", in.preset); + + auto makeSlider = [this]() { + auto* slider = new DoubleSliderWidget(true, this); + slider->setLineEditWidth(70); + slider->setResolution(1000); + return slider; + }; + in.center = makeSlider(); + in.width = makeSlider(); + in.centerLabel = new QLabel(this); + in.widthLabel = new QLabel(this); + layout->addRow(in.centerLabel, in.center); + layout->addRow(in.widthLabel, in.width); + + in.colorFollows = new QCheckBox("Color map end follows the Gaussian", this); + in.colorFollows->setToolTip( + "Move the color map's upper end to three widths past the center, so " + "the brightest color sits where the opacity fades out."); + layout->addRow(in.colorFollows); + + auto* buttons = new QHBoxLayout; + auto* reset = new QPushButton("Reset", this); + reset->setToolTip("Put back the opacity curve from when this opened."); + buttons->addStretch(); + buttons->addWidget(reset); + layout->addRow(buttons); + + in.configureSliders(); + in.relabel(); + in.setDefaults(); + + auto applyNow = [this]() { m_internals->apply(); }; + connect(in.preset, QOverload::of(&QComboBox::currentIndexChanged), + this, [this, applyNow]() { + auto& internals = *m_internals; + internals.relabel(); + { + QSignalBlocker b1(internals.center), b2(internals.width); + internals.setDefaults(); + } + applyNow(); + }); + for (auto* slider : { in.center, in.width }) { + connect(slider, &DoubleSliderWidget::valueChanged, this, applyNow); + connect(slider, &DoubleSliderWidget::valueEdited, this, applyNow); + } + connect(in.colorFollows, &QCheckBox::toggled, this, [this, applyNow](bool on) { + if (on) { + applyNow(); + } else { + m_internals->restore(); + applyNow(); + } + }); + connect(reset, &QPushButton::clicked, this, + [this]() { m_internals->restore(); }); + + // Opening the dialog does not change anything until a control is used +} + +OpacityPresetWidget::~OpacityPresetWidget() = default; + +void OpacityPresetWidget::setVolumeData(pipeline::VolumeDataPtr volumeData) +{ + m_internals->volumeData = volumeData; +} + +void OpacityPresetWidget::setLut(vtkDiscretizableColorTransferFunction* lut) +{ + m_internals->lut = lut; +} + +void OpacityPresetWidget::updateGui() +{ + // A new dataset or color map: re-baseline against it + auto& in = *m_internals; + in.readRange(); + in.snapshot(); + // Only a control touched by the user may change the curve + QSignalBlocker b1(in.center), b2(in.width); + in.configureSliders(); + in.setDefaults(); +} + +} // namespace tomviz diff --git a/tomviz/OpacityPresetWidget.h b/tomviz/OpacityPresetWidget.h new file mode 100644 index 000000000..8fcd7a3a5 --- /dev/null +++ b/tomviz/OpacityPresetWidget.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 tomvizOpacityPresetWidget_h +#define tomvizOpacityPresetWidget_h + +#include +#include + +#include + +class vtkDiscretizableColorTransferFunction; + +namespace tomviz { + +namespace pipeline { +class VolumeData; +using VolumeDataPtr = std::shared_ptr; +} // namespace pipeline + +/// Replace the scalar opacity function with a prescribed shape +/// (Gaussian, linear ramp, or linear ramp above a cutoff) over the data +/// range, with sliders for its center and width. Edits apply live; the +/// histogram editor picks them up through the function's Modified event. +class OpacityPresetWidget : public QWidget +{ + Q_OBJECT + +public: + OpacityPresetWidget(pipeline::VolumeDataPtr volumeData, + vtkDiscretizableColorTransferFunction* lut, + QWidget* parent = nullptr); + ~OpacityPresetWidget() override; + + void setVolumeData(pipeline::VolumeDataPtr volumeData); + void setLut(vtkDiscretizableColorTransferFunction* lut); + void updateGui(); + +private: + class Internals; + QScopedPointer m_internals; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/OperatorSearchDialog.cxx b/tomviz/OperatorSearchDialog.cxx new file mode 100644 index 000000000..b92ae9453 --- /dev/null +++ b/tomviz/OperatorSearchDialog.cxx @@ -0,0 +1,323 @@ +/* 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 + +#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::removeCategory(const QString& category) +{ + m_operators.erase(std::remove_if(m_operators.begin(), m_operators.end(), + [&category](const OperatorEntry& entry) { + return entry.category == category; + }), + m_operators.end()); +} + +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) { + if (!entry.action) { + continue; + } + QString name = entry.action->text().remove('&'); + nameCounts[name]++; + } + + for (int i = 0; i < m_operators.size(); ++i) { + const auto& entry = m_operators[i]; + if (!entry.action) { + continue; + } + 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]; + if (!entry.action) { + return; + } + 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 && 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..9cee1bb5c --- /dev/null +++ b/tomviz/OperatorSearchDialog.h @@ -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". */ + +#ifndef tomvizOperatorSearchDialog_h +#define tomvizOperatorSearchDialog_h + +#include +#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); + /// Forget every action registered under exactly @a category, so a menu + /// that rebuilds its actions can register the new ones. + void removeCategory(const QString& category); + + 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 + { + /// Guarded: a menu may delete and recreate its actions while the + /// entry is still registered; a gone action is simply not listed. + QPointer 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..1a58b6565 --- /dev/null +++ b/tomviz/PipelineModuleMenu.cxx @@ -0,0 +1,364 @@ +/* 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/PipelineUtils.h" +#include "pipeline/OutputPort.h" +#include "pipeline/InputPort.h" +#include "pipeline/SinkGroupNode.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/data/LabelMapData.h" +#include "pipeline/sinks/LabelMapSink.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", "Label Map", "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" }, + { "Label Map", ":/pipeline/port_labelmap.svg" }, + { "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; + if (type == "Label Map") + return pipeline::PortType::LabelMap; + return pipeline::PortType::ImageData; +} + +bool PipelineModuleMenu::sinkSuitsPort(const QString& type, + pipeline::OutputPort* port) +{ + if (!port) { + return false; + } + + const auto portType = port->type(); + + // Label Map is worth offering for any volume whose values read as + // labels, not only for a port already typed as one. A segmentation + // loaded from a TIFF or an EMD arrives as an ordinary volume, since + // the reader has no way to know the numbers are labels, and there + // would otherwise be no way to say so. + if (type == "Label Map" && portType != pipeline::PortType::LabelMap) { + if (!pipeline::isVolumeType(portType) || !port->hasData()) { + return false; + } + return pipeline::canInterpretAsLabelMap( + port->data().value()); + } + + if (!pipeline::isPortTypeCompatible(portType, sinkAcceptedTypes(type))) { + return false; + } + // Volume accepts ImageData, and LabelMap is one of its subtypes, so + // the compatibility check alone would keep offering the plain volume + // rendering for a segmentation. Label Map is the one to reach for + // there: it renders the same way and adds the per-label controls. + // + // Only for a port actually typed as a label map: a plain volume that + // merely could be read as one is far more often just a volume. + if (type == "Volume" && portType == pipeline::PortType::LabelMap) { + return false; + } + return true; +} + +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(sinkSuitsPort(type, tipPort)); + } +} + +pipeline::LegacyModuleSink* PipelineModuleMenu::createSink(const QString& type) +{ + if (type == "Volume") { + return new pipeline::VolumeSink(); + } else if (type == "Label Map") { + return new pipeline::LabelMapSink(); + } 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; + } + + // Hang the sink off the tip port's SinkGroupNode, creating one when + // the port has none yet. + pip->createLink(pipeline::sinkAttachPort(pip, targetPort, input), + input); + } + pip->executeWhenIdle(); +} + +} // namespace tomviz diff --git a/tomviz/PipelineModuleMenu.h b/tomviz/PipelineModuleMenu.h new file mode 100644 index 000000000..c89446e46 --- /dev/null +++ b/tomviz/PipelineModuleMenu.h @@ -0,0 +1,65 @@ +/* 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); + /// Whether @a type is the right visualization to offer for @a port. + /// Narrower than isPortTypeCompatible(): a sink can accept a base + /// type but still not be what the menu should suggest for one of its + /// subtypes, and Label Map is offered for what a port's data holds + /// rather than only for how the port is typed. + static bool sinkSuitsPort(const QString& type, + pipeline::OutputPort* port); + 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.cxx b/tomviz/PtychoDialog.cxx deleted file mode 100644 index ce7630e69..000000000 --- a/tomviz/PtychoDialog.cxx +++ /dev/null @@ -1,935 +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 "PtychoDialog.h" -#include "ui_PtychoDialog.h" - -#include "PythonUtilities.h" -#include "Utilities.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tomviz { - -class PtychoDialog::Internal : public QObject -{ - Q_OBJECT - -public: - Ui::PtychoDialog ui; - QPointer parent; - - bool ptychoguiIsRunning = false; - - // Key is SID - QMap versionOptions; - // First key for these is the SID. Second key is the version. - QMap> angleOptions; - QMap> allErrorLists; - - QList sidList; - QList angleList; - QStringList versionList; - QList useList; - QStringList errorReasonList; - - QList filteredSidList; - - QMap tableColumns; - - Python::Module ptychoModule; - - Internal(PtychoDialog* p) - : parent(p) - { - ui.setupUi(p); - setParent(p); - - importModule(); - - setupTable(); - setupConnections(); - } - - void setupConnections() - { - connect(ui.startPtychoGUI, &QPushButton::clicked, this, - &Internal::startPtychoGUI); - - connect(ui.ptychoDirectory, &QLineEdit::editingFinished, - this, &Internal::ptychoDirEdited); - connect(ui.selectPtychoDirectory, &QPushButton::clicked, this, - &Internal::selectPtychoDirectory); - - connect(ui.loadFromCSVFile, &QLineEdit::editingFinished, - this, &Internal::setUseAndVersionsFromCSV); - connect(ui.selectLoadFromCSVFile, &QPushButton::clicked, this, - &Internal::selectLoadFromCSV); - - connect(ui.filterSIDsString, &QLineEdit::editingFinished, - this, &Internal::updateFilteredSidList); - 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"); }); - } - - void setupTable() - { - auto* table = ui.table; - auto& columns = tableColumns; - - columns.clear(); - columns[0] = "SID"; - columns[1] = "Angle"; - columns[2] = "Version"; - columns[3] = "Use"; - columns[4] = "Error Reason"; - - table->setColumnCount(columns.size()); - for (int i = 0; i < columns.size(); ++i) { - auto* header = new QTableWidgetItem(columns[i]); - table->setHorizontalHeaderItem(i, header); - } - } - - void importModule() - { - Python python; - - if (ptychoModule.isValid()) { - return; - } - - ptychoModule = python.import("tomviz.ptycho"); - if (!ptychoModule.isValid()) { - qCritical() << "Failed to import \"tomviz.ptycho\" module"; - } - } - - void accepted() - { - QString reason; - if (!validate(reason)) { - QString title = "Invalid Settings"; - QMessageBox::critical(parent.data(), title, reason); - parent->show(); - return; - } - - writeSettings(); - parent->accept(); - } - - QList selectedSids() - { - // Only include the filtered ones - QList ret; - for (auto& sid : filteredSidList) { - auto idx = sidList.indexOf(sid); - auto use = useList[idx]; - if (use) { - ret.append(sid); - } - } - - return ret; - } - - QStringList selectedVersions() - { - QStringList versions; - for (auto sid : selectedSids()) { - auto idx = sidList.indexOf(sid); - versions.append(versionList[idx]); - } - return versions; - } - - QList selectedAngles() - { - QList angles; - for (auto sid : selectedSids()) { - auto idx = sidList.indexOf(sid); - angles.append(angleList[idx]); - } - return angles; - } - - QList invalidSidsSelected() - { - QList invalid; - for (auto sid : selectedSids()) { - auto idx = sidList.indexOf(sid); - if (!errorReasonList[idx].isEmpty()) { - invalid.append(sid); - } - } - return invalid; - } - - bool validate(QString& reason) - { - // Validate settings - if (ptychoDirectory().isEmpty() || !QDir(ptychoDirectory()).exists()) { - reason = "Ptycho directory does not exist: " + ptychoDirectory(); - return false; - } - - if (sidList.isEmpty()) { - reason = "No SIDs found in ptycho directory: " + ptychoDirectory(); - return false; - } - - auto invalid = invalidSidsSelected(); - if (!invalid.isEmpty()) { - QString title = "Invalid SID and version combinations selected"; - QString text = "Invalid SIDs were selected. "; - text += "Do you wish to automatically deselect them and continue?"; - if (QMessageBox::question(parent, title, text) == QMessageBox::No) { - reason = "Invalid SIDs were selected"; - return false; - } - - for (auto sid : invalid) { - auto idx = sidList.indexOf(sid); - useList[idx] = false; - updateTable(); - } - } - - 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; - } - - void updateTable() - { - auto* table = ui.table; - - int scrollbarPosition = 0; - auto scrollbar = table->verticalScrollBar(); - if (scrollbar) { - scrollbarPosition = scrollbar->value(); - } - - table->clearContents(); - - table->setRowCount(filteredSidList.size()); - for (int i = 0; i < filteredSidList.size(); ++i) { - auto sid = filteredSidList[i]; - bool invalid = false; - for (auto j : tableColumns.keys()) { - auto column = tableColumns[j]; - auto value = tableValue(sid, column); - if (column == "Version") { - auto* cb = createVersionComboBox(sid, value); - table->setCellWidget(i, j, cb); - continue; - } else if (column == "Use") { - auto* w = createUseCheckBox(sid, value); - table->setCellWidget(i, j, w); - continue; - } else if (column == "Error Reason") { - invalid = !value.isEmpty(); - } - - auto* item = new QTableWidgetItem(value); - item->setTextAlignment(Qt::AlignCenter); - table->setItem(i, j, item); - } - - if (invalid) { - // Make every item have a red background - for (int j = 0; j < tableColumns.size(); ++j) { - auto* item = table->item(i, j); - if (item) { - item->setBackground(QBrush(Qt::red)); - } else { - auto* cw = table->cellWidget(i, j); - if (cw) { - cw->setStyleSheet("background-color: red"); - } - } - } - } - } - - if (scrollbar) { - scrollbar->setValue(scrollbarPosition); - } - } - - QWidget* createVersionComboBox(long sid, QString value) - { - if (versionOptions[sid].size() < 2) { - // If there aren't any options, the item will just be a label - QString text = "None"; - if (versionOptions[sid].size() == 1) { - text = versionOptions[sid][0]; - } - return createTableWidget(new QLabel(text, parent)); - } - - auto cb = new QComboBox(parent); - for (auto& option: versionOptions[sid]) { - cb->addItem(option); - } - cb->setCurrentText(value); - - connect(cb, &QComboBox::currentIndexChanged, this, [this, sid, cb]() { - auto idx = sidList.indexOf(sid); - versionList[idx] = cb->currentText(); - onSelectedVersionsChanged(); - // Update the table, because the angle and error reason likely changed - updateTable(); - }); - - return createTableWidget(cb); - } - - QWidget* createUseCheckBox(long sid, QString value) - { - auto cb = new QCheckBox(parent); - cb->setChecked(value == "x" || value == "1"); - connect(cb, &QCheckBox::toggled, this, [this, sid](bool b) { - auto idx = sidList.indexOf(sid); - useList[idx] = b; - }); - - return createTableWidget(cb); - } - - QWidget* createTableWidget(QWidget* w) - { - // This is required to center the widget - auto* tw = new QWidget(ui.table); - auto* layout = new QHBoxLayout(tw); - layout->addWidget(w); - layout->setAlignment(Qt::AlignCenter); - layout->setContentsMargins(0, 0, 0, 0); - return tw; - } - - QString tableValue(long sid, QString column) - { - auto idx = sidList.indexOf(sid); - if (column == "SID") { - return QString::number(sidList[idx]); - } else if (column == "Version") { - return versionList[idx]; - } else if (column == "Angle") { - return QString::number(angleList[idx]); - } else if (column == "Use") { - return useList[idx] ? "x" : ""; - } else if (column == "Error Reason") { - return errorReasonList[idx]; - } - - qCritical() << "Unknown table column: " << column; - return ""; - } - - QString defaultOutputDirectory() { return QDir::home().filePath("ptycho_output"); } - - void readSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("ptycho"); - settings->beginGroup("process"); - - setPtychoGUICommand( - settings->value("ptychoGUICommand", "run-ptycho").toString()); - - auto savedPtychoDir = settings->value("ptychoDirectory", "").toString(); - if (!savedPtychoDir.isEmpty() && !QDir(savedPtychoDir).exists()) { - savedPtychoDir = ""; - } - setPtychoDirectory(savedPtychoDir); - setCsvFile(settings->value("loadFromCSVFile", "").toString()); - setFilterSIDsString(settings->value("filterSIDsString", "").toString()); - - setOutputDirectory( - settings->value("outputDirectory", defaultOutputDirectory()).toString()); - setRotateDatasets( - settings->value("rotateDatasets", true).toBool()); - - QVariantList sidListV = settings->value("sidListV").toList(); - QVariantList versionListV = settings->value("versionListV").toList(); - QVariantList useListV = settings->value("useListV").toList(); - - QList savedSidList; - for (const auto& var : sidListV) { - savedSidList.append(var.value()); - } - - QStringList savedVersionList; - for (const auto& var : versionListV) { - savedVersionList.append(var.value()); - } - - QList savedUseList; - for (const auto& var : useListV) { - savedUseList.append(var.value()); - } - - settings->endGroup(); - settings->endGroup(); - - if (!ptychoDirectory().isEmpty()) { - // Trigger a load - loadPtychoDir(); - - if (!csvFile().isEmpty()) { - // Trigger applying the CSV file - setUseAndVersionsFromCSV(); - } - - if (!filterSIDsString().isEmpty()) { - // Trigger an update via the filters - updateFilteredSidList(); - } - - if (savedSidList == sidList) { - // If the saved SID list matches, we can also load the settings - // for "use" and "version" - versionList = savedVersionList; - useList = savedUseList; - onSelectedVersionsChanged(); - updateTable(); - } - } - } - - void writeSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("ptycho"); - settings->beginGroup("process"); - - // Save general settings - settings->setValue("ptychoGUICommand", ptychoGUICommand()); - settings->setValue("ptychoDirectory", ptychoDirectory()); - settings->setValue("loadFromCSVFile", csvFile()); - - settings->setValue("filterSIDsString", filterSIDsString()); - - settings->setValue("outputDirectory", outputDirectory()); - settings->setValue("rotateDatasets", rotateDatasets()); - - // Save out our lists - QVariantList sidListV; - for (auto v: sidList) { - sidListV.append(QVariant::fromValue(v)); - } - - QVariantList versionListV; - for (auto v: versionList) { - versionListV.append(QVariant::fromValue(v)); - } - - QVariantList useListV; - for (auto b: useList) { - useListV.append(QVariant::fromValue(b)); - } - - settings->setValue("sidListV", sidListV); - settings->setValue("versionListV", versionListV); - settings->setValue("useListV", useListV); - - settings->endGroup(); - settings->endGroup(); - } - - void startPtychoGUI() - { - if (ptychoguiIsRunning) { - // It's already running. Just return. - return; - } - - QString program = ptychoGUICommand(); - QStringList args; - - auto* process = new QProcess(this); - - auto processEnv = QProcessEnvironment::systemEnvironment(); - - // Remove variables related to python environment - processEnv.remove("PYTHONHOME"); - processEnv.remove("PYTHONPATH"); - - process->setProcessEnvironment(processEnv); - - // Forward stdout/stderr to this process - process->setProcessChannelMode(QProcess::ForwardedChannels); - - process->start(program, args); - - ptychoguiIsRunning = true; - - connect(process, - QOverload::of(&QProcess::finished), this, - [this]() { - ptychoguiIsRunning = false; - loadPtychoDir(); - }); - - connect( - process, &QProcess::errorOccurred, this, - [this, process](QProcess::ProcessError err) { - ptychoguiIsRunning = false; - - QString title; - QString msg; - - if (err == QProcess::FailedToStart) { - title = "Ptycho GUI failed to start"; - msg = QString("The program \"%1\" failed to start.\n\n") - .arg(process->program()); - } else { - QString output = process->readAllStandardOutput(); - QString error = process->readAllStandardError(); - title = "Ptycho GUI exited with an error"; - msg = - QString("stdout: \"%1\"\n\nstderr: \"%2\"").arg(output).arg(error); - } - QMessageBox::critical(parent.data(), title, msg); - }); - } - - void selectPtychoDirectory() - { - QString caption = "Select Ptycho GUI Directory"; - auto file = - QFileDialog::getExistingDirectory(parent.data(), caption, - ptychoDirectory()); - if (file.isEmpty()) { - return; - } - - // If "recon_result" exists underneath the selected directory, - // it means the parent directory was selected. - // We should automatically select the child one. - auto possibleChildPath = QDir(file).filePath("recon_result"); - if (QFile::exists(possibleChildPath)) { - file = possibleChildPath; - } - - setPtychoDirectory(file); - ptychoDirEdited(); - } - - void ptychoDirEdited() - { - auto dir = ptychoDirectory(); - if (!dir.isEmpty() && !QDir(dir).exists()) { - QMessageBox::critical(parent.data(), "Directory Not Found", - "Ptycho directory does not exist: " + dir); - setPtychoDirectory(""); - setCsvFile(""); - setFilterSIDsString(""); - return; - } - - // Whenever this is called, make sure we clear the CSV file and SID filters - setCsvFile(""); - setFilterSIDsString(""); - - loadPtychoDir(); - } - - void loadPtychoDir() - { - clearTable(); - - Python python; - - auto func = ptychoModule.findFunction("gather_ptycho_info"); - if (!func.isValid()) { - QString msg = "Failed to import \"tomviz.ptycho.gather_ptycho_info\""; - qCritical() << msg; - return; - } - - Python::Dict kwargs; - kwargs.set("ptycho_dir", ptychoDirectory()); - auto result = func.call(kwargs); - - if (!result.isValid() || !result.isDict()) { - QString msg = "Error calling \"tomviz.ptycho.gather_ptycho_info\""; - qCritical() << msg; - return; - } - - auto resultDict = result.toDict(); - - auto sidListV = resultDict["sid_list"].toVariant().toList(); - auto versionDictV = resultDict["version_list"].toVariant().toList(); - auto angleDictV = resultDict["angle_list"].toVariant().toList(); - auto errorDictV = resultDict["error_list"].toVariant().toList(); - - sidList.clear(); - versionOptions.clear(); - angleOptions.clear(); - allErrorLists.clear(); - for (size_t i = 0; i < sidListV.size(); ++i) { - auto sid = sidListV[i].toLong(); - sidList.append(sid); - - auto versionOptionsV = versionDictV[i].toList(); - auto theseAnglesV = angleDictV[i].toList(); - auto theseErrorsV = errorDictV[i].toList(); - - QStringList versions; - QMap angles; - QMap errors; - for (size_t j = 0; j < versionOptionsV.size(); ++j) { - auto version = QString::fromStdString(versionOptionsV[j].toString()); - versions.append(version); - angles[version] = theseAnglesV[j].toDouble(); - errors[version] = QString::fromStdString(theseErrorsV[j].toString()); - } - versionOptions[sid] = versions; - angleOptions[sid] = angles; - allErrorLists[sid] = errors; - } - - resetSelectedVersionsAndUseList(); - updateFilteredSidList(); - } - - void resetSelectedVersionsAndUseList() - { - versionList.clear(); - useList.clear(); - - for (auto sid: sidList) { - bool set = false; - for (auto& version: versionOptions[sid]) { - if (allErrorLists[sid][version].isEmpty()) { - // This one is valid. - versionList.append(version); - useList.append(true); - set = true; - break; - } - } - if (!set) { - // Do the first one and don't set it to be used. - versionList.append(versionOptions[sid][0]); - useList.append(false); - } - } - - onSelectedVersionsChanged(); - } - - void onSelectedVersionsChanged() - { - angleList.clear(); - errorReasonList.clear(); - - for (int i = 0; i < sidList.size(); ++i) { - auto sid = sidList[i]; - auto version = versionList[i]; - angleList.append(angleOptions[sid][version]); - errorReasonList.append(allErrorLists[sid][version]); - } - } - - void updateFilteredSidList() - { - auto filterString = filterSIDsString(); - - Python python; - - auto func = ptychoModule.findFunction("filter_sid_list"); - if (!func.isValid()) { - qCritical() << "Failed to find function \"filter_sid_list\""; - return; - } - - Python::Dict kwargs; - kwargs.set("sid_list", sidList); - kwargs.set("filter_string", filterString); - auto result = func.call(kwargs); - if (!result.isValid() || !result.isList()) { - qCritical() << "Failed to call function \"filter_sid_list\""; - return; - } - - filteredSidList.clear(); - auto resultList = result.toList(); - for (int i = 0; i < resultList.length(); ++i) { - filteredSidList.append(resultList[i].toLong()); - } - - updateTable(); - } - - void onLoadSidsFromTxtClicked() - { - QString caption = "Select txt file"; - QString filter = "*.txt"; - auto startPath = ptychoDirectory(); - 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(", ")); - - updateFilteredSidList(); - } - - void selectLoadFromCSV() - { - QString caption = "Select CSV file to load Use and Version settings"; - auto startPath = !csvFile().isEmpty() ? csvFile() : ptychoDirectory(); - auto file = - QFileDialog::getOpenFileName(parent.data(), caption, startPath); - if (file.isEmpty()) { - return; - } - ui.loadFromCSVFile->setText(file); - - setUseAndVersionsFromCSV(); - } - - void setUseAndVersionsFromCSV() - { - Python python; - - auto func = ptychoModule.findFunction("get_use_and_versions_from_csv"); - if (!func.isValid()) { - qCritical() << "Failed to find function \"get_use_and_versions_from_csv\""; - return; - } - - Python::Dict kwargs; - kwargs.set("csv_path", csvFile()); - auto result = func.call(kwargs); - if (!result.isValid() || !result.isDict()) { - qCritical() << "Failed to call function \"get_use_and_versions_from_csv\""; - return; - } - - auto resultDict = result.toDict(); - - QList sids; - QList use; - QStringList versions; - - auto sidsPy = resultDict["sids"].toList(); - auto usePy = resultDict["use"].toList(); - auto versionsPy = resultDict["versions"].toList(); - - for (auto i = 0; i < sidsPy.length(); ++i) { - sids.append(sidsPy[i].toLong()); - if (i < usePy.length()) { - use.append(usePy[i].toBool()); - } - if (i < versionsPy.length()) { - versions.append(versionsPy[i].toString()); - } - } - - if (sids.size() == 0) { - qCritical() << "No SIDs found in CSV file. Aborting"; - return; - } - - if (use.size() != 0) { - // Set the "Use" for every current one to "false"; - for (int i = 0; i < useList.size(); ++i) { - useList[i] = false; - } - } - - for (int i = 0; i < sids.size(); ++i) { - auto sid = sids[i]; - auto idx = sidList.indexOf(sid); - if (i < use.size()) { - useList[idx] = use[i]; - } - - if (i < versions.size()) { - // Verify it is a valid version - auto newVersion = versions[i]; - if (!versionOptions[sid].contains(newVersion)) { - qCritical() << "SID \"" << sid << "\" from CSV file " - << "indicated a version of " << newVersion << ", " - << "but that did not match the available versions " - << "found within the ptycho directory for that SID. " - << "Skipping..."; - } else { - versionList[idx] = newVersion; - } - } - } - - updateTable(); - } - - void selectOutputDirectory() - { - QString caption = "Select output directory"; - auto dir = QFileDialog::getExistingDirectory(parent.data(), caption, - outputDirectory()); - if (dir.isEmpty()) { - return; - } - - setOutputDirectory(dir); - } - - void clearTable() - { - versionOptions.clear(); - angleOptions.clear(); - allErrorLists.clear(); - - sidList.clear(); - angleList.clear(); - versionList.clear(); - useList.clear(); - errorReasonList.clear(); - - filteredSidList.clear(); - } - - QString ptychoGUICommand() const { return ui.ptychoGUICommand->text(); } - - void setPtychoGUICommand(QString s) { ui.ptychoGUICommand->setText(s); } - - QString ptychoDirectory() const { return ui.ptychoDirectory->text(); } - - void setPtychoDirectory(QString s) { ui.ptychoDirectory->setText(s); } - - QString csvFile() const { return ui.loadFromCSVFile->text(); } - - void setCsvFile(QString s) { ui.loadFromCSVFile->setText(s); } - - QString filterSIDsString() const { return ui.filterSIDsString->text().trimmed(); } - - void setFilterSIDsString(QString s) const { ui.filterSIDsString->setText(s); } - - QString outputDirectory() const { return ui.outputDirectory->text(); } - - void setOutputDirectory(QString s) { ui.outputDirectory->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)) -{ -} - -PtychoDialog::~PtychoDialog() = default; - -void PtychoDialog::show() -{ - m_internal->readSettings(); - QDialog::show(); -} - -QString PtychoDialog::ptychoDirectory() const -{ - return m_internal->ptychoDirectory(); -} - -QList PtychoDialog::selectedSids() const -{ - return m_internal->selectedSids(); -} - -QStringList PtychoDialog::selectedVersions() const -{ - return m_internal->selectedVersions(); -} - -QList PtychoDialog::selectedAngles() const -{ - return m_internal->selectedAngles(); -} - -QString PtychoDialog::outputDirectory() const -{ - return m_internal->outputDirectory(); -} - -bool PtychoDialog::rotateDatasets() const -{ - return m_internal->rotateDatasets(); -} - -} // namespace tomviz - -#include "PtychoDialog.moc" 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/PtychoWidget.cxx b/tomviz/PtychoWidget.cxx new file mode 100644 index 000000000..e73d12c13 --- /dev/null +++ b/tomviz/PtychoWidget.cxx @@ -0,0 +1,1275 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "PtychoWidget.h" +#include "ui_PtychoWidget.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 +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tomviz { + +// Emits results from the background ptycho directory scan. The worker +// thread emits; the widget receives via queued connections. +class PtychoScanCall : public QObject +{ + Q_OBJECT + +signals: + void listed(int total); + void sidScanned(qlonglong sid, QStringList versions, QVariantList angles, + QStringList errors, int index, int total); + void finished(bool ok); +}; + +class PtychoWidget::Internal : public QObject +{ + Q_OBJECT + +public: + Ui::PtychoWidget ui; + QPointer parent; + + bool ptychoguiIsRunning = false; + + // Key is SID + QMap versionOptions; + // First key for these is the SID. Second key is the version. + QMap> angleOptions; + QMap> allErrorLists; + + QList sidList; + QList angleList; + QStringList versionList; + QList useList; + QStringList errorReasonList; + + QList filteredSidList; + + QMap tableColumns; + + Python::Module ptychoModule; + + // Background directory scan state. The generation counter invalidates + // results from a superseded scan; the flag cancels its worker loop. + std::shared_ptr> scanCancelFlag; + int scanGeneration = 0; + bool scanInProgress = false; + // Runs after the next scan that completes; carried across a + // superseded scan so a pending selection restore is not lost. + std::function pendingScanDone; + + Internal(PtychoWidget* p) + : parent(p) + { + ui.setupUi(p); + setParent(p); + + ui.scanProgressBar->hide(); + + importModule(); + + setupTable(); + setupConnections(); + } + + ~Internal() + { + if (scanCancelFlag) { + scanCancelFlag->store(true); + } + } + +signals: + void scanFinished(); + +public: + void setupConnections() + { + connect(ui.startPtychoGUI, &QPushButton::clicked, this, + &Internal::startPtychoGUI); + + connect(ui.ptychoDirectory, &QLineEdit::editingFinished, + this, &Internal::ptychoDirEdited); + connect(ui.selectPtychoDirectory, &QPushButton::clicked, this, + &Internal::selectPtychoDirectory); + + connect(ui.loadFromCSVFile, &QLineEdit::editingFinished, + this, &Internal::setUseAndVersionsFromCSV); + connect(ui.selectLoadFromCSVFile, &QPushButton::clicked, this, + &Internal::selectLoadFromCSV); + + connect(ui.filterSIDsString, &QLineEdit::editingFinished, + this, &Internal::updateFilteredSidList); + connect(ui.loadSidsFromTxt, &QPushButton::clicked, this, + &Internal::onLoadSidsFromTxtClicked); + + // Write the table out without running the operator, so a scan list + // can be prepared up front and shared with the PyXRF workflow. + auto* saveScanListButton = new QPushButton("Save Scan List...", parent); + saveScanListButton->setToolTip( + "Save the listed scans as a CSV (Scan ID, Theta, Use, Version) that " + "this dialog and the PyXRF dialog can load back in."); + ui.horizontalLayout->addWidget(saveScanListButton); + connect(saveScanListButton, &QPushButton::clicked, this, + &Internal::saveScanList); + + connect(ui.selectOutputInfoFile, &QPushButton::clicked, this, + &Internal::selectOutputInfoFile); + } + + void setupTable() + { + auto* table = ui.table; + auto& columns = tableColumns; + + columns.clear(); + columns[0] = "SID"; + columns[1] = "Angle"; + columns[2] = "Version"; + columns[3] = "Use"; + columns[4] = "Error Reason"; + + table->setColumnCount(columns.size()); + for (int i = 0; i < columns.size(); ++i) { + 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 showTableContextMenu(const QPoint& pos) + { + auto* table = ui.table; + auto selectedRows = table->selectionModel()->selectedRows(); + if (selectedRows.isEmpty()) { + return; + } + + 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; + } + + 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 importModule() + { + Python python; + + if (ptychoModule.isValid()) { + return; + } + + ptychoModule = python.import("tomviz.ptycho"); + if (!ptychoModule.isValid()) { + qCritical() << "Failed to import \"tomviz.ptycho\" module"; + } + } + + QList selectedSids() + { + // Only include the filtered ones + QList ret; + for (auto& sid : filteredSidList) { + auto idx = sidList.indexOf(sid); + auto use = useList[idx]; + if (use) { + ret.append(sid); + } + } + + return ret; + } + + QStringList selectedVersions() + { + QStringList versions; + for (auto sid : selectedSids()) { + auto idx = sidList.indexOf(sid); + versions.append(versionList[idx]); + } + return versions; + } + + QList selectedAngles() + { + QList angles; + for (auto sid : selectedSids()) { + auto idx = sidList.indexOf(sid); + angles.append(angleList[idx]); + } + return angles; + } + + void updateTable() + { + auto* table = ui.table; + + int scrollbarPosition = 0; + auto scrollbar = table->verticalScrollBar(); + if (scrollbar) { + scrollbarPosition = scrollbar->value(); + } + + table->clearContents(); + + table->setRowCount(filteredSidList.size()); + for (int i = 0; i < filteredSidList.size(); ++i) { + fillTableRow(i, filteredSidList[i]); + } + + if (scrollbar) { + scrollbar->setValue(scrollbarPosition); + } + } + + void fillTableRow(int i, long sid) + { + auto* table = ui.table; + + bool invalid = false; + for (auto j : tableColumns.keys()) { + auto column = tableColumns[j]; + auto value = tableValue(sid, column); + if (column == "Version") { + auto* cb = createVersionComboBox(sid, value); + table->setCellWidget(i, j, cb); + continue; + } else if (column == "Use") { + auto* w = createUseCheckBox(sid, value); + table->setCellWidget(i, j, w); + continue; + } else if (column == "Error Reason") { + invalid = !value.isEmpty(); + } + + auto* item = new QTableWidgetItem(value); + item->setTextAlignment(Qt::AlignCenter); + table->setItem(i, j, item); + } + + if (invalid) { + // Make every item have a red background + for (int j = 0; j < tableColumns.size(); ++j) { + auto* item = table->item(i, j); + if (item) { + item->setBackground(QBrush(Qt::red)); + } else { + auto* cw = table->cellWidget(i, j); + if (cw) { + cw->setStyleSheet("background-color: red"); + } + } + } + } + } + + QWidget* createVersionComboBox(long sid, QString value) + { + if (versionOptions[sid].size() < 2) { + // If there aren't any options, the item will just be a label + QString text = "None"; + if (versionOptions[sid].size() == 1) { + text = versionOptions[sid][0]; + } + return createTableWidget(new QLabel(text, parent)); + } + + auto cb = new QComboBox(parent); + for (auto& option: versionOptions[sid]) { + cb->addItem(option); + } + cb->setCurrentText(value); + + connect(cb, &QComboBox::currentIndexChanged, this, [this, sid, cb]() { + auto idx = sidList.indexOf(sid); + versionList[idx] = cb->currentText(); + onSelectedVersionsChanged(); + // Update the table, because the angle and error reason likely changed + updateTable(); + }); + + return createTableWidget(cb); + } + + QWidget* createUseCheckBox(long sid, QString value) + { + auto cb = new QCheckBox(parent); + cb->setChecked(value == "x" || value == "1"); + connect(cb, &QCheckBox::toggled, this, [this, sid](bool b) { + auto idx = sidList.indexOf(sid); + useList[idx] = b; + }); + + return createTableWidget(cb); + } + + QWidget* createTableWidget(QWidget* w) + { + // This is required to center the widget + auto* tw = new QWidget(ui.table); + auto* layout = new QHBoxLayout(tw); + layout->addWidget(w); + layout->setAlignment(Qt::AlignCenter); + layout->setContentsMargins(0, 0, 0, 0); + return tw; + } + + QString tableValue(long sid, QString column) + { + auto idx = sidList.indexOf(sid); + if (column == "SID") { + return QString::number(sidList[idx]); + } else if (column == "Version") { + return versionList[idx]; + } else if (column == "Angle") { + return QString::number(angleList[idx]); + } else if (column == "Use") { + return useList[idx] ? "x" : ""; + } else if (column == "Error Reason") { + return errorReasonList[idx]; + } + + qCritical() << "Unknown table column: " << column; + return ""; + } + + QString defaultOutputInfoFile() { return ""; } + + void readSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("ptycho"); + settings->beginGroup("process"); + + setPtychoGUICommand( + settings->value("ptychoGUICommand", "run-ptycho").toString()); + + auto savedPtychoDir = settings->value("ptychoDirectory", "").toString(); + if (!savedPtychoDir.isEmpty() && !QDir(savedPtychoDir).exists()) { + savedPtychoDir = ""; + } + setPtychoDirectory(savedPtychoDir); + setCsvFile(settings->value("loadFromCSVFile", "").toString()); + setFilterSIDsString(settings->value("filterSIDsString", "").toString()); + + setOutputInfoFile( + settings->value("outputInfoFile", defaultOutputInfoFile()).toString()); + setRotateDatasets( + settings->value("rotateDatasets", true).toBool()); + + QVariantList sidListV = settings->value("sidListV").toList(); + QVariantList versionListV = settings->value("versionListV").toList(); + QVariantList useListV = settings->value("useListV").toList(); + + QList savedSidList; + for (const auto& var : sidListV) { + savedSidList.append(var.value()); + } + + QStringList savedVersionList; + for (const auto& var : versionListV) { + savedVersionList.append(var.value()); + } + + QList savedUseList; + for (const auto& var : useListV) { + savedUseList.append(var.value()); + } + + settings->endGroup(); + settings->endGroup(); + + if (!ptychoDirectory().isEmpty()) { + // Trigger a load; the rest applies after the scan completes + loadPtychoDir([this, savedSidList, savedVersionList, savedUseList]() { + if (!csvFile().isEmpty()) { + // Trigger applying the CSV file + setUseAndVersionsFromCSV(); + } + + if (!filterSIDsString().isEmpty()) { + // Trigger an update via the filters + updateFilteredSidList(); + } + + if (savedSidList == sidList) { + // If the saved SID list matches, we can also load the settings + // for "use" and "version" + versionList = savedVersionList; + useList = savedUseList; + onSelectedVersionsChanged(); + updateTable(); + } + }); + } + } + + void writeSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("ptycho"); + settings->beginGroup("process"); + + // Save general settings + settings->setValue("ptychoGUICommand", ptychoGUICommand()); + settings->setValue("ptychoDirectory", ptychoDirectory()); + settings->setValue("loadFromCSVFile", csvFile()); + + settings->setValue("filterSIDsString", filterSIDsString()); + + settings->setValue("outputInfoFile", outputInfoFile()); + settings->setValue("rotateDatasets", rotateDatasets()); + + // Save out our lists + QVariantList sidListV; + for (auto v: sidList) { + sidListV.append(QVariant::fromValue(v)); + } + + QVariantList versionListV; + for (auto v: versionList) { + versionListV.append(QVariant::fromValue(v)); + } + + QVariantList useListV; + for (auto b: useList) { + useListV.append(QVariant::fromValue(b)); + } + + settings->setValue("sidListV", sidListV); + settings->setValue("versionListV", versionListV); + settings->setValue("useListV", useListV); + + settings->endGroup(); + settings->endGroup(); + } + + void startPtychoGUI() + { + if (ptychoguiIsRunning) { + // It's already running. Just return. + return; + } + + QString program = ptychoGUICommand(); + QStringList args; + + auto* process = new QProcess(this); + + auto processEnv = QProcessEnvironment::systemEnvironment(); + + // Remove variables related to python environment + processEnv.remove("PYTHONHOME"); + processEnv.remove("PYTHONPATH"); + + process->setProcessEnvironment(processEnv); + + // Forward stdout/stderr to this process + process->setProcessChannelMode(QProcess::ForwardedChannels); + + process->start(program, args); + + ptychoguiIsRunning = true; + + connect(process, + QOverload::of(&QProcess::finished), this, + [this]() { + ptychoguiIsRunning = false; + loadPtychoDir(); + }); + + connect( + process, &QProcess::errorOccurred, this, + [this, process](QProcess::ProcessError err) { + ptychoguiIsRunning = false; + + QString title; + QString msg; + + if (err == QProcess::FailedToStart) { + title = "Ptycho GUI failed to start"; + msg = QString("The program \"%1\" failed to start.\n\n") + .arg(process->program()); + } else { + QString output = process->readAllStandardOutput(); + QString error = process->readAllStandardError(); + title = "Ptycho GUI exited with an error"; + msg = + QString("stdout: \"%1\"\n\nstderr: \"%2\"").arg(output).arg(error); + } + QMessageBox::critical(parent.data(), title, msg); + }); + } + + void selectPtychoDirectory() + { + QString caption = "Select Ptycho GUI Directory"; + auto file = + QFileDialog::getExistingDirectory(parent.data(), caption, + ptychoDirectory()); + if (file.isEmpty()) { + return; + } + + // If "recon_result" exists underneath the selected directory, + // it means the parent directory was selected. + // We should automatically select the child one. + auto possibleChildPath = QDir(file).filePath("recon_result"); + if (QFile::exists(possibleChildPath)) { + file = possibleChildPath; + } + + setPtychoDirectory(file); + ptychoDirEdited(); + } + + void ptychoDirEdited() + { + auto dir = ptychoDirectory(); + if (!dir.isEmpty() && !QDir(dir).exists()) { + QMessageBox::critical(parent.data(), "Directory Not Found", + "Ptycho directory does not exist: " + dir); + setPtychoDirectory(""); + setCsvFile(""); + setFilterSIDsString(""); + return; + } + + // Whenever this is called, make sure we clear the CSV file and SID filters + setCsvFile(""); + setFilterSIDsString(""); + + loadPtychoDir(); + } + + void cancelScan() + { + if (scanCancelFlag) { + scanCancelFlag->store(true); + } + ++scanGeneration; + if (scanInProgress) { + scanInProgress = false; + ui.scanProgressBar->hide(); + emit scanFinished(); + } + } + + // Blocks (event loop running) until no scan is in progress, so an + // Apply during a scan waits for complete results. + void waitForScan() + { + // The dialog refuses to close during an apply; if this object is + // destroyed under the nested loop anyway, quit rather than spin. + QPointer self(this); + while (self && self->scanInProgress) { + QEventLoop loop; + connect(this, &Internal::scanFinished, &loop, &QEventLoop::quit); + connect(this, &QObject::destroyed, &loop, &QEventLoop::quit); + loop.exec(); + } + } + + // Scans the ptycho directory in a background thread, streaming SIDs + // into the table as they are found. onDone runs after the next scan + // that completes, carrying over if this scan is superseded. + void loadPtychoDir(std::function onDone = {}) + { + cancelScan(); + clearTable(); + updateTable(); + + if (onDone) { + pendingScanDone = std::move(onDone); + } + + scanInProgress = true; + scanCancelFlag = std::make_shared>(false); + + int gen = scanGeneration; + auto cancel = scanCancelFlag; + auto dir = ptychoDirectory(); + + // Busy indicator until the SID list arrives + ui.scanProgressBar->setRange(0, 0); + ui.scanProgressBar->show(); + + auto* call = new PtychoScanCall; + connect(call, &PtychoScanCall::listed, this, [this, gen](int total) { + if (gen != scanGeneration) { + return; + } + ui.scanProgressBar->setRange(0, total); + ui.scanProgressBar->setValue(0); + }); + connect(call, &PtychoScanCall::sidScanned, this, + [this, gen](qlonglong sid, QStringList versions, + QVariantList angles, QStringList errors, int index, + int total) { + Q_UNUSED(total) + if (gen != scanGeneration) { + return; + } + appendScannedSid(static_cast(sid), versions, angles, errors); + ui.scanProgressBar->setValue(index + 1); + }); + connect(call, &PtychoScanCall::finished, this, + [this, gen](bool ok) { + if (gen != scanGeneration) { + return; + } + scanInProgress = false; + ui.scanProgressBar->hide(); + if (!ok) { + qCritical() << "The ptycho directory scan failed"; + } + updateFilteredSidList(); + auto done = std::move(pendingScanDone); + pendingScanDone = nullptr; + if (done) { + done(); + } + emit scanFinished(); + }); + connect(call, &PtychoScanCall::finished, call, &QObject::deleteLater); + + auto future = QtConcurrent::run([call, cancel, dir]() { + QList sids; + Python::Module module; + { + Python python; + module = python.import("tomviz.ptycho"); + Python::Function listFunc; + if (module.isValid()) { + listFunc = module.findFunction("list_ptycho_sids"); + } + if (!listFunc.isValid()) { + qCritical() + << "Failed to find \"tomviz.ptycho.list_ptycho_sids\""; + emit call->finished(false); + return; + } + + Python::Dict kwargs; + kwargs.set("ptycho_dir", dir); + auto res = listFunc.call(kwargs); + if (!res.isValid() || !res.isList()) { + qCritical() << "Error calling \"tomviz.ptycho.list_ptycho_sids\""; + emit call->finished(false); + return; + } + + auto resList = res.toList(); + for (int i = 0; i < resList.length(); ++i) { + sids.append(resList[i].toLong()); + } + } + + int total = sids.size(); + emit call->listed(total); + + for (int i = 0; i < total; ++i) { + if (cancel->load()) { + emit call->finished(false); + return; + } + + auto sid = sids[i]; + QStringList versions, errors; + QVariantList angles; + { + // Scoped so the GIL is released between SIDs + Python python; + auto func = module.findFunction("gather_sid_info"); + if (!func.isValid()) { + qCritical() << "Failed to find \"tomviz.ptycho.gather_sid_info\""; + emit call->finished(false); + return; + } + + Python::Dict kwargs; + kwargs.set("ptycho_dir", dir); + kwargs.set("sid", Variant(static_cast(sid))); + auto res = func.call(kwargs); + if (!res.isValid() || !res.isDict()) { + // Skip this SID, but keep scanning the rest + qCritical() << "Error gathering ptycho info for SID" << sid; + continue; + } + + auto resDict = res.toDict(); + for (auto& v : resDict["versions"].toVariant().toList()) { + versions.append(QString::fromStdString(v.toString())); + } + for (auto& a : resDict["angles"].toVariant().toList()) { + angles.append(a.toDouble()); + } + for (auto& e : resDict["errors"].toVariant().toList()) { + errors.append(QString::fromStdString(e.toString())); + } + } + emit call->sidScanned(sid, versions, angles, errors, i, total); + } + + emit call->finished(!cancel->load()); + }); + Q_UNUSED(future) + } + + // Record one scanned SID with its default selection (first valid + // version, used) and stream its row in when no SID filter is active. + void appendScannedSid(long sid, const QStringList& versions, + const QVariantList& angles, const QStringList& errors) + { + if (sidList.contains(sid)) { + return; + } + + QMap angleMap; + QMap errorMap; + for (int j = 0; j < versions.size(); ++j) { + angleMap[versions[j]] = angles.value(j).toDouble(); + errorMap[versions[j]] = errors.value(j); + } + + sidList.append(sid); + versionOptions[sid] = versions; + angleOptions[sid] = angleMap; + allErrorLists[sid] = errorMap; + + QString chosen = versions.isEmpty() ? QString("t1") : versions[0]; + bool use = false; + for (auto& version : versions) { + if (errorMap[version].isEmpty()) { + // This one is valid. + chosen = version; + use = true; + break; + } + } + versionList.append(chosen); + useList.append(use); + angleList.append(angleMap[chosen]); + errorReasonList.append(errorMap[chosen]); + + if (filterSIDsString().isEmpty()) { + filteredSidList.append(sid); + int row = ui.table->rowCount(); + ui.table->setRowCount(row + 1); + fillTableRow(row, sid); + } + } + + void onSelectedVersionsChanged() + { + angleList.clear(); + errorReasonList.clear(); + + for (int i = 0; i < sidList.size(); ++i) { + auto sid = sidList[i]; + auto version = versionList[i]; + angleList.append(angleOptions[sid][version]); + errorReasonList.append(allErrorLists[sid][version]); + } + } + + void updateFilteredSidList() + { + auto filterString = filterSIDsString(); + + Python python; + + auto func = ptychoModule.findFunction("filter_sid_list"); + if (!func.isValid()) { + qCritical() << "Failed to find function \"filter_sid_list\""; + return; + } + + Python::Dict kwargs; + kwargs.set("sid_list", sidList); + kwargs.set("filter_string", filterString); + auto result = func.call(kwargs); + if (!result.isValid() || !result.isList()) { + qCritical() << "Failed to call function \"filter_sid_list\""; + return; + } + + filteredSidList.clear(); + auto resultList = result.toList(); + for (int i = 0; i < resultList.length(); ++i) { + filteredSidList.append(resultList[i].toLong()); + } + + updateTable(); + } + + void onLoadSidsFromTxtClicked() + { + QString caption = "Select txt file"; + QString filter = "*.txt"; + auto startPath = ptychoDirectory(); + 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); + auto sids = readSidsFromText(reader); + ui.filterSIDsString->setText(sids.join(", ")); + + updateFilteredSidList(); + } + + void selectLoadFromCSV() + { + QString caption = "Select CSV file to load Use and Version settings"; + auto startPath = !csvFile().isEmpty() ? csvFile() : ptychoDirectory(); + auto file = + QFileDialog::getOpenFileName(parent.data(), caption, startPath); + if (file.isEmpty()) { + return; + } + ui.loadFromCSVFile->setText(file); + + setUseAndVersionsFromCSV(); + } + + void setUseAndVersionsFromCSV() + { + // Apply against the complete SID list, not a partial scan + waitForScan(); + + Python python; + + auto func = ptychoModule.findFunction("get_use_and_versions_from_csv"); + if (!func.isValid()) { + qCritical() << "Failed to find function \"get_use_and_versions_from_csv\""; + return; + } + + Python::Dict kwargs; + kwargs.set("csv_path", csvFile()); + auto result = func.call(kwargs); + if (!result.isValid() || !result.isDict()) { + qCritical() << "Failed to call function \"get_use_and_versions_from_csv\""; + return; + } + + auto resultDict = result.toDict(); + + QList sids; + QList use; + QStringList versions; + + auto sidsPy = resultDict["sids"].toList(); + auto usePy = resultDict["use"].toList(); + auto versionsPy = resultDict["versions"].toList(); + + for (auto i = 0; i < sidsPy.length(); ++i) { + sids.append(sidsPy[i].toLong()); + if (i < usePy.length()) { + use.append(usePy[i].toBool()); + } + if (i < versionsPy.length()) { + versions.append(versionsPy[i].toString()); + } + } + + if (sids.size() == 0) { + qCritical() << "No SIDs found in CSV file. Aborting"; + return; + } + + if (use.size() != 0) { + // Set the "Use" for every current one to "false"; + for (int i = 0; i < useList.size(); ++i) { + useList[i] = false; + } + } + + QList missing; + for (int i = 0; i < sids.size(); ++i) { + auto sid = sids[i]; + auto idx = sidList.indexOf(sid); + if (idx < 0) { + // The file lists a SID that is not in the ptycho directory + missing.append(sid); + continue; + } + if (i < use.size()) { + useList[idx] = use[i]; + } + + if (i < versions.size()) { + // Verify it is a valid version + auto newVersion = versions[i]; + if (!versionOptions[sid].contains(newVersion)) { + qCritical() << "SID \"" << sid << "\" from CSV file " + << "indicated a version of " << newVersion << ", " + << "but that did not match the available versions " + << "found within the ptycho directory for that SID. " + << "Skipping..."; + } else { + versionList[idx] = newVersion; + } + } + } + + if (!missing.isEmpty()) { + qWarning() << missing.size() << "SIDs from the file are not in the" + << "ptycho directory and were skipped:" << missing; + } + + updateTable(); + } + + void saveScanList() + { + if (filteredSidList.isEmpty()) { + QMessageBox::information(parent.data(), "Save Scan List", + "There are no scans to save."); + return; + } + auto startPath = + outputInfoFile().isEmpty() ? ptychoDirectory() : outputInfoFile(); + auto path = QFileDialog::getSaveFileName(parent.data(), "Save scan list", + startPath, "CSV files (*.csv)"); + if (path.isEmpty()) { + return; + } + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(parent.data(), "Save Scan List", + QString("Could not write %1").arg(path)); + return; + } + // Same columns as the CSV the operator writes for its output info file + QTextStream out(&file); + out << "Scan ID,Theta,Use,Version\n"; + for (auto sid : filteredSidList) { + int idx = sidList.indexOf(sid); + if (idx < 0) { + continue; + } + out << sid << ',' << QString::number(angleList[idx], 'f', 3) << ',' + << (useList[idx] ? 1 : 0) << ',' << versionList[idx] << '\n'; + } + } + + void selectOutputInfoFile() + { + QString caption = "Select output info file"; + auto startPath = outputInfoFile(); + if (startPath.isEmpty()) { + startPath = ptychoDirectory(); + } + auto file = QFileDialog::getSaveFileName( + parent.data(), caption, startPath, + "CSV files (*.csv);;Text files (*.txt)"); + if (file.isEmpty()) { + return; + } + + setOutputInfoFile(file); + } + + void clearTable() + { + versionOptions.clear(); + angleOptions.clear(); + allErrorLists.clear(); + + sidList.clear(); + angleList.clear(); + versionList.clear(); + useList.clear(); + errorReasonList.clear(); + + filteredSidList.clear(); + } + + QString ptychoGUICommand() const { return ui.ptychoGUICommand->text(); } + + void setPtychoGUICommand(QString s) { ui.ptychoGUICommand->setText(s); } + + QString ptychoDirectory() const { return ui.ptychoDirectory->text(); } + + void setPtychoDirectory(QString s) { ui.ptychoDirectory->setText(s); } + + QString csvFile() const { return ui.loadFromCSVFile->text(); } + + void setCsvFile(QString s) { ui.loadFromCSVFile->setText(s); } + + QString filterSIDsString() const { return ui.filterSIDsString->text().trimmed(); } + + void setFilterSIDsString(QString s) { ui.filterSIDsString->setText(s); } + + QString outputInfoFile() const { return ui.outputInfoFile->text(); } + + void setOutputInfoFile(QString s) { ui.outputInfoFile->setText(s); } + + bool rotateDatasets() const { return ui.rotateDatasets->isChecked(); } + + void setRotateDatasets(bool b) { ui.rotateDatasets->setChecked(b); } +}; + +PtychoWidget::PtychoWidget( + const QMap& /*inputs*/, QWidget* p) + : pipeline::CustomPythonNodeWidget(p), m_internal(new Internal(this)) +{ +} + +PtychoWidget::~PtychoWidget() = default; + +void PtychoWidget::getValues(QMap& map) +{ + // If a directory scan is still running, wait for its results so we + // never hand back a partially-populated selection. + m_internal->waitForScan(); + + auto sids = m_internal->selectedSids(); + auto versions = m_internal->selectedVersions(); + auto angles = m_internal->selectedAngles(); + + 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); + } + + 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; + + map.insert("ui_state", QString::fromUtf8( + QJsonDocument(uiState).toJson(QJsonDocument::Compact))); +} + +void PtychoWidget::setValues(const QMap& map) +{ + auto dir = map.value("ptycho_dir").toString(); + if (dir.isEmpty()) { + m_internal->readSettings(); + return; + } + + { + 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()); + } + + // Restore the selections after the directory scan completes + m_internal->loadPtychoDir([this, map]() { + 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(); + } + }); +} + +void PtychoWidget::writeSettings() +{ + m_internal->writeSettings(); +} + +} // namespace tomviz + +#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..2cad4ae77 100644 --- a/tomviz/PtychoDialog.ui +++ b/tomviz/PtychoWidget.ui @@ -1,18 +1,7 @@ - PtychoDialog - - - - 0 - 0 - 669 - 732 - - - - Ptycho Dialog - + PtychoWidget + 6 @@ -35,13 +24,29 @@ QAbstractItemView::NoEditTriggers - QAbstractItemView::NoSelection + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows true + + + + <html><head/><body><p>Progress of the ptycho directory scan. SIDs appear in the table above as they are scanned.</p></body></html> + + + Scanning SIDs: %v of %m + + + true + + + @@ -101,25 +106,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 file with scan IDs, angles, and versions. A .csv file can be loaded back into this dialog or into the PyXRF dialog; a .txt file uses the legacy text format. Leave empty to skip.</p></body></html> Select - - - - Qt::Horizontal - - - QDialogButtonBox::Close|QDialogButtonBox::Help|QDialogButtonBox::Ok - - - @@ -131,15 +126,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 file with scan IDs, angles, and versions. A .csv file can be loaded back into this dialog or into the PyXRF dialog; a .txt file uses the legacy text format. Leave empty to skip.</p></body></html> - Output Directory: + Output Info File: - outputDirectory + outputInfoFile @@ -177,9 +172,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 file with scan IDs, angles, and versions. A .csv file can be loaded back into this dialog or into the PyXRF dialog; a .txt file uses the legacy text format. Leave empty to skip.</p></body></html> @@ -226,27 +221,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..770bb2646 --- /dev/null +++ b/tomviz/PyXRFWidget.cxx @@ -0,0 +1,887 @@ +/* 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/data/hxn/legacy/users/data_analysis/tomviz_utils/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); + + // Write the table out without running the operator, so a scan list + // can be prepared up front and shared with the ptycho workflow. + auto* saveScanListButton = new QPushButton("Save Scan List...", parent); + saveScanListButton->setToolTip( + "Save the listed scans as a CSV (Scan ID, Theta, Use) that this " + "dialog and the ptycho dialog can load back in."); + ui.filterLayout->addWidget(saveScanListButton); + connect(saveScanListButton, &QPushButton::clicked, this, + &Internal::saveScanList); + + 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 saveScanList() + { + if (scanEntries.isEmpty()) { + QMessageBox::information(parent.data(), "Save Scan List", + "There are no scans to save."); + return; + } + auto startPath = + csvOutput().isEmpty() ? workingDirectory() : csvOutput(); + auto path = QFileDialog::getSaveFileName(parent.data(), "Save scan list", + startPath, "CSV Files (*.csv)"); + if (path.isEmpty()) { + return; + } + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(parent.data(), "Save Scan List", + QString("Could not write %1").arg(path)); + return; + } + // No version column: PyXRF scans have no reconstruction versions. + // The ptycho dialog reads this file as scan ids plus Use flags. + QTextStream out(&file); + out << "Scan ID,Theta,Use\n"; + for (const auto& entry : scanEntries) { + bool hasTheta = entry.status != "fail" && entry.status != "missing"; + out << entry.scanId << ',' + << (hasTheta ? QString::number(entry.theta, 'f', 3) : QString()) + << ',' << (entry.use ? 1 : 0) << '\n'; + } + } + + 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) + { + auto sids = readSidsFromText(reader); + 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(); + } + + // Match headers the way the ptycho scan list parser does: case- + // and punctuation-insensitive, so "Scan ID", "Scan_ID", and "SID" + // are all understood. + auto normalized = [](const QString& s) { + QString out; + for (auto ch : s.toLower()) { + if (ch.isLetterOrNumber()) { + out.append(ch); + } + } + return out; + }; + int sidCol = -1; + int useCol = -1; + for (int i = 0; i < columns.size(); ++i) { + auto key = normalized(columns[i]); + if (sidCol < 0 && (key == "scanid" || key == "sid")) { + sidCol = i; + } else if (useCol < 0 && key == "use") { + useCol = i; + } + } + if (sidCol < 0) { + qCritical() << "CSV file has no \"Scan ID\" column"; + return; + } + + 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(); + auto lower = val.toLower(); + useFlags.append(lower == "1" || lower == "x" || lower == "true" || + lower == "yes"); + } + } + + 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..8ac7196b6 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); @@ -164,18 +156,25 @@ bool Python::Object::isValid() const return m_smartPyObject->GetPointer() != nullptr; } +// The Dict / List constructors take ownership of the pointer they are +// given (vtkSmartPyObject semantics), so the new wrapper needs its own +// reference: handing over the bare pointer would leave two owners of one +// reference and free the object while its container still holds it. Python::Dict Python::Object::toDict() { - return m_smartPyObject->GetPointer(); + return m_smartPyObject->GetAndIncreaseReferenceCount(); } Python::List Python::Object::toList() { - return m_smartPyObject->GetPointer(); + return m_smartPyObject->GetAndIncreaseReferenceCount(); } 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 +188,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()); } @@ -389,6 +394,8 @@ Python::List::List(PyObject* obj) : Object(obj) {} Python::List::List(const List& other) : Object(other) {} +Python::List::List(const Object& obj) : Object(obj) {} + Python::Object Python::List::operator[](int index) { PyObject* item = PyList_GetItem(m_smartPyObject->GetPointer(), index); @@ -598,44 +605,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 +629,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 +641,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..dcdaa5a03 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); @@ -134,6 +130,10 @@ class Python public: List(PyObject* obj); List(const List& other); + // Shares the reference. Without this overload a List built from an + // Object went through operator PyObject*() into List(PyObject*), + // which takes ownership of a reference the Object still owns. + List(const Object& obj); Object operator[](int index); int length(); Variant toVariant() override; @@ -192,9 +192,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 +208,22 @@ 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; + /// True when the files live in the user data directory (userDataPath()), + /// the one place tomviz may edit or delete an operator's files itself. + bool userOwned = false; }; 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/QVTKGLWidget.cxx b/tomviz/QVTKGLWidget.cxx index b2c4b69e8..9d75f9d81 100644 --- a/tomviz/QVTKGLWidget.cxx +++ b/tomviz/QVTKGLWidget.cxx @@ -3,6 +3,8 @@ #include "QVTKGLWidget.h" +#include "TrackpadTouchFix.h" + #include #include @@ -13,6 +15,7 @@ namespace tomviz { QVTKGLWidget::QVTKGLWidget(QWidget* parent, Qt::WindowFlags f) : QVTKOpenGLNativeWidget(parent, f) { + disableTrackpadTouchEvents(this); } QVTKGLWidget::~QVTKGLWidget() = default; 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..0ead97c7b 100644 --- a/tomviz/ResetReaction.cxx +++ b/tomviz/ResetReaction.cxx @@ -3,7 +3,11 @@ #include "ResetReaction.h" -#include "ModuleManager.h" +#include "ActiveObjects.h" +#include "HistogramManager.h" +#include "animations/CameraViewpoints.h" +#include "animations/ModuleAnimations.h" +#include "pipeline/Pipeline.h" #include "Utilities.h" #include @@ -15,14 +19,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 +36,15 @@ void ResetReaction::reset() return; } } - ModuleManager::instance().reset(); + if (pipeline) { + pipeline->clear(); + } + // The animations describe the data that just went: the viewpoints + // framed it and their recorded state names its nodes. Clearing them + // also stops the camera path, and lets the next dataset start with + // its own opening orbit. + CameraViewpoints::instance().clear(); + ModuleAnimations::instance().clear(); + HistogramManager::instance().clearCaches(); } } // namespace tomviz diff --git a/tomviz/RotateAlignWidget.cxx b/tomviz/RotateAlignWidget.cxx index 905c6f9ad..bcea5b98e 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, @@ -336,6 +342,11 @@ class RotateAlignWidget::RAWInternal auto settings = pqApplicationCore::instance()->settings(); m_orientation = settings->value("RotateAlignWidget.orientation", 0).toInt(); + // Only 0 (horizontal) and 1 (vertical) are valid; anything else in the + // settings file would index dims[] out of range. + if (m_orientation != 0 && m_orientation != 1) { + m_orientation = 0; + } } void writeSettings() @@ -411,51 +422,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 +482,125 @@ 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]); + // The preview slices index the axis selected by the (persisted) + // orientation, exactly as onOrientationChanged() does. Seeding them + // from dims[0] regardless read past the end of the tilt series when a + // vertical tilt axis was restored for a wider-than-tall stack (#2308). + const int sliceAxis = d->m_orientation; + d->m_slice0 = vtkMath::Round(0.25 * dims[sliceAxis]); + d->m_slice1 = vtkMath::Round(0.50 * dims[sliceAxis]); + d->m_slice2 = vtkMath::Round(0.75 * dims[sliceAxis]); 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 +778,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..76ff30a34 --- /dev/null +++ b/tomviz/SaveDataDialog.cxx @@ -0,0 +1,702 @@ +/* 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->allPortsRadio->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; + +namespace { + +// The output ports @a scope covers within @a node, before any test of +// what they hold +QList portsInScope(Node* node, SaveDataDialog::Scope scope) +{ + if (!node || isSinkLike(node)) { + return {}; + } + if (scope == SaveDataDialog::Scope::LeafNodes && !isLeafNode(node)) { + return {}; + } + return node->outputPorts(); +} + +bool writable(OutputPort* port) +{ + return port && !PortDataWriter::formats(port->type()).isEmpty(); +} + +// Transient, and its data has already been let go +bool released(OutputPort* port) +{ + return writable(port) && !port->hasData() && !port->isPersistent(); +} + +} // namespace + +QList SaveDataDialog::candidatePorts(OutputPort* port, Scope) +{ + if (!writable(port) || !port->hasData()) { + return {}; + } + return { port }; +} + +QList SaveDataDialog::candidatePorts(Node* node, Scope scope) +{ + QList result; + for (auto* port : portsInScope(node, scope)) { + 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::releasedPorts(OutputPort* port, Scope) +{ + if (!released(port)) { + return {}; + } + return { port }; +} + +QList SaveDataDialog::releasedPorts(Node* node, Scope scope) +{ + QList result; + for (auto* port : portsInScope(node, scope)) { + result.append(releasedPorts(port, scope)); + } + return result; +} + +QList SaveDataDialog::releasedPorts(Pipeline* pipeline, + Scope scope) +{ + QList result; + if (!pipeline) { + return result; + } + for (auto* node : pipeline->nodes()) { + result.append(releasedPorts(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::AllPorts; + } + return m_ui->leafOnlyRadio->isChecked() ? Scope::LeafNodes + : Scope::AllPorts; +} + +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); +} + +QList SaveDataDialog::currentReleased() const +{ + auto scope = currentScope(); + if (m_port) { + return releasedPorts(m_port, scope); + } + if (m_node) { + return releasedPorts(m_node, scope); + } + return releasedPorts(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); + + // Say which outputs are missing and why, rather than leave them to be + // wondered about + const auto gone = currentReleased(); + if (gone.isEmpty()) { + m_ui->releasedNote->hide(); + return; + } + QStringList names; + for (auto* port : gone.mid(0, 3)) { + names << QStringLiteral("%1 (%2)").arg(port->node()->label(), + port->name()); + } + if (gone.size() > 3) { + names << tr("%1 more").arg(gone.size() - 3); + } + const QString joined = names.join(QStringLiteral(", ")); + m_ui->releasedNote->setText( + gone.size() == 1 + ? tr("%1 cannot be saved because it is transient and its data is no " + "longer in memory. To save it, right-click its port and choose " + "Persist in Memory; tomviz runs that step again.") + .arg(joined) + : tr("%1 outputs cannot be saved because they are transient and their " + "data is no longer in memory: %2. To save one, right-click its " + "port and choose Persist in Memory; tomviz runs that step again.") + .arg(gone.size()) + .arg(joined)); + m_ui->releasedNote->show(); +} + +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->allPortsRadio->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->allPortsRadio->isChecked()); + } + settings->endGroup(); +} + +} // namespace tomviz diff --git a/tomviz/SaveDataDialog.h b/tomviz/SaveDataDialog.h new file mode 100644 index 000000000..ac106aec3 --- /dev/null +++ b/tomviz/SaveDataDialog.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 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 port currently holding data, in memory or on disk. Transient + /// ports count while their data is still in memory: persistence + /// decides how long data is kept, not whether it may be saved. + AllPorts + }; + + /// 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 AllPorts 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, or no writer + /// for its type. + static QList candidatePorts( + pipeline::OutputPort* port, Scope scope); + + /// Ports in the same scope that could be written but no longer can: + /// transient ports whose data has been released from memory. The + /// dialog names them so the user knows why they are missing. + static QList releasedPorts( + pipeline::Pipeline* pipeline, Scope scope); + static QList releasedPorts(pipeline::Node* node, + Scope scope); + static QList releasedPorts( + 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; + /// releasedPorts() for the current restriction and scope. + QList currentReleased() 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..219a00a45 --- /dev/null +++ b/tomviz/SaveDataDialog.ui @@ -0,0 +1,238 @@ + + + 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 ports with data + + + Every output port currently holding data, in memory or on disk, whether it is persistent or transient. + + + true + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + + + + + + + + + + false + + + true + + + true + + + + Node + + + + + Port + + + + + Type + + + + + File + + + + + + + + true + + + false + + + + + + + + + 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..4ae1b78fc 100644 --- a/tomviz/SaveDataReaction.cxx +++ b/tomviz/SaveDataReaction.cxx @@ -3,227 +3,68 @@ #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(); + // AllPorts covers every port LeafNodes does. Released ports count too, + // so the dialog can say why they cannot be saved. + const auto scope = SaveDataDialog::Scope::AllPorts; + bool anything = + !SaveDataDialog::candidatePorts(pipeline, scope).isEmpty() || + !SaveDataDialog::releasedPorts(pipeline, scope).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..943012271 100644 --- a/tomviz/SaveLoadStateReaction.cxx +++ b/tomviz/SaveLoadStateReaction.cxx @@ -3,10 +3,21 @@ #include "SaveLoadStateReaction.h" -#include "ModuleManager.h" +#include "ActiveObjects.h" +#include "AnimationSerializer.h" +#include "animations/AnimationSceneGuard.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 +56,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 +77,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 +102,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 +116,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; @@ -117,19 +128,141 @@ bool SaveLoadStateReaction::loadState(const QString& filename) if (success) { 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); + // state, whether it was done programmatically or via file dialog. + // There is no main window to tell when a state file is loaded + // headlessly, which is how the load path is tested. + if (auto* window = MainWindow::instance()) { + window->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) { - return Tvh5Format::read(filename.toStdString()); + 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) +{ + 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; + } + + // A playback still running would tear down its own scene from + // inside a tick + interruptAnimationPlayback(/*rewind=*/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); + AnimationSerializer::restore(state, 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 +286,39 @@ 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()). + // A playback still running would tear down its own scene from + // inside a tick + interruptAnimationPlayback(/*rewind=*/false); + 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); + AnimationSerializer::restore(object, pipeline); + finalizeNewFormatLoad(pipeline, executePipelines); return true; - } // else continue to error message + } + return pipeline::LegacyStateLoader::load( + object, QFileInfo(filename).dir(), executePipelines); } if (!legacyStateFile) { @@ -193,31 +337,48 @@ 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); + AnimationSerializer::save(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); + AnimationSerializer::save(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..726ee0579 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()) { + path = tomviz::userTemplatesPath(); + } + 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); + } + + 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); } - QFile openFile(path); - if (!openFile.open(QIODevice::ReadOnly)) { - qWarning("Could not open template."); + 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/SelectVolumeRangeWidget.cxx b/tomviz/SelectVolumeRangeWidget.cxx new file mode 100644 index 000000000..8abce0f80 --- /dev/null +++ b/tomviz/SelectVolumeRangeWidget.cxx @@ -0,0 +1,153 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "SelectVolumeRangeWidget.h" + +#include "ActiveObjects.h" +#include "SelectVolumeWidget.h" +#include "pipeline/Node.h" +#include "pipeline/data/VolumeData.h" + +#include + +#include +#include +#include +#include +#include + +#include + +namespace tomviz { + +SelectVolumeRangeWidget::SelectVolumeRangeWidget( + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent) +{ + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + auto* image = vol->imageData(); + image->GetOrigin(m_origin); + image->GetSpacing(m_spacing); + image->GetExtent(m_extent); + auto position = vol->displayPosition(); + std::copy(position.begin(), position.end(), m_position); + } + } + + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + buildSelector(m_extent); + + connect(&ActiveObjects::instance(), &ActiveObjects::activeNodeChanged, this, + [this](pipeline::Node*) { updateBoxEnabled(); }); + + auto* form = new QFormLayout; + m_fillValue = new QDoubleSpinBox(this); + m_fillValue->setRange(std::numeric_limits::lowest(), + std::numeric_limits::max()); + m_fillValue->setDecimals(3); + m_fillValue->setValue(0.0); + form->addRow("Fill Value", m_fillValue); + m_layout->addLayout(form); +} + +SelectVolumeRangeWidget::~SelectVolumeRangeWidget() = default; + +void SelectVolumeRangeWidget::buildSelector(const int selection[6]) +{ + if (m_selector) { + m_layout->removeWidget(m_selector); + delete m_selector; + } + m_selector = new SelectVolumeWidget(m_origin, m_spacing, m_extent, selection, + m_position, this); + m_selector->setBoxEnabled(false); // shown by updateBoxEnabled when due + m_layout->insertWidget(0, m_selector); + updateBoxEnabled(); +} + +void SelectVolumeRangeWidget::updateBoxEnabled() +{ + if (!m_selector) { + return; + } + bool active = !m_node || ActiveObjects::instance().activeNode() == m_node; + m_selector->setBoxEnabled(isVisible() && active); +} + +void SelectVolumeRangeWidget::setNodeContext(pipeline::Node* node, + pipeline::Pipeline*) +{ + m_node = node; + updateBoxEnabled(); +} + +void SelectVolumeRangeWidget::showEvent(QShowEvent* event) +{ + CustomPythonNodeWidget::showEvent(event); + updateBoxEnabled(); +} + +void SelectVolumeRangeWidget::hideEvent(QHideEvent* event) +{ + CustomPythonNodeWidget::hideEvent(event); + updateBoxEnabled(); +} + +void SelectVolumeRangeWidget::getValues(QMap& map) +{ + int selected[6] = { 0, 0, 0, 0, 0, 0 }; + if (m_selector) { + m_selector->getExtentOfSelection(selected); + } + // The box reports inclusive VTK extents; the operator slices the numpy + // array [start, end), which starts at the extent's low corner + const char* keys[3] = { "XRANGE", "YRANGE", "ZRANGE" }; + for (int axis = 0; axis < 3; ++axis) { + int base = m_extent[2 * axis]; + map[keys[axis]] = QVariantList{ selected[2 * axis] - base, + selected[2 * axis + 1] + 1 - base }; + } + map["fill_value"] = m_fillValue->value(); +} + +void SelectVolumeRangeWidget::setValues(const QMap& map) +{ + if (map.contains("fill_value")) { + m_fillValue->setValue(map["fill_value"].toDouble()); + } + + int selection[6]; + std::copy(m_extent, m_extent + 6, selection); + bool anyRange = false; + const char* keys[3] = { "XRANGE", "YRANGE", "ZRANGE" }; + for (int axis = 0; axis < 3; ++axis) { + auto range = map.value(keys[axis]).toList(); + if (range.size() != 2) { + continue; + } + int base = m_extent[2 * axis]; + int start = range[0].toInt() + base; + int end = range[1].toInt() - 1 + base; + if (end < start) { + continue; // the declared [0, 0] default: nothing chosen yet + } + selection[2 * axis] = qBound(m_extent[2 * axis], start, m_extent[2 * axis + 1]); + selection[2 * axis + 1] = + qBound(m_extent[2 * axis], end, m_extent[2 * axis + 1]); + anyRange = true; + } + if (!anyRange) { + return; + } + int current[6]; + m_selector->getExtentOfSelection(current); + if (!std::equal(current, current + 6, selection)) { + buildSelector(selection); + } +} + +} // namespace tomviz diff --git a/tomviz/SelectVolumeRangeWidget.h b/tomviz/SelectVolumeRangeWidget.h new file mode 100644 index 000000000..a40a4802d --- /dev/null +++ b/tomviz/SelectVolumeRangeWidget.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 tomvizSelectVolumeRangeWidget_h +#define tomvizSelectVolumeRangeWidget_h + +#include "CustomPythonNodeWidget.h" +#include "PortData.h" + +#include +#include + +class QDoubleSpinBox; +class QHideEvent; +class QShowEvent; +class QVBoxLayout; + +namespace tomviz { + +namespace pipeline { +class Node; +} +class SelectVolumeWidget; + +/// Box selection for operators that act on a sub-volume, such as Clear +/// Subvolume: a draggable box in the 3D view plus start/end spin boxes, +/// reported as [start, end) index ranges, and a fill value. +class SelectVolumeRangeWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + SelectVolumeRangeWidget(const QMap& inputs, + QWidget* parent = nullptr); + ~SelectVolumeRangeWidget() override; + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + void setNodeContext(pipeline::Node* node, + pipeline::Pipeline* pipeline) override; + +protected: + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + +private: + // The box widget takes its selection at construction, so restoring a + // saved range means rebuilding it + void buildSelector(const int selection[6]); + // The box is only shown while this editor is visible and its node is + // the active one, so it never lingers over unrelated work + void updateBoxEnabled(); + + QPointer m_node; + + double m_origin[3] = { 0, 0, 0 }; + double m_spacing[3] = { 1, 1, 1 }; + double m_position[3] = { 0, 0, 0 }; + int m_extent[6] = { 0, 0, 0, 0, 0, 0 }; + QVBoxLayout* m_layout = nullptr; + QPointer m_selector; + QDoubleSpinBox* m_fillValue = nullptr; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/SelectVolumeWidget.cxx b/tomviz/SelectVolumeWidget.cxx index f66e4c4c4..a6b59652e 100644 --- a/tomviz/SelectVolumeWidget.cxx +++ b/tomviz/SelectVolumeWidget.cxx @@ -22,6 +22,8 @@ #include #include +#include +#include #include #include @@ -154,6 +156,28 @@ SelectVolumeWidget::SelectVolumeWidget(const double origin[3], this->valueChanged(); } +void SelectVolumeWidget::setBoxEnabled(bool enabled) +{ + auto* box = this->Internals->boxWidget.GetPointer(); + if (!box->GetInteractor() || (box->GetEnabled() != 0) == enabled) { + return; + } + box->SetEnabled(enabled ? 1 : 0); + this->Internals->interactor->GetRenderWindow()->Render(); +} + +void SelectVolumeWidget::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + setBoxEnabled(true); +} + +void SelectVolumeWidget::hideEvent(QHideEvent* event) +{ + QWidget::hideEvent(event); + setBoxEnabled(false); +} + SelectVolumeWidget::~SelectVolumeWidget() { this->Internals->boxWidget->SetInteractor(nullptr); diff --git a/tomviz/SelectVolumeWidget.h b/tomviz/SelectVolumeWidget.h index f54b76f6d..46fd98d79 100644 --- a/tomviz/SelectVolumeWidget.h +++ b/tomviz/SelectVolumeWidget.h @@ -30,9 +30,17 @@ class SelectVolumeWidget : public QWidget // without the origin and spacing factored in. void getExtentOfSelection(int extent[6]); + /// Show or hide the box in the 3D view. It follows this widget's own + /// visibility by default, so a hidden or closed dialog leaves no box. + void setBoxEnabled(bool enabled); + public slots: void dataMoved(double newX, double newY, double newZ); +protected: + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + private slots: void interactionEnd(vtkObject* caller); void valueChanged(); 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..fc0311e75 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,112 @@ 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 numpy-backed LegacyDataset (the + // test script may rely on the v1 create_child_dataset API). + py::module_ boundary = py::module_::import("tomviz._boundary"); + py::object dataset = boundary.attr("wrap_vtk_image")( + py::cast(image.Get(), py::return_value_policy::reference), + /*legacy=*/true); + + // 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); + // The script returns a numpy-backed dataset; materialize it as + // vtkImageData the same way the pipeline does for node outputs. + py::object imagesObj = + boundary.attr("payload_to_vtk")(result["images"]); + vtkImageData* imageData = nullptr; + if (!imagesObj.is_none()) { + 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 +1105,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 +1160,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/ThresholdRangeWidget.cxx b/tomviz/ThresholdRangeWidget.cxx new file mode 100644 index 000000000..38bb089dc --- /dev/null +++ b/tomviz/ThresholdRangeWidget.cxx @@ -0,0 +1,117 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ThresholdRangeWidget.h" + +#include "DoubleSliderWidget.h" +#include "pipeline/data/VolumeData.h" + +#include +#include +#include +#include +#include + +namespace tomviz { + +ThresholdRangeWidget::ThresholdRangeWidget( + const QMap& inputs, QWidget* parent) + : CustomPythonNodeWidget(parent) +{ + double lowerGuess = 0.0; + if (auto it = inputs.constFind(QStringLiteral("volume")); + it != inputs.constEnd()) { + if (auto vol = it.value().value(); + vol && vol->isValid()) { + auto range = vol->scalarRange(); + m_dataRange[0] = range[0]; + m_dataRange[1] = range[1]; + lowerGuess = vol->thresholdSeed(); + } + } + if (!(m_dataRange[1] > m_dataRange[0])) { + m_dataRange[1] = m_dataRange[0] + 1.0; + } + + auto* layout = new QFormLayout(this); + auto makeSlider = [this]() { + auto* slider = new DoubleSliderWidget(true, this); + slider->setLineEditWidth(70); + slider->setMinimum(m_dataRange[0]); + slider->setMaximum(m_dataRange[1]); + slider->setResolution(1000); + return slider; + }; + m_lower = makeSlider(); + m_upper = makeSlider(); + // wireParameterBindings finds the controls by parameter name + m_lower->setObjectName("lower_threshold"); + m_upper->setObjectName("upper_threshold"); + layout->addRow("Lower Threshold", m_lower); + layout->addRow("Upper Threshold", m_upper); + layout->addRow(new QLabel(QString("Data range: %1 to %2") + .arg(m_dataRange[0]) + .arg(m_dataRange[1]), + this)); + + // Keep lower <= upper + connect(m_lower, &DoubleSliderWidget::valueEdited, this, [this](double v) { + if (v > m_upper->value()) { + m_upper->setValue(v); + } + }); + connect(m_upper, &DoubleSliderWidget::valueEdited, this, [this](double v) { + if (v < m_lower->value()) { + m_lower->setValue(v); + } + }); + + // Until told otherwise, mirror ThresholdSink's initial pick: the + // brightest voxels of the data + setThresholds(lowerGuess, m_dataRange[1]); +} + +void ThresholdRangeWidget::setThresholds(double lower, double upper) +{ + m_lower->setValue(lower); + m_upper->setValue(upper); +} + +void ThresholdRangeWidget::getValues(QMap& map) +{ + map["lower_threshold"] = m_lower->value(); + map["upper_threshold"] = m_upper->value(); +} + +void ThresholdRangeWidget::setValues(const QMap& map) +{ + // Values still equal to the JSON defaults mean the node has never been + // edited, so keep the data-based guess rather than the meaningless + // declared numbers. A bound Threshold visualization overrides both. + auto isDefault = [this, &map](const QString& key) { + return !map.contains(key) || + (m_jsonDefaults.contains(key) && + qFuzzyCompare(1.0 + map[key].toDouble(), + 1.0 + m_jsonDefaults[key])); + }; + if (isDefault("lower_threshold") && isDefault("upper_threshold")) { + return; + } + setThresholds(map.value("lower_threshold", m_lower->value()).toDouble(), + map.value("upper_threshold", m_upper->value()).toDouble()); +} + +void ThresholdRangeWidget::setJSONDescription(const QString& json) +{ + CustomPythonNodeWidget::setJSONDescription(json); + m_jsonDefaults.clear(); + auto doc = QJsonDocument::fromJson(json.toUtf8()); + for (const auto& value : doc.object()["parameters"].toArray()) { + auto param = value.toObject(); + if (param["default"].isDouble()) { + m_jsonDefaults[param["name"].toString()] = param["default"].toDouble(); + } + } +} + +} // namespace tomviz diff --git a/tomviz/ThresholdRangeWidget.h b/tomviz/ThresholdRangeWidget.h new file mode 100644 index 000000000..4823f9f65 --- /dev/null +++ b/tomviz/ThresholdRangeWidget.h @@ -0,0 +1,44 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizThresholdRangeWidget_h +#define tomvizThresholdRangeWidget_h + +#include "CustomPythonNodeWidget.h" +#include "PortData.h" + +#include + +namespace tomviz { + +class DoubleSliderWidget; + +/// Lower/upper threshold sliders that span the input's actual scalar +/// range, for the Binary Threshold operator. The sliders are named after +/// their parameters so the operator's "bindToSink" declarations can link +/// them live to a Threshold visualization on the same data. +class ThresholdRangeWidget : public pipeline::CustomPythonNodeWidget +{ + Q_OBJECT + +public: + ThresholdRangeWidget(const QMap& inputs, + QWidget* parent = nullptr); + + void getValues(QMap& map) override; + void setValues(const QMap& map) override; + void setJSONDescription(const QString& json) override; + +private: + void setThresholds(double lower, double upper); + + DoubleSliderWidget* m_lower = nullptr; + DoubleSliderWidget* m_upper = nullptr; + double m_dataRange[2] = { 0.0, 1.0 }; + // Defaults declared in the JSON, to recognise a never-edited node + QMap m_jsonDefaults; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/TimeSeriesLabel.cxx b/tomviz/TimeSeriesLabel.cxx index 0302a2284..9f112f3b0 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,8 +33,8 @@ class TimeSeriesLabel::Internal : public QObject vtkNew textRepresentation; vtkNew textWidget; - QPointer activeDataSource; QPointer activeView; + QPointer watchedPort; Internal(QObject* p) : QObject(p) { @@ -50,8 +53,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 +80,55 @@ 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); + try { + return port->data().value(); + } catch (const std::bad_any_cast&) { + return nullptr; } + } - if (ds) { - connect(ds, &DataSource::timeStepsModified, this, - &Internal::timeStepsModified); - connect(ds, &DataSource::timeStepChanged, this, - &Internal::timeStepChanged); + void activeDataChanged() + { + // Follow the active port so in-place data mutations (time series + // playback switching steps) refresh the label text too. + auto* port = activeObjects().activeTipOutputPort(); + if (port != watchedPort) { + if (watchedPort) { + disconnect(watchedPort, &pipeline::OutputPort::intermediateDataApplied, + this, nullptr); + } + watchedPort = port; + if (port) { + connect(port, &pipeline::OutputPort::intermediateDataApplied, this, + &Internal::updateLabel); + } } - activeDataSource = ds; updateVisibility(); - timeStepChanged(); + updateLabel(); } - void timeStepsModified() + void updateLabel() { - // In case there wasn't a time series before... - updateVisibility(); - timeStepChanged(); - } + auto vol = activeVolumeData(); + if (!vol || !vol->hasTimeSteps()) { + return; + } - void timeStepChanged() - { - if (!activeDataSource || !activeDataSource->hasTimeSteps()) { + 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 +140,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/TomographyTiltSeries.cxx b/tomviz/TomographyTiltSeries.cxx index b3398df86..1d471a48d 100644 --- a/tomviz/TomographyTiltSeries.cxx +++ b/tomviz/TomographyTiltSeries.cxx @@ -6,6 +6,8 @@ #include "vtkFieldData.h" #include "vtkImageData.h" #include + +#include #define PI 3.14159265359 #include "vtkFloatArray.h" #include "vtkPointData.h" @@ -17,12 +19,12 @@ namespace { // conversion code template -vtkSmartPointer convertToFloatT(T* data, int len) +vtkSmartPointer convertToFloatT(T* data, vtkIdType len) { vtkSmartPointer array = vtkSmartPointer::New(); array->SetNumberOfTuples(len); float* f = static_cast(array->GetVoidPointer(0)); - for (int i = 0; i < len; ++i) { + for (vtkIdType i = 0; i < len; ++i) { f[i] = (float)data[i]; } return array; @@ -31,7 +33,7 @@ vtkSmartPointer convertToFloatT(T* data, int len) vtkSmartPointer convertToFloat(vtkImageData* image) { vtkDataArray* scalars = image->GetPointData()->GetScalars(); - int len = scalars->GetNumberOfTuples(); + vtkIdType len = scalars->GetNumberOfTuples(); vtkSmartPointer array; switch (scalars->GetDataType()) { vtkTemplateMacro(array = convertToFloatT( @@ -49,9 +51,13 @@ void getSinogram(vtkImageData* tiltSeries, int sliceNumber, float* sinogram) { int extents[6]; tiltSeries->GetExtent(extents); - int xDim = extents[1] - extents[0] + 1; // Number of slices - int yDim = extents[3] - extents[2] + 1; // Number of rays - int zDim = extents[5] - extents[4] + 1; // Number of tilts + const vtkIdType xDim = extents[1] - extents[0] + 1; // Number of slices + const vtkIdType yDim = extents[3] - extents[2] + 1; // Number of rays + const vtkIdType zDim = extents[5] - extents[4] + 1; // Number of tilts + + // Keep the requested slice inside the volume rather than reading past it. + sliceNumber = + static_cast(std::clamp(sliceNumber, 0, xDim - 1)); // Convert tiltSeries type to float vtkSmartPointer dataAsFloats = convertToFloat(tiltSeries); @@ -59,9 +65,9 @@ void getSinogram(vtkImageData* tiltSeries, int sliceNumber, float* sinogram) 0)); // Get pointer to tilt series (of type float) // Extract sinograms from tilt series. Make a deep copy - for (int t = 0; t < zDim; ++t) // Loop through tilts (z-direction) + for (vtkIdType t = 0; t < zDim; ++t) // Loop through tilts (z-direction) { - for (int r = 0; r < yDim; ++r) // Loop through rays (y-direction) + for (vtkIdType r = 0; r < yDim; ++r) // Loop through rays (y-direction) { sinogram[t * yDim + r] = dataPtr[t * xDim * yDim + r * xDim + sliceNumber]; @@ -75,17 +81,24 @@ void getSinogram(vtkImageData* tiltSeries, int sliceNumber, float* sinogram, { int extents[6]; tiltSeries->GetExtent(extents); - int xDim = extents[1] - extents[0] + 1; // number of slices - int yDim = extents[3] - extents[2] + 1; // number of rays in tilt series - int zDim = extents[5] - extents[4] + 1; // number of tilts + const vtkIdType xDim = extents[1] - extents[0] + 1; // number of slices + const vtkIdType yDim = extents[3] - extents[2] + 1; // number of rays + const vtkIdType zDim = extents[5] - extents[4] + 1; // number of tilts // Note that the meaning of x and y flip if the tiltAxis is flipped - int tiltAxDim; + vtkIdType tiltAxDim; if (tiltAxis == 0) tiltAxDim = yDim; else tiltAxDim = xDim; + // sliceNumber runs along the axis the tilt axis is not, so it must stay + // below that axis' length. A slice past the end reads beyond the float + // copy of the tilt series (#2308). + const vtkIdType sliceAxDim = (tiltAxis == 0) ? xDim : yDim; + sliceNumber = + static_cast(std::clamp(sliceNumber, 0, sliceAxDim - 1)); + // Convert tiltSeries type to float vtkSmartPointer dataAsFloats = convertToFloat(tiltSeries); float* dataPtr = static_cast(dataAsFloats->GetVoidPointer( @@ -98,7 +111,7 @@ void getSinogram(vtkImageData* tiltSeries, int sliceNumber, float* sinogram, std::vector index2(Nray); // Store indices for linear interpolation // Extract sinograms from tilt series. Make a deep copy - for (int z = 0; z < zDim; ++z) // Loop through tilts (z-direction) + for (vtkIdType z = 0; z < zDim; ++z) // Loop through tilts (z-direction) { for (int r = 0; r < Nray; ++r) // Loop through rays (y-direction) { @@ -110,7 +123,7 @@ void getSinogram(vtkImageData* tiltSeries, int sliceNumber, float* sinogram, weight2[r] = 1 - weight1[r]; } sinogram[z * Nray + r] = 0; - size_t dataInd; + vtkIdType dataInd; if (index1[r] >= 0 && index1[r] < tiltAxDim) { if (tiltAxis == 0) dataInd = z * xDim * yDim + index1[r] * xDim + sliceNumber; @@ -135,18 +148,18 @@ void averageTiltSeries(vtkImageData* tiltSeries, float* average) { int extents[6]; tiltSeries->GetExtent(extents); - int xDim = extents[1] - extents[0] + 1; // Number of slices - int yDim = extents[3] - extents[2] + 1; // Number of rays in tilt series - int zDim = extents[5] - extents[4] + 1; // Number of tilts + const vtkIdType xDim = extents[1] - extents[0] + 1; // Number of slices + const vtkIdType yDim = extents[3] - extents[2] + 1; // Number of rays + const vtkIdType zDim = extents[5] - extents[4] + 1; // Number of tilts // Convert tiltSeries type to float vtkSmartPointer dataAsFloats = convertToFloat(tiltSeries); float* dataPtr = static_cast(dataAsFloats->GetVoidPointer( 0)); // Get pointer to tilt series (of type float) - for (int z = 0; z < zDim; ++z) { - for (int y = 0; y < yDim; ++y) { - for (int x = 0; x < xDim; ++x) { + for (vtkIdType z = 0; z < zDim; ++z) { + for (vtkIdType y = 0; y < yDim; ++y) { + for (vtkIdType x = 0; x < xDim; ++x) { if (z == 0) average[y * xDim + x] = 0; diff --git a/tomviz/TrackpadTouchFix.cxx b/tomviz/TrackpadTouchFix.cxx new file mode 100644 index 000000000..64998f14a --- /dev/null +++ b/tomviz/TrackpadTouchFix.cxx @@ -0,0 +1,65 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "TrackpadTouchFix.h" + +#include +#include + +namespace tomviz { + +#ifdef Q_OS_MAC + +namespace { + +// Qt recreates a gesture's state lazily, and the recognizers set the touch +// attributes again each time they do, so clearing the attributes once is not +// enough on its own. Drop the events on the way in as well. +class TouchEventFilter : public QObject +{ +public: + TouchEventFilter(QObject* parent) : QObject(parent) {} + +protected: + bool eventFilter(QObject*, QEvent* event) override + { + switch (event->type()) { + case QEvent::TouchBegin: + case QEvent::TouchUpdate: + case QEvent::TouchEnd: + case QEvent::TouchCancel: + event->accept(); + return true; + default: + return false; + } + } +}; + +} // namespace + +void disableTrackpadTouchEvents(QWidget* widget) +{ + if (!widget) { + return; + } + + // Pinch and swipe reach us as QNativeGestureEvent on macOS rather than as + // touch events, so leaving those grabbed keeps trackpad zooming working. + widget->ungrabGesture(Qt::PanGesture); + widget->ungrabGesture(Qt::TapGesture); + widget->ungrabGesture(Qt::TapAndHoldGesture); + + widget->setAttribute(Qt::WA_AcceptTouchEvents, false); + widget->setAttribute(Qt::WA_TouchPadAcceptSingleTouchEvents, false); + + widget->installEventFilter(new TouchEventFilter(widget)); +} + +#else + +void disableTrackpadTouchEvents(QWidget*) {} + +#endif + +} // namespace tomviz diff --git a/tomviz/TrackpadTouchFix.h b/tomviz/TrackpadTouchFix.h new file mode 100644 index 000000000..ba8f1565c --- /dev/null +++ b/tomviz/TrackpadTouchFix.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 tomvizTrackpadTouchFix_h +#define tomvizTrackpadTouchFix_h + +class QWidget; + +namespace tomviz { + +/// Stop a macOS trackpad from looking like a held left mouse button in a VTK +/// widget. +/// +/// QVTKOpenGLNativeWidget's constructor calls grabGesture(Qt::PanGesture). +/// On macOS Qt's pan recognizer is touch based, so grabbing it sets both +/// Qt::WA_AcceptTouchEvents and Qt::WA_TouchPadAcceptSingleTouchEvents on the +/// widget. The latter is what makes Qt stop discarding single-point touchpad +/// events, so a single finger merely moving across the trackpad starts +/// delivering QTouchEvents even though nothing has been clicked. +/// +/// QVTKInteractorAdapter::ProcessEvent then translates a touch point in the +/// pressed state into vtkCommand::LeftButtonPressEvent (and a released point +/// into LeftButtonReleaseEvent). Because a hovering finger produces presses +/// that never get a matching release, VTK is left believing the left button is +/// held down. In the 2D views that draws rubber band rectangles +/// (vtkInteractorStyleRubberBand2D) and drags histogram contour markers. +/// +/// This is a no-op off macOS, where the touch to left button mapping is the +/// intended behavior for real touchscreens. +void disableTrackpadTouchEvents(QWidget* widget); + +} // namespace tomviz + +#endif // tomvizTrackpadTouchFix_h 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..236cd91bd 100644 --- a/tomviz/Tvh5Format.cxx +++ b/tomviz/Tvh5Format.cxx @@ -3,236 +3,739 @@ #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/LabelMapData.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 = pipeline::makeVolumeData(image.Get(), type); + 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..a3c22402c 100644 --- a/tomviz/Utilities.cxx +++ b/tomviz/Utilities.cxx @@ -4,10 +4,8 @@ #include "Utilities.h" #include "ActiveObjects.h" -#include "DataSource.h" #include "tomvizConfig.h" -#include #include #include #include @@ -27,7 +25,6 @@ #include #include #include -#include #include #include @@ -60,12 +57,18 @@ #include #include #include +#include #include #include +#include +#include +#include #include #include +#include #include #include +#include #include namespace tomviz { @@ -298,19 +301,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 +587,38 @@ vtkPVArrayInformation* scalarArrayInformation(vtkSMSourceProxy* proxy) : nullptr; } -bool rescaleColorMap(vtkSMProxy* colorMap, DataSource* dataSource) +namespace { +// vtkSMProxy keeps SetPropertyModifiedFlag protected; naming it through +// a derived class yields an ordinary pointer to the base member, which +// is the sanctioned way to reach it from outside. +struct ProxyModifiedFlag : vtkSMProxy +{ + static void clear(vtkSMProxy* proxy, const char* name) + { + constexpr auto flag = &ProxyModifiedFlag::SetPropertyModifiedFlag; + (proxy->*flag)(name, 0); + } +}; +} // namespace + +void recordProxyValues(vtkSMProxy* proxy, const char* name, + const double* values, unsigned int count) +{ + auto* prop = proxy ? proxy->GetProperty(name) : nullptr; + if (!prop) { + return; + } + vtkSMPropertyHelper(prop).Set(values, count); + ProxyModifiedFlag::clear(proxy, name); +} + +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) { @@ -637,116 +670,39 @@ QString readInJSONDescription(const QString& fileName) return readInTextFile(fileName, ".json"); } -void clearCameraCues(vtkSMRenderViewProxy* renderView) +bool ensureAnimationFrames() { pqAnimationScene* scene = pqPVApplicationCore::instance()->animationManager()->getActiveScene(); - - for (auto* cue : scene->getCues()) { - if (!cue->getSMName().startsWith("CameraAnimationCue")) { - continue; - } - - vtkSMProxy* animatedProxy = pqSMAdaptor::getProxyProperty( - cue->getProxy()->GetProperty("AnimatedProxy")); - if (renderView && animatedProxy != renderView) { - continue; - } - - // If we made it this far, we should remove this cue - scene->removeCue(cue); - } -} - -void createCameraOrbit(vtkSMSourceProxy* data, vtkSMRenderViewProxy* renderView) -{ - // Get camera position at start - double* normal = renderView->GetActiveCamera()->GetViewUp(); - double* origin = renderView->GetActiveCamera()->GetPosition(); - - // Get center of data - double center[3]; - vtkTrivialProducer* t = - vtkTrivialProducer::SafeDownCast(data->GetClientSideObject()); - if (!t) { - return; - } - auto imageData = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); - double data_bounds[6]; - imageData->GetBounds(data_bounds); - vtkBoundingBox box; - box.SetBounds(data_bounds); - box.GetCenter(center); - QList centerList; - centerList << center[0] << center[1] << center[2]; - - // Generate camera orbit - vtkSmartPointer pts; - pts.TakeReference(vtkSMUtilities::CreateOrbit(center, normal, 7, origin)); - QList points; - for (vtkIdType i = 0; i < pts->GetNumberOfPoints(); ++i) { - double coords[3]; - pts->GetPoint(i, coords); - points << coords[0] << coords[1] << coords[2]; + if (!scene) { + return false; } - pqAnimationScene* scene = - pqPVApplicationCore::instance()->animationManager()->getActiveScene(); - - pqAnimationCue* cue = - scene->createCue(renderView, "Camera", 0, "CameraAnimationCue"); - pqSMAdaptor::setElementProperty(cue->getProxy()->GetProperty("Mode"), 1); - cue->getProxy()->UpdateVTKObjects(); - vtkSMProxy* kf = cue->getKeyFrame(0); - pqSMAdaptor::setMultipleElementProperty(kf->GetProperty("PositionPathPoints"), - points); - pqSMAdaptor::setMultipleElementProperty(kf->GetProperty("FocalPathPoints"), - centerList); - pqSMAdaptor::setElementProperty(kf->GetProperty("ClosedPositionPath"), 1); - kf->UpdateVTKObjects(); -} - -void createCameraOrbit(vtkSMRenderViewProxy* renderView) -{ - // Get camera position at start - double* normal = renderView->GetActiveCamera()->GetViewUp(); - double* origin = renderView->GetActiveCamera()->GetPosition(); - double* center = renderView->GetActiveCamera()->GetFocalPoint(); - - QList centerList; - centerList << center[0] << center[1] << center[2]; - - // Generate camera orbit - vtkSmartPointer pts; - pts.TakeReference(vtkSMUtilities::CreateOrbit(center, normal, 7, origin)); - QList points; - for (vtkIdType i = 0; i < pts->GetNumberOfPoints(); ++i) { - auto* coords = pts->GetPoint(i); - points << coords[0] << coords[1] << coords[2]; + // Only a count that cannot animate at all is overridden; anything the + // user or a data load chose is left alone. + if (pqSMAdaptor::getElementProperty( + scene->getProxy()->GetProperty("NumberOfFrames")) + .toInt() > 1) { + return false; } - pqAnimationScene* scene = - pqPVApplicationCore::instance()->animationManager()->getActiveScene(); - - pqAnimationCue* cue = - scene->createCue(renderView, "Camera", 0, "CameraAnimationCue"); - pqSMAdaptor::setElementProperty(cue->getProxy()->GetProperty("Mode"), 1); - cue->getProxy()->UpdateVTKObjects(); - vtkSMProxy* kf = cue->getKeyFrame(0); - pqSMAdaptor::setMultipleElementProperty(kf->GetProperty("PositionPathPoints"), - points); - pqSMAdaptor::setMultipleElementProperty(kf->GetProperty("FocalPathPoints"), - centerList); - pqSMAdaptor::setElementProperty(kf->GetProperty("ClosedPositionPath"), 1); - kf->UpdateVTKObjects(); + setAnimationNumberOfFrames(defaultAnimationFrames); + return true; } void setAnimationNumberOfFrames(int numFrames) { pqAnimationScene* scene = pqPVApplicationCore::instance()->animationManager()->getActiveScene(); + if (!scene) { + return; + } pqSMAdaptor::setElementProperty( scene->getProxy()->GetProperty("NumberOfFrames"), numFrames); + // Without this the value sits on the proxy but never reaches the + // animation player, so playback keeps using the previous frame count + // until something else happens to flush the scene proxy. + scene->getProxy()->UpdateVTKObjects(); } void snapAnimationToTimeSteps(const std::vector& timeSteps) @@ -755,14 +711,19 @@ void snapAnimationToTimeSteps(const std::vector& timeSteps) pqAnimationScene* scene = pqPVApplicationCore::instance()->animationManager()->getActiveScene(); + if (!scene) { + return; + } pqSMAdaptor::setEnumerationProperty( scene->getProxy()->GetProperty("PlayMode"), "Snap To TimeSteps"); + scene->getProxy()->UpdateVTKObjects(); 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); + proxy->UpdateVTKObjects(); } void setupRenderer(vtkRenderer* renderer, vtkImageSliceMapper* mapper, @@ -981,6 +942,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 +1205,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); @@ -1410,16 +1394,20 @@ double getVoxelValue(vtkImageData* data, const vtkVector3d& point, return scalar; } -QString userDataPath() { - // Ensure the tomviz directory exists - QStringList locations = +QString userDataPath() +{ + // TOMVIZ_USER_DIRECTORY relocates the whole user directory, custom + // operators and templates included. + QString path = QString::fromLocal8Bit(qgetenv("TOMVIZ_USER_DIRECTORY")); + if (path.isEmpty()) { + const QStringList homes = QStandardPaths::standardLocations(QStandardPaths::HomeLocation); - QString home = locations[0]; - QString path = QString("%1%2tomviz").arg(home).arg(QDir::separator()); - QDir dir(path); - // dir.mkpath() returns true if the path already exists or if it was - // successfully created. - if (!dir.mkpath(path)) { + path = QDir(homes.first()).filePath("tomviz"); + } + path = QDir(path).absolutePath(); + + // mkpath() is also true when the directory already exists. + if (!QDir().mkpath(path)) { QMessageBox::warning( tomviz::mainWidget(), "Could not create tomviz directory", QString("Could not create tomviz directory '%1'.").arg(path)); @@ -1428,6 +1416,69 @@ QString userDataPath() { return path; } +QString userTemplatesPath() +{ + const QString base = userDataPath(); + return base.isEmpty() ? QString() : base + "/templates"; +} + +QStringList customOperatorSearchPaths() +{ + QStringList paths; + auto addIfDir = [&paths](const QString& path) { + if (QFileInfo(path).isDir()) { + paths.append(QDir::cleanPath(path)); + } + }; + + const QByteArray envOverride = qgetenv("TOMVIZ_CUSTOM_TRANSFORMS_PATH"); + if (!envOverride.isEmpty()) { + const QStringList entries = QString::fromLocal8Bit(envOverride) + .split(QDir::listSeparator(), + Qt::SkipEmptyParts); + for (const QString& path : entries) { + addIfDir(path); + } + return paths; + } + + // ~/.tomviz, where earlier releases also looked; kept so operators + // placed there keep appearing (read-only, unlike userDataPath()). + for (const QString& home : + QStandardPaths::standardLocations(QStandardPaths::HomeLocation)) { + addIfDir(QDir(home).filePath(QStringLiteral(".tomviz"))); + } + // The platform app-data directories, e.g. + // C:/Users//AppData/Local/tomviz on Windows. + for (const QString& path : + QStandardPaths::standardLocations(QStandardPaths::AppDataLocation)) { + addIfDir(path); + } + return paths; +} + +bool writeTextFile(QWidget* parent, const QString& path, const QString& text) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + QMessageBox::critical(parent, QObject::tr("Failed to save"), + QObject::tr("Could not open \"%1\" for writing:\n%2") + .arg(QDir::toNativeSeparators(path), + file.errorString())); + return false; + } + const QByteArray bytes = text.toUtf8(); + // Report a short write too: close() can still fail to flush. + if (file.write(bytes) != bytes.size() || !file.flush()) { + QMessageBox::critical(parent, QObject::tr("Failed to save"), + QObject::tr("Could not write to \"%1\":\n%2") + .arg(QDir::toNativeSeparators(path), + file.errorString())); + return false; + } + return true; +} + } // namespace tomviz namespace tomviz { @@ -1457,7 +1508,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 +1516,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 +1571,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 +1579,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 +1700,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 +1730,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]); @@ -1817,4 +1859,35 @@ void relabelXAndZAxes(vtkImageData* image) image->SetFieldData(fd); } +QStringList readSidsFromText(QTextStream& reader) +{ + QStringList sids; + int sidCol = 0; + static const QRegularExpression ws("\\s+"); + while (!reader.atEnd()) { + auto line = reader.readLine().trimmed(); + if (line.isEmpty()) { + continue; + } + if (line.startsWith('#')) { + // A header comment can name the columns, e.g. "# Angle SID Version" + auto tokens = line.mid(1).trimmed().split(ws, Qt::SkipEmptyParts); + for (int i = 0; i < tokens.size(); ++i) { + auto t = tokens[i].toLower(); + if (t == "sid" || t == "scanid" || t == "scan_id") { + sidCol = i; + break; + } + } + continue; + } + + auto fields = line.split(ws, Qt::SkipEmptyParts); + if (sidCol < fields.size()) { + sids.append(fields[sidCol]); + } + } + return sids; +} + } // namespace tomviz diff --git a/tomviz/Utilities.h b/tomviz/Utilities.h index b342369dc..40d727c3c 100644 --- a/tomviz/Utilities.h +++ b/tomviz/Utilities.h @@ -22,6 +22,8 @@ #include #include +class QTextStream; + #include class pqAnimationScene; @@ -42,6 +44,7 @@ class vtkTable; class QDir; class QLayout; class QUrl; +class QWidget; namespace tomviz { @@ -53,8 +56,6 @@ struct Attributes static const char* FILENAME; }; -class DataSource; - //=========================================================================== // Functions for converting from pqProxy to vtkSMProxy and vice-versa. //=========================================================================== @@ -163,7 +164,17 @@ 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); + +/// Record @a values on the proxy's vector property @a name without +/// pushing them to the VTK object, for when that object already holds +/// exactly these values (the caller set them on it directly). The +/// property is left unmodified, so the next UpdateVTKObjects() skips +/// it. A push would replay the property's command per element; for a +/// transfer function's points that is AddPoint with a re-sort each +/// time, quadratic in the number of points and minutes at 100k. +void recordProxyValues(vtkSMProxy* proxy, const char* name, + const double* values, unsigned int count); // Given the root of a file and an extension, reades the file fileName + // extension and returns the content in a QString. @@ -179,26 +190,45 @@ QString readInPythonScript(const QString& scriptName); // of the built-in tomviz python operator scripts. QString readInJSONDescription(const QString& scriptName); -// Get the path for the Tomviz directory +// The user's tomviz directory: custom operators, pipeline templates and +// the like live here, and tomviz may write to it. /tomviz unless +// TOMVIZ_USER_DIRECTORY says otherwise. Created on demand; empty (after a +// warning) when it cannot be. QString userDataPath(); +// The pipeline templates directory inside userDataPath(); empty when that +// is. Not created here. +QString userTemplatesPath(); + +// The directories to scan for user-defined Python operators in addition to +// userDataPath(), which is always scanned first (see +// MainWindow::findCustomOperators). With TOMVIZ_CUSTOM_TRANSFORMS_PATH set, +// these are its existing entries; otherwise ~/.tomviz (the pre-3.1 +// location) and the platform app-data directories. Paths are cleaned, in +// scan order. +QStringList customOperatorSearchPaths(); + +// Write @a text to @a path as UTF-8, telling the user about any failure in +// a message box parented to @a parent. False when the file may be +// incomplete. +bool writeTextFile(QWidget* parent, const QString& path, const QString& text); + // Remove all camera cues from the animation scene. // If a render view is provided, only camera cues associated with that // render view will be removed. -void clearCameraCues(vtkSMRenderViewProxy* renderView = nullptr); - -// Create a camera orbit animation for the given renderview around the given -// object -void createCameraOrbit(vtkSMSourceProxy* data, - vtkSMRenderViewProxy* renderView); - -// Create a camera orbit animation for the given renderview around the current -// camera focal point. -void createCameraOrbit(vtkSMRenderViewProxy* renderView); // Set the number of frames in the animation scene void setAnimationNumberOfFrames(int numFrames); +/// The frame count tomviz gives an animation it starts itself. +constexpr int defaultAnimationFrames = 200; + +/// Raise the scene to defaultAnimationFrames if it still holds too few +/// frames to show any motion. A scene nothing has configured holds one, +/// which plays as a single still and reads as the animation having done +/// nothing at all. Returns true if the count was changed. +bool ensureAnimationFrames(); + // Set the animation time steps and set the play mode to "Snap to TimeSteps" // This will also set the "TimeRange". // The time steps must be sorted. @@ -226,6 +256,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 +282,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 +331,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); @@ -324,6 +368,14 @@ bool loadPlugins(); */ void relabelXAndZAxes(vtkImageData* image); +/** + * Parse scan IDs from a whitespace-delimited text stream. By default + * the first column holds them; a '#' comment header naming the columns + * (e.g. the ptycho output info file's "# Angle SID Version") selects + * the SID column instead. Used by the ptycho and pyxrf dialogs. + */ +QStringList readSidsFromText(QTextStream& reader); + } // namespace tomviz #endif diff --git a/tomviz/ViewMenuManager.cxx b/tomviz/ViewMenuManager.cxx index af24c2e92..076a9559d 100644 --- a/tomviz/ViewMenuManager.cxx +++ b/tomviz/ViewMenuManager.cxx @@ -4,21 +4,15 @@ #include "ViewMenuManager.h" #include -#include #include -#include #include -#include #include #include -#include #include #include #include -#include #include -#include #include #include @@ -27,41 +21,14 @@ #include #include #include -#include #include #include #include "ActiveObjects.h" -#include "CameraReaction.h" -#include "DataSource.h" -#include "ModuleManager.h" -#include "ModuleSlice.h" -#include "SliceViewDialog.h" #include "Utilities.h" namespace tomviz { -class PreviousImageViewerSettings -{ -public: - vtkNew camera; - QString projection; - bool newSliceModule; - QPointer sliceModule; - QJsonObject sliceModuleSettings; - QList> visibleModules; - int interactionMode; - - void clear() - { - // Only clear whatever needs to be cleared - visibleModules.clear(); - newSliceModule = false; - sliceModule = nullptr; - sliceModuleSettings = QJsonObject(); - }; -}; - ViewMenuManager::ViewMenuManager(QMainWindow* mainWindow, QMenu* menu) : pqViewMenuManager(mainWindow, menu), m_perspectiveProjectionAction(nullptr), m_orthographicProjectionAction(nullptr) @@ -77,14 +44,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); - Menu->addSeparator(); // Projection modes QActionGroup* projectionGroup = new QActionGroup(this); @@ -115,25 +74,6 @@ ViewMenuManager::ViewMenuManager(QMainWindow* mainWindow, QMenu* menu) connect(m_showOrientationAxesAction, &QAction::triggered, this, &ViewMenuManager::setShowOrientationAxes); - Menu->addSeparator(); - - m_imageViewerModeAction = Menu->addAction("Image Viewer Mode"); - m_imageViewerModeAction->setCheckable(true); - m_imageViewerModeAction->setChecked(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()) { setupLookingGlassPlaceholder(mainWindow); } @@ -277,20 +217,6 @@ void ViewMenuManager::setShowOrientationAxes(bool show) render(); } -int ViewMenuManager::interactionMode() const -{ - auto* renderView = ActiveObjects::instance().activePqRenderView(); - return vtkSMPropertyHelper(renderView->getProxy(), "InteractionMode") - .GetAsInt(); -} - -void ViewMenuManager::setInteractionMode(int mode) -{ - auto* renderView = ActiveObjects::instance().activePqRenderView(); - vtkSMPropertyHelper(renderView->getProxy(), "InteractionMode").Set(mode); - renderView->getProxy()->UpdateProperty("InteractionMode", 1); -} - void ViewMenuManager::render() { auto* view = tomviz::convert(m_view); @@ -299,194 +225,6 @@ void ViewMenuManager::render() } } -static void resize2DCameraToFit(vtkSMRenderViewProxy* view, double bounds[6], - int axis) -{ - double lengths[3] = { - bounds[1] - bounds[0], bounds[3] - bounds[2], bounds[5] - bounds[4], - }; - - int* size = view->GetRenderWindow()->GetSize(); - double w = size[0]; - double h = size[1]; - - double bw, bh; - if (axis == 0 || axis == 2) { - bw = lengths[(axis + 1) % 3]; - bh = lengths[(axis + 2) % 3]; - } else { - bw = lengths[(axis + 2) % 3]; - bh = lengths[(axis + 1) % 3]; - } - double viewAspect = w / h; - double boundsAspect = bw / bh; - - double scale = 0; - if (viewAspect >= boundsAspect) { - scale = bh / 2; - } else { - scale = bw / 2 / viewAspect; - } - - auto* camera = view->GetActiveCamera(); - camera->SetParallelScale(scale); -} - -void ViewMenuManager::setImageViewerMode(bool enable) -{ - if (m_imageViewerModeAction->isChecked() != enable) { - QSignalBlocker blocked(m_imageViewerModeAction); - m_imageViewerModeAction->setChecked(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* 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; - - oldSettings->newSliceModule = !sliceModule; - if (sliceModule) { - // Save it's settings before modifying it... - oldSettings->sliceModuleSettings = sliceModule->serialize(); - // Make sure it is visible - sliceModule->show(); - } else { - // If there are no slice modules, create one - sliceModule = qobject_cast( - moduleManager.createAndAddModule("Slice", ds, view)); - } - 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(); - } - } - - // Use XY direction, set the index to 0, and hide the arrow - sliceModule->onDirectionChanged(ModuleSlice::XY); - sliceModule->onSliceChanged(0); - sliceModule->setShowArrow(false); - - CameraReaction::resetNegativeZ(); - - double bounds[6]; - sliceModule->planeBounds(bounds); - resize2DCameraToFit(view, bounds, 2); - - emit imageViewerModeToggled(enable); - emit moduleManager.pipelineViewRenderNeeded(); -} - -void ViewMenuManager::restoreImageViewerSettings() -{ - auto& settings = m_previousImageViewerSettings; - - 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); - } else { - // Restore the settings on the slice module we grabbed - settings->sliceModule->deserialize(settings->sliceModuleSettings); - } - } - - // Restore visible modules - for (auto module : settings->visibleModules) { - if (module) { - module->show(); - } - } - 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(); -} - -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) { // Add the Looking Glass menu item placeholder, which, when checked, diff --git a/tomviz/ViewMenuManager.h b/tomviz/ViewMenuManager.h index e56c90899..70133dd99 100644 --- a/tomviz/ViewMenuManager.h +++ b/tomviz/ViewMenuManager.h @@ -7,7 +7,6 @@ #include #include -#include #include class QDialog; @@ -17,10 +16,6 @@ class vtkSMViewProxy; namespace tomviz { -class DataSource; -class PreviousImageViewerSettings; -class SliceViewDialog; - enum class ScaleLegendStyle : unsigned int; class ViewMenuManager : public pqViewMenuManager @@ -34,12 +29,6 @@ class ViewMenuManager : public pqViewMenuManager QString projectionMode() const; void setProjectionMode(QString mode); - int interactionMode() const; - void setInteractionMode(int mode); - -signals: - void imageViewerModeToggled(bool b); - private slots: void setProjectionModeToPerspective(); void setProjectionModeToOrthographic(); @@ -48,37 +37,22 @@ private slots: void setShowCenterAxes(bool show); 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(); - void setupLookingGlassPlaceholder(QMainWindow* mainWindow); QPointer m_perspectiveProjectionAction; QPointer m_orthographicProjectionAction; 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; }; } // namespace tomviz 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/AnimatableProperties.cxx b/tomviz/animations/AnimatableProperties.cxx new file mode 100644 index 000000000..884e1d483 --- /dev/null +++ b/tomviz/animations/AnimatableProperties.cxx @@ -0,0 +1,379 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "AnimatableProperties.h" + +#include "ClipAnimation.h" +#include "ContourAnimation.h" +#include "CutOutAnimation.h" +#include "ExplodedAnimation.h" +#include "OpacityAnimation.h" +#include "SliceAnimation.h" +#include "SolidityAnimation.h" +#include "ThresholdAnimation.h" + +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ThresholdSink.h" +#include "pipeline/sinks/VolumeSink.h" + +namespace tomviz { + +using pipeline::ClipSink; +using pipeline::ContourSink; +using pipeline::Node; +using pipeline::SliceSink; +using pipeline::ThresholdSink; +using pipeline::VolumeSink; + +namespace { + +template +bool isA(Node* node) +{ + return qobject_cast(node) != nullptr; +} + +AnimatableProperty isoValue() +{ + AnimatableProperty p; + p.id = "iso"; + p.label = [](Node*) { return QString("Iso value"); }; + p.applies = isA; + p.range = [](Node* node) { + PropertyRange r; + r.label = "Iso value:"; + double range[2] = { 0.0, 1.0 }; + if (auto* contour = qobject_cast(node)) { + contour->scalarRange(range); + } + r.lo = range[0]; + r.hi = range[1]; + r.start = (r.hi - r.lo) / 3 + r.lo; + r.stop = (r.hi - r.lo) * 2 / 3 + r.lo; + return r; + }; + p.make = [](Node* node, double start, double stop) -> ModuleAnimation* { + auto* contour = qobject_cast(node); + return contour ? new ContourAnimation(contour, start, stop) : nullptr; + }; + p.matches = [](ModuleAnimation* animation, double& start, double& stop) { + auto* contour = qobject_cast(animation); + if (!contour) { + return false; + } + start = contour->startValue; + stop = contour->stopValue; + return true; + }; + return p; +} + +// A slice and a clip both move by index while axis aligned and by +// signed distance from the center of the data along their normal while +// custom, so the two entries share their shape. +template +AnimatableProperty planeProperty(const QString& id) +{ + AnimatableProperty p; + p.id = id; + p.label = [](Node* node) { + auto* sink = qobject_cast(node); + return sink && !sink->isOrtho() ? QString("Position") + : QString("Slice index"); + }; + p.applies = isA; + p.range = [](Node* node) { + PropertyRange r; + auto* sink = qobject_cast(node); + if (!sink || sink->isOrtho()) { + r.label = "Slice:"; + r.decimals = 0; + r.hi = sink ? sink->maxSlice() : 0; + r.stop = r.hi; + } else { + // A plane at an arbitrary angle has no slices to count + r.label = "Position:"; + sink->planeDistanceRange(r.lo, r.hi); + r.start = r.lo; + r.stop = r.hi; + } + return r; + }; + p.make = [](Node* node, double start, double stop) -> ModuleAnimation* { + auto* sink = qobject_cast(node); + if (!sink) { + return nullptr; + } + auto unit = sink->isOrtho() ? AnimationT::Slice : AnimationT::Distance; + return new AnimationT(sink, start, stop, unit); + }; + p.matches = [](ModuleAnimation* animation, double& start, double& stop) { + auto* plane = qobject_cast(animation); + if (!plane) { + return false; + } + start = plane->startValue; + stop = plane->stopValue; + return true; + }; + return p; +} + +AnimatableProperty threshold(ThresholdAnimation::End end) +{ + AnimatableProperty p; + const bool lower = end == ThresholdAnimation::Lower; + p.id = lower ? "thresholdLower" : "thresholdUpper"; + p.label = [lower](Node*) { + return QString(lower ? "Lower threshold" : "Upper threshold"); + }; + p.applies = isA; + p.range = [lower](Node* node) { + PropertyRange r; + r.label = lower ? "Lower:" : "Upper:"; + double range[2] = { 0.0, 1.0 }; + auto* sink = qobject_cast(node); + if (sink) { + sink->scalarRange(range); + } + r.lo = range[0]; + r.hi = range[1]; + // Growing the segmentation out from where it is now, to everything + // (lower end) or to the brightest voxels (upper end) + if (lower) { + r.start = sink ? sink->lowerThreshold() : r.lo; + r.stop = r.lo; + } else { + r.start = sink ? sink->upperThreshold() : r.hi; + r.stop = r.hi; + } + return r; + }; + p.make = [end](Node* node, double start, double stop) -> ModuleAnimation* { + auto* sink = qobject_cast(node); + return sink ? new ThresholdAnimation(sink, start, stop, end) : nullptr; + }; + p.matches = [end](ModuleAnimation* animation, double& start, double& stop) { + auto* sweep = qobject_cast(animation); + if (!sweep || sweep->end != end) { + return false; + } + start = sweep->startValue; + stop = sweep->stopValue; + return true; + }; + return p; +} + +AnimatableProperty opacity() +{ + AnimatableProperty p; + p.id = "opacity"; + p.label = [](Node*) { return QString("Opacity"); }; + p.applies = OpacityAnimation::supports; + p.range = [](Node* node) { + PropertyRange r; + r.label = "Opacity:"; + // Fading out from wherever the module sits now is the useful + // default; the user can invert it by swapping the two values. + r.start = OpacityAnimation::opacityOf(node); + r.stop = 0.0; + return r; + }; + p.make = [](Node* node, double start, double stop) -> ModuleAnimation* { + return OpacityAnimation::supports(node) + ? new OpacityAnimation(node, start, stop) + : nullptr; + }; + p.matches = [](ModuleAnimation* animation, double& start, double& stop) { + auto* fade = qobject_cast(animation); + if (!fade) { + return false; + } + start = fade->startValue; + stop = fade->stopValue; + return true; + }; + return p; +} + +AnimatableProperty solidity() +{ + AnimatableProperty p; + p.id = "solidity"; + p.label = [](Node*) { return QString("Solidity"); }; + p.applies = isA; + p.range = [](Node* node) { + PropertyRange r; + r.label = "Solidity:"; + // Zero would make the volume vanish and cannot be set + r.lo = 0.01; + r.hi = 1.0; + auto* volume = qobject_cast(node); + r.start = volume ? volume->solidity() : 1.0; + r.stop = r.start >= 0.5 ? 0.1 : 1.0; + return r; + }; + p.make = [](Node* node, double start, double stop) -> ModuleAnimation* { + auto* volume = qobject_cast(node); + return volume ? new SolidityAnimation(volume, start, stop) : nullptr; + }; + p.matches = [](ModuleAnimation* animation, double& start, double& stop) { + auto* sweep = qobject_cast(animation); + if (!sweep) { + return false; + } + start = sweep->startValue; + stop = sweep->stopValue; + return true; + }; + return p; +} + +AnimatableProperty exploded(ExplodedAnimation::Unit unit) +{ + AnimatableProperty p; + p.id = unit == ExplodedAnimation::Gap ? "explodedGap" + : unit == ExplodedAnimation::Chunks ? "explodedChunks" + : "explodedOffset"; + p.label = [unit](Node*) { + return QString(unit == ExplodedAnimation::Gap ? "Exploded gap" + : unit == ExplodedAnimation::Chunks ? "Exploded chunks" + : "Exploded offset"); + }; + p.applies = isA; + p.range = [unit](Node* node) { + PropertyRange r; + auto* volume = qobject_cast(node); + if (unit == ExplodedAnimation::Gap) { + // Pulling the slabs apart from closed to wherever the panel has + // the gap set is the useful default. + r.label = "Gap:"; + r.start = 0.0; + r.stop = volume ? volume->explodedGap() : 0.25; + } else if (unit == ExplodedAnimation::Chunks) { + r.label = "Chunks:"; + r.decimals = 0; + r.lo = 2; + r.hi = 16; + r.start = 2; + r.stop = volume ? volume->explodedChunks() : 4; + } else { + // Sliding the cuts from one end of their travel to the other + r.label = "Offset:"; + r.decimals = 0; + const int limit = volume ? volume->explodedOffsetLimit() : 0; + r.lo = -limit; + r.hi = limit; + r.start = -limit; + r.stop = limit; + } + return r; + }; + p.make = [unit](Node* node, double start, double stop) -> ModuleAnimation* { + auto* volume = qobject_cast(node); + return volume ? new ExplodedAnimation(volume, start, stop, unit) : nullptr; + }; + p.matches = [unit](ModuleAnimation* animation, double& start, double& stop) { + auto* sweep = qobject_cast(animation); + if (!sweep || sweep->unit != unit) { + return false; + } + start = sweep->startValue; + stop = sweep->stopValue; + return true; + }; + return p; +} + +AnimatableProperty cutOut(int axis) +{ + AnimatableProperty p; + const QChar name('X' + axis); + p.id = QString("cutOut") + name; + p.label = [name](Node*) { return QString("Cut-out ") + name; }; + p.applies = isA; + p.range = [axis](Node* node) { + PropertyRange r; + r.label = QString("Cut-out ") + QChar('X' + axis) + ":"; + auto* volume = qobject_cast(node); + // Sweep the cut from where it sits to the far side of the volume + r.start = volume ? volume->cutOutPosition(axis) : 0.5; + r.stop = r.start < 0.5 ? 1.0 : 0.0; + return r; + }; + p.make = [axis](Node* node, double start, double stop) -> ModuleAnimation* { + auto* volume = qobject_cast(node); + return volume ? new CutOutAnimation(volume, start, stop, axis) : nullptr; + }; + p.matches = [axis](ModuleAnimation* animation, double& start, double& stop) { + auto* sweep = qobject_cast(animation); + if (!sweep || sweep->axis != axis) { + return false; + } + start = sweep->startValue; + stop = sweep->stopValue; + return true; + }; + return p; +} + +} // namespace + +const QList& animatableProperties() +{ + static const QList properties = { + isoValue(), + planeProperty("slice"), + planeProperty("clip"), + threshold(ThresholdAnimation::Lower), + threshold(ThresholdAnimation::Upper), + opacity(), + solidity(), + exploded(ExplodedAnimation::Gap), + exploded(ExplodedAnimation::Chunks), + exploded(ExplodedAnimation::Offset), + cutOut(0), + cutOut(1), + cutOut(2), + }; + return properties; +} + +const AnimatableProperty* animatableProperty(const QString& id) +{ + for (const auto& property : animatableProperties()) { + if (property.id == id) { + return &property; + } + } + return nullptr; +} + +const AnimatableProperty* animatablePropertyOf(ModuleAnimation* animation, + double& start, double& stop) +{ + if (!animation) { + return nullptr; + } + for (const auto& property : animatableProperties()) { + if (property.matches(animation, start, stop)) { + return &property; + } + } + return nullptr; +} + +bool hasAnimatableProperties(pipeline::Node* node) +{ + for (const auto& property : animatableProperties()) { + if (property.applies(node)) { + return true; + } + } + return false; +} + +} // namespace tomviz diff --git a/tomviz/animations/AnimatableProperties.h b/tomviz/animations/AnimatableProperties.h new file mode 100644 index 000000000..c23097108 --- /dev/null +++ b/tomviz/animations/AnimatableProperties.h @@ -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". */ + +#ifndef tomvizAnimatableProperties_h +#define tomvizAnimatableProperties_h + +#include +#include + +#include + +namespace tomviz { + +class ModuleAnimation; + +namespace pipeline { +class Node; +} + +/// The bounds and defaults of a property's range controls. +struct PropertyRange +{ + /// The caption in front of the spin boxes, e.g. "Iso value:". + QString label = "Range:"; + int decimals = 2; + double lo = 0.0; + double hi = 1.0; + double start = 0.0; + double stop = 1.0; +}; + +/// One numeric property of a visualization that the Animation Helper +/// can sweep from one value to another: how it is listed, what it +/// applies to, the range its controls offer, how to build the +/// animation, and how to recognise one so an existing row can be put +/// back into the controls. Adding a property is one entry in +/// animatableProperties(); the dialog never names properties itself. +/// +/// The opacity curve morph is not in the table: it is keyframed by +/// viewpoint rather than swept between two numbers, and has its own +/// page in the dialog. +struct AnimatableProperty +{ + /// Stable identifier, also used by RecordedChange::controlProperty and + /// as the combo's item data. + QString id; + /// The combo text, which can depend on the node (a slice reads by + /// index while axis aligned and by position while custom). + std::function label; + std::function applies; + std::function range; + std::function + make; + /// If @a animation was built by this property, fill in its values. + std::function + matches; +}; + +/// Every property the dialog can author, in the order they are listed. +const QList& animatableProperties(); + +/// The property with the given id, or null. +const AnimatableProperty* animatableProperty(const QString& id); + +/// The property that built @a animation and its values, or null for an +/// animation no property owns (the curve morph, recorded animations). +const AnimatableProperty* animatablePropertyOf(ModuleAnimation* animation, + double& start, double& stop); + +/// True if any property applies to @a node. +bool hasAnimatableProperties(pipeline::Node* node); + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/AnimationSceneGuard.cxx b/tomviz/animations/AnimationSceneGuard.cxx new file mode 100644 index 000000000..df230f7c7 --- /dev/null +++ b/tomviz/animations/AnimationSceneGuard.cxx @@ -0,0 +1,223 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "AnimationSceneGuard.h" + +#include "ActiveObjects.h" +#include "CameraViewpoints.h" +#include "ModuleAnimations.h" +#include "SceneSnapshot.h" +#include "Utilities.h" +#include "pipeline/Pipeline.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include + +#include + +namespace tomviz { + +namespace { + +pqAnimationScene* activeScene() +{ + auto* core = pqPVApplicationCore::instance(); + auto* manager = core ? core->animationManager() : nullptr; + return manager ? manager->getActiveScene() : nullptr; +} + +vtkSMAnimationScene* sceneObject(pqAnimationScene* scene) +{ + auto* proxy = scene ? scene->getProxy() : nullptr; + return proxy + ? vtkSMAnimationScene::SafeDownCast(proxy->GetClientSideObject()) + : nullptr; +} + +// Stop() only raises a flag the play loop honours after the current +// tick, and a scene time set inside that tick is ignored, so the rewind +// waits for the loop to unwind. The scene is the context, so a scene +// replaced meanwhile (a state load) is simply not rewound. +void rewindOnceStopped(pqAnimationScene* scene) +{ + QTimer::singleShot(0, scene, [scene]() { + auto* object = sceneObject(scene); + if (!object) { + return; + } + if (object->GetInPlay()) { + rewindOnceStopped(scene); + return; + } + scene->setAnimationTime(object->GetStartTime()); + }); +} + +} // namespace + +pqRenderView* animationRenderView() +{ + if (auto* renderView = ActiveObjects::instance().activePqRenderView()) { + return renderView; + } + auto* core = pqApplicationCore::instance(); + auto* model = core ? core->getServerManagerModel() : nullptr; + if (!model) { + return nullptr; + } + auto renderViews = model->findItems(); + return renderViews.isEmpty() ? nullptr : renderViews.first(); +} + +void interruptAnimationPlayback(bool rewind) +{ + auto* scene = activeScene(); + auto* object = sceneObject(scene); + if (!object || !object->GetInPlay()) { + return; + } + scene->getProxy()->InvokeCommand("Stop"); + if (rewind) { + rewindOnceStopped(scene); + } +} + +AnimationSceneGuard::AnimationSceneGuard(QObject* parent) : QObject(parent) +{ + auto* core = pqPVApplicationCore::instance(); + auto* manager = core ? core->animationManager() : nullptr; + if (manager) { + // Loading a state file replaces the scene, so follow the active one. + connect(manager, &pqAnimationManager::activeSceneChanged, this, + &AnimationSceneGuard::follow); + follow(manager->getActiveScene()); + } + + auto& viewpoints = CameraViewpoints::instance(); + connect(&viewpoints, &CameraViewpoints::changed, this, [this]() { + interruptAnimationPlayback(); + syncFlight(); + syncFrames(); + }); + connect(&ModuleAnimations::instance(), &ModuleAnimations::changed, this, + []() { interruptAnimationPlayback(); }); + // A flight dies with its view; the next view picks the path up. + connect(&ActiveObjects::instance(), + qOverload(&ActiveObjects::viewChanged), this, + [this]() { syncFlight(); }); + syncFlight(); +} + +void AnimationSceneGuard::syncFlight() +{ + if (CameraViewpoints::instance().syncFlight(animationRenderView())) { + ensureAnimationFrames(); + } +} + +void AnimationSceneGuard::syncFrames() +{ + const int frames = CameraViewpoints::instance().totalFrames(); + if (frames > 0) { + setAnimationNumberOfFrames(frames); + } +} + +void AnimationSceneGuard::follow(pqAnimationScene* scene) +{ + if (scene == m_scene) { + return; + } + if (m_scene) { + disconnect(m_scene.data(), nullptr, this, nullptr); + } + m_scene = scene; + if (!scene) { + return; + } + // Both run before the player reads the scene time or starts ticking + connect(scene, &pqAnimationScene::beginPlay, this, + [this, scene](vtkObject*, unsigned long, void*, void* reversed) { + provideDefaultAnimation(); + syncFrames(); + rewindIfAtEnd(scene, reversed && *static_cast(reversed)); + }); +} + +void AnimationSceneGuard::provideDefaultAnimation() +{ + auto& viewpoints = CameraViewpoints::instance(); + auto& active = ActiveObjects::instance(); + auto* pipeline = active.pipeline(); + auto* timeKeeper = active.activeTimeKeeper(); + // Nothing set up, but something to look at. A lone plain viewpoint is + // a path in the making and is left alone; a time series plays its own + // steps. + if (viewpoints.size() != 0 || !ModuleAnimations::instance().isEmpty() || + !pipeline || pipeline->nodes().isEmpty() || + (timeKeeper && !timeKeeper->getTimeSteps().empty())) { + return; + } + auto* view = animationRenderView(); + auto* proxy = view ? view->getRenderViewProxy() : nullptr; + auto* camera = proxy ? proxy->GetActiveCamera() : nullptr; + if (!camera) { + return; + } + Viewpoint viewpoint; + viewpoint.readFrom(camera); + viewpoint.name = "Camera Orbit"; + viewpoint.orbitTurns = 1; + // As long as the animation was going to be + if (auto* scene = activeScene()) { + viewpoint.orbitFrames = std::max( + 2, vtkSMPropertyHelper(scene->getProxy(), "NumberOfFrames").GetAsInt()); + } + // Recorded like a viewpoint the user adds, so a visualization added + // later fades in on the leg that first has it + viewpoint.scene = SceneSnapshot::capture(pipeline); + // Appending arms the flight through the change handler + viewpoints.append(viewpoint); +} + +void AnimationSceneGuard::rewindIfAtEnd(pqAnimationScene* scene, bool reversed) +{ + auto* object = sceneObject(scene); + if (!object) { + return; + } + const double start = object->GetStartTime(); + const double end = object->GetEndTime(); + const double time = object->GetSceneTime(); + if (end <= start) { + return; + } + // Rounding from summing a frame's worth at a time is far below this; + // a frame is not. + const double tolerance = 1e-9 * (end - start); + const bool atEnd = reversed ? time <= start + tolerance + : time >= end - tolerance; + if (!atEnd) { + return; + } + // Through the proxy, so its AnimationTime property agrees with what + // the scene now says; outside a tick the scene applies it at once. + scene->setAnimationTime(reversed ? end : start); +} + +} // namespace tomviz diff --git a/tomviz/animations/AnimationSceneGuard.h b/tomviz/animations/AnimationSceneGuard.h new file mode 100644 index 000000000..435dc8b72 --- /dev/null +++ b/tomviz/animations/AnimationSceneGuard.h @@ -0,0 +1,68 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizAnimationSceneGuard_h +#define tomvizAnimationSceneGuard_h + +#include +#include + +class pqAnimationScene; +class pqRenderView; + +namespace tomviz { + +/// The render view animations play in: the active one, or the first +/// there is when the active view is something else (a chart). +pqRenderView* animationRenderView(); + +/// Stop a running playback and, once the play loop has unwound, rewind +/// it to the start. For edits that change what the animation means: +/// the spot it was at no longer corresponds to anything, and Play then +/// shows the edit from the top. A paused animation is left where it is. +/// Without @a rewind the playback just stops where it is, for an action +/// that puts the camera somewhere on purpose and must not be undone by +/// the first frame. +void interruptAnimationPlayback(bool rewind = true); + +/// Keeps the animation machinery consistent whatever the user does, +/// whether or not the Animation Helper was ever opened. +/// +/// - The camera flies the viewpoint path whenever there is one, in the +/// render view that is current, and stops when the path goes; a view +/// destroyed and recreated picks the flight up again. +/// - A change to the path or to the module animations interrupts a +/// playback (see interruptAnimationPlayback). +/// - Play with nothing set up to animate turns the current view into a +/// Camera Orbit viewpoint first, so a fresh dataset spins out of the +/// box and the viewpoint list stays empty until then. +/// - While there is a path, the scene's frame count is what its legs +/// and orbits add up to. +/// - A finished animation is rewound before it plays again. ParaView's +/// player only rewinds when the scene time is at or past the end, but +/// its sequence player reaches the last frame by summing frame-sized +/// steps, and for some counts (50 is one) that lands a rounding error +/// short of the end; every later Play would then start there, tick +/// once at an unchanged time and stop. The player announces a playback +/// before it reads the scene time, so a rewind done then is what it +/// starts from. +class AnimationSceneGuard : public QObject +{ + Q_OBJECT + +public: + explicit AnimationSceneGuard(QObject* parent = nullptr); + +private: + void follow(pqAnimationScene* scene); + void rewindIfAtEnd(pqAnimationScene* scene, bool reversed); + void provideDefaultAnimation(); + void syncFlight(); + void syncFrames(); + + QPointer m_scene; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/CameraAnimation.h b/tomviz/animations/CameraAnimation.h new file mode 100644 index 000000000..115d7eb21 --- /dev/null +++ b/tomviz/animations/CameraAnimation.h @@ -0,0 +1,170 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizCameraAnimation_h +#define tomvizCameraAnimation_h + +#include "CameraViewpoints.h" +#include "ModuleAnimation.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +/// Flies the camera through the saved viewpoints as the animation plays. +/// +/// The camera orbit is a real ParaView camera cue, but this is not: a +/// keyframe cue interpolates at a constant rate, and the whole point of +/// the viewpoint path is that each segment can have its own length and +/// ease in and out. So the camera is driven directly, like the other +/// tomviz animations. Only one of the two should exist at a time, or +/// they fight over the camera on every tick. +class CameraAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + CameraAnimation(pqRenderView* view) : ModuleAnimation(nullptr), m_view(view) + { + if (m_view) { + connect(m_view.data(), &QObject::destroyed, this, &QObject::deleteLater); + } + // Viewpoint captions: readable on any background, centered where the + // path says (the bottom middle unless the user moved them) + auto* text = m_caption->GetTextProperty(); + text->SetFontSize(22); + text->SetBold(true); + text->SetColor(1.0, 1.0, 1.0); + text->SetShadow(true); + text->SetJustificationToCentered(); + text->SetVerticalJustificationToCentered(); + m_caption->GetPositionCoordinate() + ->SetCoordinateSystemToNormalizedViewport(); + placeCaption(); + m_caption->SetVisibility(0); + // Nudging the position in the Animation Helper moves a caption that + // is already on screen, so the user sees where it lands. + connect(&CameraViewpoints::instance(), + &CameraViewpoints::captionPositionChanged, this, [this]() { + placeCaption(); + if (m_caption->GetVisibility() && m_view) { + m_view->render(); + } + }); + } + + void onPlaybackEnded() override + { + // A caption belongs to the path; nothing should linger once it stops + if (hideCaption() && m_view) { + m_view->render(); + } + } + + ~CameraAnimation() override + { + if (m_captionAdded) { + if (auto* ren = renderer()) { + ren->RemoveViewProp(m_caption); + } + } + } + + void onTimeChanged() override { applyProgress(progress()); } + + // Frame 0 is not announced when the clock already sits there + void onPlaybackStarted() override { applyProgress(0.0); } + +private: + void applyProgress(double p) + { + if (!timeKeeper() || !m_view) { + return; + } + + auto& viewpoints = CameraViewpoints::instance(); + if (!viewpoints.isPath()) { + hideCaption(); + return; + } + + auto* proxy = m_view->getRenderViewProxy(); + auto* camera = proxy ? proxy->GetActiveCamera() : nullptr; + if (!camera) { + return; + } + + const double t = viewpoints.remapProgress(p); + viewpoints.interpolate(t, camera); + updateCaption(t); + + // The interpolated clipping range is blended from the saved + // viewpoints, which can clip the data at positions in between. + if (auto* renderer = proxy->GetRenderer()) { + renderer->ResetCameraClippingRange(); + } + + m_view->render(); + } + + vtkRenderer* renderer() const + { + auto* proxy = m_view ? m_view->getRenderViewProxy() : nullptr; + return proxy ? proxy->GetRenderer() : nullptr; + } + + // Show the label of the viewpoint the path is leaving: the last one + // whose time is at or before t. Hidden while that label is empty. + void updateCaption(double t) + { + auto& viewpoints = CameraViewpoints::instance(); + auto stops = viewpoints.stops(); + int current = 0; + while (current + 1 < stops.size() && current + 1 < viewpoints.size() && + stops[current + 1] <= t) { + ++current; + } + QString label = + current < viewpoints.size() ? viewpoints.at(current).label : QString(); + auto* ren = renderer(); + if (!ren) { + return; + } + if (!m_captionAdded) { + ren->AddViewProp(m_caption); + m_captionAdded = true; + } + m_caption->SetInput(label.toUtf8().constData()); + m_caption->SetVisibility(label.isEmpty() ? 0 : 1); + } + + void placeCaption() + { + const auto position = CameraViewpoints::instance().captionPosition(); + m_caption->SetPosition(position[0], position[1]); + } + + // True when the caption was showing + bool hideCaption() + { + bool wasVisible = m_caption->GetVisibility() != 0; + m_caption->SetVisibility(0); + return wasVisible; + } + + QPointer m_view; + vtkNew m_caption; + bool m_captionAdded = false; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/CameraViewpoints.cxx b/tomviz/animations/CameraViewpoints.cxx new file mode 100644 index 000000000..69fc7eaa5 --- /dev/null +++ b/tomviz/animations/CameraViewpoints.cxx @@ -0,0 +1,703 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "CameraViewpoints.h" + +#include "ActiveObjects.h" +#include "CameraAnimation.h" + +#include +#include + +#include +#include +#include +#include + +#include +#include + +namespace tomviz { + +namespace { + +QJsonArray toArray(const std::array& v) +{ + return QJsonArray({ v[0], v[1], v[2] }); +} + +std::array toVector(const QJsonValue& value, + const std::array& fallback) +{ + auto array = value.toArray(); + if (array.size() != 3) { + return fallback; + } + return { array[0].toDouble(), array[1].toDouble(), array[2].toDouble() }; +} + +} // anonymous namespace + +void Viewpoint::readFrom(vtkCamera* camera) +{ + camera->GetPosition(position.data()); + camera->GetFocalPoint(focalPoint.data()); + camera->GetViewUp(viewUp.data()); + viewAngle = camera->GetViewAngle(); + parallelScale = camera->GetParallelScale(); + parallelProjection = camera->GetParallelProjection() != 0; +} + +void Viewpoint::applyTo(vtkCamera* camera) const +{ + camera->SetPosition(position[0], position[1], position[2]); + camera->SetFocalPoint(focalPoint[0], focalPoint[1], focalPoint[2]); + camera->SetViewUp(viewUp[0], viewUp[1], viewUp[2]); + camera->SetViewAngle(viewAngle); + camera->SetParallelScale(parallelScale); + camera->SetParallelProjection(parallelProjection ? 1 : 0); +} + +QJsonObject Viewpoint::serialize() const +{ + QJsonObject json; + json["position"] = toArray(position); + json["focalPoint"] = toArray(focalPoint); + json["viewUp"] = toArray(viewUp); + json["viewAngle"] = viewAngle; + json["parallelScale"] = parallelScale; + json["parallelProjection"] = parallelProjection; + json["legFrames"] = legFrames; + json["eased"] = eased; + if (orbitTurns != 0) { + json["orbitTurns"] = orbitTurns; + json["orbitFrames"] = orbitFrames; + } + json["orbitEased"] = orbitEased; + json["name"] = name; + if (!label.isEmpty()) { + json["label"] = label; + } + if (!thumbnail.isEmpty()) { + json["thumbnail"] = QString::fromLatin1(thumbnail.toBase64()); + } + if (!scene.isEmpty()) { + json["scene"] = scene.serialize(); + } + return json; +} + +Viewpoint Viewpoint::deserialize(const QJsonObject& json) +{ + Viewpoint viewpoint; + viewpoint.position = toVector(json["position"], viewpoint.position); + viewpoint.focalPoint = toVector(json["focalPoint"], viewpoint.focalPoint); + viewpoint.viewUp = toVector(json["viewUp"], viewpoint.viewUp); + viewpoint.viewAngle = json["viewAngle"].toDouble(viewpoint.viewAngle); + viewpoint.parallelScale = + json["parallelScale"].toDouble(viewpoint.parallelScale); + viewpoint.parallelProjection = + json["parallelProjection"].toBool(viewpoint.parallelProjection); + viewpoint.legFrames = json["legFrames"].toInt(viewpoint.legFrames); + viewpoint.eased = json["eased"].toBool(viewpoint.eased); + viewpoint.orbitTurns = json["orbitTurns"].toInt(0); + viewpoint.orbitFrames = json["orbitFrames"].toInt(viewpoint.orbitFrames); + // Before orbits had their own easing they followed the leg's, so an + // orbit saved then plays as it did; a viewpoint saved then without + // one gets the default + viewpoint.orbitEased = + json.contains("orbitEased") + ? json["orbitEased"].toBool() + : (viewpoint.orbitTurns != 0 ? viewpoint.eased : false); + viewpoint.name = json["name"].toString(); + viewpoint.label = json["label"].toString(); + viewpoint.thumbnail = + QByteArray::fromBase64(json["thumbnail"].toString().toLatin1()); + viewpoint.scene = SceneSnapshot::deserialize(json["scene"].toObject()); + return viewpoint; +} + +void orbitAt(const Viewpoint& viewpoint, double u, vtkCamera* camera) +{ + if (!camera) { + return; + } + u = std::clamp(u, 0.0, 1.0); + viewpoint.applyTo(camera); + + // The offset from the focal point splits into a height along the + // view-up axis, which the orbit keeps, and a radius in the plane + // across it, which it swings round. + double axis[3] = { viewpoint.viewUp[0], viewpoint.viewUp[1], + viewpoint.viewUp[2] }; + if (vtkMath::Normalize(axis) == 0.0) { + return; + } + double offset[3]; + for (int k = 0; k < 3; ++k) { + offset[k] = viewpoint.position[k] - viewpoint.focalPoint[k]; + } + const double height = vtkMath::Dot(offset, axis); + double e1[3]; + for (int k = 0; k < 3; ++k) { + e1[k] = offset[k] - height * axis[k]; + } + const double radius = vtkMath::Normalize(e1); + if (radius < 1e-9) { + // Looking straight along the axis: nothing to swing. + return; + } + double e2[3]; + vtkMath::Cross(axis, e1, e2); + + const double angle = viewpoint.orbitTurns * 2.0 * vtkMath::Pi() * u; + double position[3]; + for (int k = 0; k < 3; ++k) { + position[k] = viewpoint.focalPoint[k] + + radius * (std::cos(angle) * e1[k] + std::sin(angle) * e2[k]) + + height * axis[k]; + } + // Both ends are the viewpoint itself; say so exactly rather than to + // rounding. + if (u <= 0.0 || u >= 1.0) { + for (int k = 0; k < 3; ++k) { + position[k] = viewpoint.position[k]; + } + } + camera->SetPosition(position); +} + +void CameraViewpoints::noteChanged() +{ + m_interpolatorStale = true; + // No path, no flight: whoever emptied the list (Reset, a state file, + // the last Remove) need not know about the flight for it to stop. + if (!isPath()) { + stopFlight(); + } + emit changed(); +} + +CameraViewpoints& CameraViewpoints::instance() +{ + static CameraViewpoints viewpoints; + return viewpoints; +} + +void CameraViewpoints::append(const Viewpoint& viewpoint) +{ + m_viewpoints.append(viewpoint); + noteChanged(); +} + +void CameraViewpoints::replace(int index, const Viewpoint& viewpoint) +{ + if (index < 0 || index >= m_viewpoints.size()) { + return; + } + + m_viewpoints[index] = viewpoint; + noteChanged(); +} + +void CameraViewpoints::removeAt(int index) +{ + if (index < 0 || index >= m_viewpoints.size()) { + return; + } + + m_viewpoints.removeAt(index); + noteChanged(); +} + +void CameraViewpoints::move(int from, int to) +{ + if (from < 0 || from >= m_viewpoints.size() || to < 0 || + to >= m_viewpoints.size() || from == to) { + return; + } + + m_viewpoints.move(from, to); + noteChanged(); +} + +void CameraViewpoints::clear() +{ + if (m_viewpoints.isEmpty()) { + return; + } + + m_viewpoints.clear(); + noteChanged(); +} + +bool CameraViewpoints::isPath() const +{ + return m_viewpoints.size() >= 2 || + (m_viewpoints.size() == 1 && m_viewpoints[0].orbitTurns != 0); +} + +int CameraViewpoints::totalFrames() const +{ + if (!isPath()) { + return 0; + } + int total = 0; + for (int i = 0; i < m_viewpoints.size(); ++i) { + if (m_viewpoints[i].orbitTurns != 0) { + total += std::max(0, m_viewpoints[i].orbitFrames); + } + if (i + 1 < m_viewpoints.size()) { + total += std::max(0, m_viewpoints[i].legFrames); + } + } + // A start and an end at the least: legs of no length are cuts, and + // one frame is not an animation the player can run. + return std::max(2, total); +} + +namespace { + +struct Timeline +{ + QList arrivals; + QList departures; +}; + +// Legs and orbits laid end to end, each weighted by its duration: the +// orbit at a viewpoint runs from its arrival to its departure, the leg +// to the next viewpoint from that departure to the next arrival. +Timeline timelineOf(const QList& viewpoints) +{ + Timeline timeline; + const int count = viewpoints.size(); + QList dwells; + QList legs; + double total = 0; + for (int i = 0; i < count; ++i) { + const double dwell = viewpoints[i].orbitTurns != 0 + ? std::max(0, viewpoints[i].orbitFrames) + : 0.0; + dwells.append(dwell); + total += dwell; + if (i + 1 < count) { + const double leg = std::max(0, viewpoints[i].legFrames); + legs.append(leg); + total += leg; + } + } + + // Everything was given a zero (or negative) duration, which would + // leave no time to run the path in. Spread it evenly instead. + if (total <= 0) { + legs.fill(1.0); + for (int i = 0; i < count; ++i) { + if (viewpoints[i].orbitTurns != 0) { + dwells[i] = 1.0; + } + } + total = legs.size() + std::count_if(dwells.begin(), dwells.end(), + [](double d) { return d > 0; }); + } + + double elapsed = 0; + for (int i = 0; i < count; ++i) { + timeline.arrivals.append(elapsed / total); + elapsed += dwells[i]; + timeline.departures.append(elapsed / total); + if (i + 1 < count) { + elapsed += legs[i]; + } + } + // Guard the ends against accumulated rounding: callers treat them as + // the start and the end of the path. + timeline.arrivals.first() = 0.0; + timeline.departures.last() = 1.0; + if (count > 1) { + timeline.arrivals.last() = + std::min(timeline.arrivals.last(), timeline.departures.last()); + } + return timeline; +} + +} // namespace + +QList CameraViewpoints::stops() const +{ + if (!isPath()) { + return {}; + } + return timelineOf(m_viewpoints).arrivals; +} + +QList CameraViewpoints::departures() const +{ + if (!isPath()) { + return {}; + } + return timelineOf(m_viewpoints).departures; +} + +QString CameraViewpoints::nextDefaultName() const +{ + int highest = 0; + QRegularExpression pattern("^Viewpoint (\\d+)$"); + for (const auto& viewpoint : m_viewpoints) { + auto match = pattern.match(viewpoint.name); + if (match.hasMatch()) { + highest = std::max(highest, match.captured(1).toInt()); + } + } + return QString("Viewpoint %1").arg(highest + 1); +} + +double CameraViewpoints::anchorTime(int anchor) const +{ + auto stopList = stops(); + if (stopList.isEmpty()) { + return anchor <= 0 ? 0.0 : 1.0; + } + return stopList[qBound(0, anchor, static_cast(stopList.size()) - 1)]; +} + +double CameraViewpoints::departureTime(int anchor) const +{ + auto departureList = departures(); + if (departureList.isEmpty()) { + return anchor <= 0 ? 0.0 : 1.0; + } + return departureList[qBound(0, anchor, + static_cast(departureList.size()) - 1)]; +} + +double CameraViewpoints::remapProgress(double progress) const +{ + if (!isPath()) { + return 0.0; + } + const auto timeline = timelineOf(m_viewpoints); + const double p = qBound(0.0, progress, 1.0); + + // Walk the pieces in order: the orbit at each viewpoint, then the leg + // leaving it. The last piece owns p == 1. A piece of no length is + // skipped over. + const int count = m_viewpoints.size(); + for (int i = 0; i < count; ++i) { + const double pieces[2][2] = { + { timeline.arrivals[i], timeline.departures[i] }, + { timeline.departures[i], + i + 1 < count ? timeline.arrivals[i + 1] : timeline.departures[i] } + }; + for (int k = 0; k < 2; ++k) { + const double start = pieces[k][0]; + const double stop = pieces[k][1]; + const double span = stop - start; + if (span <= 0) { + continue; + } + const bool last = stop >= 1.0; + if (p < stop || last) { + double u = (p - start) / span; + const bool eased = k == 0 ? m_viewpoints[i].orbitEased + : m_viewpoints[i].eased; + if (eased) { + // Smoothstep: zero slope at both ends, so the camera + // accelerates away from a stop and decelerates into the next. + u = u * u * (3.0 - 2.0 * u); + } + return start + u * span; + } + } + } + return 1.0; +} + +double CameraViewpoints::segmentProgress(double progress, int segment) const +{ + auto stopList = stops(); + if (segment < 0 || segment + 1 >= stopList.size()) { + return progress; + } + + // The leg runs from leaving one viewpoint to reaching the next; an + // orbit at either end is time the animation holds still. + const double start = departures()[segment]; + const double stop = stopList[segment + 1]; + const double span = stop - start; + if (span <= 0) { + return progress >= stop ? 1.0 : 0.0; + } + + return qBound(0.0, (progress - start) / span, 1.0); +} + +void CameraViewpoints::interpolate(double t, vtkCamera* camera) +{ + if (!camera || !isPath()) { + return; + } + + if (m_interpolatorStale) { + rebuildInterpolator(); + } + + const auto timeline = timelineOf(m_viewpoints); + t = qBound(0.0, t, 1.0); + + // The viewpoint whose orbit or leg t falls in: the last one reached. + const int count = m_viewpoints.size(); + int current = 0; + while (current + 1 < count && t >= timeline.arrivals[current + 1]) { + ++current; + } + + const auto& viewpoint = m_viewpoints[current]; + const double departure = timeline.departures[current]; + if (viewpoint.orbitTurns != 0 && + (t < departure || current + 1 == count)) { + const double span = departure - timeline.arrivals[current]; + const double u = + span > 0 ? (t - timeline.arrivals[current]) / span : 1.0; + orbitAt(viewpoint, u, camera); + return; + } + + for (const auto& run : m_runs) { + if (current >= run.first && current < run.last) { + run.interpolator->InterpolateCamera(t, camera); + return; + } + } + // A viewpoint in no run (between two orbits, or the last) is held. + viewpoint.applyTo(camera); +} + +void CameraViewpoints::rebuildInterpolator() +{ + m_runs.clear(); + m_interpolatorStale = false; + if (!isPath()) { + return; + } + const auto timeline = timelineOf(m_viewpoints); + + // A spline needs three points to curve through; with two it would + // ease in and out of the straight line on its own, on top of the + // segment's easing, so two viewpoints get a plain linear blend. + // + // The interpolators are supplied ready-made rather than through + // vtkCameraInterpolator's Linear/Spline modes: in the VTK we build + // against, changing a vtkTupleInterpolator's type wipes its component + // count, and the camera interpolator sets the count first and the type + // second, so its linear mode stores nothing and hands back + // uninitialized memory, which parked the camera at the origin on every + // two-viewpoint path. In manual mode it leaves the types alone. + // + // A run's first camera is added at its departure (the orbit there, if + // any, is over) and the others at their arrival. A leg of no length + // puts two cameras at the same time, where the later one replaces the + // earlier (the path has arrived), so it is the distinct times that + // decide whether there is anything for a spline to curve through. + auto makeRun = [this, &timeline](int first, int last) { + Run run; + run.first = first; + run.last = last; + int distinct = 0; + double previous = -1.0; + for (int i = first; i <= last; ++i) { + const double t = + i == first ? timeline.departures[i] : timeline.arrivals[i]; + if (t != previous) { + ++distinct; + } + previous = t; + } + const bool linear = distinct < 3; + auto tuple = [linear]() { + auto interpolator = vtkSmartPointer::New(); + if (linear) { + interpolator->SetInterpolationTypeToLinear(); + } else { + interpolator->SetInterpolationTypeToSpline(); + } + return interpolator; + }; + run.interpolator = vtkSmartPointer::New(); + run.interpolator->SetInterpolationTypeToManual(); + run.interpolator->SetPositionInterpolator(tuple()); + run.interpolator->SetFocalPointInterpolator(tuple()); + run.interpolator->SetViewUpInterpolator(tuple()); + run.interpolator->SetViewAngleInterpolator(tuple()); + run.interpolator->SetParallelScaleInterpolator(tuple()); + run.interpolator->SetClippingRangeInterpolator(tuple()); + for (int i = first; i <= last; ++i) { + vtkNew camera; + m_viewpoints[i].applyTo(camera); + run.interpolator->AddCamera( + i == first ? timeline.departures[i] : timeline.arrivals[i], camera); + } + return run; + }; + + // A spline cannot hold still, so a viewpoint that orbits ends the run + // arriving at it and starts the next one leaving it. + int first = 0; + for (int i = 0; i < m_viewpoints.size(); ++i) { + const bool last = i + 1 == m_viewpoints.size(); + if (last || m_viewpoints[i].orbitTurns != 0) { + if (i > first) { + m_runs.push_back(makeRun(first, i)); + } + first = i; + } + } +} + +void CameraViewpoints::startFlight(pqRenderView* view, bool snapToHead) +{ + stopFlight(); + if (view && isPath()) { + auto* flight = new CameraAnimation(view); + m_flight = flight; + // Playback that starts at time zero never announces a time change, + // so put the camera at the head of the path now rather than leaving + // it wherever it was until the clock first moves. Callers that arm + // the flight as a side effect (e.g. adding a viewpoint) skip the + // snap so the camera does not jump away from where the user just + // framed it. + if (snapToHead) { + flight->onTimeChanged(); + } + } +} + +void CameraViewpoints::stopFlight() +{ + if (m_flight) { + m_flight->deleteLater(); + m_flight = nullptr; + } +} + +bool CameraViewpoints::isFlying() const +{ + return !m_flight.isNull(); +} + +bool CameraViewpoints::syncFlight(pqRenderView* view) +{ + if (!isPath()) { + stopFlight(); + return false; + } + if (isFlying() || !view) { + return false; + } + startFlight(view, /*snapToHead=*/false); + return isFlying(); +} + +void CameraViewpoints::setCaptionPosition(double x, double y) +{ + x = std::clamp(x, 0.0, 1.0); + y = std::clamp(y, 0.0, 1.0); + if (x == m_captionPosition[0] && y == m_captionPosition[1]) { + return; + } + m_captionPosition = { x, y }; + emit captionPositionChanged(); +} + +QJsonObject CameraViewpoints::serialize() const +{ + QJsonArray array; + for (const auto& viewpoint : m_viewpoints) { + array.append(viewpoint.serialize()); + } + + QJsonObject json; + json["viewpoints"] = array; + json["flying"] = isFlying(); + json["captionPosition"] = + QJsonArray{ m_captionPosition[0], m_captionPosition[1] }; + return json; +} + +bool CameraViewpoints::deserialize(const QJsonObject& json) +{ + if (!json["viewpoints"].isArray()) { + return false; + } + + m_viewpoints.clear(); + const auto entries = json["viewpoints"].toArray(); + for (const auto& value : entries) { + m_viewpoints.append(Viewpoint::deserialize(value.toObject())); + // Files from before viewpoints had names get positional ones. + if (m_viewpoints.last().name.isEmpty()) { + m_viewpoints.last().name = + QString("Viewpoint %1").arg(m_viewpoints.size()); + } + } + + // Files from before legs and orbits had frame counts of their own + // carried relative durations and one total: each piece took its share + // of the total, so that share is its frame count now. + bool legacy = !entries.isEmpty(); + for (const auto& value : entries) { + if (value.toObject().contains("legFrames")) { + legacy = false; + } + } + if (legacy) { + const int count = m_viewpoints.size(); + const int total = std::max(2, json["numberOfFrames"].toInt(200)); + QList legWeights; + QList orbitWeights; + double sum = 0; + for (int i = 0; i < count; ++i) { + const auto entry = entries[i].toObject(); + const double leg = i + 1 < count + ? std::max(0.0, entry["duration"].toDouble(1.0)) + : 0.0; + const double orbit = + m_viewpoints[i].orbitTurns != 0 + ? std::max(0.0, entry["orbitDuration"].toDouble(1.0)) + : 0.0; + legWeights.append(leg); + orbitWeights.append(orbit); + sum += leg + orbit; + } + for (int i = 0; i < count; ++i) { + auto share = [&](double weight) { + return sum > 0 ? std::max(1, static_cast(std::lround( + total * weight / sum))) + : total; + }; + if (i + 1 < count) { + m_viewpoints[i].legFrames = share(legWeights[i]); + } + if (m_viewpoints[i].orbitTurns != 0) { + m_viewpoints[i].orbitFrames = share(orbitWeights[i]); + } + } + } + + // Captions are centered on the position now. The old default put their + // corner just inside the view's, where a centered caption would be cut + // in half, so it becomes the new default, as does a file from before + // the caption could be moved. + auto position = json["captionPosition"].toArray(); + const double x = position.size() == 2 ? position[0].toDouble(0.5) : 0.5; + const double y = position.size() == 2 ? position[1].toDouble(0.05) : 0.05; + if (x == 0.02 && y == 0.03) { + setCaptionPosition(0.5, 0.05); + } else { + setCaptionPosition(x, y); + } + + noteChanged(); + return true; +} + +} // namespace tomviz diff --git a/tomviz/animations/CameraViewpoints.h b/tomviz/animations/CameraViewpoints.h new file mode 100644 index 000000000..42d14cd16 --- /dev/null +++ b/tomviz/animations/CameraViewpoints.h @@ -0,0 +1,237 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizCameraViewpoints_h +#define tomvizCameraViewpoints_h + +#include "SceneSnapshot.h" + +#include +#include +#include +#include +#include + +#include + +#include +#include + +class pqRenderView; +class vtkCamera; +class vtkCameraInterpolator; + +namespace tomviz { + +/// A saved camera position, plus how an animation leaves it for the next +/// one. +struct Viewpoint +{ + std::array position = { 0, 0, 1 }; + std::array focalPoint = { 0, 0, 0 }; + std::array viewUp = { 0, 1, 0 }; + double viewAngle = 30.0; + double parallelScale = 1.0; + bool parallelProjection = false; + + /// Frames the leg leaving this viewpoint takes. The animation is as + /// long as its legs and orbits add up to, so lengthening one leg + /// never shortens another. The last viewpoint has no leg leaving it, + /// so its value is unused. + int legFrames = 100; + + /// Ease in and out of that segment, so the camera slows to a stop at + /// each end instead of rounding the corner at full speed. + bool eased = true; + + /// Full turns the camera makes around the focal point once it has + /// arrived here, before the leg to the next viewpoint; 0 is none. + /// Positive turns counterclockwise as seen from above (looking down + /// this viewpoint's view-up), negative clockwise. The last viewpoint + /// can orbit too, which ends the animation on a spin. + int orbitTurns = 0; + + /// Frames that orbit takes. Unused when orbitTurns is 0. + int orbitFrames = 120; + + /// Ease in and out of that orbit. Off by default: a spin at constant + /// speed loops without a visible stop, as tomviz's camera orbit always + /// has. Separate from eased, which is for the leg. + bool orbitEased = false; + + /// What the user calls this viewpoint. Stable: renumbering on every + /// delete would silently repoint anything that refers to viewpoints by + /// name, so default names never reuse a number. + QString name; + + /// A small PNG of the view this was saved from. "Viewpoint 3" says + /// nothing about which view it is; the picture does. Captured when the + /// viewpoint is saved, because it cannot be regenerated later without + /// moving the camera there and back. + QByteArray thumbnail; + + /// Caption shown in the render view (and exported movies) while the + /// path is at this viewpoint and on the leg leaving it. Empty = none. + QString label; + + /// Module state recorded with this viewpoint (visibility, opacity, + /// volume curve, cut-out, exploded view). RecordedAnimations turns + /// the differences between viewpoints into animations and rows in the + /// Animation Helper. + SceneSnapshot scene; + + void readFrom(vtkCamera* camera); + void applyTo(vtkCamera* camera) const; + + QJsonObject serialize() const; + static Viewpoint deserialize(const QJsonObject& json); +}; + +/// Put @a camera @a u of the way (0 to 1) through the orbit at +/// @a viewpoint: its position swung around the focal point on the +/// viewpoint's view-up axis, through viewpoint.orbitTurns full turns, +/// with everything else as saved. It starts and ends at the viewpoint +/// itself. A camera sitting on the axis has nothing to swing and stays +/// put. +void orbitAt(const Viewpoint& viewpoint, double u, vtkCamera* camera); + +/// The camera viewpoints saved for the current session, and the timing +/// that turns them into a path. +/// +/// The list outlives the Animation Helper dialog, which is created once +/// and only hidden, and is saved with the state file, so it lives here +/// rather than in the dialog. +class CameraViewpoints : public QObject +{ + Q_OBJECT + +public: + static CameraViewpoints& instance(); + + const QList& viewpoints() const { return m_viewpoints; } + int size() const { return m_viewpoints.size(); } + const Viewpoint& at(int index) const { return m_viewpoints.at(index); } + + /// Whether there is anything to fly: two or more viewpoints, or one + /// that orbits, which is an animation on its own. + bool isPath() const; + + /// Frames the whole path takes: its legs and orbits added up, never + /// below two. Zero without a path. The animation scene is kept at + /// this count while there is a path (see AnimationSceneGuard). + int totalFrames() const; + + void append(const Viewpoint& viewpoint); + void replace(int index, const Viewpoint& viewpoint); + void removeAt(int index); + void move(int from, int to); + void clear(); + + /// The progress at which each viewpoint is reached, with every leg and + /// every orbit weighted by its duration. The first entry is always 0. + /// Empty when there is no path (see isPath). A viewpoint that orbits + /// is reached at its stop and left at its departure; for the others + /// the two coincide, and the last departure is always 1. + QList stops() const; + /// The progress at which the camera leaves each viewpoint, after any + /// orbit there. Same shape as stops(). + QList departures() const; + + /// The next unused default name, "Viewpoint N". Numbers are never + /// reused within a session, so deleting Viewpoint 2 does not cause the + /// next viewpoint to take its name. + QString nextDefaultName() const; + + /// The time at which the path reaches viewpoint `anchor`, in [0, 1]. + /// With no path, anchor 0 is the start of the animation and anything + /// later is the end, so a curve keyed to anchors still spans the + /// timeline. Out-of-range anchors clamp. + double anchorTime(int anchor) const; + /// The time at which the path leaves viewpoint `anchor`, after its + /// orbit if it has one; what an animation from that anchor waits for, + /// so the state recorded there holds through the spin. + double departureTime(int anchor) const; + + /// Map animation progress in [0, 1] onto a time along the path, also + /// in [0, 1], applying each segment's easing. Segment boundaries are + /// fixed points, so this only changes the pacing within a segment, + /// never which viewpoint is reached when. + double remapProgress(double progress) const; + + /// How far through one leg of the path the animation is, given its + /// overall progress. Before the leg starts this is 0 and after it ends + /// it is 1, so a visualization bound to a leg does its whole sweep + /// while the camera flies that leg and holds still either side of it. + /// + /// A leg that no longer exists gives back `progress` unchanged, so + /// the animation runs over the whole timeline rather than freezing. + double segmentProgress(double progress, int segment) const; + + /// Move `camera` to time `t` in [0, 1] along the path. Parallel + /// projection is left alone, since switching it mid-path would jump. + void interpolate(double t, vtkCamera* camera); + + /// Start or stop flying the camera along the path. It lives here + /// rather than in the dialog so it survives the dialog closing and is + /// visible to the state file. + /// Arm the flight so animation playback drives the camera along the + /// path. When @a snapToHead is true the camera jumps to the start of + /// the path immediately as a preview; pass false to leave the camera + /// where it is until playback moves it. + void startFlight(pqRenderView* view, bool snapToHead = true); + void stopFlight(); + bool isFlying() const; + /// The camera flies whenever there is a path and not otherwise: arm + /// the flight in @a view if a path appeared, stop it if the path + /// went. The camera is left where it is either way. Returns true when + /// a flight was just armed. + bool syncFlight(pqRenderView* view); + + /// Where viewpoint captions are centered, as fractions of the view's + /// width and height from its lower-left corner, so the placement + /// holds at whatever size a movie is exported. One position for the + /// whole path: captions that hop around the frame read as a mistake. + std::array captionPosition() const { return m_captionPosition; } + void setCaptionPosition(double x, double y); + + QJsonObject serialize() const; + bool deserialize(const QJsonObject& json); + +signals: + /// The list or its timing changed. The path has to be rebuilt. + void changed(); + /// The caption moved; a caption on screen should follow at once. + void captionPositionChanged(); + +private: + Q_DISABLE_COPY(CameraViewpoints) + + CameraViewpoints() = default; + + void rebuildInterpolator(); + /// After any change to the list: drop the flight if there is no path + /// left, and tell everyone. + void noteChanged(); + + QList m_viewpoints; + std::array m_captionPosition = { 0.5, 0.05 }; + QPointer m_flight; + /// A stretch of consecutive viewpoints joined by ordinary legs, and + /// the interpolator that flies them. Orbit legs split the path into + /// these, so the spline through one stretch never bends towards a + /// viewpoint an orbit reaches by other means. Held by pointer rather + /// than by value so the header does not have to pull in the + /// interpolator to destroy it. + struct Run + { + int first = 0; + int last = 0; + vtkSmartPointer interpolator; + }; + std::vector m_runs; + bool m_interpolatorStale = true; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/ClipAnimation.h b/tomviz/animations/ClipAnimation.h new file mode 100644 index 000000000..1f318b543 --- /dev/null +++ b/tomviz/animations/ClipAnimation.h @@ -0,0 +1,79 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizClipAnimation_h +#define tomvizClipAnimation_h + +#include "ModuleAnimation.h" + +#include "pipeline/sinks/ClipSink.h" + +namespace tomviz { + +/// Sweeps a clipping plane between two positions along its own normal. +/// An axis-aligned clip moves by slice index, the unit its own property +/// panel uses; a custom-oriented clip has no slices, so it moves by +/// signed distance from the center of the data instead. +class ClipAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + enum Unit + { + Slice, + Distance + }; + + double startValue = 0; + double stopValue = 0; + Unit unit = Slice; + + ClipAnimation(pipeline::ClipSink* sink, double start, double stop, Unit u) + : ModuleAnimation(sink), startValue(start), stopValue(stop), unit(u) + { + } + + pipeline::ClipSink* sink() + { + return qobject_cast(baseNode.data()); + } + + QString type() const override { return "clip"; } + + QString describeParameters() const override + { + return QString("%1 %2 to %3") + .arg(unit == Slice ? "slice" : "position") + .arg(startValue) + .arg(stopValue); + } + + QJsonObject serialize() const override + { + // The unit is saved rather than re-derived on load: a clip whose + // direction changed between sessions would otherwise come back + // reading slice indices as distances, or the other way around. + return { { "start", startValue }, + { "stop", stopValue }, + { "unit", unit == Slice ? "slice" : "distance" } }; + } + + void onTimeChanged() override + { + if (!timeKeeper() || !sink()) { + return; + } + + double value = (stopValue - startValue) * progress() + startValue; + if (unit == Slice) { + sink()->setSlice(qRound(value)); + } else { + sink()->setPlaneDistance(value); + } + } +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/ContourAnimation.h b/tomviz/animations/ContourAnimation.h index 32153187f..62d01194d 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,36 @@ 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()); + } + + QString type() const override { return "contour"; } + + QString describeParameters() const override + { + return QString("iso value %1 to %2").arg(startValue).arg(stopValue); + } + + QJsonObject serialize() const override + { + return { { "start", startValue }, { "stop", stopValue } }; + } 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/CutOutAnimation.h b/tomviz/animations/CutOutAnimation.h new file mode 100644 index 000000000..a927c4b37 --- /dev/null +++ b/tomviz/animations/CutOutAnimation.h @@ -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". */ + +#ifndef tomvizCutOutAnimation_h +#define tomvizCutOutAnimation_h + +#include "ModuleAnimation.h" + +#include "pipeline/sinks/VolumeSink.h" + +namespace tomviz { + +/// Slides the cut-out of a volume along one axis, as a fraction of the +/// volume, so the interior is revealed progressively. Switches the +/// cut-out on if it is off, since the sweep would otherwise show nothing. +class CutOutAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + double startValue = 0; + double stopValue = 0; + int axis = 0; + + CutOutAnimation(pipeline::VolumeSink* sink, double start, double stop, + int a) + : ModuleAnimation(sink), startValue(start), stopValue(stop), + axis(qBound(0, a, 2)) + { + } + + pipeline::VolumeSink* sink() + { + return qobject_cast(baseNode.data()); + } + + QString type() const override { return "cutOut"; } + + QString describeParameters() const override + { + return QString("cut-out %1 %2 to %3") + .arg(QChar('X' + axis)) + .arg(startValue) + .arg(stopValue); + } + + QJsonObject serialize() const override + { + return { { "start", startValue }, { "stop", stopValue }, { "axis", axis } }; + } + + void onPlaybackStarted() override { m_switchedOn = false; } + + void onTimeChanged() override + { + if (!timeKeeper() || !sink()) { + return; + } + + double value = (stopValue - startValue) * progress() + startValue; + sink()->setCutOutPosition(axis, value); + // Once per playback, so a volume that cannot be cut (rendered in + // bricks) does not warn on every tick + if (!m_switchedOn && !sink()->cutOutEnabled()) { + sink()->setCutOutEnabled(true); + } + m_switchedOn = true; + } + +private: + bool m_switchedOn = false; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/ExplodedAnimation.h b/tomviz/animations/ExplodedAnimation.h new file mode 100644 index 000000000..198c8be28 --- /dev/null +++ b/tomviz/animations/ExplodedAnimation.h @@ -0,0 +1,96 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizExplodedAnimation_h +#define tomvizExplodedAnimation_h + +#include "ModuleAnimation.h" + +#include "pipeline/sinks/VolumeSink.h" + +namespace tomviz { + +/// Sweeps one setting of a volume's exploded view: the gap between the +/// slabs, how many slabs the volume is cut into, or the offset of the +/// cuts. Switches the exploded view on if it is off, since the sweep +/// would otherwise show nothing. +class ExplodedAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + enum Unit + { + Gap, + Chunks, + Offset + }; + + double startValue = 0; + double stopValue = 0; + Unit unit = Gap; + + ExplodedAnimation(pipeline::VolumeSink* sink, double start, double stop, + Unit u) + : ModuleAnimation(sink), startValue(start), stopValue(stop), unit(u) + { + } + + pipeline::VolumeSink* sink() + { + return qobject_cast(baseNode.data()); + } + + QString type() const override { return "exploded"; } + + QString describeParameters() const override + { + return QString("%1 %2 to %3") + .arg(unit == Gap ? "exploded gap" + : unit == Chunks ? "exploded chunks" + : "exploded offset") + .arg(startValue) + .arg(stopValue); + } + + QJsonObject serialize() const override + { + return { { "start", startValue }, + { "stop", stopValue }, + { "unit", unit == Gap ? "gap" + : unit == Chunks ? "chunks" + : "offset" } }; + } + + void onPlaybackStarted() override { m_switchedOn = false; } + + void onTimeChanged() override + { + if (!timeKeeper() || !sink()) { + return; + } + + double value = (stopValue - startValue) * progress() + startValue; + if (unit == Gap) { + sink()->setExplodedGap(value); + } else if (unit == Chunks) { + sink()->setExplodedChunks(qRound(value)); + } else { + sink()->setExplodedOffset(qRound(value)); + } + // Once per playback: a volume that cannot be exploded (rendered in + // bricks) refuses with a warning, which need not repeat every tick. + // No camera refit, since a camera path may be flying. + if (!m_switchedOn && !sink()->explodedEnabled()) { + sink()->setExplodedEnabled(true, /*refitCamera=*/false); + } + m_switchedOn = true; + } + +private: + bool m_switchedOn = false; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/ModuleAnimation.h b/tomviz/animations/ModuleAnimation.h index c850fe3cd..88cbf37fd 100644 --- a/tomviz/animations/ModuleAnimation.h +++ b/tomviz/animations/ModuleAnimation.h @@ -5,12 +5,18 @@ #define tomvizModuleAnimation_h #include "ActiveObjects.h" -#include "Module.h" -#include "ModuleManager.h" +#include "CameraViewpoints.h" +#include "pipeline/Node.h" +#include +#include +#include #include +#include #include +#include +#include namespace tomviz { @@ -19,9 +25,18 @@ class ModuleAnimation : public QObject Q_OBJECT public: - Module* baseModule = nullptr; + QPointer baseNode; - ModuleAnimation(Module* module) : baseModule(module) { setupConnections(); } + /// The leg of the camera path this animation runs during, or -1 to run + /// over the whole timeline. Binding to a leg is what lets one + /// visualization change while the camera flies from one viewpoint to + /// the next, and another change on the leg after that. + int segment = -1; + + ModuleAnimation(pipeline::Node* node) : baseNode(node) + { + setupConnections(); + } virtual void setupConnections() { @@ -30,19 +45,34 @@ 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(); + // Playback leaves whatever the last tick applied. Animations that + // override module state (e.g. the scalar opacity morph) use the + // playback-ended hook to hand control back to the user's settings. + // The scene is replaced on state load, so follow the active one. + auto* core = pqPVApplicationCore::instance(); + auto* manager = core ? core->animationManager() : nullptr; + if (manager) { + connect(manager, &pqAnimationManager::activeSceneChanged, this, + [this]() { bindToScene(); }); + } + bindToScene(); } + /// Called when animation playback starts, before the first frame. The + /// time keeper only announces changes, so a playback that begins where + /// the clock already sits never ticks its first frame; an animation + /// that must show its starting state overrides this. Default does + /// nothing. + virtual void onPlaybackStarted() {} + + /// Called when animation playback finishes. Default does nothing. + virtual void onPlaybackEnded() {} + virtual ActiveObjects& activeObjects() { return ActiveObjects::instance(); } virtual pqTimeKeeper* timeKeeper() { @@ -66,7 +96,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); } @@ -76,11 +105,65 @@ class ModuleAnimation : public QObject virtual double timeStart() { return timeSteps().front(); } virtual double timeStop() { return timeSteps().back(); } - virtual double progress() { return time() / (timeStop() - timeStart()); } - virtual ModuleManager& moduleManager() { return ModuleManager::instance(); } + virtual double progress() + { + double start = timeStart(); + double stop = timeStop(); + if (stop <= start) { + // A single time step (or none) has no range to animate over. + return 0; + } + double elapsed = (time() - start) / (stop - start); + if (segment < 0) { + return elapsed; + } + + return CameraViewpoints::instance().segmentProgress(elapsed, segment); + } virtual void onTimeChanged() {} + + /// A short phrase for the animation list, e.g. "iso value 120 to 400". + virtual QString describeParameters() const { return {}; } + + /// True for an animation derived from the state recorded with the + /// camera viewpoints (see RecordedAnimations). Rebuilt from the + /// viewpoints rather than saved, and never authored by hand. + virtual bool recorded() const { return false; } + + /// The name this animation is saved under in a state file. Empty means + /// it is not saved, because whatever created it rebuilds it instead. + virtual QString type() const { return QString(); } + + /// The parameters needed to rebuild this animation. The node it runs + /// on is written by the caller, which is the only one holding the ids. + virtual QJsonObject serialize() const { return QJsonObject(); } + +private: + /// Track the active animation scene's endPlay so onPlaybackEnded() + /// fires no matter which scene is current. + void bindToScene() + { + auto* core = pqPVApplicationCore::instance(); + auto* manager = core ? core->animationManager() : nullptr; + auto* scene = manager ? manager->getActiveScene() : nullptr; + if (scene == m_boundScene) { + return; + } + if (m_boundScene) { + disconnect(m_boundScene.data(), nullptr, this, nullptr); + } + m_boundScene = scene; + if (scene) { + connect(scene, &pqAnimationScene::beginPlay, this, + [this]() { onPlaybackStarted(); }); + connect(scene, &pqAnimationScene::endPlay, this, + [this]() { onPlaybackEnded(); }); + } + } + + QPointer m_boundScene; }; } // namespace tomviz diff --git a/tomviz/animations/ModuleAnimations.cxx b/tomviz/animations/ModuleAnimations.cxx new file mode 100644 index 000000000..a95e3647a --- /dev/null +++ b/tomviz/animations/ModuleAnimations.cxx @@ -0,0 +1,329 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ModuleAnimations.h" + +#include "ActiveObjects.h" +#include "CameraViewpoints.h" +#include "ClipAnimation.h" +#include "ContourAnimation.h" +#include "CutOutAnimation.h" +#include "ExplodedAnimation.h" +#include "ModuleAnimation.h" +#include "OpacityAnimation.h" +#include "ScalarOpacityAnimation.h" +#include "SliceAnimation.h" +#include "SolidityAnimation.h" +#include "ThresholdAnimation.h" + +#include "pipeline/Pipeline.h" + +#include +#include +#include + +namespace tomviz { + +namespace { + +// Rebuild one saved animation. Returns null for an entry naming a type +// we no longer have, or a node that cannot carry that animation. +ModuleAnimation* buildAnimation(const QString& type, pipeline::Node* node, + const QJsonObject& json) +{ + double start = json["start"].toDouble(); + double stop = json["stop"].toDouble(); + + if (type == "contour") { + if (auto* sink = qobject_cast(node)) { + return new ContourAnimation(sink, start, stop); + } + } else if (type == "solidity") { + if (auto* sink = qobject_cast(node)) { + return new SolidityAnimation(sink, start, stop); + } + } else if (type == "slice") { + if (auto* sink = qobject_cast(node)) { + // Files from before custom slices could be swept carry no unit + auto unit = json["unit"].toString() == "distance" + ? SliceAnimation::Distance + : SliceAnimation::Slice; + if ((unit == SliceAnimation::Slice) != sink->isOrtho()) { + qWarning() << "Dropping slice animation: the slice changed" + << "orientation since it was saved."; + return nullptr; + } + return new SliceAnimation(sink, start, stop, unit); + } + } else if (type == "clip") { + if (auto* sink = qobject_cast(node)) { + auto unit = json["unit"].toString() == "distance" + ? ClipAnimation::Distance + : ClipAnimation::Slice; + // A clip that changed orientation since it was saved cannot use + // the saved unit: slice indices are meaningless once the plane is + // off axis, and a distance sweep would be ignored while it is on + // one. Drop it rather than move the plane somewhere arbitrary. + if ((unit == ClipAnimation::Slice) != sink->isOrtho()) { + qWarning() << "Dropping clip animation: the clip changed" + << "orientation since it was saved."; + return nullptr; + } + return new ClipAnimation(sink, start, stop, unit); + } + } else if (type == "scalarOpacity") { + auto* sink = qobject_cast(node); + if (sink && ScalarOpacityAnimation::supports(node)) { + QList keyframes; + for (const auto& value : json["keyframes"].toArray()) { + auto entry = value.toObject(); + OpacityKeyframe keyframe; + keyframe.anchor = entry["anchor"].toInt(); + keyframe.curve = vtkSmartPointer::New(); + tomviz::deserialize(keyframe.curve.Get(), entry["curve"].toObject()); + keyframes.append(keyframe); + } + if (!keyframes.isEmpty()) { + return new ScalarOpacityAnimation(sink, keyframes); + } + } + } else if (type == "opacity") { + if (OpacityAnimation::supports(node)) { + return new OpacityAnimation(node, start, stop); + } + } else if (type == "exploded") { + if (auto* sink = qobject_cast(node)) { + const QString name = json["unit"].toString(); + auto unit = name == "chunks" ? ExplodedAnimation::Chunks + : name == "offset" ? ExplodedAnimation::Offset + : ExplodedAnimation::Gap; + return new ExplodedAnimation(sink, start, stop, unit); + } + } else if (type == "cutOut") { + if (auto* sink = qobject_cast(node)) { + return new CutOutAnimation(sink, start, stop, json["axis"].toInt(0)); + } + } else if (type == "threshold") { + if (auto* sink = qobject_cast(node)) { + auto end = json["end"].toString() == "upper" ? ThresholdAnimation::Upper + : ThresholdAnimation::Lower; + return new ThresholdAnimation(sink, start, stop, end); + } + } + + return nullptr; +} + +} // anonymous namespace + +ModuleAnimations& ModuleAnimations::instance() +{ + static ModuleAnimations animations; + return animations; +} + +void ModuleAnimations::add(ModuleAnimation* animation) +{ + if (!animation) { + return; + } + + prune(); + m_animations.append(animation); + emit changed(); +} + +void ModuleAnimations::remove(ModuleAnimation* animation) +{ + prune(); + for (int i = 0; i < m_animations.size(); ++i) { + if (m_animations[i] == animation) { + m_animations[i]->deleteLater(); + m_animations.removeAt(i); + emit changed(); + return; + } + } +} + +QList ModuleAnimations::animations() +{ + prune(); + + QList alive; + for (const auto& animation : m_animations) { + alive.append(animation.data()); + } + return alive; +} + +bool ModuleAnimations::isEmpty() +{ + prune(); + return m_animations.isEmpty(); +} + +void ModuleAnimations::clear() +{ + if (m_animations.isEmpty()) { + return; + } + + for (const auto& animation : m_animations) { + if (animation) { + animation->deleteLater(); + } + } + m_animations.clear(); + emit changed(); +} + +void ModuleAnimations::prune() +{ + for (int i = m_animations.size() - 1; i >= 0; --i) { + if (m_animations[i].isNull()) { + m_animations.removeAt(i); + } + } +} + +void ModuleAnimations::pinExportQuality() +{ + using Curve = vtkSmartPointer; + // Every sequence of curves a volume will blend through: the explicit + // opacity morphs, plus the curves recorded with the viewpoints. + QHash>> sequences; + for (const auto& animation : m_animations) { + auto* morph = qobject_cast(animation.data()); + auto* sink = morph ? morph->sink() : nullptr; + if (!sink || morph->keyframes().isEmpty()) { + continue; + } + QList curves; + for (const auto& keyframe : morph->keyframes()) { + curves.append(keyframe.curve); + } + sequences[sink].append(curves); + } + if (auto* pipeline = ActiveObjects::instance().pipeline()) { + QHash> recorded; + for (const auto& viewpoint : CameraViewpoints::instance().viewpoints()) { + for (auto it = viewpoint.scene.sinks.cbegin(); + it != viewpoint.scene.sinks.cend(); ++it) { + auto* sink = + qobject_cast(pipeline->nodeById(it.key())); + if (sink && it.value().scalarOpacity) { + recorded[sink].append(it.value().scalarOpacity); + } + } + } + for (auto it = recorded.cbegin(); it != recorded.cend(); ++it) { + sequences[it.key()].append(it.value()); + } + } + + for (auto it = sequences.cbegin(); it != sequences.cend(); ++it) { + auto* sink = it.key(); + double range[2] = { 0.0, 1.0 }; + auto volume = sink->volumeData(); + if (volume && volume->isValid()) { + auto volumeRange = volume->colorMapRange(); + range[0] = volumeRange[0]; + range[1] = volumeRange[1]; + } + + // The most expensive curve is not necessarily any of the captures - a + // spike dissolving into a ramp is widest somewhere in the middle - so + // walk every blend rather than just comparing the keyframes. + vtkNew worst; + double worstCost = -1.0; + auto consider = [&](vtkPiecewiseFunction* curve) { + const double cost = pipeline::VolumeSink::opacityRenderCost(curve); + if (cost > worstCost) { + worstCost = cost; + worst->DeepCopy(curve); + } + }; + const int steps = 10; + for (const auto& curves : it.value()) { + consider(curves.first()); + for (int pair = 0; pair + 1 < curves.size(); ++pair) { + for (int i = 1; i <= steps; ++i) { + vtkNew sample; + interpolateOpacity(curves[pair], curves[pair + 1], + static_cast(i) / steps, range, sample); + consider(sample); + } + } + } + sink->setWorstCaseOpacity(worst); + } +} + +void ModuleAnimations::releaseExportQuality() +{ + for (const auto& animation : m_animations) { + auto* morph = qobject_cast(animation.data()); + if (auto* sink = morph ? morph->sink() : nullptr) { + sink->setWorstCaseOpacity(nullptr); + } + } +} + +QJsonObject ModuleAnimations::serialize(pipeline::Pipeline* pipeline) const +{ + QJsonArray array; + for (const auto& animation : m_animations) { + // Recorded animations are rebuilt from the viewpoints on load + if (!pipeline || !animation || !animation->baseNode || + animation->recorded()) { + continue; + } + + auto type = animation->type(); + if (type.isEmpty()) { + continue; + } + + auto entry = animation->serialize(); + entry["type"] = type; + entry["node"] = pipeline->nodeId(animation->baseNode); + entry["segment"] = animation->segment; + array.append(entry); + } + + QJsonObject json; + json["modules"] = array; + return json; +} + +void ModuleAnimations::deserialize(const QJsonObject& json, + pipeline::Pipeline* pipeline) +{ + clear(); + + if (!pipeline || !json["modules"].isArray()) { + return; + } + + for (const auto& value : json["modules"].toArray()) { + auto entry = value.toObject(); + auto* node = pipeline->nodeById(entry["node"].toInt(-1)); + if (!node) { + qWarning() << "Dropping a" << entry["type"].toString() + << "animation: its visualization is not in this state" + << "file."; + continue; + } + + if (auto* animation = + buildAnimation(entry["type"].toString(), node, entry)) { + animation->segment = entry["segment"].toInt(-1); + m_animations.append(animation); + } + } + + emit changed(); +} + +} // namespace tomviz diff --git a/tomviz/animations/ModuleAnimations.h b/tomviz/animations/ModuleAnimations.h new file mode 100644 index 000000000..5188edc6b --- /dev/null +++ b/tomviz/animations/ModuleAnimations.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 tomvizModuleAnimations_h +#define tomvizModuleAnimations_h + +#include +#include +#include +#include + +namespace tomviz { + +class ModuleAnimation; + +namespace pipeline { +class Node; +class Pipeline; +} // namespace pipeline + +/// The visualization animations running in the current session. +/// +/// These used to live in the Animation Helper dialog, but they outlive +/// it (it is created once and only hidden) and they are saved with the +/// state file, so the list belongs somewhere both the dialog and the +/// state serializer can reach. Same arrangement as CameraViewpoints. +/// +/// Animations delete themselves when their node goes away, so the list +/// holds guarded pointers and drops the dead ones as it goes. +class ModuleAnimations : public QObject +{ + Q_OBJECT + +public: + static ModuleAnimations& instance(); + + /// Take on `animation`, which must already be constructed against the + /// node it animates. + void add(ModuleAnimation* animation); + + /// Drop one animation. No-op if it is not in the list. + void remove(ModuleAnimation* animation); + + /// The animations still alive, dead entries pruned. + QList animations(); + + bool isEmpty(); + void clear(); + + /// The pipeline is passed in because nodes are saved by the id it + /// assigns them, which is the same id the rest of the state file uses. + /// Size each volume's render quality for the most expensive opacity + /// curve its animation passes through, so an exported sequence holds one + /// sharpness throughout. Undone by releaseExportQuality(). + void pinExportQuality(); + void releaseExportQuality(); + + QJsonObject serialize(pipeline::Pipeline* pipeline) const; + + /// Rebuild the animations described by `json` against the nodes of + /// `pipeline`. Entries naming a node that is not there any more are + /// skipped. Replaces whatever is currently in the list. + void deserialize(const QJsonObject& json, pipeline::Pipeline* pipeline); + +signals: + void changed(); + +private: + Q_DISABLE_COPY(ModuleAnimations) + + ModuleAnimations() = default; + + void prune(); + + QList> m_animations; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/OpacityAnimation.h b/tomviz/animations/OpacityAnimation.h new file mode 100644 index 000000000..edc5fca94 --- /dev/null +++ b/tomviz/animations/OpacityAnimation.h @@ -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". */ + +#ifndef tomvizOpacityAnimation_h +#define tomvizOpacityAnimation_h + +#include "ModuleAnimation.h" + +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/LabelMapSink.h" +#include "pipeline/sinks/SegmentSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ThresholdSink.h" + +namespace tomviz { + +/// Ramps a visualization's opacity from one value to another, so a +/// module can fade in or out over the course of the animation. +/// +/// The sinks that have an opacity are unrelated types that happen to +/// share a setter name rather than a base class declaring one, so the +/// target is resolved per type. +class OpacityAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + double startValue = 1; + double stopValue = 1; + + OpacityAnimation(pipeline::Node* node, double start, double stop) + : ModuleAnimation(node), startValue(start), stopValue(stop) + { + } + + /// True if `node` has an opacity this animation can drive: the flat + /// opacity of the surface and plane modules, and a label map's surface. + static bool supports(pipeline::Node* node) + { + return qobject_cast(node) || + qobject_cast(node) || + qobject_cast(node) || + qobject_cast(node) || + qobject_cast(node) || + qobject_cast(node); + } + + /// The current opacity of `node`, or 1 if it has none. + static double opacityOf(pipeline::Node* node) + { + if (auto* contour = qobject_cast(node)) { + return contour->opacity(); + } + if (auto* slice = qobject_cast(node)) { + return slice->opacity(); + } + if (auto* clip = qobject_cast(node)) { + return clip->opacity(); + } + if (auto* threshold = qobject_cast(node)) { + return threshold->opacity(); + } + if (auto* segment = qobject_cast(node)) { + return segment->opacity(); + } + if (auto* labels = qobject_cast(node)) { + return labels->surfaceOpacity(); + } + return 1; + } + + QString type() const override { return "opacity"; } + + QString describeParameters() const override + { + return QString("opacity %1 to %2").arg(startValue).arg(stopValue); + } + + QJsonObject serialize() const override + { + return { { "start", startValue }, { "stop", stopValue } }; + } + + void onTimeChanged() override + { + if (!timeKeeper() || !baseNode) { + return; + } + + double value = (stopValue - startValue) * progress() + startValue; + setOpacity(baseNode.data(), value); + } + +private: + static void setOpacity(pipeline::Node* node, double value) + { + if (auto* contour = qobject_cast(node)) { + contour->setOpacity(value); + } else if (auto* slice = qobject_cast(node)) { + slice->setOpacity(value); + } else if (auto* clip = qobject_cast(node)) { + clip->setOpacity(value); + } else if (auto* threshold = qobject_cast(node)) { + threshold->setOpacity(value); + } else if (auto* segment = qobject_cast(node)) { + segment->setOpacity(value); + } else if (auto* labels = qobject_cast(node)) { + labels->setSurfaceOpacity(value); + } + } +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/OpacityInterpolation.cxx b/tomviz/animations/OpacityInterpolation.cxx new file mode 100644 index 000000000..fb00850dc --- /dev/null +++ b/tomviz/animations/OpacityInterpolation.cxx @@ -0,0 +1,95 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "OpacityInterpolation.h" + +#include +#include + +#include +#include + +namespace tomviz { + +namespace { + +// Enough to hold a hard edge without the ramp between two samples reading +// as a visible step, and small enough that rebuilding one every frame costs +// nothing next to the render it feeds. +const int kSamples = 256; + +void blendPointwise(vtkPiecewiseFunction* from, vtkPiecewiseFunction* to, + double u, vtkPiecewiseFunction* out) +{ + const int count = from->GetSize(); + out->RemoveAllPoints(); + for (int i = 0; i < count; ++i) { + double a[4]; + double b[4]; + from->GetNodeValue(i, a); + to->GetNodeValue(i, b); + // Position, opacity, midpoint and sharpness all interpolate. + out->AddPoint(a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, + a[2] + (b[2] - a[2]) * u, a[3] + (b[3] - a[3]) * u); + } +} + +void blendThroughTable(vtkPiecewiseFunction* from, vtkPiecewiseFunction* to, + double u, const double range[2], + vtkPiecewiseFunction* out) +{ + std::vector fromTable(kSamples); + std::vector toTable(kSamples); + from->GetTable(range[0], range[1], kSamples, fromTable.data()); + to->GetTable(range[0], range[1], kSamples, toTable.data()); + + std::vector blended(kSamples); + for (int i = 0; i < kSamples; ++i) { + blended[i] = fromTable[i] + (toTable[i] - fromTable[i]) * u; + } + + out->RemoveAllPoints(); + out->BuildFunctionFromTable(range[0], range[1], kSamples, blended.data()); +} + +} // anonymous namespace + +void interpolateOpacity(vtkPiecewiseFunction* from, vtkPiecewiseFunction* to, + double u, const double range[2], vtkPiecewiseFunction* out) +{ + if (!out || !from || !to) { + return; + } + + u = std::min(1.0, std::max(0.0, u)); + + // Build into a scratch curve so the caller may pass one of the inputs as + // the destination. + vtkNew result; + + const int fromCount = from->GetSize(); + const int toCount = to->GetSize(); + + if (fromCount == 0 && toCount == 0) { + out->RemoveAllPoints(); + return; + } + + // With one side empty there is nothing to blend towards, so the other + // side stands for the whole animation rather than fading to nothing. + if (fromCount == 0 || toCount == 0) { + result->DeepCopy(fromCount == 0 ? to : from); + } else if (fromCount == toCount) { + blendPointwise(from, to, u, result); + } else if (range[1] > range[0]) { + blendThroughTable(from, to, u, range, result); + } else { + // No window to sample over. Rather than emit a curve built from a + // degenerate range, hold whichever end is nearer. + result->DeepCopy(u < 0.5 ? from : to); + } + + out->DeepCopy(result); +} + +} // namespace tomviz diff --git a/tomviz/animations/OpacityInterpolation.h b/tomviz/animations/OpacityInterpolation.h new file mode 100644 index 000000000..4c99c8ce9 --- /dev/null +++ b/tomviz/animations/OpacityInterpolation.h @@ -0,0 +1,24 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizOpacityInterpolation_h +#define tomvizOpacityInterpolation_h + +class vtkPiecewiseFunction; + +namespace tomviz { + +/// Blend two scalar opacity curves into @a out, which may alias either +/// input. @a u runs 0 (all @a from) to 1 (all @a to) and is clamped. +/// +/// Both curves are read over @a range, normally +/// VolumeData::colorMapRange(): they are stored in data coordinates and +/// move when the data range does, so two curves only agree once read +/// over the same window. +void interpolateOpacity(vtkPiecewiseFunction* from, vtkPiecewiseFunction* to, + double u, const double range[2], + vtkPiecewiseFunction* out); + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/RecordedAnimations.cxx b/tomviz/animations/RecordedAnimations.cxx new file mode 100644 index 000000000..0871be8fa --- /dev/null +++ b/tomviz/animations/RecordedAnimations.cxx @@ -0,0 +1,1146 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "RecordedAnimations.h" + +#include "ActiveObjects.h" +#include "CameraViewpoints.h" +#include "ModuleAnimations.h" +#include "SolidityAnimation.h" +#include "pipeline/Pipeline.h" +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/LabelMapSink.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ThresholdSink.h" +#include "pipeline/sinks/VolumeSink.h" + +#include + +#include + +#include +#include + +namespace tomviz { + +using pipeline::ClipSink; +using pipeline::ContourSink; +using pipeline::LabelMapSink; +using pipeline::LegacyModuleSink; +using pipeline::ThresholdSink; +using pipeline::Pipeline; +using pipeline::VolumeSink; + +namespace { + +double lerp(double a, double b, double u) +{ + return a + (b - a) * u; +} + +/// How far an effect that is on at one end of a leg and off (or set up +/// differently) at the other has got: collapsing out of the old state +/// (a fraction from 1 down to 0) or growing into the new one (0 up to +/// 1). Turning off collapses over the whole leg and turning on grows +/// over it, unless the exclusive other effect is on at the far end, +/// when each keeps to its half; a change of setup collapses in the +/// first half and grows in the second, passing through nothing. +struct Sweep +{ + bool into = false; // growing into b, rather than collapsing out of a + bool on = false; // the effect is on at all + double amount = 0.0; // of the side's own extent, 0 to 1 +}; + +Sweep sweepAt(double u, bool aOn, bool bOn, bool aOtherOn, bool bOtherOn) +{ + Sweep sweep; + if (aOn && bOn) { + sweep.on = true; + sweep.into = u >= 0.5; + sweep.amount = sweep.into ? 2 * u - 1 : 1 - 2 * u; + } else if (aOn) { + const double end = bOtherOn ? 0.5 : 1.0; + sweep.on = u < end; + sweep.amount = sweep.on ? 1 - u / end : 0.0; + } else if (bOn) { + const double start = aOtherOn ? 0.5 : 0.0; + sweep.into = true; + sweep.on = u > start; + sweep.amount = sweep.on ? (u - start) / (1 - start) : 0.0; + } + return sweep; +} + +/// Cut-out fractions that remove nothing: the corner of the volume the +/// cut-out grows from +std::array emptyCutOut(int corner) +{ + return { corner & 1 ? 1.0 : 0.0, corner & 2 ? 1.0 : 0.0, + corner & 4 ? 1.0 : 0.0 }; +} + +/// Viewpoint indices that recorded a scene, in order. +QList recordedAnchors() +{ + QList anchors; + auto& viewpoints = CameraViewpoints::instance(); + for (int i = 0; i < viewpoints.size(); ++i) { + if (!viewpoints.at(i).scene.isEmpty()) { + anchors.append(i); + } + } + return anchors; +} + +/// The module's recorded state at @a anchor. A recorded viewpoint that +/// lacks the module was saved before it existed, so the module was not +/// on screen there: the answer is a hidden copy of @a fallback, which +/// carries the values a fade needs. +SinkSnapshot snapshotAt(int anchor, int id, const SinkSnapshot& fallback) +{ + const auto& scene = CameraViewpoints::instance().at(anchor).scene; + if (scene.sinks.contains(id)) { + return scene.sinks[id]; + } + SinkSnapshot hidden = fallback; + hidden.visible = false; + return hidden; +} + +/// Some snapshot of the module, from whichever recorded viewpoint has +/// one, to seed the hidden copies with. +bool anySnapshot(const QList& anchors, int id, SinkSnapshot& found) +{ + for (int anchor : anchors) { + const auto& scene = CameraViewpoints::instance().at(anchor).scene; + if (scene.sinks.contains(id)) { + found = scene.sinks[id]; + return true; + } + } + return false; +} + +/// Every module id any recorded viewpoint mentions. +QSet recordedIds(const QList& anchors) +{ + QSet ids; + for (int anchor : anchors) { + const auto& scene = CameraViewpoints::instance().at(anchor).scene; + for (auto it = scene.sinks.cbegin(); it != scene.sinks.cend(); ++it) { + ids.insert(it.key()); + } + } + return ids; +} + +OpacityKey opacityKey(const SinkSnapshot& snapshot) +{ + return { snapshot.visible, snapshot.opacity.value_or(1.0) }; +} + +CutOutKey cutOutKey(const SinkSnapshot& snapshot) +{ + CutOutKey key; + key.enabled = snapshot.cutOutEnabled.value_or(false); + key.corner = snapshot.cutOutCorner.value_or(0); + key.position = snapshot.cutOutPosition.value_or( + std::array{ 0.5, 0.5, 0.5 }); + key.otherOn = snapshot.explodedEnabled.value_or(false); + return key; +} + +ExplodedKey explodedKey(const SinkSnapshot& snapshot) +{ + ExplodedKey key; + key.enabled = snapshot.explodedEnabled.value_or(false); + key.axis = snapshot.explodedAxis.value_or(2); + key.direction = snapshot.explodedDirection.value_or( + std::array{ 1.0, 1.0, 1.0 }); + key.chunks = snapshot.explodedChunks.value_or(4); + key.gap = snapshot.explodedGap.value_or(0.25); + key.offset = snapshot.explodedOffset.value_or(0); + key.otherOn = snapshot.cutOutEnabled.value_or(false); + return key; +} + +ThresholdKey thresholdKey(const SinkSnapshot& snapshot) +{ + ThresholdKey key; + key.lower = snapshot.thresholdLower.value_or(0.0); + key.upper = snapshot.thresholdUpper.value_or(1.0); + return key; +} + +/// The labels named in @a b but not in @a a, as "3, 5, 10". +QString labelsOnlyIn(const QVector& b, const QVector& a) +{ + QStringList names; + for (double value : b) { + if (!a.contains(value)) { + names.append(QString::number(value)); + } + } + return names.join(", "); +} + +PlaneKey planeKey(const SinkSnapshot& snapshot) +{ + PlaneKey key; + key.direction = snapshot.planeDirection.value_or(0); + key.slice = snapshot.sliceIndex.value_or(0); + key.center = snapshot.planeCenter.value_or(std::array{ 0, 0, 0 }); + key.normal = snapshot.planeNormal.value_or(std::array{ 0, 0, 1 }); + return key; +} + +/// The authored animation type that can take a plane's leg over. +QString planeType(LegacyModuleSink* sink) +{ + return qobject_cast(sink) ? "clip" : "slice"; +} + +/// The curve that stands for a snapshot: its own while shown, an +/// all-zero copy of @a shape while hidden, or null when it has none. +vtkSmartPointer curveOf(const SinkSnapshot& snapshot, + vtkPiecewiseFunction* shape) +{ + if (snapshot.visible) { + return snapshot.scalarOpacity; + } + return shape ? zeroedCurve(shape) : nullptr; +} + +/// Which property family a module's recorded state animates through. +enum class Family +{ + Curve, + Opacity, + Visibility +}; + +Family familyOf(LegacyModuleSink* sink, const SinkSnapshot& sample) +{ + if (ScalarOpacityAnimation::supports(sink) && sample.scalarOpacity) { + return Family::Curve; + } + if (sample.opacity) { + return Family::Opacity; + } + return Family::Visibility; +} + +/// The animation type an authored animation must have to own a leg of +/// this recorded property. +QString authoredTypeFor(const QString& property) +{ + if (property == "curve") { + return "scalarOpacity"; + } + if (property == "iso") { + return "contour"; + } + return property; +} + +/// Legs (start index) an authored animation of @a type on @a node owns. +/// An animation over the whole timeline owns every leg. +QSet authoredSegments(pipeline::Node* node, const QString& type) +{ + QSet segments; + const int legs = std::max(0, CameraViewpoints::instance().size() - 1); + for (auto* animation : ModuleAnimations::instance().animations()) { + if (!animation || animation->recorded() || animation->baseNode != node || + animation->type() != type) { + continue; + } + if (animation->segment < 0) { + for (int leg = 0; leg < legs; ++leg) { + segments.insert(leg); + } + } else { + segments.insert(animation->segment); + } + } + return segments; +} + +/// True if any leg between the two anchors is owned by an authored one. +bool stretchIsAuthored(const QSet& authored, int from, int to) +{ + for (int leg = from; leg < to; ++leg) { + if (authored.contains(leg)) { + return true; + } + } + return false; +} + +QString number(double value) +{ + return QString::number(value, 'g', 3); +} + +} // namespace + +AnchorSpan anchorSpanAt(const QList& anchors, double t) +{ + AnchorSpan span; + if (anchors.isEmpty()) { + return span; + } + auto& viewpoints = CameraViewpoints::instance(); + int next = 0; + while (next < anchors.size() && viewpoints.anchorTime(anchors[next]) <= t) { + ++next; + } + if (next == 0) { + span.from = span.to = anchors.first(); + span.u = 0.0; + } else if (next == anchors.size()) { + span.from = span.to = anchors.last(); + span.u = 1.0; + } else { + span.from = anchors[next - 1]; + span.to = anchors[next]; + // From leaving one anchor to reaching the next: an orbit at the + // first holds the state recorded there until the camera moves on. + const double start = viewpoints.departureTime(span.from); + const double stop = viewpoints.anchorTime(span.to); + span.u = stop > start ? std::clamp((t - start) / (stop - start), 0.0, 1.0) + : 1.0; + } + return span; +} + +double effectiveOpacity(const OpacityKey& key) +{ + return key.visible ? key.opacity : 0.0; +} + +// --- RecordedAnimation --- + +void RecordedAnimation::onTimeChanged() +{ + if (!timeKeeper()) { + return; + } + // Eased along with the camera: the stops are fixed points of the + // remap, so the anchors still land where the camera arrives. + applyPathTime(CameraViewpoints::instance().remapProgress(progress())); +} + +void RecordedAnimation::applyPathTime(double t) +{ + if (!baseNode) { + return; + } + auto& viewpoints = CameraViewpoints::instance(); + auto stops = viewpoints.stops(); + if (stops.size() >= 2) { + int leg = 0; + while (leg + 2 < stops.size() && stops[leg + 1] <= t) { + ++leg; + } + if (excludedSegments.contains(leg)) { + return; + } + } + applySpan(anchorSpanAt(anchors(), t)); +} + +// --- Opacity --- + +void RecordedOpacityAnimation::applySpan(const AnchorSpan& span) +{ + auto* sink = qobject_cast(baseNode.data()); + if (!sink || m_keys.isEmpty()) { + return; + } + const OpacityKey& a = m_keys[span.from]; + const OpacityKey& b = m_keys[span.to]; + const double value = lerp(effectiveOpacity(a), effectiveOpacity(b), span.u); + // Fully transparent is hidden. A hidden module keeps the opacity it + // will have once shown, as the snapshots do, so a fade never leaves + // it invisible-but-visible with an opacity of zero. + const bool visible = value > 0.0; + setSinkFlatOpacity(sink, visible ? value + : (span.u < 0.5 ? a.opacity : b.opacity)); + if (sink->visibility() != visible) { + sink->setVisibility(visible); + } +} + +// --- Visibility --- + +void RecordedVisibilityAnimation::applySpan(const AnchorSpan& span) +{ + auto* sink = qobject_cast(baseNode.data()); + if (!sink || m_keys.isEmpty()) { + return; + } + // Nothing to fade through, so switch halfway along the leg + const bool visible = span.u < 0.5 ? m_keys[span.from] : m_keys[span.to]; + if (sink->visibility() != visible) { + sink->setVisibility(visible); + } +} + +// --- Cut-out --- + +RecordedCutOutAnimation::RecordedCutOutAnimation( + VolumeSink* sink, const QMap& keys) + : RecordedAnimation(sink), m_keys(keys) +{ +} + +void RecordedCutOutAnimation::applySpan(const AnchorSpan& span) +{ + auto* volume = qobject_cast(baseNode.data()); + if (!volume || m_keys.isEmpty()) { + return; + } + const auto& a = m_keys[span.from]; + const auto& b = m_keys[span.to]; + // The sink's setters ignore values that do not change. + auto apply = [volume](int corner, const std::array& from, + const std::array& to, double u, + bool enabled) { + volume->setCutOutCorner(corner); + for (int axis = 0; axis < 3; ++axis) { + volume->setCutOutPosition(axis, lerp(from[axis], to[axis], u)); + } + volume->setCutOutEnabled(enabled); + }; + + if (a.enabled == b.enabled && (!a.enabled || a.corner == b.corner)) { + // The same box, or none: the position slides, and a corner that + // nobody sees flips halfway + apply(span.u < 0.5 ? a.corner : b.corner, a.position, b.position, + span.u, a.enabled); + return; + } + // Switched on or off, or moved to another corner: the box shrinks + // into its corner and grows out of the new one + const auto sweep = + sweepAt(span.u, a.enabled, b.enabled, a.otherOn, b.otherOn); + if (!sweep.on) { + // Not yet on, or already gone: the settings of the end it is off at + const auto& side = sweep.into ? a : b; + apply(side.corner, side.position, side.position, 0.0, false); + return; + } + const auto& side = sweep.into ? b : a; + apply(side.corner, emptyCutOut(side.corner), side.position, sweep.amount, + true); +} + +// --- Exploded view --- + +RecordedExplodedAnimation::RecordedExplodedAnimation( + VolumeSink* sink, const QMap& keys) + : RecordedAnimation(sink), m_keys(keys) +{ +} + +void RecordedExplodedAnimation::applySpan(const AnchorSpan& span) +{ + auto* volume = qobject_cast(baseNode.data()); + if (!volume || m_keys.isEmpty()) { + return; + } + const auto& a = m_keys[span.from]; + const auto& b = m_keys[span.to]; + auto apply = [volume](const ExplodedKey& setup, double gap, int offset, + bool enabled) { + volume->setExplodedDirection(setup.direction[0], setup.direction[1], + setup.direction[2]); + volume->setExplodedAxis(setup.axis, /*refitCamera=*/false); + volume->setExplodedChunks(setup.chunks); + volume->setExplodedGap(gap); + volume->setExplodedOffset(offset); + volume->setExplodedEnabled(enabled, /*refitCamera=*/false); + }; + + const bool sameSlabs = + a.axis == b.axis && a.chunks == b.chunks && + (a.axis != pipeline::kExplodedCustomAxis || a.direction == b.direction); + if (a.enabled == b.enabled && (!a.enabled || sameSlabs)) { + // The same slabs, or none: the gap and offset slide, and a setup + // nobody sees changes halfway + apply(span.u < 0.5 ? a : b, lerp(a.gap, b.gap, span.u), + static_cast(std::lround(lerp(a.offset, b.offset, span.u))), + a.enabled); + return; + } + // Switched on or off, or cut differently: the slabs close up and open + // again in their new arrangement, which changes while they touch + const auto sweep = + sweepAt(span.u, a.enabled, b.enabled, a.otherOn, b.otherOn); + if (!sweep.on) { + const auto& side = sweep.into ? a : b; + apply(side, side.gap, side.offset, false); + return; + } + const auto& side = sweep.into ? b : a; + apply(side, side.gap * sweep.amount, side.offset, true); +} + +// --- Slice and clip planes --- + +QString RecordedPlaneAnimation::type() const +{ + return planeType(qobject_cast(baseNode.data())); +} + +void RecordedPlaneAnimation::applySpan(const AnchorSpan& span) +{ + auto* sink = qobject_cast(baseNode.data()); + if (!sink || m_keys.isEmpty()) { + return; + } + const PlaneKey& a = m_keys[span.from]; + const PlaneKey& b = m_keys[span.to]; + if (a.direction == b.direction && !a.custom()) { + applyPlane(sink, a.direction, + static_cast(std::lround(lerp(a.slice, b.slice, span.u))), + a.center, a.normal); + } else if (a.custom() && b.custom()) { + std::array center, normal; + double length = 0.0; + for (int i = 0; i < 3; ++i) { + center[i] = lerp(a.center[i], b.center[i], span.u); + normal[i] = lerp(a.normal[i], b.normal[i], span.u); + length += normal[i] * normal[i]; + } + if (length < 1e-12) { + // Opposite normals pass through zero; hold the nearer one + normal = span.u < 0.5 ? a.normal : b.normal; + } + applyPlane(sink, 3, a.slice, center, normal); + } else if (span.u <= 0.0 || span.u >= 1.0) { + const PlaneKey& side = span.u <= 0.0 ? a : b; + applyPlane(sink, side.direction, side.slice, side.center, side.normal); + } else { + // A new direction: the plane swings from one orientation to the + // other as a custom plane, turning its normal about the axis the two + // share, and settles into the recorded direction at the end + std::array from = a.normal, to = b.normal; + if (vtkMath::Normalize(from.data()) == 0.0 || + vtkMath::Normalize(to.data()) == 0.0) { + const PlaneKey& side = span.u < 0.5 ? a : b; + applyPlane(sink, side.direction, side.slice, side.center, side.normal); + return; + } + // A slice looks the same from either side, so it takes the short way + // round; a clip keeps its normal, which says which side is cut away + double cosine = vtkMath::Dot(from.data(), to.data()); + if (cosine < 0 && !qobject_cast(sink)) { + vtkMath::MultiplyScalar(to.data(), -1.0); + cosine = -cosine; + } + std::array axis; + vtkMath::Cross(from.data(), to.data(), axis.data()); + if (vtkMath::Normalize(axis.data()) < 1e-9) { + // Parallel: nothing to turn, or a half turn about any axis in the + // plane + double other[3] = { 1, 0, 0 }; + if (std::abs(from[0]) > 0.9) { + other[0] = 0, other[1] = 1; + } + vtkMath::Cross(from.data(), other, axis.data()); + vtkMath::Normalize(axis.data()); + } + const double angle = std::acos(std::clamp(cosine, -1.0, 1.0)) * span.u; + // Rodrigues: the from normal turned by angle about axis + std::array across, normal, center; + vtkMath::Cross(axis.data(), from.data(), across.data()); + for (int i = 0; i < 3; ++i) { + normal[i] = from[i] * std::cos(angle) + across[i] * std::sin(angle); + center[i] = lerp(a.center[i], b.center[i], span.u); + } + applyPlane(sink, 3, a.slice, center, normal); + } +} + +// --- Threshold range --- + +void RecordedThresholdAnimation::applySpan(const AnchorSpan& span) +{ + auto* threshold = qobject_cast(baseNode.data()); + if (!threshold || m_keys.isEmpty()) { + return; + } + const ThresholdKey& a = m_keys[span.from]; + const ThresholdKey& b = m_keys[span.to]; + const double lower = lerp(a.lower, b.lower, span.u); + const double upper = lerp(a.upper, b.upper, span.u); + if (threshold->lowerThreshold() != lower || + threshold->upperThreshold() != upper) { + threshold->setThresholdRange(lower, upper); + } +} + +// --- Hidden labels --- + +void RecordedLabelsAnimation::applySpan(const AnchorSpan& span) +{ + auto* labels = qobject_cast(baseNode.data()); + if (!labels || m_keys.isEmpty()) { + return; + } + // Nothing to fade through, so switch halfway along the leg. The sink + // compares before changing anything. + labels->setHiddenLabels(span.u < 0.5 ? m_keys[span.from] : m_keys[span.to]); +} + +// --- Iso value --- + +void RecordedIsoAnimation::applySpan(const AnchorSpan& span) +{ + auto* contour = qobject_cast(baseNode.data()); + if (!contour || m_keys.isEmpty()) { + return; + } + const double value = lerp(m_keys[span.from], m_keys[span.to], span.u); + if (contour->isoValue() != value) { + contour->setIsoValue(value); + } +} + +// --- Solidity --- + +void RecordedSolidityAnimation::applySpan(const AnchorSpan& span) +{ + auto* volume = qobject_cast(baseNode.data()); + if (!volume || m_keys.isEmpty()) { + return; + } + const double value = + SolidityAnimation::blend(m_keys[span.from], m_keys[span.to], span.u); + if (volume->solidity() != value) { + volume->setSolidity(value); + } +} + +// --- Curve --- + +bool RecordedCurveAnimation::legIsExcluded(double t) const +{ + auto stops = CameraViewpoints::instance().stops(); + if (stops.size() < 2) { + return false; + } + int leg = 0; + while (leg + 2 < stops.size() && stops[leg + 1] <= t) { + ++leg; + } + return m_excludedSegments.contains(leg); +} + +void RecordedCurveAnimation::onTimeChanged() +{ + if (!timeKeeper()) { + return; + } + const double t = CameraViewpoints::instance().remapProgress(progress()); + if (!legIsExcluded(t)) { + applyProgress(t); + } +} + +void RecordedCurveAnimation::onPlaybackStarted() +{ + if (!legIsExcluded(0.0)) { + applyProgress(0.0); + } +} + +void RecordedCurveAnimation::curveApplied(vtkPiecewiseFunction* current) +{ + auto* target = sink(); + if (!target || !current) { + return; + } + bool any = false; + double node[4]; + for (int i = 0; i < current->GetSize() && !any; ++i) { + current->GetNodeValue(i, node); + any = node[1] > 0.0; + } + if (target->visibility() != any) { + target->setVisibility(any); + } +} + +// --- RecordedAnimations --- + +RecordedAnimations& RecordedAnimations::instance() +{ + static RecordedAnimations animations; + return animations; +} + +void RecordedAnimations::install() +{ + if (m_installed) { + return; + } + m_installed = true; + auto resync = [this]() { sync(ActiveObjects::instance().pipeline()); }; + connect(&CameraViewpoints::instance(), &CameraViewpoints::changed, this, + resync); + // Authored animations take legs away from recorded ones and give + // them back when removed. The sync itself edits the list, so it is + // guarded against re-entering through this connection. + connect(&ModuleAnimations::instance(), &ModuleAnimations::changed, this, + resync); + resync(); +} + +QSet RecordedAnimations::recordedNodeIds() const +{ + return recordedIds(recordedAnchors()); +} + +void RecordedAnimations::sync(Pipeline* pipeline) +{ + if (m_syncing) { + return; + } + m_syncing = true; + + auto& registry = ModuleAnimations::instance(); + for (auto* animation : registry.animations()) { + if (animation->recorded()) { + registry.remove(animation); + } + } + + const auto anchors = recordedAnchors(); + if (pipeline && anchors.size() >= 2) { + for (int id : recordedIds(anchors)) { + auto* sink = qobject_cast(pipeline->nodeById(id)); + SinkSnapshot sample; + if (!sink || !anySnapshot(anchors, id, sample)) { + continue; + } + const Family family = familyOf(sink, sample); + auto* volume = qobject_cast(sink); + + if (family == Family::Curve) { + const auto authored = authoredSegments(sink, "scalarOpacity"); + QList keyframes; + bool differs = false; + vtkSmartPointer previous; + for (int anchor : anchors) { + auto curve = + curveOf(snapshotAt(anchor, id, sample), sample.scalarOpacity); + if (!curve) { + continue; + } + differs = differs || (previous && !opacityCurvesEqual(previous, curve)); + previous = curve; + keyframes.append({ anchor, curve }); + } + if (differs && volume) { + registry.add(new RecordedCurveAnimation(volume, keyframes, authored)); + } + } else if (family == Family::Opacity) { + const auto authored = authoredSegments(sink, "opacity"); + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = opacityKey(snapshotAt(anchor, id, sample)); + differs = differs || effectiveOpacity(keys[anchor]) != + effectiveOpacity(keys[anchors.first()]); + } + if (differs) { + auto* animation = new RecordedOpacityAnimation(sink, keys); + animation->excludedSegments = authored; + registry.add(animation); + } + } else { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = snapshotAt(anchor, id, sample).visible; + differs = differs || keys[anchor] != keys[anchors.first()]; + } + if (differs) { + registry.add(new RecordedVisibilityAnimation(sink, keys)); + } + } + + if (volume && sample.cutOutEnabled) { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = cutOutKey(snapshotAt(anchor, id, sample)); + differs = differs || !(keys[anchor] == keys[anchors.first()]); + } + if (differs) { + auto* animation = new RecordedCutOutAnimation(volume, keys); + animation->excludedSegments = authoredSegments(sink, "cutOut"); + registry.add(animation); + } + } + if (volume && sample.explodedEnabled) { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = explodedKey(snapshotAt(anchor, id, sample)); + differs = differs || !(keys[anchor] == keys[anchors.first()]); + } + if (differs) { + auto* animation = new RecordedExplodedAnimation(volume, keys); + animation->excludedSegments = authoredSegments(sink, "exploded"); + registry.add(animation); + } + } + if (sample.planeDirection) { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = planeKey(snapshotAt(anchor, id, sample)); + differs = differs || !(keys[anchor] == keys[anchors.first()]); + } + if (differs) { + auto* animation = new RecordedPlaneAnimation(sink, keys); + animation->excludedSegments = authoredSegments(sink, planeType(sink)); + registry.add(animation); + } + } + if (volume && sample.solidity) { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = snapshotAt(anchor, id, sample).solidity.value_or( + *sample.solidity); + differs = differs || keys[anchor] != keys[anchors.first()]; + } + if (differs) { + auto* animation = new RecordedSolidityAnimation(volume, keys); + animation->excludedSegments = authoredSegments(sink, "solidity"); + registry.add(animation); + } + } + if (sample.isoValue) { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = snapshotAt(anchor, id, sample).isoValue.value_or( + *sample.isoValue); + differs = differs || keys[anchor] != keys[anchors.first()]; + } + if (differs) { + auto* animation = new RecordedIsoAnimation(sink, keys); + animation->excludedSegments = authoredSegments(sink, "contour"); + registry.add(animation); + } + } + if (sample.thresholdLower) { + QMap keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = thresholdKey(snapshotAt(anchor, id, sample)); + differs = differs || !(keys[anchor] == keys[anchors.first()]); + } + if (differs) { + auto* animation = new RecordedThresholdAnimation(sink, keys); + animation->excludedSegments = authoredSegments(sink, "threshold"); + registry.add(animation); + } + } + if (sample.hiddenLabels) { + QMap> keys; + bool differs = false; + for (int anchor : anchors) { + keys[anchor] = snapshotAt(anchor, id, sample) + .hiddenLabels.value_or(*sample.hiddenLabels); + differs = differs || keys[anchor] != keys[anchors.first()]; + } + if (differs) { + registry.add(new RecordedLabelsAnimation(sink, keys)); + } + } + } + } + + m_syncing = false; +} + +QList RecordedAnimations::changes(Pipeline* pipeline) const +{ + QList rows; + const auto anchors = recordedAnchors(); + if (!pipeline || anchors.size() < 2) { + return rows; + } + + // Rows come out grouped by leg, then by module in pipeline order, so + // the list reads as a story rather than a hash walk. + QList ids; + for (auto* node : pipeline->nodes()) { + if (auto* sink = qobject_cast(node)) { + int id = pipeline->nodeId(sink); + SinkSnapshot unused; + if (anySnapshot(anchors, id, unused)) { + ids.append(id); + } + } + } + + for (int step = 0; step + 1 < anchors.size(); ++step) { + const int from = anchors[step]; + const int to = anchors[step + 1]; + for (int id : ids) { + auto* sink = qobject_cast(pipeline->nodeById(id)); + SinkSnapshot sample; + if (!sink || !anySnapshot(anchors, id, sample)) { + continue; + } + const auto a = snapshotAt(from, id, sample); + const auto b = snapshotAt(to, id, sample); + auto* volume = qobject_cast(sink); + auto row = [&](const QString& property, const QString& description, + std::optional start = std::nullopt, + std::optional stop = std::nullopt, + const QString& controlProperty = QString()) { + if (stretchIsAuthored(authoredSegments(sink, authoredTypeFor(property)), + from, to)) { + return; + } + rows.append({ id, property, controlProperty, from, to, description, + start, stop }); + }; + + const Family family = familyOf(sink, sample); + if (family == Family::Curve) { + auto curveA = curveOf(a, sample.scalarOpacity); + auto curveB = curveOf(b, sample.scalarOpacity); + if (curveA && curveB && !opacityCurvesEqual(curveA, curveB)) { + row("curve", !a.visible ? "fades in" + : !b.visible ? "fades out" + : "opacity curve changes"); + } + } else if (family == Family::Opacity) { + const double start = effectiveOpacity(opacityKey(a)); + const double stop = effectiveOpacity(opacityKey(b)); + if (start != stop) { + row("opacity", + start == 0.0 ? "fades in to " + number(stop) + : stop == 0.0 ? "fades out from " + number(start) + : "opacity " + number(start) + " to " + number(stop), + start, stop); + } + } else if (a.visible != b.visible) { + row("visibility", b.visible ? "shown" : "hidden"); + } + + if (volume && sample.cutOutEnabled) { + const auto ka = cutOutKey(a); + const auto kb = cutOutKey(b); + if (!(ka == kb)) { + // A move along one axis is something the controls can show + int moved = -1; + int movedCount = 0; + for (int axis = 0; axis < 3; ++axis) { + if (ka.position[axis] != kb.position[axis]) { + moved = axis; + ++movedCount; + } + } + if (ka.enabled != kb.enabled) { + row("cutOut", kb.enabled ? "cut-out on" : "cut-out off"); + } else if (movedCount == 1 && ka.corner == kb.corner) { + row("cutOut", + QString("cut-out %1 %2 to %3") + .arg(QChar('X' + moved)) + .arg(number(ka.position[moved])) + .arg(number(kb.position[moved])), + ka.position[moved], kb.position[moved], + QString("cutOut") + QChar('X' + moved)); + } else { + row("cutOut", "cut-out moves"); + } + } + } + if (volume && sample.explodedEnabled) { + const auto ka = explodedKey(a); + const auto kb = explodedKey(b); + if (!(ka == kb)) { + if (ka.enabled != kb.enabled) { + row("exploded", + kb.enabled ? "exploded view on" : "exploded view off"); + } else if (ka.axis == kb.axis && ka.direction == kb.direction && + ka.chunks == kb.chunks && ka.offset == kb.offset) { + row("exploded", + "exploded gap " + number(ka.gap) + " to " + number(kb.gap), + ka.gap, kb.gap, "explodedGap"); + } else if (ka.axis == kb.axis && ka.direction == kb.direction && + ka.gap == kb.gap && ka.offset == kb.offset) { + row("exploded", + QString("exploded chunks %1 to %2") + .arg(ka.chunks) + .arg(kb.chunks), + ka.chunks, kb.chunks, "explodedChunks"); + } else if (ka.axis == kb.axis && ka.direction == kb.direction && + ka.gap == kb.gap && ka.chunks == kb.chunks) { + row("exploded", + QString("exploded offset %1 to %2 voxels") + .arg(ka.offset) + .arg(kb.offset), + ka.offset, kb.offset, "explodedOffset"); + } else { + row("exploded", "exploded view changes"); + } + } + } + if (sample.planeDirection) { + const auto ka = planeKey(a); + const auto kb = planeKey(b); + if (!(ka == kb)) { + const QString property = planeType(sink); + if (ka.direction == kb.direction && !ka.custom()) { + row(property, + property + " " + QString::number(ka.slice) + " to " + + QString::number(kb.slice), + ka.slice, kb.slice); + } else if (ka.custom() && kb.custom()) { + row(property, "plane moves"); + } else { + row(property, "plane direction changes"); + } + } + } + if (volume && sample.solidity) { + const double start = a.solidity.value_or(*sample.solidity); + const double stop = b.solidity.value_or(*sample.solidity); + if (start != stop) { + row("solidity", "solidity " + number(start) + " to " + number(stop), + start, stop); + } + } + if (sample.isoValue) { + const double start = a.isoValue.value_or(*sample.isoValue); + const double stop = b.isoValue.value_or(*sample.isoValue); + if (start != stop) { + row("iso", "iso value " + number(start) + " to " + number(stop), + start, stop); + } + } + if (sample.thresholdLower) { + const auto ka = thresholdKey(a); + const auto kb = thresholdKey(b); + if (!(ka == kb)) { + if (ka.upper == kb.upper) { + row("threshold", + "lower threshold " + number(ka.lower) + " to " + + number(kb.lower), + ka.lower, kb.lower, "thresholdLower"); + } else if (ka.lower == kb.lower) { + row("threshold", + "upper threshold " + number(ka.upper) + " to " + + number(kb.upper), + ka.upper, kb.upper, "thresholdUpper"); + } else { + row("threshold", "threshold range changes"); + } + } + } + if (sample.hiddenLabels) { + const auto la = a.hiddenLabels.value_or(*sample.hiddenLabels); + const auto lb = b.hiddenLabels.value_or(*sample.hiddenLabels); + if (la != lb) { + const QString hidden = labelsOnlyIn(lb, la); + const QString shown = labelsOnlyIn(la, lb); + QString description; + if (!hidden.isEmpty()) { + description = "labels " + hidden + " hidden"; + } + if (!shown.isEmpty()) { + description += (description.isEmpty() ? "labels " : ", ") + + shown + " shown"; + } + row("labels", description); + } + } + } + } + return rows; +} + +void RecordedAnimations::remove(const RecordedChange& change, + Pipeline* pipeline) +{ + auto& viewpoints = CameraViewpoints::instance(); + if (!pipeline || change.fromAnchor < 0 || change.toAnchor < 0 || + change.toAnchor >= viewpoints.size() || + change.fromAnchor >= viewpoints.size()) { + return; + } + const auto anchors = recordedAnchors(); + SinkSnapshot sample; + if (!anySnapshot(anchors, change.nodeId, sample)) { + return; + } + const auto earlier = snapshotAt(change.fromAnchor, change.nodeId, sample); + + // The later viewpoint takes the earlier value of just this property, + // so the leg no longer changes it and later legs start from there. + Viewpoint viewpoint = viewpoints.at(change.toAnchor); + auto& sinks = viewpoint.scene.sinks; + if (!sinks.contains(change.nodeId)) { + SinkSnapshot hidden = sample; + hidden.visible = false; + sinks.insert(change.nodeId, hidden); + } + SinkSnapshot& later = sinks[change.nodeId]; + if (change.property == "opacity") { + later.visible = earlier.visible; + later.opacity = earlier.opacity; + } else if (change.property == "visibility") { + later.visible = earlier.visible; + } else if (change.property == "curve") { + later.visible = earlier.visible; + later.scalarOpacity = nullptr; + if (earlier.scalarOpacity) { + later.scalarOpacity = vtkSmartPointer::New(); + later.scalarOpacity->DeepCopy(earlier.scalarOpacity); + } + } else if (change.property == "cutOut") { + later.cutOutEnabled = earlier.cutOutEnabled; + later.cutOutCorner = earlier.cutOutCorner; + later.cutOutPosition = earlier.cutOutPosition; + } else if (change.property == "exploded") { + later.explodedEnabled = earlier.explodedEnabled; + later.explodedAxis = earlier.explodedAxis; + later.explodedDirection = earlier.explodedDirection; + later.explodedChunks = earlier.explodedChunks; + later.explodedGap = earlier.explodedGap; + later.explodedOffset = earlier.explodedOffset; + } else if (change.property == "slice" || change.property == "clip") { + later.planeDirection = earlier.planeDirection; + later.sliceIndex = earlier.sliceIndex; + later.planeCenter = earlier.planeCenter; + later.planeNormal = earlier.planeNormal; + } else if (change.property == "iso") { + later.isoValue = earlier.isoValue; + } else if (change.property == "solidity") { + later.solidity = earlier.solidity; + } else if (change.property == "threshold") { + later.thresholdLower = earlier.thresholdLower; + later.thresholdUpper = earlier.thresholdUpper; + } else if (change.property == "labels") { + later.hiddenLabels = earlier.hiddenLabels; + } else { + return; + } + viewpoints.replace(change.toAnchor, viewpoint); +} + +} // namespace tomviz diff --git a/tomviz/animations/RecordedAnimations.h b/tomviz/animations/RecordedAnimations.h new file mode 100644 index 000000000..12baef359 --- /dev/null +++ b/tomviz/animations/RecordedAnimations.h @@ -0,0 +1,453 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizRecordedAnimations_h +#define tomvizRecordedAnimations_h + +#include "ModuleAnimation.h" +#include "ScalarOpacityAnimation.h" +#include "SceneSnapshot.h" +#include "pipeline/sinks/ExplodedGeometry.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace tomviz { + +namespace pipeline { +class Pipeline; +class VolumeSink; +} // namespace pipeline + +/// The animations that play the module state recorded with the camera +/// viewpoints, and the list of rows that shows it. +/// +/// The viewpoints' snapshots are the only record. Everything here is +/// derived from them: whenever the viewpoints change, the recorded +/// animations in ModuleAnimations are thrown away and rebuilt, and the +/// Animation Helper lists one row per property that differs between two +/// consecutive recorded viewpoints. Removing such a row edits the later +/// viewpoint so the property keeps its earlier value, which is why the +/// row stays gone. An animation the user authored for the same +/// visualization and property takes over the legs it runs on; the +/// recorded animation skips those legs and the list shows the user's +/// row instead. + +/// One property of one visualization changing between two recorded +/// viewpoints, as the Animation Helper lists it. +struct RecordedChange +{ + int nodeId = -1; + /// "opacity", "visibility", "curve", "cutOut", "exploded", "slice", + /// "clip", "iso", "threshold" or "labels". + QString property; + /// The Animation Helper property that authors this kind of change, + /// when the change is one the controls can show: "explodedGap", + /// "cutOutX", "thresholdLower" and so on. Empty means the property + /// itself, or nothing authorable (visibility, labels). + QString controlProperty; + /// Viewpoint indices. Viewpoints between them recorded nothing. + int fromAnchor = 0; + int toAnchor = 1; + /// e.g. "fades in to 0.6" or "cut-out moves". + QString description; + /// For a change with two plain numbers behind it (opacity, slice + /// index, iso value), the value at each end, so the Animation Helper + /// can show it in its controls. + std::optional startValue; + std::optional stopValue; +}; + +/// Where @a t (path time in [0, 1]) falls among the recorded anchors: +/// the surrounding anchors and the fraction between them. Before the +/// first anchor both are the first; after the last, both are the last. +struct AnchorSpan +{ + int from = 0; + int to = 0; + double u = 0.0; +}; +AnchorSpan anchorSpanAt(const QList& anchors, double t); + +/// A recorded module state at one viewpoint. Hidden modules carry the +/// values they will have once shown, so a fade has somewhere to go. +struct OpacityKey +{ + bool visible = true; + double opacity = 1.0; +}; +struct CutOutKey +{ + bool enabled = false; + int corner = 0; + std::array position = { 0.5, 0.5, 0.5 }; + /// The exploded view is on here. The two cannot both be on, so a leg + /// that swaps one for the other gives each half of it. Not part of + /// the cut-out's own state. + bool otherOn = false; + bool operator==(const CutOutKey& o) const + { + return enabled == o.enabled && corner == o.corner && position == o.position; + } +}; +/// A Threshold visualization's range at one viewpoint. +struct ThresholdKey +{ + double lower = 0.0; + double upper = 1.0; + bool operator==(const ThresholdKey& o) const + { + return lower == o.lower && upper == o.upper; + } +}; + +struct ExplodedKey +{ + bool enabled = false; + int axis = 2; + std::array direction = { 1.0, 1.0, 1.0 }; + int chunks = 4; + double gap = 0.25; + int offset = 0; + /// The cut-out is on here; see CutOutKey::otherOn. + bool otherOn = false; + bool operator==(const ExplodedKey& o) const + { + // The direction only shows while the axis is custom + return enabled == o.enabled && axis == o.axis && + (axis != pipeline::kExplodedCustomAxis || + direction == o.direction) && + chunks == o.chunks && gap == o.gap && offset == o.offset; + } +}; + +/// Where a slice or clip plane sits: the slice index while axis +/// aligned, the plane itself while custom (direction 3). +struct PlaneKey +{ + int direction = 0; + int slice = 0; + std::array center = { 0.0, 0.0, 0.0 }; + std::array normal = { 0.0, 0.0, 1.0 }; + bool custom() const { return direction == 3; } + bool operator==(const PlaneKey& o) const + { + if (direction != o.direction) { + return false; + } + return custom() ? (center == o.center && normal == o.normal) + : slice == o.slice; + } +}; + +/// Effective opacity of a key: zero while hidden. +double effectiveOpacity(const OpacityKey& key); + +/// Base of the animations built from the recorded snapshots: keyframed +/// by viewpoint anchor, eased along with the camera, and skipping any +/// leg an authored animation owns. +class RecordedAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + RecordedAnimation(pipeline::Node* node) : ModuleAnimation(node) {} + + bool recorded() const override { return true; } + + /// Legs (viewpoint index of the leg's start) this animation leaves to + /// an authored animation. + QSet excludedSegments; + + void onTimeChanged() override; + void onPlaybackStarted() override { applyPathTime(0.0); } + + /// Apply the state for path time @a t in [0, 1]. Public so it can be + /// driven without a time keeper. + void applyPathTime(double t); + +protected: + virtual void applySpan(const AnchorSpan& span) = 0; + virtual QList anchors() const = 0; +}; + +class RecordedOpacityAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedOpacityAnimation(pipeline::Node* node, + const QMap& keys) + : RecordedAnimation(node), m_keys(keys) + { + } + QString type() const override { return "opacity"; } + QString describeParameters() const override { return "recorded opacity"; } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +class RecordedVisibilityAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedVisibilityAnimation(pipeline::Node* node, const QMap& keys) + : RecordedAnimation(node), m_keys(keys) + { + } + QString type() const override { return "visibility"; } + QString describeParameters() const override + { + return "recorded visibility"; + } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +class RecordedCutOutAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedCutOutAnimation(pipeline::VolumeSink* sink, + const QMap& keys); + QString type() const override { return "cutOut"; } + QString describeParameters() const override { return "recorded cut-out"; } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +class RecordedExplodedAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedExplodedAnimation(pipeline::VolumeSink* sink, + const QMap& keys); + QString type() const override { return "exploded"; } + QString describeParameters() const override + { + return "recorded exploded view"; + } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +/// A slice or clip plane moving between recorded positions: the index +/// slides while both ends share an axis, a custom plane slides and +/// turns, and anything else switches halfway. +class RecordedPlaneAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedPlaneAnimation(pipeline::Node* node, const QMap& keys) + : RecordedAnimation(node), m_keys(keys) + { + } + /// "slice" for a slice, "clip" for a clip: the authored types that + /// take a leg over. + QString type() const override; + QString describeParameters() const override { return "recorded plane"; } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +class RecordedIsoAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedIsoAnimation(pipeline::Node* node, const QMap& keys) + : RecordedAnimation(node), m_keys(keys) + { + } + QString type() const override { return "contour"; } + QString describeParameters() const override { return "recorded iso value"; } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +/// A volume's solidity between recorded values, swept geometrically as +/// SolidityAnimation does. +class RecordedSolidityAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedSolidityAnimation(pipeline::VolumeSink* sink, + const QMap& keys) + : RecordedAnimation(sink), m_keys(keys) + { + } + QString type() const override { return "solidity"; } + QString describeParameters() const override { return "recorded solidity"; } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +class RecordedThresholdAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedThresholdAnimation(pipeline::Node* node, + const QMap& keys) + : RecordedAnimation(node), m_keys(keys) + { + } + QString type() const override { return "threshold"; } + QString describeParameters() const override + { + return "recorded threshold range"; + } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap m_keys; +}; + +/// The labels of a label map hidden at each viewpoint. There is nothing +/// to fade through, so the set switches halfway along the leg. +class RecordedLabelsAnimation : public RecordedAnimation +{ + Q_OBJECT + +public: + RecordedLabelsAnimation(pipeline::Node* node, + const QMap>& keys) + : RecordedAnimation(node), m_keys(keys) + { + } + QString type() const override { return "labels"; } + QString describeParameters() const override + { + return "recorded label visibility"; + } + +protected: + void applySpan(const AnchorSpan& span) override; + QList anchors() const override { return m_keys.keys(); } + +private: + QMap> m_keys; +}; + +/// The recorded opacity curves of a volume. A viewpoint where the +/// volume was hidden contributes an all-zero curve, and the volume is +/// shown exactly while the blended curve has any opacity. +class RecordedCurveAnimation : public ScalarOpacityAnimation +{ + Q_OBJECT + +public: + RecordedCurveAnimation(pipeline::VolumeSink* sink, + const QList& keyframes, + const QSet& excludedSegments) + : ScalarOpacityAnimation(sink, keyframes), + m_excludedSegments(excludedSegments) + { + } + bool recorded() const override { return true; } + QString describeParameters() const override + { + return "recorded opacity curve"; + } + void onTimeChanged() override; + void onPlaybackStarted() override; + +protected: + void curveApplied(vtkPiecewiseFunction* current) override; + +private: + bool legIsExcluded(double t) const; + QSet m_excludedSegments; +}; + +/// Keeps ModuleAnimations in step with the viewpoints and answers for +/// the Animation Helper. +class RecordedAnimations : public QObject +{ + Q_OBJECT + +public: + static RecordedAnimations& instance(); + + /// Follow the viewpoints and the authored animations of the active + /// pipeline from now on. Safe to call more than once. + void install(); + + /// Rebuild the recorded animations in ModuleAnimations from the + /// viewpoints, against the visualizations of @a pipeline. + void sync(pipeline::Pipeline* pipeline); + + /// The rows to list: every property that differs between two + /// consecutive recorded viewpoints, except on legs an authored + /// animation owns. + QList changes(pipeline::Pipeline* pipeline) const; + + /// Edit the later viewpoint of @a change so the property keeps its + /// value from the earlier one. The viewpoints then re-sync. + void remove(const RecordedChange& change, pipeline::Pipeline* pipeline); + + /// Every visualization some viewpoint recorded. Go To hides those a + /// particular viewpoint lacks; ones no viewpoint knows are left alone. + QSet recordedNodeIds() const; + +private: + RecordedAnimations() = default; + Q_DISABLE_COPY(RecordedAnimations) + + bool m_installed = false; + bool m_syncing = false; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/ScalarOpacityAnimation.h b/tomviz/animations/ScalarOpacityAnimation.h new file mode 100644 index 000000000..b0f932ea0 --- /dev/null +++ b/tomviz/animations/ScalarOpacityAnimation.h @@ -0,0 +1,201 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizScalarOpacityAnimation_h +#define tomvizScalarOpacityAnimation_h + +#include "CameraViewpoints.h" +#include "ModuleAnimation.h" +#include "OpacityInterpolation.h" + +#include "Utilities.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/LabelMapSink.h" +#include "pipeline/sinks/VolumeSink.h" + +#include + +#include +#include +#include + +#include + +namespace tomviz { + +/// An opacity curve captured for one point of the animation. The anchor +/// is a viewpoint index, resolved to a time through the camera path's +/// stops, so retiming a leg moves the curve change with it. With no +/// camera path, anchor 0 is the start of the animation and anything +/// later is the end. +struct OpacityKeyframe +{ + int anchor = 0; + vtkSmartPointer curve; +}; + +/// Morphs a volume through a sequence of opacity curves, one keyed to +/// each chosen viewpoint, so a feature can dissolve away leg by leg. +/// +/// One animation holds the whole sequence: separate morphs on the same +/// volume would each write its opacity every tick and the last writer +/// would win. The blend goes to the sink separately rather than into +/// the curve the histogram editor owns, which an interrupted morph +/// would otherwise leave replaced by a sampled one. +class ScalarOpacityAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + ScalarOpacityAnimation(pipeline::VolumeSink* sink, + const QList& keyframes) + : ModuleAnimation(sink) + { + for (const auto& keyframe : keyframes) { + if (!keyframe.curve || keyframe.curve->GetSize() == 0) { + continue; + } + OpacityKeyframe copy; + copy.anchor = keyframe.anchor; + copy.curve = vtkSmartPointer::New(); + copy.curve->DeepCopy(keyframe.curve); + m_keyframes.append(copy); + } + std::sort(m_keyframes.begin(), m_keyframes.end(), + [](const OpacityKeyframe& a, const OpacityKeyframe& b) { + return a.anchor < b.anchor; + }); + } + + ~ScalarOpacityAnimation() override + { + // Hand the volume back to the curve in the histogram editor. + if (auto* target = sink()) { + target->setAnimatedScalarOpacity(nullptr); + } + } + + void onPlaybackEnded() override + { + // While the animation exists its blend owns the volume's opacity; + // when playback stops, hand the volume back to the histogram + // editor's curve. Without this the override stays installed and + // editing the transfer function appears to do nothing. + if (auto* target = sink()) { + target->setAnimatedScalarOpacity(nullptr); + } + } + + /// True if @a node has a scalar opacity worth animating. Only volume + /// rendering reads one; the surface sinks have a flat opacity instead, + /// which OpacityAnimation covers. Label maps render through the same + /// sink but rebuild their transfer functions from the label table, so + /// a morph would fight that projection every tick. + static bool supports(pipeline::Node* node) + { + return qobject_cast(node) && + !qobject_cast(node); + } + + pipeline::VolumeSink* sink() + { + return qobject_cast(baseNode.data()); + } + + const QList& keyframes() const { return m_keyframes; } + + QString type() const override { return "scalarOpacity"; } + + QString describeParameters() const override + { + return QString("opacity curve, %1 keyframes").arg(m_keyframes.size()); + } + + QJsonObject serialize() const override + { + QJsonArray array; + for (const auto& keyframe : m_keyframes) { + QJsonObject entry; + entry["anchor"] = keyframe.anchor; + entry["curve"] = tomviz::serialize(keyframe.curve.Get()); + array.append(entry); + } + QJsonObject json; + json["keyframes"] = array; + return json; + } + + void onTimeChanged() override + { + if (timeKeeper()) { + applyProgress(progress()); + } + } + + // Frame 0 is not announced when the clock already sits there + void onPlaybackStarted() override { applyProgress(0.0); } + + /// Apply the blend for progress @a t in [0, 1]. Public so it can be + /// driven without a time keeper. + void applyProgress(double t) + { + auto* target = sink(); + if (!target || m_keyframes.isEmpty()) { + return; + } + + auto& viewpoints = CameraViewpoints::instance(); + + // The keyframe pair whose window contains the current time. Before + // the first keyframe the first curve holds; after the last, the last. + int next = 0; + while (next < m_keyframes.size() && + viewpoints.anchorTime(m_keyframes[next].anchor) <= t) { + ++next; + } + + if (next == 0) { + m_current->DeepCopy(m_keyframes.first().curve); + } else if (next == m_keyframes.size()) { + m_current->DeepCopy(m_keyframes.last().curve); + } else { + const auto& from = m_keyframes[next - 1]; + const auto& to = m_keyframes[next]; + // The morph runs on the leg: from leaving one anchor, after any + // orbit there, to reaching the next. + const double start = viewpoints.departureTime(from.anchor); + const double stop = viewpoints.anchorTime(to.anchor); + const double u = + stop > start ? std::clamp((t - start) / (stop - start), 0.0, 1.0) + : 1.0; + + // Both curves are read over the volume's own window, which is what + // makes two curves captured at different times comparable. + double range[2] = { 0.0, 1.0 }; + auto volume = target->volumeData(); + if (volume && volume->isValid()) { + auto volumeRange = volume->colorMapRange(); + range[0] = volumeRange[0]; + range[1] = volumeRange[1]; + } + + interpolateOpacity(from.curve, to.curve, u, range, m_current); + } + + target->setAnimatedScalarOpacity(m_current); + curveApplied(m_current); + } + +protected: + /// The blend just handed to the volume. A subclass that ties the + /// volume's visibility to its curve reads it here. + virtual void curveApplied(vtkPiecewiseFunction*) {} + +private: + QList m_keyframes; + vtkNew m_current; +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/SceneSnapshot.cxx b/tomviz/animations/SceneSnapshot.cxx new file mode 100644 index 000000000..f35401e0c --- /dev/null +++ b/tomviz/animations/SceneSnapshot.cxx @@ -0,0 +1,514 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "SceneSnapshot.h" + +#include "OpacityInterpolation.h" +#include "ScalarOpacityAnimation.h" +#include "Utilities.h" +#include "pipeline/Pipeline.h" +#include "pipeline/data/VolumeData.h" +#include "pipeline/sinks/ClipSink.h" +#include "pipeline/sinks/ContourSink.h" +#include "pipeline/sinks/LabelMapSink.h" +#include "pipeline/sinks/LegacyModuleSink.h" +#include "pipeline/sinks/SegmentSink.h" +#include "pipeline/sinks/SliceSink.h" +#include "pipeline/sinks/ThresholdSink.h" +#include "pipeline/sinks/VolumeSink.h" + +#include +#include +#include + +#include +#include + +#include + +namespace tomviz { + +using pipeline::ClipSink; +using pipeline::ContourSink; +using pipeline::LabelMapSink; +using pipeline::LegacyModuleSink; +using pipeline::Pipeline; +using pipeline::SegmentSink; +using pipeline::SliceSink; +using pipeline::ThresholdSink; +using pipeline::VolumeSink; + +namespace { + +// Flat opacity getters/setters for the module types that have one +std::optional flatOpacity(LegacyModuleSink* sink) +{ + if (auto* contour = qobject_cast(sink)) { + return contour->opacity(); + } else if (auto* slice = qobject_cast(sink)) { + return slice->opacity(); + } else if (auto* clip = qobject_cast(sink)) { + return clip->opacity(); + } else if (auto* threshold = qobject_cast(sink)) { + return threshold->opacity(); + } else if (auto* segment = qobject_cast(sink)) { + return segment->opacity(); + } else if (auto* labels = qobject_cast(sink)) { + // A label map's volume representation has no flat opacity; the + // surface one is what fades + return labels->surfaceOpacity(); + } + return std::nullopt; +} + +void setFlatOpacity(LegacyModuleSink* sink, double value) +{ + if (auto* contour = qobject_cast(sink)) { + contour->setOpacity(value); + } else if (auto* slice = qobject_cast(sink)) { + slice->setOpacity(value); + } else if (auto* clip = qobject_cast(sink)) { + clip->setOpacity(value); + } else if (auto* threshold = qobject_cast(sink)) { + threshold->setOpacity(value); + } else if (auto* segment = qobject_cast(sink)) { + segment->setOpacity(value); + } else if (auto* labels = qobject_cast(sink)) { + labels->setSurfaceOpacity(value); + } +} + +vtkPiecewiseFunction* liveOpacityCurve(VolumeSink* volume) +{ + auto* proxy = volume->opacityMap(); + return proxy ? vtkPiecewiseFunction::SafeDownCast(proxy->GetClientSideObject()) + : nullptr; +} + +void colorMapRange(VolumeSink* volume, double range[2]) +{ + range[0] = 0.0; + range[1] = 1.0; + auto data = volume->volumeData(); + if (data && data->isValid()) { + auto r = data->colorMapRange(); + range[0] = r[0]; + range[1] = r[1]; + } +} + +bool curvesEqual(vtkPiecewiseFunction* a, vtkPiecewiseFunction* b) +{ + if (!a || !b || a->GetSize() != b->GetSize()) { + return false; + } + double pa[4], pb[4]; + for (int i = 0; i < a->GetSize(); ++i) { + a->GetNodeValue(i, pa); + b->GetNodeValue(i, pb); + for (int k = 0; k < 4; ++k) { + if (std::abs(pa[k] - pb[k]) > 1e-12) { + return false; + } + } + } + return true; +} + +// The same curve with every opacity at zero: the "hidden" end of a fade +vtkSmartPointer zeroed(vtkPiecewiseFunction* curve) +{ + auto out = vtkSmartPointer::New(); + double p[4]; + for (int i = 0; i < curve->GetSize(); ++i) { + curve->GetNodeValue(i, p); + out->AddPoint(p[0], 0.0, p[2], p[3]); + } + return out; +} + +} // namespace + +SinkSnapshot SinkSnapshot::capture(LegacyModuleSink* sink) +{ + SinkSnapshot snapshot; + snapshot.visible = sink->visibility(); + snapshot.opacity = flatOpacity(sink); + if (auto* volume = qobject_cast(sink)) { + if (ScalarOpacityAnimation::supports(volume)) { + if (auto* live = liveOpacityCurve(volume)) { + snapshot.scalarOpacity = vtkSmartPointer::New(); + snapshot.scalarOpacity->DeepCopy(live); + // The editor's end placeholders are chart furniture, not data + removePlaceholderNodes(snapshot.scalarOpacity); + } + } + snapshot.solidity = volume->solidity(); + snapshot.cutOutEnabled = volume->cutOutEnabled(); + snapshot.cutOutCorner = volume->cutOutCorner(); + snapshot.cutOutPosition = { { volume->cutOutPosition(0), + volume->cutOutPosition(1), + volume->cutOutPosition(2) } }; + snapshot.explodedEnabled = volume->explodedEnabled(); + snapshot.explodedAxis = volume->explodedAxis(); + snapshot.explodedDirection = volume->explodedDirection(); + snapshot.explodedChunks = volume->explodedChunks(); + snapshot.explodedGap = volume->explodedGap(); + snapshot.explodedOffset = volume->explodedOffset(); + } + double center[3], normal[3]; + if (auto* slice = qobject_cast(sink)) { + snapshot.planeDirection = static_cast(slice->direction()); + snapshot.sliceIndex = slice->slice(); + slice->planeCenter(center); + slice->planeNormal(normal); + snapshot.planeCenter = { { center[0], center[1], center[2] } }; + snapshot.planeNormal = { { normal[0], normal[1], normal[2] } }; + } else if (auto* clip = qobject_cast(sink)) { + snapshot.planeDirection = static_cast(clip->direction()); + snapshot.sliceIndex = clip->slice(); + clip->planeCenter(center); + // What setPlaneNormal takes back; the world normal differs once the + // volume is moved or turned + clip->planeNormalInData(normal); + snapshot.planeCenter = { { center[0], center[1], center[2] } }; + snapshot.planeNormal = { { normal[0], normal[1], normal[2] } }; + } else if (auto* contour = qobject_cast(sink)) { + snapshot.isoValue = contour->isoValue(); + } else if (auto* threshold = qobject_cast(sink)) { + snapshot.thresholdLower = threshold->lowerThreshold(); + snapshot.thresholdUpper = threshold->upperThreshold(); + } + if (auto* labels = qobject_cast(sink)) { + snapshot.hiddenLabels = labels->hiddenLabels(); + } + return snapshot; +} + +void applyPlane(LegacyModuleSink* sink, int direction, int sliceIndex, + const std::array& center, + const std::array& normal) +{ + if (auto* slice = qobject_cast(sink)) { + auto dir = static_cast(direction); + if (slice->direction() != dir) { + slice->setDirection(dir); + } + if (dir == SliceSink::Custom) { + slice->setPlaneCenter(center[0], center[1], center[2]); + slice->setPlaneNormal(normal[0], normal[1], normal[2]); + } else if (slice->slice() != sliceIndex) { + slice->setSlice(sliceIndex); + } + } else if (auto* clip = qobject_cast(sink)) { + auto dir = static_cast(direction); + if (clip->direction() != dir) { + clip->setDirection(dir); + } + if (dir == ClipSink::Custom) { + clip->setPlaneOrigin(center[0], center[1], center[2]); + clip->setPlaneNormal(normal[0], normal[1], normal[2]); + } else if (clip->slice() != sliceIndex) { + clip->setSlice(sliceIndex); + } + } +} + +QJsonObject SinkSnapshot::serialize() const +{ + QJsonObject json; + json["visible"] = visible; + if (opacity) { + json["opacity"] = *opacity; + } + if (scalarOpacity) { + json["scalarOpacity"] = tomviz::serialize(scalarOpacity.Get()); + } + if (cutOutEnabled) { + QJsonObject cutOut; + cutOut["enabled"] = *cutOutEnabled; + cutOut["corner"] = cutOutCorner.value_or(0); + auto pos = cutOutPosition.value_or(std::array{ 0.5, 0.5, 0.5 }); + cutOut["position"] = QJsonArray{ pos[0], pos[1], pos[2] }; + json["cutOut"] = cutOut; + } + if (explodedEnabled) { + QJsonObject exploded; + exploded["enabled"] = *explodedEnabled; + exploded["axis"] = explodedAxis.value_or(2); + if (explodedDirection) { + const auto& d = *explodedDirection; + exploded["direction"] = QJsonArray{ d[0], d[1], d[2] }; + } + exploded["chunks"] = explodedChunks.value_or(4); + exploded["gap"] = explodedGap.value_or(0.25); + exploded["offset"] = explodedOffset.value_or(0); + json["exploded"] = exploded; + } + if (planeDirection) { + QJsonObject plane; + plane["direction"] = *planeDirection; + plane["slice"] = sliceIndex.value_or(0); + auto c = planeCenter.value_or(std::array{ 0.0, 0.0, 0.0 }); + auto n = planeNormal.value_or(std::array{ 0.0, 0.0, 1.0 }); + plane["center"] = QJsonArray{ c[0], c[1], c[2] }; + plane["normal"] = QJsonArray{ n[0], n[1], n[2] }; + json["plane"] = plane; + } + if (isoValue) { + json["iso"] = *isoValue; + } + if (solidity) { + json["solidity"] = *solidity; + } + if (thresholdLower && thresholdUpper) { + json["threshold"] = + QJsonObject{ { "lower", *thresholdLower }, { "upper", *thresholdUpper } }; + } + if (hiddenLabels) { + QJsonArray labels; + for (double value : *hiddenLabels) { + labels.append(value); + } + json["hiddenLabels"] = labels; + } + return json; +} + +SinkSnapshot SinkSnapshot::deserialize(const QJsonObject& json) +{ + SinkSnapshot snapshot; + snapshot.visible = json["visible"].toBool(true); + if (json.contains("opacity")) { + snapshot.opacity = json["opacity"].toDouble(); + } + if (json.contains("scalarOpacity")) { + snapshot.scalarOpacity = vtkSmartPointer::New(); + tomviz::deserialize(snapshot.scalarOpacity.Get(), + json["scalarOpacity"].toObject()); + } + if (json.contains("exploded")) { + auto exploded = json["exploded"].toObject(); + snapshot.explodedEnabled = exploded["enabled"].toBool(); + snapshot.explodedAxis = exploded["axis"].toInt(2); + auto direction = exploded["direction"].toArray(); + if (direction.size() == 3) { + snapshot.explodedDirection = { { direction[0].toDouble(), + direction[1].toDouble(), + direction[2].toDouble() } }; + } + snapshot.explodedChunks = exploded["chunks"].toInt(4); + snapshot.explodedGap = exploded["gap"].toDouble(0.25); + snapshot.explodedOffset = exploded["offset"].toInt(0); + } + if (json.contains("plane")) { + auto plane = json["plane"].toObject(); + snapshot.planeDirection = plane["direction"].toInt(); + snapshot.sliceIndex = plane["slice"].toInt(); + auto center = plane["center"].toArray(); + auto normal = plane["normal"].toArray(); + if (center.size() == 3 && normal.size() == 3) { + snapshot.planeCenter = { { center[0].toDouble(), center[1].toDouble(), + center[2].toDouble() } }; + snapshot.planeNormal = { { normal[0].toDouble(), normal[1].toDouble(), + normal[2].toDouble() } }; + } + } + if (json.contains("iso")) { + snapshot.isoValue = json["iso"].toDouble(); + } + if (json.contains("solidity")) { + snapshot.solidity = json["solidity"].toDouble(); + } + if (json.contains("threshold")) { + auto threshold = json["threshold"].toObject(); + snapshot.thresholdLower = threshold["lower"].toDouble(); + snapshot.thresholdUpper = threshold["upper"].toDouble(); + } + if (json.contains("hiddenLabels")) { + QVector labels; + for (const auto& value : json["hiddenLabels"].toArray()) { + labels.append(value.toDouble()); + } + snapshot.hiddenLabels = labels; + } + if (json.contains("cutOut")) { + auto cutOut = json["cutOut"].toObject(); + snapshot.cutOutEnabled = cutOut["enabled"].toBool(); + snapshot.cutOutCorner = cutOut["corner"].toInt(); + auto pos = cutOut["position"].toArray(); + if (pos.size() == 3) { + snapshot.cutOutPosition = { { pos[0].toDouble(), pos[1].toDouble(), + pos[2].toDouble() } }; + } + } + return snapshot; +} + +SceneSnapshot SceneSnapshot::capture(Pipeline* pipeline) +{ + SceneSnapshot scene; + if (!pipeline) { + return scene; + } + scene.recorded = true; + for (auto* node : pipeline->nodes()) { + if (auto* sink = qobject_cast(node)) { + scene.sinks.insert(pipeline->nodeId(sink), SinkSnapshot::capture(sink)); + } + } + return scene; +} + +void SceneSnapshot::apply(Pipeline* pipeline, const QSet* known) const +{ + if (!pipeline) { + return; + } + // A module some viewpoint recorded but this one lacks was not on + // screen when this one was saved. Modules no viewpoint knows are left + // alone, and so is everything when this viewpoint recorded nothing. + if (!sinks.isEmpty() && known) { + for (auto* node : pipeline->nodes()) { + auto* sink = qobject_cast(node); + if (!sink) { + continue; + } + const int id = pipeline->nodeId(sink); + if (known->contains(id) && !sinks.contains(id) && sink->visibility()) { + sink->setVisibility(false); + } + } + } + + for (auto it = sinks.cbegin(); it != sinks.cend(); ++it) { + auto* sink = qobject_cast(pipeline->nodeById(it.key())); + if (!sink) { + continue; + } + const auto& snapshot = it.value(); + if (snapshot.opacity) { + setFlatOpacity(sink, *snapshot.opacity); + } + if (auto* volume = qobject_cast(sink)) { + if (snapshot.scalarOpacity) { + if (auto* live = liveOpacityCurve(volume)) { + double range[2]; + colorMapRange(volume, range); + live->DeepCopy(snapshot.scalarOpacity); + addPlaceholderNodes(live, range); + live->Modified(); + } + } + if (snapshot.cutOutEnabled) { + volume->setCutOutCorner(snapshot.cutOutCorner.value_or(0)); + if (snapshot.cutOutPosition) { + for (int axis = 0; axis < 3; ++axis) { + volume->setCutOutPosition(axis, (*snapshot.cutOutPosition)[axis]); + } + } + volume->setCutOutEnabled(*snapshot.cutOutEnabled); + } + if (snapshot.explodedEnabled) { + if (snapshot.explodedDirection) { + const auto& d = *snapshot.explodedDirection; + volume->setExplodedDirection(d[0], d[1], d[2]); + } + // The camera was just placed at the viewpoint, or is flying; + // neither switch may refit it. + volume->setExplodedAxis(snapshot.explodedAxis.value_or(2), + /*refitCamera=*/false); + volume->setExplodedChunks(snapshot.explodedChunks.value_or(4)); + volume->setExplodedGap(snapshot.explodedGap.value_or(0.25)); + volume->setExplodedOffset(snapshot.explodedOffset.value_or(0)); + volume->setExplodedEnabled(*snapshot.explodedEnabled, + /*refitCamera=*/false); + } + } + if (snapshot.planeDirection && snapshot.planeCenter && + snapshot.planeNormal) { + applyPlane(sink, *snapshot.planeDirection, snapshot.sliceIndex.value_or(0), + *snapshot.planeCenter, *snapshot.planeNormal); + } + if (snapshot.solidity) { + if (auto* volume = qobject_cast(sink)) { + volume->setSolidity(*snapshot.solidity); + } + } + if (snapshot.isoValue) { + if (auto* contour = qobject_cast(sink)) { + if (contour->isoValue() != *snapshot.isoValue) { + contour->setIsoValue(*snapshot.isoValue); + } + } + } + if (snapshot.thresholdLower && snapshot.thresholdUpper) { + if (auto* threshold = qobject_cast(sink)) { + if (threshold->lowerThreshold() != *snapshot.thresholdLower || + threshold->upperThreshold() != *snapshot.thresholdUpper) { + threshold->setThresholdRange(*snapshot.thresholdLower, + *snapshot.thresholdUpper); + } + } + } + if (snapshot.hiddenLabels) { + if (auto* labels = qobject_cast(sink)) { + labels->setHiddenLabels(*snapshot.hiddenLabels); + } + } + if (sink->visibility() != snapshot.visible) { + sink->setVisibility(snapshot.visible); + } + } +} + +QJsonObject SceneSnapshot::serialize() const +{ + QJsonObject json; + if (!recorded) { + return json; + } + json["recorded"] = true; + for (auto it = sinks.cbegin(); it != sinks.cend(); ++it) { + json[QString::number(it.key())] = it.value().serialize(); + } + return json; +} + +SceneSnapshot SceneSnapshot::deserialize(const QJsonObject& json) +{ + SceneSnapshot scene; + for (auto it = json.begin(); it != json.end(); ++it) { + bool ok = false; + int id = it.key().toInt(&ok); + if (ok) { + scene.sinks.insert(id, SinkSnapshot::deserialize(it.value().toObject())); + } + } + // Files from before the flag recorded a scene exactly when it listed + // modules + scene.recorded = json["recorded"].toBool(!scene.sinks.isEmpty()); + return scene; +} + +std::optional sinkFlatOpacity(LegacyModuleSink* sink) +{ + return flatOpacity(sink); +} + +void setSinkFlatOpacity(LegacyModuleSink* sink, double value) +{ + setFlatOpacity(sink, value); +} + +bool opacityCurvesEqual(vtkPiecewiseFunction* a, vtkPiecewiseFunction* b) +{ + return curvesEqual(a, b); +} + +vtkSmartPointer zeroedCurve(vtkPiecewiseFunction* curve) +{ + return zeroed(curve); +} + +} // namespace tomviz diff --git a/tomviz/animations/SceneSnapshot.h b/tomviz/animations/SceneSnapshot.h new file mode 100644 index 000000000..a70c97bd7 --- /dev/null +++ b/tomviz/animations/SceneSnapshot.h @@ -0,0 +1,112 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizSceneSnapshot_h +#define tomvizSceneSnapshot_h + +#include + +#include +#include +#include +#include + +#include +#include + +class vtkPiecewiseFunction; + +namespace tomviz { + +namespace pipeline { +class Pipeline; +class LegacyModuleSink; +} // namespace pipeline + +/// The whitelisted state of one visualization module that a viewpoint +/// records: visibility, the flat opacity of surface/plane modules (and +/// a label map's surface), the scalar opacity curve and solidity of a +/// volume, the volume cut-out and exploded view, where a slice or clip plane sits, +/// a contour's iso value, a threshold's range, and which labels of a +/// label map are hidden. Fields a module does not have stay unset. +struct SinkSnapshot +{ + bool visible = true; + std::optional opacity; + vtkSmartPointer scalarOpacity; + /// Volumes: the solidity. + std::optional solidity; + std::optional cutOutEnabled; + std::optional cutOutCorner; + std::optional> cutOutPosition; + std::optional explodedEnabled; + std::optional explodedAxis; + /// The custom direction, kept even while an axis is selected so + /// switching back finds it. + std::optional> explodedDirection; + std::optional explodedChunks; + std::optional explodedGap; + std::optional explodedOffset; + /// Slice and clip planes: the direction (0..2 axis aligned, 3 custom), + /// the slice index while axis aligned, and the plane while custom. + std::optional planeDirection; + std::optional sliceIndex; + std::optional> planeCenter; + std::optional> planeNormal; + /// Contours: the iso value. + std::optional isoValue; + /// Threshold visualizations: the range shown. + std::optional thresholdLower; + std::optional thresholdUpper; + /// Label maps: the labels the user hid, ascending. + std::optional> hiddenLabels; + + static SinkSnapshot capture(pipeline::LegacyModuleSink* sink); + QJsonObject serialize() const; + static SinkSnapshot deserialize(const QJsonObject& json); +}; + +/// Module state recorded with a camera viewpoint, keyed by pipeline node +/// id so it can be matched up again after a state file round trip. +struct SceneSnapshot +{ + QHash sinks; + /// Whether anything was recorded at all. A viewpoint saved with + /// recording on but no visualizations yet has recorded that there + /// were none, which is not the same as having recorded nothing. + bool recorded = false; + + bool isEmpty() const { return !recorded; } + + static SceneSnapshot capture(pipeline::Pipeline* pipeline); + /// Put every recorded module back the way it was, as "Go To" does. + /// Of the modules in @a known (those some viewpoint recorded), any + /// this snapshot lacks is hidden, since it was not on screen when the + /// snapshot was taken. An empty snapshot (recording was off) touches + /// nothing, and neither does a null @a known. + void apply(pipeline::Pipeline* pipeline, + const QSet* known = nullptr) const; + + QJsonObject serialize() const; + static SceneSnapshot deserialize(const QJsonObject& json); +}; + +/// Flat opacity of the surface and plane modules that have one. +std::optional sinkFlatOpacity(pipeline::LegacyModuleSink* sink); +void setSinkFlatOpacity(pipeline::LegacyModuleSink* sink, double value); + +/// Node-for-node equality of two opacity curves. +bool opacityCurvesEqual(vtkPiecewiseFunction* a, vtkPiecewiseFunction* b); + +/// The same curve with every opacity at zero: the hidden end of a fade. +vtkSmartPointer zeroedCurve(vtkPiecewiseFunction* curve); + +/// Put a slice or clip plane where a snapshot recorded it: the slice +/// index while axis aligned, the plane itself while custom. +void applyPlane(pipeline::LegacyModuleSink* sink, int direction, + int sliceIndex, const std::array& center, + const std::array& normal); + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/SliceAnimation.h b/tomviz/animations/SliceAnimation.h index 1a2e6dfc8..cc5301884 100644 --- a/tomviz/animations/SliceAnimation.h +++ b/tomviz/animations/SliceAnimation.h @@ -6,34 +6,72 @@ #include "ModuleAnimation.h" -#include "ModuleSlice.h" +#include "pipeline/sinks/SliceSink.h" namespace tomviz { +/// Sweeps a slice through the data. An axis-aligned slice moves by +/// slice index, the unit its own property panel uses; a custom-oriented +/// slice has no slices, so it moves by signed distance from the center +/// of the data along its own normal instead. class SliceAnimation : public ModuleAnimation { Q_OBJECT public: + enum Unit + { + Slice, + Distance + }; + double startValue = 0; double stopValue = 0; + Unit unit = Slice; + + SliceAnimation(pipeline::SliceSink* sink, double start, double stop, + Unit u = Slice) + : ModuleAnimation(sink), startValue(start), stopValue(stop), unit(u) + { + } + + pipeline::SliceSink* sink() + { + return qobject_cast(baseNode.data()); + } + + QString type() const override { return "slice"; } - SliceAnimation(ModuleSlice* module, double start, double stop) - : ModuleAnimation(module), startValue(start), stopValue(stop) + QString describeParameters() const override { + return QString("%1 %2 to %3") + .arg(unit == Slice ? "slice" : "position") + .arg(startValue) + .arg(stopValue); } - ModuleSlice* module() { return qobject_cast(baseModule); } + QJsonObject serialize() const override + { + // The unit is saved rather than re-derived on load: a slice whose + // direction changed between sessions would otherwise come back + // reading slice indices as distances, or the other way around. + return { { "start", startValue }, + { "stop", stopValue }, + { "unit", unit == Slice ? "slice" : "distance" } }; + } void onTimeChanged() override { - if (!timeKeeper()) { + if (!timeKeeper() || !sink()) { return; } - // Simple interpolation double value = (stopValue - startValue) * progress() + startValue; - module()->onSliceChanged(value); + if (unit == Slice) { + sink()->setSlice(static_cast(value)); + } else { + sink()->setPlaneDistance(value); + } } }; diff --git a/tomviz/animations/SolidityAnimation.h b/tomviz/animations/SolidityAnimation.h new file mode 100644 index 000000000..0eec24201 --- /dev/null +++ b/tomviz/animations/SolidityAnimation.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 tomvizSolidityAnimation_h +#define tomvizSolidityAnimation_h + +#include "ModuleAnimation.h" +#include "pipeline/sinks/VolumeSink.h" + +#include + +namespace tomviz { + +/// A volume's solidity swept from one value to another. Solidity scales +/// how much each unit of depth absorbs, so the sweep is geometric: equal +/// steps of time multiply it by equal factors, which reads as an even +/// thickening rather than one that is all over in the first moments. +class SolidityAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + double startValue = 1; + double stopValue = 1; + + SolidityAnimation(pipeline::VolumeSink* sink, double start, double stop) + : ModuleAnimation(sink), startValue(start), stopValue(stop) + { + } + + /// The value @a u (0 to 1) of the way from @a start to @a stop + static double blend(double start, double stop, double u) + { + if (start <= 0 || stop <= 0) { + return start + (stop - start) * u; + } + return std::exp(std::log(start) + (std::log(stop) - std::log(start)) * u); + } + + pipeline::VolumeSink* sink() + { + return qobject_cast(baseNode.data()); + } + + QString type() const override { return "solidity"; } + QString describeParameters() const override + { + return QString("solidity %1 to %2").arg(startValue).arg(stopValue); + } + + QJsonObject serialize() const override + { + return { { "start", startValue }, { "stop", stopValue } }; + } + + void onTimeChanged() override + { + if (!timeKeeper() || !sink()) { + return; + } + sink()->setSolidity(blend(startValue, stopValue, progress())); + } +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/ThresholdAnimation.h b/tomviz/animations/ThresholdAnimation.h new file mode 100644 index 000000000..0400f8afe --- /dev/null +++ b/tomviz/animations/ThresholdAnimation.h @@ -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". */ + +#ifndef tomvizThresholdAnimation_h +#define tomvizThresholdAnimation_h + +#include "ModuleAnimation.h" + +#include "pipeline/sinks/ThresholdSink.h" + +namespace tomviz { + +/// Sweeps one end of a Threshold visualization's range, so a +/// segmentation grows or shrinks over the animation while the other end +/// stays where the panel has it. +class ThresholdAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + enum End + { + Lower, + Upper + }; + + double startValue = 0; + double stopValue = 0; + End end = Lower; + + ThresholdAnimation(pipeline::ThresholdSink* sink, double start, double stop, + End e) + : ModuleAnimation(sink), startValue(start), stopValue(stop), end(e) + { + } + + pipeline::ThresholdSink* sink() + { + return qobject_cast(baseNode.data()); + } + + QString type() const override { return "threshold"; } + + QString describeParameters() const override + { + return QString("%1 threshold %2 to %3") + .arg(end == Lower ? "lower" : "upper") + .arg(startValue) + .arg(stopValue); + } + + QJsonObject serialize() const override + { + return { { "start", startValue }, + { "stop", stopValue }, + { "end", end == Lower ? "lower" : "upper" } }; + } + + void onTimeChanged() override + { + if (!timeKeeper() || !sink()) { + return; + } + + double value = (stopValue - startValue) * progress() + startValue; + if (end == Lower) { + sink()->setThresholdRange(value, sink()->upperThreshold()); + } else { + sink()->setThresholdRange(sink()->lowerThreshold(), value); + } + } +}; + +} // namespace tomviz + +#endif diff --git a/tomviz/animations/TimeSeriesAnimation.h b/tomviz/animations/TimeSeriesAnimation.h new file mode 100644 index 000000000..2405b4c56 --- /dev/null +++ b/tomviz/animations/TimeSeriesAnimation.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 tomvizTimeSeriesAnimation_h +#define tomvizTimeSeriesAnimation_h + +#include "ModuleAnimation.h" + +#include "pipeline/OutputPort.h" +#include "pipeline/Pipeline.h" +#include "pipeline/SourceNode.h" +#include "pipeline/data/VolumeData.h" + +namespace tomviz { + +/// Plays a time series loaded on a source node: on every animation tick +/// it switches the VolumeData to the step nearest the current time and +/// re-notifies the port's consumers so directly-linked sinks re-consume +/// the new image. Created by LoadDataReaction when a time series is +/// loaded; like every ModuleAnimation it deletes itself when its node +/// goes away. Gated at tick time on the global enable flag, which the +/// Animation Helper's "time series" checkbox toggles. +class TimeSeriesAnimation : public ModuleAnimation +{ + Q_OBJECT + +public: + TimeSeriesAnimation(pipeline::SourceNode* source) : ModuleAnimation(source) + { + } + + void onTimeChanged() override + { + if (!timeKeeper() || !baseNode) { + return; + } + if (!activeObjects().timeSeriesAnimationsEnabled()) { + return; + } + // The worker thread may be reading this VolumeData mid-execution; + // don't swap images out from under it. + auto* pip = activeObjects().pipeline(); + if (pip && pip->isExecuting()) { + return; + } + + auto* port = volumePort(); + if (!port) { + return; + } + auto vol = port->data().value(); + if (!vol) { + return; + } + + int numSteps = vol->timeSteps().size(); + if (numSteps <= 1) { + return; + } + + int index = qRound(progress() * (numSteps - 1)); + index = qBound(0, index, numSteps - 1); + if (index == vol->currentTimeStepIndex()) { + return; + } + + vol->switchTimeStep(index); + port->notifyDataMutated(); + } + +private: + // The output port on the source carrying a time-series VolumeData. + pipeline::OutputPort* volumePort() + { + for (auto* port : baseNode->outputPorts()) { + if (!port->hasData()) { + continue; + } + // value() falls back to an empty pointer when the port carries + // something else, so a non-volume port is skipped, not thrown. + auto vol = port->data().value(); + if (vol && vol->hasTimeSteps()) { + return port; + } + } + return nullptr; + } +}; + +} // namespace tomviz + +#endif 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..4611bef12 100644 --- a/tomviz/h5cpp/h5readwrite.cpp +++ b/tomviz/h5cpp/h5readwrite.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "h5capi.h" @@ -29,6 +30,25 @@ void setOk(bool* ok, bool status) *ok = status; } +// HDF5 built without thread safety (both the VTK-vendored build and the +// Homebrew one) mutates global state - the ID table, free lists, the +// error stack - on every API call, so entry into the library must be +// serialized process-wide. Tomviz calls HDF5 from more than one thread: +// pipeline executors write/read tvh5 files on worker threads (shim +// files, port-data disk cache) while the GUI thread loads, saves and +// decodes intermediates. All of that funnels through H5ReadWrite, so +// its public methods are the chokepoint; every one of them takes this +// lock. Recursive because they call each other (e.g. readData -> +// getDimensions). Intentionally leaked so it stays valid if a static +// H5ReadWrite is destroyed during exit. +std::recursive_mutex& h5Mutex() +{ + static auto* mutex = new std::recursive_mutex; + return *mutex; +} + +using H5Lock = std::lock_guard; + } // end namespace namespace h5 { @@ -304,7 +324,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); } @@ -490,10 +511,17 @@ class H5ReadWrite::H5ReadWriteImpl }; H5ReadWrite::H5ReadWrite(const string& file, OpenMode mode) - : m_impl(new H5ReadWriteImpl(file, mode)) -{} +{ + H5Lock lock(h5Mutex()); + m_impl.reset(new H5ReadWriteImpl(file, mode)); +} -H5ReadWrite::~H5ReadWrite() = default; +H5ReadWrite::~H5ReadWrite() +{ + // The impl's destructor closes the file, which is an HDF5 call. + H5Lock lock(h5Mutex()); + m_impl.reset(); +} string H5ReadWrite::fileName() const { @@ -502,11 +530,13 @@ string H5ReadWrite::fileName() const void H5ReadWrite::close() { + H5Lock lock(h5Mutex()); m_impl->clear(); } vector H5ReadWrite::children(const string& path, bool* ok) { + H5Lock lock(h5Mutex()); setOk(ok, false); vector result; @@ -540,6 +570,7 @@ vector H5ReadWrite::children(const string& path, bool* ok) template T H5ReadWrite::attribute(const string& path, const string& name, bool* ok) { + H5Lock lock(h5Mutex()); setOk(ok, false); T result; @@ -557,6 +588,7 @@ template <> string H5ReadWrite::attribute(const string& path, const string& name, bool* ok) { + H5Lock lock(h5Mutex()); setOk(ok, false); string result; @@ -615,16 +647,19 @@ string H5ReadWrite::attribute(const string& path, const string& name, bool H5ReadWrite::hasAttribute(const string& path) { + H5Lock lock(h5Mutex()); return m_impl->hasAttribute(path); } bool H5ReadWrite::hasAttribute(const string& path, const string& name) { + H5Lock lock(h5Mutex()); return m_impl->attributeExists(path, name); } DataType H5ReadWrite::attributeType(const string& path, const string& name) { + H5Lock lock(h5Mutex()); if (!m_impl->attributeExists(path, name)) { cerr << "Attribute " << path << name << " not found!" << endl; return DataType::None; @@ -648,21 +683,25 @@ DataType H5ReadWrite::attributeType(const string& path, const string& name) bool H5ReadWrite::isDataSet(const string& path) { + H5Lock lock(h5Mutex()); return m_impl->isDataSet(path); } bool H5ReadWrite::isGroup(const string& path) { + H5Lock lock(h5Mutex()); return m_impl->isGroup(path); } vector H5ReadWrite::allDataSets(const string& path) { + H5Lock lock(h5Mutex()); return m_impl->allDataSets(path); } DataType H5ReadWrite::dataType(const string& path) { + H5Lock lock(h5Mutex()); if (!m_impl->isDataSet(path)) { cerr << path << " is not a data set.\n"; return DataType::None; @@ -685,23 +724,26 @@ DataType H5ReadWrite::dataType(const string& path) vector H5ReadWrite::getDimensions(const string& path) { + H5Lock lock(h5Mutex()); return m_impl->getDimensions(path); } int H5ReadWrite::dimensionCount(const string& path) { + H5Lock lock(h5Mutex()); vector dims = getDimensions(path); if (dims.empty()) { cerr << "Failed to get the dimensions\n"; return -1; } - return dims.size(); + return static_cast(dims.size()); } template vector H5ReadWrite::readData(const string& path) { + H5Lock lock(h5Mutex()); vector dims; vector result = readData(path, dims); if (result.empty()) { @@ -723,6 +765,7 @@ vector H5ReadWrite::readData(const string& path) template vector H5ReadWrite::readData(const string& path, vector& dims) { + H5Lock lock(h5Mutex()); vector result; dims = getDimensions(path); @@ -747,6 +790,7 @@ vector H5ReadWrite::readData(const string& path, vector& dims) template bool H5ReadWrite::readData(const string& path, T* data) { + H5Lock lock(h5Mutex()); const hid_t dataTypeId = BasicTypeToH5::dataTypeId(); const hid_t memTypeId = BasicTypeToH5::memTypeId(); @@ -761,6 +805,7 @@ bool H5ReadWrite::readData(const string& path, T* data) bool H5ReadWrite::readData(const string& path, const DataType& type, void* data, int* strides, size_t* start, size_t* counts) { + H5Lock lock(h5Mutex()); auto it = DataTypeToH5DataType.find(type); if (it == DataTypeToH5DataType.end()) { cerr << "Failed to get H5 data type for " << dataTypeToString(type) << "\n"; @@ -790,6 +835,7 @@ template bool H5ReadWrite::writeData(const string& path, const string& name, const vector& dims, const T* data) { + H5Lock lock(h5Mutex()); const hid_t dataTypeId = BasicTypeToH5::dataTypeId(); const hid_t memTypeId = BasicTypeToH5::memTypeId(); @@ -800,6 +846,7 @@ bool H5ReadWrite::writeData(const string& path, const string& name, const vector& dims, const DataType& type, const void* data) { + H5Lock lock(h5Mutex()); auto it = DataTypeToH5DataType.find(type); if (it == DataTypeToH5DataType.end()) { cerr << "Failed to get H5 data type for " << dataTypeToString(type) << "\n"; @@ -822,6 +869,7 @@ bool H5ReadWrite::writeData(const string& path, const string& name, template bool H5ReadWrite::setAttribute(const string& path, const string& name, T value) { + H5Lock lock(h5Mutex()); const hid_t dataTypeId = BasicTypeToH5::dataTypeId(); const hid_t memTypeId = BasicTypeToH5::memTypeId(); @@ -834,6 +882,7 @@ bool H5ReadWrite::setAttribute(const string& path, const string& name, const string& value) { + H5Lock lock(h5Mutex()); if (!m_impl->fileIsValid()) { cerr << "File is not valid\n"; return false; @@ -887,11 +936,13 @@ bool H5ReadWrite::setAttribute(const string& path, const string& name, const char* value) { + H5Lock lock(h5Mutex()); return setAttribute(path, name, value); } bool H5ReadWrite::createGroup(const string& path) { + H5Lock lock(h5Mutex()); if (!m_impl->fileIsValid()) { cerr << "File is not valid\n"; return false; @@ -911,11 +962,13 @@ bool H5ReadWrite::createGroup(const string& path) bool H5ReadWrite::createSoftLink(const string& target, const string& path) { + H5Lock lock(h5Mutex()); return m_impl->createSoftLink(target, path); } bool H5ReadWrite::isSoftLink(const string& path) { + H5Lock lock(h5Mutex()); return m_impl->isSoftLink(path); } @@ -954,7 +1007,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 +1066,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/autoexecute.svg b/tomviz/icons/autoexecute.svg new file mode 100644 index 000000000..fbf4adf55 --- /dev/null +++ b/tomviz/icons/autoexecute.svg @@ -0,0 +1,30 @@ + + + + + + + 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/clone.svg b/tomviz/icons/clone.svg new file mode 100644 index 000000000..b3e3cd888 --- /dev/null +++ b/tomviz/icons/clone.svg @@ -0,0 +1,7 @@ + + + + + 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/folder.svg b/tomviz/icons/folder.svg new file mode 100644 index 000000000..28a0a076e --- /dev/null +++ b/tomviz/icons/folder.svg @@ -0,0 +1,7 @@ + + + + + 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/ModuleVolumeWidget.cxx b/tomviz/modules/ModuleVolumeWidget.cxx deleted file mode 100644 index 52e80acd1..000000000 --- a/tomviz/modules/ModuleVolumeWidget.cxx +++ /dev/null @@ -1,295 +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 "ModuleVolumeWidget.h" -#include "Module.h" -#include "ui_LightingParametersForm.h" -#include "ui_ModuleVolumeWidget.h" - -#include "vtkVolumeMapper.h" - -namespace tomviz { - -// If we make this bigger, such as 1000, and we make the max too -// close to the data minimum or the min too close to the data maximum, -// we run into errors like these: -// ( 118.718s) [paraview ]vtkOpenGLVolumeLookupTa:84 WARN| -// vtkOpenGLVolumeRGBTable (0x55ba0c5cc970): This OpenGL implementation does not -// support the required texture size of 65536, falling back to maximum allowed, -// 32768.This may cause an incorrect lookup table mapping. -static const double RANGE_INCREMENT = 500; - -ModuleVolumeWidget::ModuleVolumeWidget(QWidget* parent_) - : QWidget(parent_), m_ui(new Ui::ModuleVolumeWidget), - m_uiLighting(new Ui::LightingParametersForm) -{ - m_ui->setupUi(this); - - QWidget* lightingWidget = new QWidget; - m_uiLighting->setupUi(lightingWidget); - QWidget::layout()->addWidget(lightingWidget); - qobject_cast(QWidget::layout())->addStretch(); - - const int leWidth = 50; - m_uiLighting->sliAmbient->setLineEditWidth(leWidth); - m_uiLighting->sliDiffuse->setLineEditWidth(leWidth); - m_uiLighting->sliSpecular->setLineEditWidth(leWidth); - m_uiLighting->sliSpecularPower->setLineEditWidth(leWidth); - - m_uiLighting->sliSpecularPower->setMaximum(150); - m_uiLighting->sliSpecularPower->setMinimum(1); - m_uiLighting->sliSpecularPower->setResolution(200); - - m_ui->soliditySlider->setLineEditWidth(leWidth); - - QStringList labelsBlending; - labelsBlending << tr("Composite") << tr("Max") << tr("Min") << tr("Average") - << tr("Additive"); - m_ui->cbBlending->addItems(labelsBlending); - - QStringList labelsTransferMode; - labelsTransferMode << tr("Scalar") << tr("Scalar-Gradient 1D") - << tr("Scalar-Gradient 2D"); - m_ui->cbTransferMode->addItems(labelsTransferMode); - - QStringList labelsInterp; - labelsInterp << tr("Nearest Neighbor") << tr("Linear"); - m_ui->cbInterpolation->addItems(labelsInterp); - - connect(m_ui->cbJittering, &QCheckBox::toggled, this, - &ModuleVolumeWidget::jitteringToggled); - connect(m_ui->cbBlending, QOverload::of(&QComboBox::currentIndexChanged), - this, &ModuleVolumeWidget::onBlendingChanged); - connect(m_ui->cbInterpolation, - QOverload::of(&QComboBox::currentIndexChanged), this, - &ModuleVolumeWidget::interpolationChanged); - connect(m_ui->cbTransferMode, - QOverload::of(&QComboBox::currentIndexChanged), this, - &ModuleVolumeWidget::transferModeChanged); - connect(m_ui->cbMultiVolume, &QCheckBox::toggled, this, - &ModuleVolumeWidget::allowMultiVolumeToggled); - connect(m_ui->cbMultiVolume, &QCheckBox::toggled, this, - &ModuleVolumeWidget::setAllowMultiVolume); - - connect(m_ui->useRgbaMapping, &QCheckBox::toggled, this, - &ModuleVolumeWidget::useRgbaMappingToggled); - - connect(m_ui->rgbaMappingCombineComponents, &QCheckBox::toggled, this, - &ModuleVolumeWidget::rgbaMappingCombineComponentsToggled); - connect(m_ui->rgbaMappingComponent, &QComboBox::currentTextChanged, this, - &ModuleVolumeWidget::rgbaMappingComponentChanged); - - // Using QueuedConnections here to circumvent DoubleSliderWidget->BlockUpdate - connect(m_ui->sliRgbaMappingMin, &DoubleSliderWidget::valueEdited, this, - &ModuleVolumeWidget::onRgbaMappingMinChanged, Qt::QueuedConnection); - connect(m_ui->sliRgbaMappingMax, &DoubleSliderWidget::valueEdited, this, - &ModuleVolumeWidget::onRgbaMappingMaxChanged, Qt::QueuedConnection); - - connect(m_uiLighting->gbLighting, &QGroupBox::toggled, this, - &ModuleVolumeWidget::lightingToggled); - connect(m_uiLighting->sliAmbient, &DoubleSliderWidget::valueEdited, this, - &ModuleVolumeWidget::ambientChanged); - connect(m_uiLighting->sliDiffuse, &DoubleSliderWidget::valueEdited, this, - &ModuleVolumeWidget::diffuseChanged); - connect(m_uiLighting->sliSpecular, &DoubleSliderWidget::valueEdited, this, - &ModuleVolumeWidget::specularChanged); - connect(m_uiLighting->sliSpecularPower, &DoubleSliderWidget::valueEdited, - this, &ModuleVolumeWidget::specularPowerChanged); - connect(m_ui->soliditySlider, &DoubleSliderWidget::valueEdited, this, - &ModuleVolumeWidget::solidityChanged); - - m_ui->groupRgbaMappingRange->setVisible(false); - m_ui->rgbaMappingComponentLabel->setVisible(false); - m_ui->rgbaMappingComponent->setVisible(false); -} - -ModuleVolumeWidget::~ModuleVolumeWidget() = default; - -void ModuleVolumeWidget::setJittering(const bool enable) -{ - m_ui->cbJittering->setChecked(enable); -} - -void ModuleVolumeWidget::setBlendingMode(const int mode) -{ - m_uiLighting->gbLighting->setEnabled(usesLighting(mode)); - m_ui->cbBlending->setCurrentIndex(static_cast(mode)); -} - -void ModuleVolumeWidget::setInterpolationType(const int type) -{ - m_ui->cbInterpolation->setCurrentIndex(type); -} - -void ModuleVolumeWidget::setLighting(const bool enable) -{ - m_uiLighting->gbLighting->setChecked(enable); -} - -void ModuleVolumeWidget::setAmbient(const double value) -{ - m_uiLighting->sliAmbient->setValue(value); -} - -void ModuleVolumeWidget::setDiffuse(const double value) -{ - m_uiLighting->sliDiffuse->setValue(value); -} - -void ModuleVolumeWidget::setSpecular(const double value) -{ - m_uiLighting->sliSpecular->setValue(value); -} - -void ModuleVolumeWidget::setSpecularPower(const double value) -{ - m_uiLighting->sliSpecularPower->setValue(value); -} - -void ModuleVolumeWidget::onBlendingChanged(const int mode) -{ - m_uiLighting->gbLighting->setEnabled(usesLighting(mode)); - emit blendingChanged(mode); -} - -bool ModuleVolumeWidget::usesLighting(const int mode) const -{ - if (mode == vtkVolumeMapper::COMPOSITE_BLEND) { - return true; - } - - return false; -} - -void ModuleVolumeWidget::setTransferMode(const int transferMode) -{ - m_ui->cbTransferMode->setCurrentIndex(transferMode); -} - -void ModuleVolumeWidget::setSolidity(const double value) -{ - m_ui->soliditySlider->setValue(value); -} - -void ModuleVolumeWidget::setRgbaMappingAllowed(const bool b) -{ - m_ui->useRgbaMapping->setVisible(b); - - if (!b) { - setUseRgbaMapping(false); - } -} - -void ModuleVolumeWidget::setUseRgbaMapping(const bool b) -{ - m_ui->useRgbaMapping->setChecked(b); -} - -void ModuleVolumeWidget::setRgbaMappingMin(const double v) -{ - m_ui->sliRgbaMappingMin->setValue(v); -} - -void ModuleVolumeWidget::setRgbaMappingMax(const double v) -{ - m_ui->sliRgbaMappingMax->setValue(v); -} - -void ModuleVolumeWidget::setRgbaMappingSliderRange(const double range[2]) -{ - double min = range[0]; - double max = range[1]; - m_ui->sliRgbaMappingMin->setMinimum(min); - m_ui->sliRgbaMappingMin->setMaximum(max); - m_ui->sliRgbaMappingMax->setMinimum(min); - m_ui->sliRgbaMappingMax->setMaximum(max); -} - -void ModuleVolumeWidget::setRgbaMappingCombineComponents(const bool b) -{ - m_ui->rgbaMappingCombineComponents->setChecked(b); - m_ui->rgbaMappingComponent->setVisible(!b); - m_ui->rgbaMappingComponentLabel->setVisible(!b); -} - -void ModuleVolumeWidget::setRgbaMappingComponentOptions( - const QStringList& components) -{ - m_ui->rgbaMappingComponent->clear(); - m_ui->rgbaMappingComponent->addItems(components); -} - -void ModuleVolumeWidget::setRgbaMappingComponent(const QString& component) -{ - m_ui->rgbaMappingComponent->setCurrentText(component); -} - -void ModuleVolumeWidget::setAllowMultiVolume(const bool checked) -{ - if (checked != m_ui->cbMultiVolume->isChecked()) { - m_ui->cbMultiVolume->setChecked(checked); - } - - m_uiLighting->gbLighting->setEnabled(!checked || - !m_ui->cbMultiVolume->isEnabled()); -} - -void ModuleVolumeWidget::setEnableAllowMultiVolume(const bool enable) -{ - if (enable != m_ui->cbMultiVolume->isEnabled()) { - m_ui->cbMultiVolume->setEnabled(enable); - } - - m_uiLighting->gbLighting->setEnabled(!enable || - !m_ui->cbMultiVolume->isChecked()); -} - -void ModuleVolumeWidget::onRgbaMappingMinChanged(double v) -{ - // Compute an increment. Don't let the min value get closer - // than this to the maximum. - double fullRange[2] = { m_ui->sliRgbaMappingMax->minimum(), - m_ui->sliRgbaMappingMax->maximum() }; - double increment = (fullRange[1] - fullRange[0]) / RANGE_INCREMENT; - double trueMaximum = fullRange[1] - increment; - if (v > trueMaximum) { - setRgbaMappingMin(trueMaximum); - v = trueMaximum; - } - - double currentMax = m_ui->sliRgbaMappingMax->value(); - if (v > currentMax) { - // Set the maximum to be an increment above... - setRgbaMappingMax(v + increment); - } - - emit rgbaMappingMinChanged(v); -} - -void ModuleVolumeWidget::onRgbaMappingMaxChanged(double v) -{ - // Compute an increment. Don't let the max value get closer - // than this to the minimum. - double fullRange[2] = { m_ui->sliRgbaMappingMin->minimum(), - m_ui->sliRgbaMappingMin->maximum() }; - double increment = (fullRange[1] - fullRange[0]) / RANGE_INCREMENT; - double trueMinimum = fullRange[0] + increment; - if (v < trueMinimum) { - setRgbaMappingMax(trueMinimum); - v = trueMinimum; - } - - double currentMin = m_ui->sliRgbaMappingMin->value(); - if (v < currentMin) { - // Set the minimum to be an increment below... - setRgbaMappingMin(v - increment); - } - - emit rgbaMappingMaxChanged(v); -} - -QFormLayout* ModuleVolumeWidget::formLayout() -{ - return m_ui->formLayout; -} -} // namespace tomviz diff --git a/tomviz/modules/ModuleVolumeWidget.h b/tomviz/modules/ModuleVolumeWidget.h deleted file mode 100644 index a25797ea5..000000000 --- a/tomviz/modules/ModuleVolumeWidget.h +++ /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". */ - -#ifndef tomvizModuleVolumeWidget_h -#define tomvizModuleVolumeWidget_h - -#include -#include - -class QFormLayout; - -/** - * \brief UI layer of ModuleVolume. - * - * Signals are forwarded to the actuators on the mapper in ModuleVolume. - * This class is intended to contain only logic related to UI actions. - */ - -namespace Ui { -class ModuleVolumeWidget; -class LightingParametersForm; -} // namespace Ui - -namespace tomviz { - -class ModuleVolumeWidget : public QWidget -{ - Q_OBJECT - -public: - ModuleVolumeWidget(QWidget* parent_ = nullptr); - ~ModuleVolumeWidget() override; - - //@{ - /** - * UI update methods. The actual model state is stored in ModelVolume (either - * in the mapper or serialized), so the UI needs to be updated if the state - * changes or when constructing the UI. - */ - void setActiveScalars(const QString& scalars); - void setJittering(const bool enable); - void setBlendingMode(const int mode); - void setInterpolationType(const int type); - void setLighting(const bool enable); - void setAmbient(const double value); - void setDiffuse(const double value); - void setSpecular(const double value); - void setSpecularPower(const double value); - void setTransferMode(const int transferMode); - void setSolidity(const double value); - void setRgbaMappingAllowed(const bool allowed); - void setUseRgbaMapping(const bool b); - void setRgbaMappingMin(const double value); - void setRgbaMappingMax(const double value); - void setRgbaMappingSliderRange(const double range[2]); - void setRgbaMappingCombineComponents(const bool b); - void setRgbaMappingComponentOptions(const QStringList& list); - void setRgbaMappingComponent(const QString& component); - void setAllowMultiVolume(const bool allow); - void setEnableAllowMultiVolume(const bool enable); - QFormLayout* formLayout(); - //@} - -signals: - //@{ - /** - * Forwarded signals. - */ - void jitteringToggled(const bool state); - void blendingChanged(const int state); - void interpolationChanged(const int state); - void lightingToggled(const bool state); - void ambientChanged(const double value); - void diffuseChanged(const double value); - void specularChanged(const double value); - void specularPowerChanged(const double value); - void transferModeChanged(const int mode); - void solidityChanged(const double value); - void useRgbaMappingToggled(const bool b); - void rgbaMappingCombineComponentsToggled(const bool b); - void rgbaMappingMinChanged(const double value); - void rgbaMappingMaxChanged(const double value); - void rgbaMappingComponentChanged(const QString& component); - void allowMultiVolumeToggled(const bool state); - //@} - -private: - ModuleVolumeWidget(const ModuleVolumeWidget&) = delete; - void operator=(const ModuleVolumeWidget&) = delete; - - bool usesLighting(const int mode) const; - - QScopedPointer m_ui; - QScopedPointer m_uiLighting; - -private slots: - void onBlendingChanged(const int mode); - void onRgbaMappingMinChanged(double value); - void onRgbaMappingMaxChanged(double value); -}; -} // 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/AutoExecuteController.cxx b/tomviz/pipeline/AutoExecuteController.cxx new file mode 100644 index 000000000..f4d17ee39 --- /dev/null +++ b/tomviz/pipeline/AutoExecuteController.cxx @@ -0,0 +1,162 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "AutoExecuteController.h" + +#include "InternalNodeExecutor.h" +#include "Node.h" +#include "NodeExecutor.h" +#include "Pipeline.h" + +#include + +namespace tomviz { +namespace pipeline { + +void AutoExecuteWorker::runQuery(Node* node) +{ + auto* executor = node ? node->nodeExecutor() : nullptr; + if (!executor) { + executor = &InternalNodeExecutor::instance(); + } + emit queryFinished(node, executor->shouldAutoExecute(node)); +} + +AutoExecuteController::AutoExecuteController(Pipeline* pipeline, + QObject* parent) + : QObject(parent), m_pipeline(pipeline) +{ + // The worker connections are queued (cross-thread); Node* must be a + // registered metatype under the spelling moc records for the + // signals. + qRegisterMetaType("Node*"); + qRegisterMetaType("tomviz::pipeline::Node*"); + + m_thread = new QThread(this); + m_thread->setObjectName(QStringLiteral("AutoExecuteQuery")); + auto* worker = new AutoExecuteWorker; + worker->moveToThread(m_thread); + m_worker = worker; + connect(m_thread, &QThread::finished, worker, &QObject::deleteLater); + connect(this, &AutoExecuteController::queryRequested, worker, + &AutoExecuteWorker::runQuery); + connect(worker, &AutoExecuteWorker::queryFinished, this, + &AutoExecuteController::onQueryFinished); + m_thread->start(); + + m_retryTimer.setSingleShot(true); + m_retryTimer.setInterval(2000); + connect(&m_retryTimer, &QTimer::timeout, this, + &AutoExecuteController::drain); + + connect(m_pipeline, &Pipeline::nodeAdded, this, + &AutoExecuteController::onNodeAdded); + connect(m_pipeline, &Pipeline::nodeRemoved, this, + &AutoExecuteController::onNodeRemoved); + connect(m_pipeline, &Pipeline::executionFinished, this, + &AutoExecuteController::drain); + connect(m_pipeline, &Pipeline::pausedChanged, this, + [this](bool) { drain(); }); + + for (auto* node : m_pipeline->nodes()) { + onNodeAdded(node); + } +} + +AutoExecuteController::~AutoExecuteController() +{ + // An in-flight query keeps running until it completes (an external + // check is bounded by its kill timeout); its result is dropped. + m_thread->quit(); + m_thread->wait(); +} + +void AutoExecuteController::onNodeAdded(Node* node) +{ + // Connected for every node, enabled or not: the setting can be + // turned on later (editor Apply, state load applying deserialize + // after addNode). + connect(node, &Node::autoExecuteChanged, this, + [this, node]() { syncNode(node); }); + syncNode(node); +} + +void AutoExecuteController::onNodeRemoved(Node* node) +{ + disconnect(node, nullptr, this, nullptr); + if (auto* timer = m_timers.take(node)) { + timer->deleteLater(); + } + m_pending.remove(node); + if (node == m_inFlight) { + // Let the worker finish with the node before it is deleted + QMetaObject::invokeMethod(m_worker, [] {}, Qt::BlockingQueuedConnection); + } +} + +void AutoExecuteController::syncNode(Node* node) +{ + if (node->autoExecuteEnabled()) { + QTimer* timer = m_timers.value(node); + if (!timer) { + timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, [this, node]() { + // Collapse: a node already pending stays a single entry no + // matter how many intervals elapse while the pipeline is busy. + m_pending.insert(node); + drain(); + }); + m_timers.insert(node, timer); + } + int intervalMs = node->autoExecuteIntervalSeconds() * 1000; + if (timer->interval() != intervalMs || !timer->isActive()) { + timer->start(intervalMs); + } + } else { + if (auto* timer = m_timers.take(node)) { + timer->deleteLater(); + } + m_pending.remove(node); + } +} + +void AutoExecuteController::onQueryFinished(Node* node, bool shouldExecute) +{ + if (node == m_inFlight) { + m_inFlight = nullptr; + } + // The m_timers guard covers both "node was removed while the query + // ran" (the pointer may be dangling — compare only, never + // dereference) and "auto-execute was turned off meanwhile". + if (shouldExecute && m_timers.contains(node)) { + node->markStale(); + m_pipeline->executeWhenIdle(); + } + drain(); +} + +void AutoExecuteController::drain() +{ + if (m_inFlight || m_pending.isEmpty()) { + return; + } + if (m_pipeline->isExecuting() || m_pipeline->isPaused()) { + // executionFinished / pausedChanged re-drain; the retry timer is + // the backstop for completions that emit no signal. + m_retryTimer.start(); + return; + } + while (!m_pending.isEmpty()) { + Node* node = *m_pending.begin(); + m_pending.remove(node); + if (!m_timers.contains(node)) { + continue; + } + m_inFlight = node; + emit queryRequested(node); + return; + } +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/AutoExecuteController.h b/tomviz/pipeline/AutoExecuteController.h new file mode 100644 index 000000000..a1d473977 --- /dev/null +++ b/tomviz/pipeline/AutoExecuteController.h @@ -0,0 +1,87 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineAutoExecuteController_h +#define tomvizPipelineAutoExecuteController_h + +#include +#include +#include +#include + +class QThread; + +namespace tomviz { +namespace pipeline { + +class Node; +class Pipeline; + +/// Runs one should_auto_execute() query. Lives on the controller's +/// worker thread so the query — which runs user Python, or spawns an +/// external subprocess — never blocks the GUI. One instance serves all +/// nodes; queued invocations serialize on its event loop, so at most +/// one query runs at a time. +class AutoExecuteWorker : public QObject +{ + Q_OBJECT + +public slots: + void runQuery(tomviz::pipeline::Node* node); + +signals: + void queryFinished(tomviz::pipeline::Node* node, bool shouldExecute); +}; + +/// Drives the per-node "periodic execution" option: keeps a timer per +/// node that has it enabled; a firing timer queues the node for a +/// should_auto_execute() query. Queries run one at a time on a worker +/// thread, and only while the pipeline is idle and unpaused — timer +/// fires that land while the pipeline is busy collapse into a single +/// pending entry per node, so a slow pipeline sees at most one +/// follow-up check (and hence at most one re-execution) no matter how +/// many intervals elapsed. A query answering true marks the node stale +/// and schedules a pipeline execution. +class AutoExecuteController : public QObject +{ + Q_OBJECT + +public: + explicit AutoExecuteController(Pipeline* pipeline, + QObject* parent = nullptr); + ~AutoExecuteController() override; + +signals: + /// Internal: hands one query to the worker thread. + void queryRequested(tomviz::pipeline::Node* node); + +private: + void onNodeAdded(Node* node); + void onNodeRemoved(Node* node); + /// Create/retune or tear down the node's timer to match its + /// auto-execute setting. + void syncNode(Node* node); + void onQueryFinished(Node* node, bool shouldExecute); + /// Dispatch the next pending query, if the pipeline is idle and no + /// query is in flight. + void drain(); + + Pipeline* m_pipeline; + /// One timer per auto-execute-enabled node. Key presence doubles as + /// "node is alive and has the feature on" for the guards. + QHash m_timers; + QSet m_pending; + Node* m_inFlight = nullptr; + QThread* m_thread = nullptr; + AutoExecuteWorker* m_worker = nullptr; + /// Backstop re-drain while the pipeline is busy: a run queued behind + /// a cancelled one finishes without emitting executionFinished (see + /// Pipeline::runPlan's single-shot wiring), so waiting on the signal + /// alone could strand pending checks. + QTimer m_retryTimer; +}; + +} // namespace pipeline +} // 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..21fe26825 --- /dev/null +++ b/tomviz/pipeline/CustomPythonNodeWidget.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 tomvizPipelineCustomPythonNodeWidget_h +#define tomvizPipelineCustomPythonNodeWidget_h + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +class Node; +class 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; + + /// Optional context: the node whose editor hosts this widget and the + /// pipeline it belongs to. The editor factory calls this right after + /// construction, before setValues(). Widgets that interact with the + /// wider pipeline (e.g. to preview against an upstream port's sink, + /// or to enumerate other ports) override this; the default ignores + /// it. Both pointers outlive the widget for the editor's lifetime, + /// but @a node's links can change while the editor is open, so + /// consumers should re-resolve ports rather than cache them blindly. + virtual void setNodeContext(Node* node, Pipeline* pipeline) + { + Q_UNUSED(node) + Q_UNUSED(pipeline) + } + + /// 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; } + + /// The node's JSON description as it currently stands in the editor's + /// Definition tab — which is not necessarily the one the node is + /// running, since the user may be part-way through editing it. + /// + /// Called once at construction and again on every edit that changes + /// the parameter declarations. Subclasses that build any of their UI + /// from the description (e.g. by embedding a NodePropertiesWidget + /// alongside their bespoke controls) should override this and + /// re-render, so descriptor edits are reflected the same way they are + /// in the auto-generated form. The default just stores it. + virtual void setJSONDescription(const QString& json) + { + m_jsonDescription = json; + } + + /// Called when the operator is applied (e.g. to persist settings). + virtual void writeSettings() {} + +protected: + QString m_script; + QString m_jsonDescription; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/DefaultExecutor.cxx b/tomviz/pipeline/DefaultExecutor.cxx new file mode 100644 index 000000000..1dcbb02d5 --- /dev/null +++ b/tomviz/pipeline/DefaultExecutor.cxx @@ -0,0 +1,178 @@ +/* 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; + } + if (node->isHeld()) { + // Inserted but not applied yet: its dialog owns the first run. + // Skipped the same way, silently. + 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/EnumOptions.cxx b/tomviz/pipeline/EnumOptions.cxx new file mode 100644 index 000000000..ab84891e9 --- /dev/null +++ b/tomviz/pipeline/EnumOptions.cxx @@ -0,0 +1,85 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "EnumOptions.h" + +#include +#include + +namespace tomviz { +namespace pipeline { +namespace PythonNodeUtils { + +namespace { + +QVariant optionValueAt(const QJsonArray& options, int idx) +{ + if (idx < 0 || idx >= options.size()) { + return QVariant(); + } + QJsonObject opt = options.at(idx).toObject(); + return opt.isEmpty() ? QVariant() : opt.constBegin().value().toVariant(); +} + +} // namespace + +int enumOptionIndex(const QVariant& value, const QJsonArray& options) +{ + if (!value.isValid()) { + return -1; + } + const bool numeric = value.canConvert() && + value.typeId() != QMetaType::QString; + for (int i = 0; i < options.size(); ++i) { + QVariant option = optionValueAt(options, i); + if (!option.isValid()) { + continue; + } + const bool optionNumeric = option.typeId() != QMetaType::QString; + if (numeric && optionNumeric) { + if (option.toDouble() == value.toDouble()) { + return i; + } + } else if (!numeric && !optionNumeric) { + if (option.toString() == value.toString()) { + return i; + } + } + } + return -1; +} + +QVariant resolveEnumValue(const QJsonValue& value, const QJsonArray& options) +{ + const int matched = enumOptionIndex(value.toVariant(), options); + if (matched >= 0) { + return optionValueAt(options, matched); + } + if (value.isString()) { + return value.toVariant(); + } + if (value.isDouble()) { + return optionValueAt(options, value.toInt()); + } + return QVariant(); +} + +QVariant resolveEnumDefault(const QJsonValue& value, + const QJsonArray& options) +{ + if (value.isDouble()) { + QVariant byIndex = optionValueAt(options, value.toInt()); + if (byIndex.isValid()) { + return byIndex; + } + } + const int matched = enumOptionIndex(value.toVariant(), options); + if (matched >= 0) { + return optionValueAt(options, matched); + } + return value.isString() ? value.toVariant() : QVariant(); +} + +} // namespace PythonNodeUtils +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/EnumOptions.h b/tomviz/pipeline/EnumOptions.h new file mode 100644 index 000000000..acd3773d6 --- /dev/null +++ b/tomviz/pipeline/EnumOptions.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 tomvizPipelineEnumOptions_h +#define tomvizPipelineEnumOptions_h + +#include +#include +#include + +namespace tomviz { +namespace pipeline { +namespace PythonNodeUtils { + +// How an enumeration parameter's ``options`` (a list of single +// {"Label": value} objects) relate to what is stored for it. Kept apart +// from PythonNodeUtils.h so code and tests that never touch Python can +// use them without pybind11. + +/// Resolve an enumeration parameter's stored form to the option value +/// the operator actually receives. The state-file convention is to +/// persist the option value (so a saved file is self-describing), so a +/// number or string that matches an option's value is that value; a +/// number matching nothing is read as an index, so older files that +/// persisted the index also load. Returns an invalid QVariant when no +/// resolution is possible — the caller falls back to its usual +/// coercion path. +QVariant resolveEnumValue(const QJsonValue& value, + const QJsonArray& options); + +/// Resolve an enumeration's declared ``default``, which by convention +/// is an index into ``options``; a string, or a number that is not a +/// valid index, is taken as an option value instead. +QVariant resolveEnumDefault(const QJsonValue& value, + const QJsonArray& options); + +/// The index of the option whose value is @a value, or -1. Numbers +/// compare as numbers, so a stored 2 matches an option declared as +/// ``2`` or ``2.0``. +int enumOptionIndex(const QVariant& value, const QJsonArray& options); + +} // namespace PythonNodeUtils +} // 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..c79e61f41 --- /dev/null +++ b/tomviz/pipeline/ExternalNodeExecutor.cxx @@ -0,0 +1,820 @@ +/* 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 "PythonEnvironmentCheck.h" +#include "ProgressReader.h" +#include "ThreadUtils.h" +#include "SourceNode.h" + +#include "Tvh5Format.h" +#include "data/LabelMapData.h" +#include "data/VolumeData.h" + +#include +#include +#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; + +// Hard timeout for the should_auto_execute subprocess. Polls fire on a +// timer, so unlike execute() a hung check would stall auto-execution +// forever; kill it and answer false instead. +constexpr int kCheckTimeoutMs = 10 * 60 * 1000; + +bool useSocketProgress() +{ +#if defined(Q_OS_WIN) || defined(Q_OS_MAC) + return false; +#else + return true; +#endif +} + +/// Write @a node's user-state bag as the `--node-state` sidecar the +/// CLI reads: {"nodes": {"": {...}}}. +bool writeNodeStateFile(Node* node, int nodeId, const QString& path) +{ + QJsonObject nodes; + nodes[QString::number(nodeId)] = + QJsonObject::fromVariantMap(node->userState()); + QJsonObject root; + root[QStringLiteral("nodes")] = nodes; + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qWarning() << "ExternalNodeExecutor: failed to write node-state file" + << path; + return false; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); + return true; +} + +/// Read the node_state.json the CLI writes back and install the entry +/// for @a nodeId as @a node's user-state bag. Missing file or entry is +/// a no-op (e.g. the run failed before writing it, or the env's tomviz +/// package predates the sidecar). +void applyNodeStateFile(Node* node, int nodeId, const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return; + } + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + QJsonValue entry = doc.object() + .value(QStringLiteral("nodes")) + .toObject() + .value(QString::number(nodeId)); + if (entry.isObject()) { + node->setUserState(entry.toObject().toVariantMap()); + } +} + +/// Read the node_parameters.json the CLI writes when the child's kernel +/// changed parameters through `self.set_parameter` (same +/// {"nodes": {"": {...}}} shape as the state sidecar, written +/// only when something changed) and install the entry for @a nodeId +/// through Node::applyParameterUpdates — quietly, no staleness. Missing +/// file or entry is a no-op: nothing changed, or the env's package +/// predates the file. +void applyNodeParametersFile(Node* node, int nodeId, const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return; + } + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + QJsonValue entry = doc.object() + .value(QStringLiteral("nodes")) + .toObject() + .value(QString::number(nodeId)); + if (entry.isObject() && !entry.toObject().isEmpty()) { + node->applyParameterUpdates(entry.toObject().toVariantMap()); + } +} + +} // 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 +{ + // Resolved the same way the editor's environment check does, so a + // path that pointed at /bin or the interpreter still runs. + return PythonEnvironmentCheck::findCliExecutable( + PythonEnvironmentCheck::resolveEnvironmentRoot(m_envPath)); +} + +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 = makeVolumeData(copy.Get(), payload.type()); + 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, and "autoExecute" — timers are the + // parent app's business, not the child's. + QJsonObject cloneJson = target->serialize(); + cloneJson.remove(QStringLiteral("executor")); + cloneJson.remove(QStringLiteral("autoExecute")); + 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; +} + +QString ExternalNodeExecutor::writeCheckStateFile(Node* target, + const QTemporaryDir& dir, + int& targetNodeId) const +{ + if (!target) { + return QString(); + } + + QString typeName = NodeFactory::typeName(target); + if (typeName.isEmpty()) { + qWarning() << "ExternalNodeExecutor: target node has no registered " + "type; cannot poll should_auto_execute."; + return QString(); + } + Node* clone = NodeFactory::create(typeName); + if (!clone) { + qWarning() << "ExternalNodeExecutor: NodeFactory could not create" + << typeName; + return QString(); + } + + Pipeline check; + QJsonObject cloneJson = target->serialize(); + cloneJson.remove(QStringLiteral("executor")); + cloneJson.remove(QStringLiteral("autoExecute")); + clone->deserialize(cloneJson); + // Drop pending per-port metadata stashed by deserialize — the check + // never touches port data, so nothing should look resumable. + for (auto* port : clone->outputPorts()) { + port->clearPendingData(); + } + check.addNode(clone); + check.setNodeId(clone, kShimTargetId); + targetNodeId = kShimTargetId; + + QString path = QDir(dir.path()).filePath(QStringLiteral("check.tvh5")); + if (!Tvh5Format::write(path.toStdString(), &check)) { + qWarning() << "ExternalNodeExecutor: failed to write check tvh5 at" + << path; + return QString(); + } + return path; +} + +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()) { + // check() needs no subprocess here: the CLI is missing, so it + // stops at the filesystem steps and says which one failed. + QString msg = + m_envPath.isEmpty() + ? QStringLiteral("No external Python environment selected. Choose " + "one containing tomviz-pipeline in the Execution " + "tab.") + : PythonEnvironmentCheck::check(m_envPath).message; + 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; + + process.setProcessEnvironment( + PythonEnvironmentCheck::childProcessEnvironment()); + + QStringList args; + args << QStringLiteral("-s") << shimPath + << QStringLiteral("-o") << outDir + << QStringLiteral("--output-format") << QStringLiteral("state") + << QStringLiteral("-p") + << (useSocketProgress() ? QStringLiteral("socket") + : QStringLiteral("files")) + << QStringLiteral("-u") << progressPath; + + // Round-trip the node's user-state bag through the child. Only when + // the node actually uses the feature — the flag is unknown to older + // tomviz packages, and passing it unconditionally would break every + // existing external env. + const bool passNodeState = + node->autoExecuteEnabled() || !node->userState().isEmpty(); + if (passNodeState) { + QString nodeStateInPath = + QDir(tmpDir.path()).filePath(QStringLiteral("node_state_in.json")); + if (writeNodeStateFile(node, kShimTargetId, nodeStateInPath)) { + args << QStringLiteral("--node-state") << nodeStateInPath; + } + } + + 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; + + // Pick up state mutations regardless of how the run ended: the CLI + // writes node_state.json after its runs, so a failed node execution + // can still have recorded state. Missing file is a no-op. + if (passNodeState) { + applyNodeStateFile(node, kShimTargetId, + QDir(outDir).filePath( + QStringLiteral("node_state.json"))); + } + applyNodeParametersFile( + node, kShimTargetId, + QDir(outDir).filePath(QStringLiteral("node_parameters.json"))); + + if (node->isCanceled()) { + node->setExecState(NodeExecState::Canceled); + return false; + } + + if (failed) { + qWarning() << "ExternalNodeExecutor: subprocess failed (exit=" << exitCode + << ", status=" << exitStatus << ")."; + // Only a failed run pays for an environment check: a stale or + // incompatible tomviz-pipeline is a likely cause the exit code + // alone doesn't name. + PythonEnvironmentInfo info = PythonEnvironmentCheck::check(m_envPath); + if (!info.ok()) { + qWarning() << "ExternalNodeExecutor:" << info.message; + node->setProgressMessage(info.message); + } + 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; +} + +bool ExternalNodeExecutor::shouldAutoExecute(Node* node) +{ + if (!node) { + return false; + } + + NodeFactory::registerBuiltins(); + + QString cli = findCliExecutable(); + if (cli.isEmpty()) { + qWarning() << "ExternalNodeExecutor: cannot poll should_auto_execute —" + << "tomviz-pipeline was not found in" << m_envPath; + return false; + } + + QTemporaryDir tmpDir; + if (!tmpDir.isValid()) { + qWarning() << "ExternalNodeExecutor: failed to create temp dir for " + "the should_auto_execute poll."; + return false; + } + + int targetNodeId = -1; + QString statePath = writeCheckStateFile(node, tmpDir, targetNodeId); + if (statePath.isEmpty()) { + return false; + } + + QString nodeStateInPath = + QDir(tmpDir.path()).filePath(QStringLiteral("node_state_in.json")); + if (!writeNodeStateFile(node, targetNodeId, nodeStateInPath)) { + return false; + } + + QString outDir = QDir(tmpDir.path()).filePath(QStringLiteral("out")); + QDir().mkpath(outDir); + + // Deliberately local (not m_process/m_reader): a check may not + // interfere with the members an in-flight execute() is using. + QProcess process; + process.setProcessEnvironment( + PythonEnvironmentCheck::childProcessEnvironment()); + + QStringList args; + args << QStringLiteral("-s") << statePath + << QStringLiteral("-o") << outDir + << QStringLiteral("--check-auto-execute") + << QString::number(targetNodeId) + << QStringLiteral("--node-state") << nodeStateInPath; + + process.start(cli, args); + if (!process.waitForStarted(30000)) { + qWarning() << "ExternalNodeExecutor: failed to start" << cli + << process.errorString(); + return false; + } + if (!process.waitForFinished(kCheckTimeoutMs)) { + qWarning() << "ExternalNodeExecutor: should_auto_execute poll timed " + "out; killing the subprocess."; + process.kill(); + process.waitForFinished(5000); + return false; + } + + auto childErr = QString::fromUtf8(process.readAllStandardError()); + if (!childErr.isEmpty()) { + qDebug().noquote() << childErr.trimmed(); + } + + // State mutations made by the hook count even when it answered "no"; + // so do parameter write-backs. + applyNodeStateFile(node, targetNodeId, + QDir(outDir).filePath( + QStringLiteral("node_state.json"))); + applyNodeParametersFile( + node, targetNodeId, + QDir(outDir).filePath(QStringLiteral("node_parameters.json"))); + + if (process.exitStatus() != QProcess::NormalExit || + process.exitCode() != 0) { + qWarning() << "ExternalNodeExecutor: should_auto_execute poll failed " + "(exit code" + << process.exitCode() + << "). If the environment's tomviz package predates " + "--check-auto-execute, update it."; + return false; + } + + QFile resultFile( + QDir(outDir).filePath(QStringLiteral("auto_execute.json"))); + if (!resultFile.open(QIODevice::ReadOnly)) { + qWarning() << "ExternalNodeExecutor: should_auto_execute poll wrote " + "no auto_execute.json."; + return false; + } + QJsonDocument doc = QJsonDocument::fromJson(resultFile.readAll()); + return doc.object().value(QStringLiteral("shouldExecute")).toBool(false); +} + +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..db9eb78e0 --- /dev/null +++ b/tomviz/pipeline/ExternalNodeExecutor.h @@ -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". */ + +#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; + + /// Auto-execute poll: spawns `tomviz-pipeline --check-auto-execute` + /// in the configured env so the node's should_auto_execute hook runs + /// with the same imports execute() would have. The node's user-state + /// bag rides a JSON sidecar in both directions. Blocks (bounded by a + /// kill timeout) — call from a worker thread. Any failure — missing + /// CLI, an env whose tomviz package predates the check mode, a + /// timeout — answers false. + bool shouldAutoExecute(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; + + /// Minimal state file for the auto-execute poll: just a clone of + /// @a target (no inputs, no payloads — the hook receives neither), + /// so polling never serializes input volumes to disk. Returns the + /// absolute path on success, an empty string on failure. + QString writeCheckStateFile(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 in the configured environment + /// (see PythonEnvironmentCheck::resolveEnvironmentRoot). 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..1a7b34060 --- /dev/null +++ b/tomviz/pipeline/LegacyStateLoader.cxx @@ -0,0 +1,1302 @@ +/* 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 "Utilities.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 +#include +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +void addUniqueProxy(vtkSMProxy* proxy, const char* property, vtkSMProxy* value) +{ + vtkSMPropertyHelper helper(proxy, property); + for (unsigned int i = 0; i < helper.GetNumberOfElements(); ++i) { + if (helper.GetAsProxy(i) == value) { + return; + } + } + helper.Add(value); +} + +// Re-do the part of vtkSMParaViewPipelineController::RegisterViewProxy() +// that pqApplicationCore::loadState() skips. +// +// Views created through pqObjectBuilder::createView() are handed to the +// controller, which adds them to the animation scene's "ViewModules" and +// the time keeper's "Views". Views recreated from state are registered +// straight with the proxy manager by vtkSMStateLoader, so neither list +// gets them, and restoreViewsAndLayouts() resets the session first, so +// both lists start out empty. vtkSMAnimationScene only updates and +// still-renders the views in "ViewModules", which means every animation +// (camera orbits, time series) ticks the cues but never redraws +// anything: the viewport just sits there. +void registerViewsWithSceneAndTimeKeeper(vtkSMSessionProxyManager* pxm) +{ + if (!pxm) { + return; + } + + vtkNew controller; + auto* session = pxm->GetSession(); + auto* scene = controller->GetAnimationScene(session); + auto* timeKeeper = controller->FindTimeKeeper(session); + + vtkNew iter; + iter->SetSessionProxyManager(pxm); + iter->SetModeToOneGroup(); + for (iter->Begin("views"); !iter->IsAtEnd(); iter->Next()) { + auto* view = vtkSMViewProxy::SafeDownCast(iter->GetProxy()); + if (!view) { + continue; + } + if (scene) { + addUniqueProxy(scene, "ViewModules", view); + } + if (timeKeeper) { + addUniqueProxy(timeKeeper, "Views", view); + } + } + + if (scene) { + scene->UpdateVTKObjects(); + } + if (timeKeeper) { + timeKeeper->UpdateVTKObjects(); + } +} + +// 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); + + registerViewsWithSceneAndTimeKeeper(server ? server->proxyManager() + : nullptr); + + // The animation scene above is a brand new one, back on ParaView's + // 10 frame default. Re-apply the frame count tomviz uses for freshly + // loaded data, otherwise a camera orbit created after a state load is + // over in a blink and looks like it did nothing. + tomviz::setAnimationNumberOfFrames(200); + + // 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..34d1bdfa1 --- /dev/null +++ b/tomviz/pipeline/Node.cxx @@ -0,0 +1,753 @@ +/* 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::isHeld() const +{ + return m_held; +} + +void Node::setHeld(bool held) +{ + m_held = held; +} + +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); + } +} + +bool Node::autoExecuteEnabled() const +{ + return m_autoExecuteEnabled; +} + +void Node::setAutoExecuteEnabled(bool enabled) +{ + if (m_autoExecuteEnabled != enabled) { + m_autoExecuteEnabled = enabled; + emit autoExecuteChanged(); + } +} + +int Node::autoExecuteIntervalSeconds() const +{ + return m_autoExecuteIntervalS; +} + +void Node::setAutoExecuteIntervalSeconds(int seconds) +{ + seconds = qMax(1, seconds); + if (m_autoExecuteIntervalS != seconds) { + m_autoExecuteIntervalS = seconds; + emit autoExecuteChanged(); + } +} + +bool Node::queryShouldAutoExecute() +{ + return false; +} + +QVariantMap Node::userState() const +{ + QMutexLocker locker(&m_userStateMutex); + return m_userState; +} + +void Node::setUserState(const QVariantMap& state) +{ + QMutexLocker locker(&m_userStateMutex); + m_userState = state; +} + +void Node::applyParameterUpdates(const QVariantMap& /*updates*/) {} + +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; + } + // The user-state bag (m_userState) is deliberately not serialized: + // it is runtime-only scratch space for the node's Python code. + if (m_autoExecuteEnabled || + m_autoExecuteIntervalS != kDefaultAutoExecuteIntervalSeconds) { + QJsonObject autoExec; + autoExec[QStringLiteral("enabled")] = m_autoExecuteEnabled; + autoExec[QStringLiteral("intervalSeconds")] = m_autoExecuteIntervalS; + json[QStringLiteral("autoExecute")] = autoExec; + } + 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("autoExecute"))) { + auto autoExec = json.value(QStringLiteral("autoExecute")).toObject(); + setAutoExecuteIntervalSeconds( + autoExec.value(QStringLiteral("intervalSeconds")) + .toInt(kDefaultAutoExecuteIntervalSeconds)); + setAutoExecuteEnabled( + autoExec.value(QStringLiteral("enabled")).toBool(false)); + } + 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..11c4d1c2d --- /dev/null +++ b/tomviz/pipeline/Node.h @@ -0,0 +1,352 @@ +/* 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 + +#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); + + /// A held node is skipped by the executors, and everything fed by it + /// waits, exactly as at a breakpoint but without the stop being + /// reported. NodeEditDialog holds a newly inserted node until its + /// first Apply: the insertion is spliced into the pipeline eagerly so + /// the strip can preview it, and without the hold any global execute + /// (adding a module, a periodic source, the histogram) would run the + /// node with its default parameters before the user finished. Not + /// serialized. + bool isHeld() const; + void setHeld(bool held); + + 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 a new volume output takes its color map from an input + /// (see colorMapSource). Nodes whose output values mean something + /// else entirely, like a Fourier spectrum, return false so the output + /// starts from the default color map. + virtual bool inheritsColorMap() const { return true; } + + /// 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); + + static constexpr int kDefaultAutoExecuteIntervalSeconds = 30; + + /// Periodic execution (schema-v2 Python nodes). When enabled, the + /// application polls queryShouldAutoExecute() every intervalSeconds + /// — through the node's executor, so external nodes are asked in + /// their external environment — and re-executes the pipeline when + /// the answer is true. Off by default. + bool autoExecuteEnabled() const; + void setAutoExecuteEnabled(bool enabled); + int autoExecuteIntervalSeconds() const; + void setAutoExecuteIntervalSeconds(int seconds); + + /// Ask the node's implementation whether a periodic execution + /// should happen now. Runs user code (the schema-v2 Python + /// should_auto_execute hook), so call it from a worker thread, never + /// while the node is executing. The base implementation never + /// requests a re-run. + virtual bool queryShouldAutoExecute(); + + /// Free-form state bag surfaced to schema-v2 Python node code as + /// `self.state`. Preserved across executions within a session but + /// deliberately not serialized. Values are restricted to the + /// JSON-compatible types so the bag can cross the external-executor + /// process boundary. Thread-safe (accessors copy under a lock). + QVariantMap userState() const; + void setUserState(const QVariantMap& state); + + /// Install parameter values the node's *own implementation* changed + /// while running — a schema-v2 kernel's `self.set_parameter()`, + /// harvested by PythonNodeBackend after the user method returns, or + /// read back from an external run's node_parameters.json. The quiet + /// counterpart of the editor's apply path: nothing is marked stale + /// and parametersApplied is NOT emitted (that one re-executes the + /// pipeline, which from inside a run would cancel the run itself). + /// The run that made the change is deemed to have consumed it; the + /// node's next run receives the new values. Implementations emit + /// parametersUpdated for the values that actually differ. The base + /// implementation (nodes without parameters) ignores the map. + virtual void applyParameterUpdates(const QVariantMap& updates); + + /// 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(); + + /// Emitted when the auto-execute setting (enabled flag or interval) + /// changes. The AutoExecuteController re-syncs its timer for this + /// node on it. + void autoExecuteChanged(); + + /// Emitted by applyParameterUpdates() with the values that changed: + /// the node's own implementation wrote back to its parameters during + /// a run. Nothing is stale and no re-execution is warranted — an + /// editor showing this node refreshes its Parameters tab. May be + /// emitted from a pipeline worker thread. + void parametersUpdated(const QVariantMap& changed); + +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_held = 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; + bool m_autoExecuteEnabled = false; + int m_autoExecuteIntervalS = kDefaultAutoExecuteIntervalSeconds; + /// Guards m_userState: executions and auto-execute queries touch it + /// from worker threads. + mutable QMutex m_userStateMutex; + QVariantMap m_userState; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeDefinitionEdits.cxx b/tomviz/pipeline/NodeDefinitionEdits.cxx new file mode 100644 index 000000000..9c253e5c9 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionEdits.cxx @@ -0,0 +1,119 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeDefinitionEdits.h" + +#include + +namespace tomviz { +namespace pipeline { + +ParameterFields readParameterFields(const QJsonObject& param) +{ + ParameterFields fields; + fields.name = param.value(QStringLiteral("name")).toString(); + fields.label = param.value(QStringLiteral("label")).toString(); + fields.type = param.value(QStringLiteral("type")).toString(); + fields.description = param.value(QStringLiteral("description")).toString(); + fields.defaultValue = param.value(QStringLiteral("default")); + fields.minimum = param.value(QStringLiteral("minimum")); + fields.maximum = param.value(QStringLiteral("maximum")); + fields.step = param.value(QStringLiteral("step")); + fields.precision = param.value(QStringLiteral("precision")); + fields.options = param.value(QStringLiteral("options")).toArray(); + fields.visibleIf = param.value(QStringLiteral("visible_if")).toString(); + fields.enableIf = param.value(QStringLiteral("enable_if")).toString(); + return fields; +} + +void applyParameterFields(QJsonObject& param, const ParameterFields& fields) +{ + setOrClear(param, QStringLiteral("name"), fields.name); + setOrClear(param, QStringLiteral("label"), fields.label); + setOrClear(param, QStringLiteral("type"), fields.type); + setOrClear(param, QStringLiteral("description"), fields.description); + setOrClearValue(param, QStringLiteral("default"), fields.defaultValue); + setOrClearValue(param, QStringLiteral("minimum"), fields.minimum); + setOrClearValue(param, QStringLiteral("maximum"), fields.maximum); + setOrClearValue(param, QStringLiteral("step"), fields.step); + setOrClearValue(param, QStringLiteral("precision"), fields.precision); + setOrClear(param, QStringLiteral("visible_if"), fields.visibleIf); + setOrClear(param, QStringLiteral("enable_if"), fields.enableIf); + + if (fields.options.isEmpty()) { + param.remove(QStringLiteral("options")); + } else { + param[QStringLiteral("options")] = fields.options; + } +} + +void setOrClear(QJsonObject& obj, const QString& key, const QString& value) +{ + if (value.isEmpty()) { + obj.remove(key); + } else { + obj[key] = value; + } +} + +void setOrClearBool(QJsonObject& obj, const QString& key, bool value, + bool schemaDefault) +{ + if (value == schemaDefault) { + obj.remove(key); + } else { + obj[key] = value; + } +} + +void setOrClearValue(QJsonObject& obj, const QString& key, + const QJsonValue& value) +{ + if (value.isUndefined() || value.isNull()) { + obj.remove(key); + } else { + obj[key] = value; + } +} + +QJsonValue parseJsonValue(const QString& text) +{ + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) { + return QJsonValue(QJsonValue::Undefined); + } + // QJsonDocument only parses documents, not bare values, so wrap the + // text in an array and unwrap the single element. + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson( + QStringLiteral("[%1]").arg(trimmed).toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !doc.isArray()) { + return QJsonValue(QJsonValue::Undefined); + } + QJsonArray array = doc.array(); + if (array.size() != 1) { + return QJsonValue(QJsonValue::Undefined); + } + return array.at(0); +} + +QString formatJsonValue(const QJsonValue& value) +{ + if (value.isUndefined() || value.isNull()) { + return {}; + } + // Same wrap-and-unwrap trick in reverse: serialize a one-element + // array and strip the brackets, so numbers keep their JSON spelling + // and strings come back quoted. + QJsonArray array{ value }; + QString text = QString::fromUtf8( + QJsonDocument(array).toJson(QJsonDocument::Compact)); + text = text.trimmed(); + if (text.startsWith(QLatin1Char('[')) && text.endsWith(QLatin1Char(']'))) { + text = text.mid(1, text.size() - 2); + } + return text; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeDefinitionEdits.h b/tomviz/pipeline/NodeDefinitionEdits.h new file mode 100644 index 000000000..ae41d663e --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionEdits.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 tomvizPipelineNodeDefinitionEdits_h +#define tomvizPipelineNodeDefinitionEdits_h + +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +/// The fields of one ``parameters[]`` entry that the definition form +/// renders. Deliberately not the whole entry: descriptions in the wild +/// also carry ``filter``, ``bindToSink``, ``input``, +/// ``apply_to_each_array`` and friends, all of which are read somewhere +/// in the codebase. applyParameterFields() writes only the keys listed +/// here and leaves everything else in place, so editing a parameter +/// through the form can never silently drop one. +struct ParameterFields +{ + QString name; + QString label; + QString type; + QString description; + QJsonValue defaultValue = QJsonValue(QJsonValue::Undefined); + QJsonValue minimum = QJsonValue(QJsonValue::Undefined); + QJsonValue maximum = QJsonValue(QJsonValue::Undefined); + QJsonValue step = QJsonValue(QJsonValue::Undefined); + QJsonValue precision = QJsonValue(QJsonValue::Undefined); + QJsonArray options; + QString visibleIf; + QString enableIf; +}; + +ParameterFields readParameterFields(const QJsonObject& param); + +/// Write @a fields onto @a param in place, preserving every key the +/// form doesn't own. Fields that are empty / undefined remove their key +/// rather than writing a blank, so the form doesn't litter documents +/// with `"help": ""`. +void applyParameterFields(QJsonObject& param, const ParameterFields& fields); + +/// Set @a key to @a value, or remove it when @a value is empty. +void setOrClear(QJsonObject& obj, const QString& key, const QString& value); + +/// Set @a key to @a value, or remove it when @a value matches the +/// schema's own default for that key (an absent `supportsCancel` and a +/// `false` one mean the same thing). +void setOrClearBool(QJsonObject& obj, const QString& key, bool value, + bool schemaDefault); + +/// Set @a key to @a value, or remove it when @a value is undefined or +/// null. +void setOrClearValue(QJsonObject& obj, const QString& key, + const QJsonValue& value); + +/// Parse @a text as a single JSON value — a number, string, bool, or +/// array, so `1.0`, `"nearest"` and `[128, 128, 128]` all work. Returns +/// Undefined for blank or unparseable input, which callers treat as +/// "remove the key". +QJsonValue parseJsonValue(const QString& text); + +/// Render @a value in the form parseJsonValue() accepts back — strings +/// come out quoted. Undefined and null both render as an empty string. +QString formatJsonValue(const QJsonValue& value); + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeDefinitionFormWidget.cxx b/tomviz/pipeline/NodeDefinitionFormWidget.cxx new file mode 100644 index 000000000..1ae35aa96 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionFormWidget.cxx @@ -0,0 +1,1179 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeDefinitionFormWidget.h" + +#include "NodeDefinitionEdits.h" +#include "PortType.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const char* kParametersKey = "parameters"; +const char* kInputsKey = "inputs"; +const char* kOutputsKey = "outputs"; + +const QStringList& parameterTypeNames() +{ + static const QStringList types = { + QStringLiteral("double"), QStringLiteral("int"), + QStringLiteral("bool"), QStringLiteral("string"), + QStringLiteral("enumeration"), QStringLiteral("file"), + QStringLiteral("save_file"), QStringLiteral("directory"), + QStringLiteral("select_scalars"), QStringLiteral("xyz_header"), + QStringLiteral("dataset") + }; + return types; +} + +/// Port types the pipeline actually implements. Image, Scalar and Array +/// exist in the PortType enum but nothing produces or consumes them, so +/// offering them here would only invite descriptions that can't run. +const QList& offeredPortTypes() +{ + using tomviz::pipeline::PortType; + static const QList types = { + PortType::ImageData, PortType::TiltSeries, PortType::Volume, + PortType::LabelMap, PortType::Table, PortType::Molecule + }; + return types; +} + +/// Ports can't be edited yet: validateNodeDefinition rejects every port +/// change, because nothing in the pipeline can remove a port or the links +/// hanging off one. Until that reconciliation lands the sections are +/// hidden rather than shown greyed — a disabled editor still advertises +/// an edit that could never be applied. Flip this with the backend work. +constexpr bool kPortsEditable = false; + +bool isStringType(const QString& type) +{ + return type == QLatin1String("string") || type == QLatin1String("file") || + type == QLatin1String("save_file") || + type == QLatin1String("directory"); +} + +bool isNumericType(const QString& type) +{ + return type == QLatin1String("double") || type == QLatin1String("int"); +} + +/// Types whose value the script actually receives. The rest are layout +/// markers (xyz_header), pickers driven by live data (select_scalars), +/// or ports in disguise (dataset) — writing a "default" for those just +/// litters the description with a meaningless 0. +bool hasDefaultValue(const QString& type) +{ + return isNumericType(type) || isStringType(type) || + type == QLatin1String("bool") || + type == QLatin1String("enumeration"); +} + +/// A titled section with no frame of its own. The tab is already a +/// container; boxing each section inside it just stacks up chrome. +/// Returns the container (hide it to hide the whole section) and writes +/// the layout its content goes into to @a contentLayout. +QWidget* addSection(QVBoxLayout* parentLayout, const QString& title, + QWidget* owner, QVBoxLayout*& contentLayout) +{ + auto* container = new QWidget(owner); + auto* outer = new QVBoxLayout(container); + outer->setContentsMargins(0, 8, 0, 0); + outer->setSpacing(4); + + auto* header = new QLabel(title, container); + header->setStyleSheet("QLabel { font-weight: bold; }"); + outer->addWidget(header); + + parentLayout->addWidget(container); + contentLayout = outer; + return container; +} + +/// Parse @a text as JSON, falling back to treating it as a plain string +/// so a user typing `nearest` gets `"nearest"` rather than nothing. +QJsonValue parseOrString(const QString& text) +{ + if (text.trimmed().isEmpty()) { + return QJsonValue(QJsonValue::Undefined); + } + QJsonValue value = tomviz::pipeline::parseJsonValue(text); + return value.isUndefined() ? QJsonValue(text) : value; +} + +/// Checkable pencil: picks which parameter the detail pane below is +/// editing and stays depressed while it is open, so clicking it again +/// closes the pane. +QPushButton* makeEditButton(QWidget* parent) +{ + auto* button = new QPushButton(QStringLiteral("✎"), parent); + button->setCheckable(true); + button->setFixedWidth(28); + button->setToolTip(QObject::tr("Show or hide this parameter's options")); + return button; +} + +QPushButton* makeRemoveButton(QWidget* parent) +{ + auto* button = new QPushButton(QStringLiteral("✕"), parent); + button->setFixedWidth(28); + button->setToolTip(QObject::tr("Remove")); + return button; +} + +} // namespace + +namespace tomviz { +namespace pipeline { + +NodeDefinitionFormWidget::NodeDefinitionFormWidget(NodeShape shape, + DefinitionSchema schema, + QWidget* parent) + : NodeDefinitionFormWidget(shape, schema, DefinitionTarget::LiveNode, + parent) +{ +} + +NodeDefinitionFormWidget::NodeDefinitionFormWidget(NodeShape shape, + DefinitionSchema schema, + DefinitionTarget target, + QWidget* parent) + : QWidget(parent), m_shape(shape), m_schema(schema), m_target(target) +{ + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + + // The form has to scroll: a node with a dozen parameters plus an open + // detail pane is taller than the properties dock. + auto* scroll = new QScrollArea(this); + scroll->setObjectName(QStringLiteral("nodeDefinitionScroll")); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + outer->addWidget(scroll); + + auto* content = new QWidget(scroll); + content->setObjectName(QStringLiteral("nodeDefinitionScrollContainer")); + auto* layout = new QVBoxLayout(content); + scroll->setWidget(content); + // setWidget() turns autoFillBackground back on, so this has to follow + // it rather than precede it. + content->setAutoFillBackground(false); + scroll->viewport()->setAutoFillBackground(false); + // ...and the flags alone aren't enough: a stylesheet anywhere up the + // tree (MainWindow sets one) switches this subtree to QStyleSheetStyle, + // which paints scroll-area backgrounds from the style instead of + // honouring those flags. Naming the pieces and declaring them + // transparent is the same escape hatch MainWindow itself uses to keep + // #pipelineScroll white — see the comment at MainWindow.cxx:196. + scroll->setStyleSheet( + QStringLiteral("#nodeDefinitionScroll, #nodeDefinitionScroll > QWidget," + " #nodeDefinitionScrollContainer" + " { background: transparent; }")); + + buildGeneralSection(layout); + buildPortsSections(layout); + buildParametersSection(layout); + buildBehaviorSection(layout); + layout->addStretch(); +} + +void NodeDefinitionFormWidget::buildGeneralSection(QVBoxLayout* layout) +{ + QVBoxLayout* content = nullptr; + auto* box = addSection(layout, tr("General"), this, content); + auto* form = new QFormLayout; + form->setContentsMargins(0, 0, 0, 0); + content->addLayout(form); + + m_nameEdit = new QLineEdit(box); + m_nameEdit->setToolTip( + tr("Identifier used for the generated Python module, so it is what " + "shows up in tracebacks.")); + form->addRow(tr("Name"), m_nameEdit); + + m_labelEdit = new QLineEdit(box); + m_labelEdit->setToolTip(tr("Menu and node-card text.")); + form->addRow(tr("Label"), m_labelEdit); + + m_descriptionEdit = new QLineEdit(box); + form->addRow(tr("Description"), m_descriptionEdit); + + if (identityEditable()) { + // A definition file is bound to no node class, so the schema a live + // node freezes is a plain field here. The custom widget key is not: + // anyone who knows what to put there can type it in Raw. + m_schemaCombo = new QComboBox(box); + m_schemaCombo->setObjectName(QStringLiteral("definitionSchemaCombo")); + m_schemaCombo->addItem(tr("Legacy (v1)"), 1); + m_schemaCombo->addItem(tr("v2"), 2); + m_schemaCombo->setToolTip( + tr("v2 declares its ports under \"inputs\" and \"outputs\"; the " + "legacy schema takes them from \"inputType\", \"outputType\", " + "\"results\", \"children\" and \"dataset\" parameters.")); + form->addRow(tr("Schema"), m_schemaCombo); + connect(m_schemaCombo, &QComboBox::currentIndexChanged, this, + [this]() { commitSchema(); }); + } else { + m_fixedInfoLabel = new QLabel(box); + m_fixedInfoLabel->setStyleSheet("QLabel { color: palette(mid); }"); + m_fixedInfoLabel->setWordWrap(true); + form->addRow(m_fixedInfoLabel); + } + + for (auto* edit : { m_nameEdit, m_labelEdit, m_descriptionEdit }) { + connect(edit, &QLineEdit::textEdited, this, [this]() { commitRoot(); }); + } +} + +void NodeDefinitionFormWidget::buildPortsSections(QVBoxLayout* layout) +{ + auto build = [this, layout](bool input) -> QWidget* { + QVBoxLayout* content = nullptr; + auto* box = addSection( + layout, input ? tr("Input Ports") : tr("Output Ports"), this, content); + + auto* rows = new QVBoxLayout; + rows->setContentsMargins(0, 0, 0, 0); + rows->setSpacing(2); + content->addLayout(rows); + (input ? m_inputRowsLayout : m_outputRowsLayout) = rows; + + auto* buttonRow = new QHBoxLayout; + auto* addButton = + new QPushButton(input ? tr("Add Input") : tr("Add Output"), box); + addButton->setObjectName(input ? QStringLiteral("addInputPortButton") + : QStringLiteral("addOutputPortButton")); + connect(addButton, &QPushButton::clicked, this, + [this, input]() { addPort(input); }); + buttonRow->addWidget(addButton); + buttonRow->addStretch(); + content->addLayout(buttonRow); + + if (!identityEditable()) { + box->setToolTip( + tr("Ports are fixed for the life of a node, because links in the " + "pipeline hang off them. Create a new node to change them.")); + } + + return box; + }; + + m_inputBox = build(true); + // Whether a v2 description is a source or a transform is decided by + // its inputs alone, so the note sits right under them. + m_shapeNote = new QLabel(m_inputBox); + m_shapeNote->setObjectName(QStringLiteral("definitionShapeNote")); + m_shapeNote->setWordWrap(true); + m_shapeNote->setStyleSheet("QLabel { color: palette(mid); }"); + m_inputBox->layout()->addWidget(m_shapeNote); + m_outputBox = build(false); + + // Legacy schema: the two port keys the form renders as type pickers, + // and a pointer to raw mode for the rest. + QVBoxLayout* legacyContent = nullptr; + m_legacyPortsBox = + addSection(layout, tr("Ports (legacy schema)"), this, legacyContent); + auto* legacyForm = new QFormLayout; + legacyForm->setContentsMargins(0, 0, 0, 0); + legacyContent->addLayout(legacyForm); + auto makeTypeCombo = [this](const char* objectName) { + auto* combo = new QComboBox(m_legacyPortsBox); + combo->setObjectName(QString::fromLatin1(objectName)); + combo->addItem(tr("(unset)"), QString()); + // Legacy operators only ever take and return volumes; tables and + // molecules travel as "results" or "children" instead. + for (auto type : offeredPortTypes()) { + if (isVolumeType(type)) { + combo->addItem(portTypeToString(type), portTypeToString(type)); + } + } + connect(combo, &QComboBox::currentIndexChanged, this, + [this]() { commitLegacyPorts(); }); + return combo; + }; + m_legacyInputType = makeTypeCombo("legacyInputTypeCombo"); + legacyForm->addRow(tr("Input type"), m_legacyInputType); + m_legacyOutputType = makeTypeCombo("legacyOutputTypeCombo"); + legacyForm->addRow(tr("Output type"), m_legacyOutputType); + + m_legacyPortsNote = new QLabel(m_legacyPortsBox); + m_legacyPortsNote->setWordWrap(true); + m_legacyPortsNote->setStyleSheet("QLabel { color: palette(mid); }"); + m_legacyPortsNote->setText( + tr("Extra result ports (\"results\", \"children\") and \"dataset\" " + "parameters have no controls here; edit them in Raw.")); + legacyContent->addWidget(m_legacyPortsNote); +} + +void NodeDefinitionFormWidget::buildParametersSection(QVBoxLayout* layout) +{ + QVBoxLayout* boxLayout = nullptr; + auto* box = addSection(layout, tr("Parameters"), this, boxLayout); + + auto* header = new QHBoxLayout; + auto* nameHeader = new QLabel(tr("Name"), box); + auto* labelHeader = new QLabel(tr("Label"), box); + auto* typeHeader = new QLabel(tr("Type"), box); + for (auto* label : { nameHeader, labelHeader, typeHeader }) { + label->setStyleSheet("QLabel { color: palette(mid); }"); + } + header->addWidget(nameHeader, 3); + header->addWidget(labelHeader, 3); + header->addWidget(typeHeader, 2); + header->addSpacing(34); + boxLayout->addLayout(header); + + m_paramRowsLayout = new QVBoxLayout; + m_paramRowsLayout->setContentsMargins(0, 0, 0, 0); + m_paramRowsLayout->setSpacing(2); + boxLayout->addLayout(m_paramRowsLayout); + + auto* buttonRow = new QHBoxLayout; + auto* addButton = new QPushButton(tr("Add Parameter"), box); + connect(addButton, &QPushButton::clicked, this, + &NodeDefinitionFormWidget::addParameter); + buttonRow->addWidget(addButton); + buttonRow->addStretch(); + boxLayout->addLayout(buttonRow); + + // --- detail pane, following the selected row --- + m_detail = new QWidget(box); + auto* form = new QFormLayout(m_detail); + form->setContentsMargins(0, 8, 0, 0); + + m_detailHeader = new QLabel(m_detail); + m_detailHeader->setStyleSheet("QLabel { font-weight: bold; }"); + form->addRow(m_detailHeader); + + m_pDescription = new QLineEdit(m_detail); + form->addRow(tr("Description"), m_pDescription); + m_pDefault = new QLineEdit(m_detail); + m_pDefault->setToolTip( + tr("A JSON value: 1.0, true, \"nearest\", or [128, 128, 128] for the " + "xyz-triple parameters.")); + form->addRow(tr("Default"), m_pDefault); + + m_pMinimum = new QLineEdit(m_detail); + form->addRow(tr("Minimum"), m_pMinimum); + m_pMaximum = new QLineEdit(m_detail); + form->addRow(tr("Maximum"), m_pMaximum); + m_pStep = new QLineEdit(m_detail); + form->addRow(tr("Step"), m_pStep); + m_pPrecision = new QSpinBox(m_detail); + m_pPrecision->setRange(-1, 15); + m_pPrecision->setSpecialValueText(tr("unset")); + form->addRow(tr("Precision"), m_pPrecision); + m_numericRows = { m_pMinimum, m_pMaximum, m_pStep }; + + // Laid out like the port rows: one line per option, each with its own + // remove button, and a single Add below. + m_optionsRow = new QWidget(m_detail); + auto* optionsLayout = new QVBoxLayout(m_optionsRow); + optionsLayout->setContentsMargins(0, 0, 0, 0); + optionsLayout->setSpacing(2); + m_optionRowsLayout = new QVBoxLayout; + m_optionRowsLayout->setContentsMargins(0, 0, 0, 0); + m_optionRowsLayout->setSpacing(2); + optionsLayout->addLayout(m_optionRowsLayout); + auto* optionButtonRow = new QHBoxLayout; + auto* addOptionButton = new QPushButton(tr("Add Option"), m_optionsRow); + connect(addOptionButton, &QPushButton::clicked, this, + &NodeDefinitionFormWidget::addOption); + optionButtonRow->addWidget(addOptionButton); + optionButtonRow->addStretch(); + optionsLayout->addLayout(optionButtonRow); + form->addRow(tr("Options"), m_optionsRow); + + boxLayout->addWidget(m_detail); + + for (auto* edit : { m_pDescription, m_pDefault, m_pMinimum, m_pMaximum, + m_pStep }) { + connect(edit, &QLineEdit::textEdited, this, + [this]() { commitParameterDetail(); }); + } + connect(m_pPrecision, &QSpinBox::valueChanged, this, [this]() { + if (!m_populating) { + commitParameterDetail(); + } + }); +} + +void NodeDefinitionFormWidget::buildBehaviorSection(QVBoxLayout* layout) +{ + QVBoxLayout* boxLayout = nullptr; + auto* box = addSection(layout, tr("Behavior"), this, boxLayout); + + m_cancelCheck = new QCheckBox(tr("Supports cancelling mid-execution"), box); + m_completeCheck = new QCheckBox(tr("Supports early completion"), box); + m_externalOnlyCheck = + new QCheckBox(tr("Requires an external Python environment"), box); + m_externalOnlyCheck->setToolTip( + tr("Disables the Internal executor choice on the Execution tab.")); + + for (auto* check : { m_cancelCheck, m_completeCheck, m_externalOnlyCheck }) { + boxLayout->addWidget(check); + connect(check, &QCheckBox::toggled, this, [this]() { + if (!m_populating) { + commitRoot(); + } + }); + } +} + +// --- population ------------------------------------------------------ + +bool NodeDefinitionFormWidget::setJson(const QString& json) +{ + QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isObject() && !json.trimmed().isEmpty()) { + return false; + } + + m_populating = true; + m_root = doc.object(); + + m_nameEdit->setText(m_root.value(QStringLiteral("name")).toString()); + m_labelEdit->setText(m_root.value(QStringLiteral("label")).toString()); + m_descriptionEdit->setText( + m_root.value(QStringLiteral("description")).toString()); + for (auto* edit : { m_nameEdit, m_labelEdit, m_descriptionEdit }) { + edit->setCursorPosition(0); + } + + const QString widgetId = m_root.value(QStringLiteral("widget")).toString(); + if (identityEditable()) { + m_schemaCombo->setCurrentIndex(m_schemaCombo->findData( + currentSchema() == DefinitionSchema::V2 ? 2 : 1)); + } else { + QStringList fixed; + fixed.append( + tr("Schema v%1").arg(m_schema == DefinitionSchema::V2 ? 2 : 1)); + fixed.append(m_shape == NodeShape::Source ? tr("source node") + : tr("transform node")); + if (!widgetId.isEmpty()) { + fixed.append(tr("custom widget \"%1\"").arg(widgetId)); + } + m_fixedInfoLabel->setText(tr("%1 — fixed for the life of this node.") + .arg(fixed.join(QStringLiteral(", ")))); + } + + m_cancelCheck->setChecked( + m_root.value(QStringLiteral("supportsCancel")).toBool(false)); + m_completeCheck->setChecked( + m_root.value(QStringLiteral("supportsComplete")).toBool(false)); + m_externalOnlyCheck->setChecked( + m_root.value(QStringLiteral("externalOnly")).toBool(false)); + + refreshSchemaDependentControls(); + refreshParameterRows(); + m_populating = false; + + selectParameter(-1); + return true; +} + +QString NodeDefinitionFormWidget::json() const +{ + return QString::fromUtf8(QJsonDocument(m_root).toJson()); +} + +QJsonArray NodeDefinitionFormWidget::parameters() const +{ + return m_root.value(QLatin1String(kParametersKey)).toArray(); +} + +void NodeDefinitionFormWidget::setParameters(const QJsonArray& parameters) +{ + m_root[QLatin1String(kParametersKey)] = parameters; +} + +// --- ports ----------------------------------------------------------- + +NodeDefinitionFormWidget::PortRow NodeDefinitionFormWidget::makePortRow( + bool input, const QJsonObject& port) +{ + PortRow row; + row.container = new QWidget(this); + row.container->setObjectName(input ? QStringLiteral("inputPortRow") + : QStringLiteral("outputPortRow")); + auto* layout = new QHBoxLayout(row.container); + layout->setContentsMargins(0, 0, 0, 0); + + row.name = new QLineEdit(port.value(QStringLiteral("name")).toString(), + row.container); + row.name->setPlaceholderText(tr("name")); + layout->addWidget(row.name, 3); + + row.type = new QComboBox(row.container); + for (auto type : offeredPortTypes()) { + row.type->addItem(portTypeToString(type)); + } + const QString portType = port.value(QStringLiteral("type")).toString(); + int typeIndex = row.type->findText(portType); + if (typeIndex < 0 && !portType.isEmpty()) { + // A type this build doesn't offer (or a typo): keep showing it + // rather than silently rewriting the port to something else. + row.type->addItem(portType); + typeIndex = row.type->count() - 1; + } + row.type->setCurrentIndex(typeIndex < 0 ? 0 : typeIndex); + layout->addWidget(row.type, 2); + + if (!input) { + row.persistence = new QComboBox(row.container); + row.persistence->addItem(tr("Transient"), false); + row.persistence->addItem(tr("Persistent"), true); + row.persistence->setCurrentIndex( + port.value(QStringLiteral("persistent")).toBool(false) ? 1 : 0); + layout->addWidget(row.persistence, 2); + } + + row.remove = makeRemoveButton(row.container); + layout->addWidget(row.remove); + + connect(row.name, &QLineEdit::textEdited, this, + [this, input]() { commitPorts(input); }); + connect(row.type, &QComboBox::currentTextChanged, this, + [this, input]() { commitPorts(input); }); + if (row.persistence) { + connect(row.persistence, &QComboBox::currentIndexChanged, this, + [this, input]() { commitPorts(input); }); + } + connect(row.remove, &QPushButton::clicked, this, + [this, input, c = row.container]() { + auto& rows = input ? m_inputRows : m_outputRows; + for (int i = 0; i < rows.size(); ++i) { + if (rows[i].container == c) { + rows.removeAt(i); + c->hide(); + c->setParent(nullptr); + c->deleteLater(); + commitPorts(input); + break; + } + } + }); + + return row; +} + +void NodeDefinitionFormWidget::rebuildPortRows(bool input) +{ + auto& rows = input ? m_inputRows : m_outputRows; + auto* layout = input ? m_inputRowsLayout : m_outputRowsLayout; + for (const auto& row : rows) { + layout->removeWidget(row.container); + // deleteLater() alone leaves the widget parented and visible until + // the event loop drains, so it paints over the rebuilt rows. + row.container->hide(); + row.container->setParent(nullptr); + row.container->deleteLater(); + } + rows.clear(); + + const auto ports = + m_root.value(QLatin1String(input ? kInputsKey : kOutputsKey)).toArray(); + for (const auto& value : ports) { + PortRow row = makePortRow(input, value.toObject()); + layout->addWidget(row.container); + rows.append(row); + } +} + +void NodeDefinitionFormWidget::refreshPortRows() +{ + const bool editable = kPortsEditable || identityEditable(); + const bool v2 = currentSchema() == DefinitionSchema::V2; + // In file mode the inputs list is what makes a description a source + // or a transform, so it shows either way; a live node's shape is fixed. + const bool showInputs = + editable && v2 && + (identityEditable() || m_shape == NodeShape::Transform); + m_inputBox->setVisible(showInputs); + m_outputBox->setVisible(editable && v2); + m_legacyPortsBox->setVisible(editable && !v2); + if (!editable) { + return; + } + + if (v2) { + rebuildPortRows(true); + rebuildPortRows(false); + updateShapeNote(); + return; + } + + // Selecting the pickers must not commit back into the document. + const bool wasPopulating = m_populating; + m_populating = true; + auto select = [this](QComboBox* combo, const char* key) { + const QString type = m_root.value(QLatin1String(key)).toString(); + int index = combo->findData(type); + if (index < 0) { + // A type this build doesn't offer: show it rather than rewrite it. + combo->addItem(type, type); + index = combo->count() - 1; + } + combo->setCurrentIndex(index); + }; + select(m_legacyInputType, "inputType"); + select(m_legacyOutputType, "outputType"); + m_populating = wasPopulating; +} + +void NodeDefinitionFormWidget::commitPorts(bool input) +{ + if (m_populating) { + return; + } + const auto& rows = input ? m_inputRows : m_outputRows; + const QString key = QLatin1String(input ? kInputsKey : kOutputsKey); + + // Mutate each existing entry so per-port keys the form doesn't render + // survive, and only drop entries whose row is actually gone. + const auto previous = m_root.value(key).toArray(); + QJsonArray ports; + for (int i = 0; i < rows.size(); ++i) { + QJsonObject port = + i < previous.size() ? previous.at(i).toObject() : QJsonObject(); + port[QStringLiteral("name")] = rows[i].name->text(); + port[QStringLiteral("type")] = rows[i].type->currentText(); + if (rows[i].persistence) { + port[QStringLiteral("persistent")] = + rows[i].persistence->currentData().toBool(); + } + ports.append(port); + } + + if (ports.isEmpty() && !m_root.contains(key)) { + return; + } + m_root[key] = ports; + updateShapeNote(); + emit changed(); +} + +void NodeDefinitionFormWidget::addPort(bool input) +{ + const QString key = QLatin1String(input ? kInputsKey : kOutputsKey); + auto ports = m_root.value(key).toArray(); + ports.append(QJsonObject{ + { QStringLiteral("name"), + input ? QStringLiteral("input") : QStringLiteral("output") }, + { QStringLiteral("type"), portTypeToString(PortType::ImageData) } }); + m_root[key] = ports; + + rebuildPortRows(input); + updateShapeNote(); + emit changed(); +} + +// --- parameters ------------------------------------------------------ + +NodeDefinitionFormWidget::ParameterRow +NodeDefinitionFormWidget::makeParameterRow(const QJsonObject& param) +{ + ParameterRow row; + row.container = new QWidget(this); + auto* layout = new QHBoxLayout(row.container); + layout->setContentsMargins(3, 1, 3, 1); + + row.name = new QLineEdit(param.value(QStringLiteral("name")).toString(), + row.container); + row.name->setPlaceholderText(tr("name")); + layout->addWidget(row.name, 3); + + row.label = new QLineEdit(param.value(QStringLiteral("label")).toString(), + row.container); + row.label->setPlaceholderText(tr("label")); + layout->addWidget(row.label, 3); + + row.type = new QComboBox(row.container); + row.type->addItems(parameterTypeNames()); + QString type = param.value(QStringLiteral("type")).toString(); + int typeIndex = row.type->findText(type); + if (typeIndex < 0 && !type.isEmpty()) { + // An unrecognized type from a hand-written description: offer it + // rather than silently rewriting it to something else. + row.type->addItem(type); + typeIndex = row.type->count() - 1; + } + row.type->setCurrentIndex(typeIndex < 0 ? 0 : typeIndex); + layout->addWidget(row.type, 2); + + row.edit = makeEditButton(row.container); + layout->addWidget(row.edit); + connect(row.edit, &QPushButton::clicked, this, [this, c = row.container]() { + int index = indexOfParameterRow(c); + if (index >= 0) { + // Clicking the pencil of the parameter that's already open closes + // it again, so the detail pane can be dismissed without having to + // pick some other parameter. + selectParameter(index == m_selectedParameter ? -1 : index); + } + }); + + row.remove = makeRemoveButton(row.container); + layout->addWidget(row.remove); + + auto commit = [this, c = row.container]() { + int index = indexOfParameterRow(c); + if (index >= 0) { + commitParameterRow(index); + } + }; + connect(row.name, &QLineEdit::textEdited, this, commit); + connect(row.label, &QLineEdit::textEdited, this, commit); + connect(row.type, &QComboBox::currentTextChanged, this, commit); + connect(row.remove, &QPushButton::clicked, this, [this, c = row.container]() { + int index = indexOfParameterRow(c); + if (index >= 0) { + removeParameter(index); + } + }); + + return row; +} + +void NodeDefinitionFormWidget::refreshParameterRows() +{ + for (const auto& row : m_paramRows) { + m_paramRowsLayout->removeWidget(row.container); + // See rebuildPortRows(): hide and unparent before deleteLater() or + // the outgoing rows paint over the new ones. + row.container->hide(); + row.container->setParent(nullptr); + row.container->deleteLater(); + } + m_paramRows.clear(); + + const auto params = parameters(); + for (const auto& value : params) { + ParameterRow row = makeParameterRow(value.toObject()); + m_paramRowsLayout->addWidget(row.container); + m_paramRows.append(row); + } +} + +int NodeDefinitionFormWidget::indexOfParameterRow( + const QWidget* container) const +{ + for (int i = 0; i < m_paramRows.size(); ++i) { + if (m_paramRows[i].container == container) { + return i; + } + } + return -1; +} + +void NodeDefinitionFormWidget::selectParameter(int index) +{ + m_selectedParameter = index; + for (int i = 0; i < m_paramRows.size(); ++i) { + // Driven rather than toggled: clicking an already-depressed pencil + // would otherwise pop it back out while its parameter stays open. + QSignalBlocker blocker(m_paramRows[i].edit); + m_paramRows[i].edit->setChecked(i == index); + } + loadParameterDetail(); +} + +void NodeDefinitionFormWidget::loadParameterDetail() +{ + const auto params = parameters(); + bool valid = m_selectedParameter >= 0 && m_selectedParameter < params.size(); + m_detail->setVisible(valid); + if (!valid) { + return; + } + + bool wasPopulating = m_populating; + m_populating = true; + + auto fields = readParameterFields(params.at(m_selectedParameter).toObject()); + m_detailHeader->setText( + fields.name.isEmpty() + ? tr("Options for this %1").arg(fields.type) + : tr("Options for \"%1\"").arg(fields.name)); + + m_pDescription->setText(fields.description); + m_pDefault->setText( + isStringType(fields.type) && fields.defaultValue.isString() + ? fields.defaultValue.toString() + : formatJsonValue(fields.defaultValue)); + m_pMinimum->setText(formatJsonValue(fields.minimum)); + m_pMaximum->setText(formatJsonValue(fields.maximum)); + m_pStep->setText(formatJsonValue(fields.step)); + m_pPrecision->setValue( + fields.precision.isUndefined() ? -1 : fields.precision.toInt(-1)); + + refreshOptionRows(fields.options); + + for (auto* edit : + { m_pDescription, m_pDefault, m_pMinimum, m_pMaximum, m_pStep }) { + edit->setCursorPosition(0); + } + + updateTypeDependentRows(); + m_populating = wasPopulating; +} + +void NodeDefinitionFormWidget::updateTypeDependentRows() +{ + auto* form = qobject_cast(m_detail->layout()); + if (!form || m_selectedParameter < 0 || + m_selectedParameter >= m_paramRows.size()) { + return; + } + const QString type = m_paramRows[m_selectedParameter].type->currentText(); + for (auto* widget : m_numericRows) { + form->setRowVisible(widget, isNumericType(type)); + } + // Decimal places only mean anything for a floating-point control. + form->setRowVisible(m_pPrecision, type == QLatin1String("double")); + form->setRowVisible(m_optionsRow, type == QLatin1String("enumeration")); + form->setRowVisible(m_pDefault, hasDefaultValue(type)); +} + +void NodeDefinitionFormWidget::commitParameterRow(int index) +{ + if (m_populating) { + return; + } + auto params = parameters(); + if (index < 0 || index >= params.size() || index >= m_paramRows.size()) { + return; + } + + QJsonObject param = params.at(index).toObject(); + const QString previousType = param.value(QStringLiteral("type")).toString(); + const QString newType = m_paramRows[index].type->currentText(); + const QString newName = m_paramRows[index].name->text(); + + setOrClear(param, QStringLiteral("name"), newName); + setOrClear(param, QStringLiteral("label"), m_paramRows[index].label->text()); + setOrClear(param, QStringLiteral("type"), newType); + params.replace(index, param); + setParameters(params); + + if (index == m_selectedParameter) { + if (previousType != newType) { + // The type decides which rows the detail pane offers and which + // keys survive, so rebuild it and re-commit under the new type. + loadParameterDetail(); + commitParameterDetail(); + } else { + // Only the header tracks the name — rebuilding the whole detail + // pane on every keystroke would tear down the option rows too. + m_detailHeader->setText(newName.isEmpty() + ? tr("Options for this %1").arg(newType) + : tr("Options for \"%1\"").arg(newName)); + } + } + emit changed(); +} + +QJsonArray NodeDefinitionFormWidget::collectOptions() const +{ + QJsonArray options; + for (const auto& row : m_optionRows) { + const QString label = row.label->text(); + if (label.isEmpty()) { + continue; + } + QJsonValue value = parseOrString(row.value->text()); + if (value.isUndefined()) { + // An option has to carry a value: writing an undefined one into a + // QJsonObject removes the key instead, leaving {} behind — which + // is not a renderable option. A half-typed row means an empty + // string, not a malformed entry. + value = QString(); + } + options.append(QJsonObject{ { label, value } }); + } + return options; +} + +NodeDefinitionFormWidget::OptionRow NodeDefinitionFormWidget::makeOptionRow( + const QString& label, const QJsonValue& value) +{ + OptionRow row; + row.container = new QWidget(m_optionsRow); + auto* layout = new QHBoxLayout(row.container); + layout->setContentsMargins(0, 0, 0, 0); + + row.label = new QLineEdit(label, row.container); + row.label->setPlaceholderText(tr("label")); + layout->addWidget(row.label, 3); + + row.value = new QLineEdit(formatJsonValue(value), row.container); + row.value->setPlaceholderText(tr("value")); + row.value->setCursorPosition(0); + layout->addWidget(row.value, 3); + + row.remove = makeRemoveButton(row.container); + layout->addWidget(row.remove); + + connect(row.label, &QLineEdit::textEdited, this, + [this]() { commitParameterDetail(); }); + connect(row.value, &QLineEdit::textEdited, this, + [this]() { commitParameterDetail(); }); + connect(row.remove, &QPushButton::clicked, this, + [this, c = row.container]() { + for (int i = 0; i < m_optionRows.size(); ++i) { + if (m_optionRows[i].container == c) { + m_optionRows.removeAt(i); + c->hide(); + c->setParent(nullptr); + c->deleteLater(); + commitParameterDetail(); + break; + } + } + }); + + return row; +} + +void NodeDefinitionFormWidget::refreshOptionRows(const QJsonArray& options) +{ + for (const auto& row : m_optionRows) { + m_optionRowsLayout->removeWidget(row.container); + row.container->hide(); + row.container->setParent(nullptr); + row.container->deleteLater(); + } + m_optionRows.clear(); + + for (const auto& value : options) { + QJsonObject option = value.toObject(); + if (option.isEmpty()) { + continue; + } + OptionRow row = + makeOptionRow(option.constBegin().key(), option.constBegin().value()); + m_optionRowsLayout->addWidget(row.container); + m_optionRows.append(row); + } +} + +void NodeDefinitionFormWidget::addOption() +{ + OptionRow row = makeOptionRow(QString(), QJsonValue(QJsonValue::Undefined)); + m_optionRowsLayout->addWidget(row.container); + m_optionRows.append(row); + row.label->setFocus(); + // Not committed yet: an option with a blank label is skipped by + // collectOptions, so it lands once the user names it. +} + +void NodeDefinitionFormWidget::commitParameterDetail() +{ + if (m_populating) { + return; + } + auto params = parameters(); + if (m_selectedParameter < 0 || m_selectedParameter >= params.size()) { + return; + } + + QJsonObject param = params.at(m_selectedParameter).toObject(); + auto fields = readParameterFields(param); + + fields.description = m_pDescription->text(); + fields.defaultValue = QJsonValue(QJsonValue::Undefined); + if (hasDefaultValue(fields.type)) { + fields.defaultValue = + isStringType(fields.type) + ? (m_pDefault->text().isEmpty() ? QJsonValue(QJsonValue::Undefined) + : QJsonValue(m_pDefault->text())) + : parseOrString(m_pDefault->text()); + } + fields.minimum = QJsonValue(QJsonValue::Undefined); + fields.maximum = QJsonValue(QJsonValue::Undefined); + fields.step = QJsonValue(QJsonValue::Undefined); + fields.precision = QJsonValue(QJsonValue::Undefined); + if (isNumericType(fields.type)) { + fields.minimum = parseJsonValue(m_pMinimum->text()); + fields.maximum = parseJsonValue(m_pMaximum->text()); + fields.step = parseJsonValue(m_pStep->text()); + } + if (fields.type == QLatin1String("double") && m_pPrecision->value() >= 0) { + fields.precision = m_pPrecision->value(); + } + fields.options = fields.type == QLatin1String("enumeration") + ? collectOptions() + : QJsonArray(); + // visible_if / enable_if aren't rendered; readParameterFields() has + // already carried them over, so they survive the write untouched. + + // Mutate the existing entry rather than building a fresh one, so keys + // the form doesn't render survive the edit. + applyParameterFields(param, fields); + params.replace(m_selectedParameter, param); + setParameters(params); + emit changed(); +} + +void NodeDefinitionFormWidget::addParameter() +{ + auto params = parameters(); + + QString name = QStringLiteral("parameter"); + QStringList taken; + for (const auto& value : params) { + taken.append(value.toObject().value(QStringLiteral("name")).toString()); + } + for (int suffix = 1; taken.contains(name); ++suffix) { + name = QStringLiteral("parameter_%1").arg(suffix); + } + + params.append(QJsonObject{ + { QStringLiteral("name"), name }, + { QStringLiteral("label"), name }, + { QStringLiteral("type"), QStringLiteral("double") }, + { QStringLiteral("default"), 0.0 } }); + setParameters(params); + + refreshParameterRows(); + selectParameter(params.size() - 1); + emit changed(); +} + +void NodeDefinitionFormWidget::removeParameter(int index) +{ + auto params = parameters(); + if (index < 0 || index >= params.size()) { + return; + } + params.removeAt(index); + setParameters(params); + + refreshParameterRows(); + selectParameter( + params.isEmpty() ? -1 : qMin(index, static_cast(params.size()) - 1)); + emit changed(); +} + +void NodeDefinitionFormWidget::commitRoot() +{ + if (m_populating) { + return; + } + setOrClear(m_root, QStringLiteral("name"), m_nameEdit->text()); + setOrClear(m_root, QStringLiteral("label"), m_labelEdit->text()); + setOrClear(m_root, QStringLiteral("description"), + m_descriptionEdit->text()); + // "help" isn't rendered, so it is left exactly as the description + // declared it rather than being cleared on every commit. + if (currentSchema() == DefinitionSchema::V2) { + setOrClearBool(m_root, QStringLiteral("supportsCancel"), + m_cancelCheck->isChecked(), false); + setOrClearBool(m_root, QStringLiteral("supportsComplete"), + m_completeCheck->isChecked(), false); + } + setOrClearBool(m_root, QStringLiteral("externalOnly"), + m_externalOnlyCheck->isChecked(), false); + emit changed(); +} + +// --- file mode ------------------------------------------------------- + +bool NodeDefinitionFormWidget::identityEditable() const +{ + return m_target == DefinitionTarget::File; +} + +DefinitionSchema NodeDefinitionFormWidget::currentSchema() const +{ + if (!identityEditable()) { + return m_schema; + } + return m_root.value(QStringLiteral("schemaVersion")).toInt(1) == 2 + ? DefinitionSchema::V2 + : DefinitionSchema::V1; +} + +NodeShape NodeDefinitionFormWidget::currentShape() const +{ + if (!identityEditable()) { + return m_shape; + } + const bool v2 = currentSchema() == DefinitionSchema::V2; + return v2 && m_root.value(QLatin1String(kInputsKey)).toArray().isEmpty() + ? NodeShape::Source + : NodeShape::Transform; +} + +void NodeDefinitionFormWidget::commitSchema() +{ + if (m_populating) { + return; + } + // Shipped v1 descriptors carry no "schemaVersion" at all, so going + // back to legacy removes the key rather than writing a 1. The port + // keys of the other schema are left in place for a round trip. + if (m_schemaCombo->currentData().toInt() == 2) { + m_root[QStringLiteral("schemaVersion")] = 2; + } else { + m_root.remove(QStringLiteral("schemaVersion")); + } + refreshSchemaDependentControls(); + emit changed(); +} + +void NodeDefinitionFormWidget::commitLegacyPorts() +{ + if (m_populating) { + return; + } + setOrClear(m_root, QStringLiteral("inputType"), + m_legacyInputType->currentData().toString()); + setOrClear(m_root, QStringLiteral("outputType"), + m_legacyOutputType->currentData().toString()); + emit changed(); +} + +void NodeDefinitionFormWidget::refreshSchemaDependentControls() +{ + // The legacy schema derives cancel/complete support from the script's + // operator base class instead, so the checkboxes would be lying. + const bool v2 = currentSchema() == DefinitionSchema::V2; + m_cancelCheck->setEnabled(v2); + m_completeCheck->setEnabled(v2); + m_cancelCheck->setToolTip( + v2 ? QString() + : tr("Legacy nodes take this from the script's operator base " + "class.")); + m_completeCheck->setToolTip(m_cancelCheck->toolTip()); + refreshPortRows(); +} + +void NodeDefinitionFormWidget::updateShapeNote() +{ + if (!identityEditable()) { + m_shapeNote->hide(); + return; + } + m_shapeNote->setText( + currentShape() == NodeShape::Source + ? tr("No inputs: this description is a source node.") + : tr("With inputs, this description is a transform node.")); + m_shapeNote->show(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeDefinitionFormWidget.h b/tomviz/pipeline/NodeDefinitionFormWidget.h new file mode 100644 index 000000000..26351bae8 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionFormWidget.h @@ -0,0 +1,191 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeDefinitionFormWidget_h +#define tomvizPipelineNodeDefinitionFormWidget_h + +#include "NodeDefinitionValidator.h" + +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QVBoxLayout; + +namespace tomviz { +namespace pipeline { + +/// Generated form over a node's JSON description — the alternative view +/// to the raw editor in NodeDefinitionWidget. +/// +/// Edits mutate the parsed document in place rather than rebuilding it +/// from the rendered fields, so the many keys the form doesn't show +/// (``filter``, ``bindToSink``, ``apply_to_each_array``, +/// ``externalCompatible``, ``input``, …) survive untouched. +/// +/// The port sections are hidden while ports can't be edited — see +/// kPortsEditable. Their rows and commit path are written, so enabling +/// them is a focused change rather than a rewrite. +/// +/// With DefinitionTarget::File the form edits a definition file rather +/// than a live node: the schema and the ports are ordinary fields, and +/// schema and shape follow the document instead of the constructor +/// arguments (which only seed the initial rendering). The custom widget +/// key is niche enough to stay raw-only. +class NodeDefinitionFormWidget : public QWidget +{ + Q_OBJECT + +public: + NodeDefinitionFormWidget(NodeShape shape, DefinitionSchema schema, + QWidget* parent = nullptr); + NodeDefinitionFormWidget(NodeShape shape, DefinitionSchema schema, + DefinitionTarget target, + QWidget* parent = nullptr); + + /// Repopulate every control from @a json. Does not emit changed(). + /// Returns false when @a json isn't a JSON object, in which case the + /// form is left untouched — the caller keeps the user in raw mode. + bool setJson(const QString& json); + + /// Serialize the current document. + QString json() const; + +signals: + /// Emitted after any edit that changed the document. + void changed(); + +private: + /// One port's controls: name, type, and (outputs only) persistency. + struct PortRow + { + QWidget* container = nullptr; + QLineEdit* name = nullptr; + QComboBox* type = nullptr; + QComboBox* persistence = nullptr; + QPushButton* remove = nullptr; + }; + + /// One enumeration option: its label and the value the script gets. + struct OptionRow + { + QWidget* container = nullptr; + QLineEdit* label = nullptr; + QLineEdit* value = nullptr; + QPushButton* remove = nullptr; + }; + + /// One parameter's inline controls. Everything type-specific lives in + /// the detail pane below, which follows the selected row. + struct ParameterRow + { + QWidget* container = nullptr; + QLineEdit* name = nullptr; + QLineEdit* label = nullptr; + QComboBox* type = nullptr; + QPushButton* edit = nullptr; + QPushButton* remove = nullptr; + }; + + /// File mode: what a live node freezes is editable here, and schema + /// and shape are read off the document. + bool identityEditable() const; + DefinitionSchema currentSchema() const; + NodeShape currentShape() const; + void commitSchema(); + void commitLegacyPorts(); + /// Cancel/complete enablement and which port sections show depend on + /// the schema, which in file mode can change under the form. + void refreshSchemaDependentControls(); + void updateShapeNote(); + void buildGeneralSection(QVBoxLayout* layout); + void buildPortsSections(QVBoxLayout* layout); + void buildParametersSection(QVBoxLayout* layout); + void buildBehaviorSection(QVBoxLayout* layout); + + void refreshPortRows(); + void refreshParameterRows(); + void refreshOptionRows(const QJsonArray& options); + void rebuildPortRows(bool input); + PortRow makePortRow(bool input, const QJsonObject& port); + ParameterRow makeParameterRow(const QJsonObject& param); + OptionRow makeOptionRow(const QString& label, const QJsonValue& value); + + void commitRoot(); + void commitPorts(bool input); + void commitParameterRow(int index); + void commitParameterDetail(); + + void selectParameter(int index); + void loadParameterDetail(); + void updateTypeDependentRows(); + QJsonArray collectOptions() const; + + void addPort(bool input); + void addParameter(); + void removeParameter(int index); + void addOption(); + + int indexOfParameterRow(const QWidget* container) const; + + QJsonArray parameters() const; + void setParameters(const QJsonArray& parameters); + + NodeShape m_shape; + DefinitionSchema m_schema; + DefinitionTarget m_target = DefinitionTarget::LiveNode; + QJsonObject m_root; + /// Set while setJson() / loadParameterDetail() drive the controls, so + /// their change signals don't loop back as user edits. + bool m_populating = false; + int m_selectedParameter = -1; + + QLineEdit* m_nameEdit = nullptr; + QLineEdit* m_labelEdit = nullptr; + QLineEdit* m_descriptionEdit = nullptr; + QLabel* m_fixedInfoLabel = nullptr; + // File mode only. + QComboBox* m_schemaCombo = nullptr; + QWidget* m_legacyPortsBox = nullptr; + QComboBox* m_legacyInputType = nullptr; + QComboBox* m_legacyOutputType = nullptr; + QLabel* m_shapeNote = nullptr; + + QWidget* m_inputBox = nullptr; + QWidget* m_outputBox = nullptr; + QLabel* m_legacyPortsNote = nullptr; + QVBoxLayout* m_inputRowsLayout = nullptr; + QVBoxLayout* m_outputRowsLayout = nullptr; + QList m_inputRows; + QList m_outputRows; + + QVBoxLayout* m_paramRowsLayout = nullptr; + QList m_paramRows; + QWidget* m_detail = nullptr; + QLabel* m_detailHeader = nullptr; + QLineEdit* m_pDescription = nullptr; + QLineEdit* m_pDefault = nullptr; + QLineEdit* m_pMinimum = nullptr; + QLineEdit* m_pMaximum = nullptr; + QLineEdit* m_pStep = nullptr; + QSpinBox* m_pPrecision = nullptr; + QWidget* m_optionsRow = nullptr; + QVBoxLayout* m_optionRowsLayout = nullptr; + QList m_optionRows; + QList m_numericRows; + + QCheckBox* m_cancelCheck = nullptr; + QCheckBox* m_completeCheck = nullptr; + QCheckBox* m_externalOnlyCheck = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeDefinitionValidator.cxx b/tomviz/pipeline/NodeDefinitionValidator.cxx new file mode 100644 index 000000000..b00507596 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionValidator.cxx @@ -0,0 +1,448 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeDefinitionValidator.h" + +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +namespace { + +// Parameter types ParameterInterfaceBuilder knows how to render. A +// parameter declared as anything else still reaches the script, but has +// no control in the Parameters tab, so it is worth flagging. +const QStringList& renderableParameterTypes() +{ + static const QStringList types = { QStringLiteral("bool"), + QStringLiteral("int"), + QStringLiteral("double"), + QStringLiteral("enumeration"), + QStringLiteral("xyz_header"), + QStringLiteral("file"), + QStringLiteral("save_file"), + QStringLiteral("directory"), + QStringLiteral("string"), + QStringLiteral("select_scalars") }; + return types; +} + +QJsonObject parseObject(const QString& json, QString* parseError) +{ + if (json.trimmed().isEmpty()) { + return {}; + } + QJsonParseError error; + QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8(), &error); + if (error.error != QJsonParseError::NoError) { + if (parseError) { + *parseError = QStringLiteral("%1 (offset %2)") + .arg(error.errorString()) + .arg(error.offset); + } + return {}; + } + if (!doc.isObject()) { + if (parseError) { + *parseError = QStringLiteral("the description must be a JSON object"); + } + return {}; + } + return doc.object(); +} + +void addError(DefinitionValidation& result, const QString& message) +{ + result.issues.append({ DefinitionIssue::Severity::Error, message }); +} + +void addWarning(DefinitionValidation& result, const QString& message) +{ + result.issues.append({ DefinitionIssue::Severity::Warning, message }); +} + +/// Compare a key that a live node cannot re-apply. Arrays and objects +/// are compared structurally; an absent key reads as an empty array so +/// `"inputs": []` and no `inputs` at all are the same thing. +void checkFrozenKey(DefinitionValidation& result, const QJsonObject& current, + const QJsonObject& candidate, const QString& key, + const QString& reason) +{ + QJsonValue before = current.value(key); + QJsonValue after = candidate.value(key); + if (before.isUndefined() && after.isArray() && after.toArray().isEmpty()) { + return; + } + if (after.isUndefined() && before.isArray() && before.toArray().isEmpty()) { + return; + } + if (before == after) { + return; + } + addError(result, QStringLiteral("\"%1\" cannot be changed on an existing " + "node: %2") + .arg(key, reason)); +} + +/// The v1 schema turns `dataset`-typed parameters into input ports, so +/// their declaration is as frozen as `results` is. +QStringList datasetParameterNames(const QJsonObject& obj) +{ + QStringList names; + for (const auto& value : obj.value(QStringLiteral("parameters")).toArray()) { + QJsonObject param = value.toObject(); + if (param.value(QStringLiteral("type")).toString() == + QLatin1String("dataset")) { + names.append(param.value(QStringLiteral("name")).toString()); + } + } + return names; +} + +void validateParameters(DefinitionValidation& result, + const QJsonObject& candidate) +{ + QSet seen; + for (const auto& value : + candidate.value(QStringLiteral("parameters")).toArray()) { + if (!value.isObject()) { + addError(result, + QStringLiteral("every entry in \"parameters\" must be an " + "object")); + continue; + } + QJsonObject param = value.toObject(); + QString name = param.value(QStringLiteral("name")).toString(); + QString type = param.value(QStringLiteral("type")).toString(); + + if (type.isEmpty()) { + addError(result, QStringLiteral("parameter \"%1\" declares no \"type\"") + .arg(name.isEmpty() ? QStringLiteral("") + : name)); + continue; + } + + // A nameless entry is a layout marker (xyz_header), not a value the + // script receives. Anything else without a name is a mistake. + if (name.isEmpty()) { + if (type != QLatin1String("xyz_header")) { + addError(result, + QStringLiteral("a \"%1\" parameter must declare a \"name\"") + .arg(type)); + } + continue; + } + + if (seen.contains(name)) { + addError(result, + QStringLiteral("parameter \"%1\" is declared more than once") + .arg(name)); + continue; + } + seen.insert(name); + + if (type == QLatin1String("enumeration")) { + QJsonArray options = param.value(QStringLiteral("options")).toArray(); + if (options.isEmpty()) { + addError(result, + QStringLiteral("enumeration parameter \"%1\" declares no " + "\"options\"") + .arg(name)); + } + for (const auto& option : options) { + // The parameter form renders each option by taking the object's + // single key as the label and its value as what the script gets, + // so anything else can't be displayed. + if (!option.isObject() || option.toObject().size() != 1) { + addError(result, + QStringLiteral("each option of \"%1\" must be a single " + "{\"Label\": value} pair") + .arg(name)); + break; + } + } + } + + if (!renderableParameterTypes().contains(type) && + type != QLatin1String("dataset")) { + addWarning(result, + QStringLiteral("parameter \"%1\" has type \"%2\", which has " + "no control in the Parameters tab — the " + "script still receives its default") + .arg(name, type)); + } + } +} + +void diffParameters(DefinitionValidation& result, const QString& currentJson, + const QString& candidateJson) +{ + auto before = parameterDeclaredTypes(currentJson); + auto after = parameterDeclaredTypes(candidateJson); + + for (auto it = after.constBegin(); it != after.constEnd(); ++it) { + auto prev = before.constFind(it.key()); + if (prev == before.constEnd()) { + result.addedParameters.append(it.key()); + } else if (prev.value() != it.value()) { + result.retypedParameters.append(it.key()); + } + } + for (auto it = before.constBegin(); it != before.constEnd(); ++it) { + if (!after.contains(it.key())) { + result.removedParameters.append(it.key()); + } + } + + result.addedParameters.sort(); + result.removedParameters.sort(); + result.retypedParameters.sort(); + + if (!result.removedParameters.isEmpty()) { + addWarning(result, + QStringLiteral("dropping %1 — any value you set is lost") + .arg(result.removedParameters.join(QStringLiteral(", ")))); + } + if (!result.retypedParameters.isEmpty()) { + addWarning(result, + QStringLiteral("%1 changed type and will revert to the " + "declared default") + .arg(result.retypedParameters.join(QStringLiteral(", ")))); + } + if (!result.addedParameters.isEmpty()) { + addWarning(result, + QStringLiteral("adding %1 — the script must accept it as a " + "keyword argument") + .arg(result.addedParameters.join(QStringLiteral(", ")))); + } +} + +} // namespace + +bool DefinitionValidation::hasErrors() const +{ + for (const auto& issue : issues) { + if (issue.severity == DefinitionIssue::Severity::Error) { + return true; + } + } + return false; +} + +QStringList DefinitionValidation::messages( + DefinitionIssue::Severity severity) const +{ + QStringList out; + for (const auto& issue : issues) { + if (issue.severity == severity) { + out.append(issue.message); + } + } + return out; +} + +DefinitionSchema definitionSchema(const QString& json) +{ + QJsonObject obj = parseObject(json, nullptr); + return obj.value(QStringLiteral("schemaVersion")).toInt(1) == 2 + ? DefinitionSchema::V2 + : DefinitionSchema::V1; +} + +NodeShape definitionShape(const QString& json) +{ + QJsonObject obj = parseObject(json, nullptr); + const bool v2 = obj.value(QStringLiteral("schemaVersion")).toInt(1) == 2; + if (v2 && obj.value(QStringLiteral("inputs")).toArray().isEmpty()) { + return NodeShape::Source; + } + return NodeShape::Transform; +} + +QMap parameterDeclaredTypes(const QString& json, + bool includeDatasets) +{ + QMap types; + QJsonObject obj = parseObject(json, nullptr); + for (const auto& value : obj.value(QStringLiteral("parameters")).toArray()) { + QJsonObject param = value.toObject(); + QString name = param.value(QStringLiteral("name")).toString(); + QString type = param.value(QStringLiteral("type")).toString(); + if (name.isEmpty()) { + continue; + } + if (!includeDatasets && type == QLatin1String("dataset")) { + continue; + } + types.insert(name, type); + } + return types; +} + +DefinitionValidation validateNodeDefinition(const QString& currentJson, + const QString& candidateJson, + NodeShape shape, + DefinitionSchema schema, + DefinitionTarget target) +{ + DefinitionValidation result; + + QString parseError; + QJsonObject candidate = parseObject(candidateJson, &parseError); + if (!parseError.isEmpty()) { + addError(result, parseError); + return result; + } + QJsonObject current = parseObject(currentJson, nullptr); + + // Blank text and a document cleared down to a bare "{}" are the same + // loss: the node's identity and every parameter go with it. The form + // cannot show text that doesn't parse, so clearing the raw editor and + // switching back produces the "{}" spelling rather than the blank one. + if (candidate.isEmpty() && !current.isEmpty()) { + addError(result, QStringLiteral("the description cannot be emptied")); + return result; + } + if (candidate.isEmpty()) { + // Node never had a description and still doesn't, nothing to check. + return result; + } + + if (target == DefinitionTarget::File) { + // A file is bound to no node class: schema, shape, ports and widget + // are all free to change. The parameter diff describes what happens + // to a live node's values, which a file doesn't have. + validateParameters(result, candidate); + return result; + } + + // --- identity: fixed by the C++ class the node was built as -------- + DefinitionSchema candidateSchema = + candidate.value(QStringLiteral("schemaVersion")).toInt(1) == 2 + ? DefinitionSchema::V2 + : DefinitionSchema::V1; + if (candidateSchema != schema) { + addError(result, + QStringLiteral("\"schemaVersion\" cannot be changed on an " + "existing node — the schema picks the node " + "class at creation time. Add a new node from " + "the menu instead.")); + // Every remaining check assumes the node's own schema, so stop here. + return result; + } + + if (schema == DefinitionSchema::V2) { + bool hasInputs = + !candidate.value(QStringLiteral("inputs")).toArray().isEmpty(); + if (shape == NodeShape::Source && hasInputs) { + addError(result, + QStringLiteral("this is a source node: it cannot declare " + "\"inputs\". Add a transform from the menu " + "instead.")); + } else if (shape == NodeShape::Transform && !hasInputs) { + addError(result, + QStringLiteral("this is a transform node: it must declare at " + "least one entry in \"inputs\". Add a source " + "from the menu instead.")); + } + } + + // --- port topology: frozen ----------------------------------------- + // Nothing in the pipeline can remove a port, or the links hanging off + // one, so a live node's ports are fixed for now. + const QString portReason = + QStringLiteral("a node's ports are fixed once it exists"); + if (schema == DefinitionSchema::V2) { + checkFrozenKey(result, current, candidate, QStringLiteral("inputs"), + portReason); + checkFrozenKey(result, current, candidate, QStringLiteral("outputs"), + portReason); + } else { + checkFrozenKey(result, current, candidate, QStringLiteral("results"), + portReason); + checkFrozenKey(result, current, candidate, QStringLiteral("children"), + portReason); + checkFrozenKey(result, current, candidate, QStringLiteral("inputType"), + portReason); + checkFrozenKey(result, current, candidate, QStringLiteral("outputType"), + portReason); + if (datasetParameterNames(current) != datasetParameterNames(candidate)) { + addError(result, + QStringLiteral("\"dataset\" parameters cannot be added or " + "removed: each one is an input port, and %1") + .arg(portReason)); + } + } + + // The custom widget replaces the whole Parameters tab and decides + // whether the node needs live input data to be edited at all, so it + // is bound at editor-construction time. + checkFrozenKey(result, current, candidate, QStringLiteral("widget"), + QStringLiteral("reopen the editor to pick up a different " + "custom widget")); + + validateParameters(result, candidate); + if (!result.hasErrors()) { + diffParameters(result, currentJson, candidateJson); + } + + return result; +} + +QMap mergeParameterValues( + const QMap& previousValues, + const QMap& previousTypes, + const QMap& newDefaults, + const QMap& newTypes, + const QMap& newEnumOptions, QStringList* resetNames) +{ + QMap merged = newDefaults; + + for (auto it = newTypes.constBegin(); it != newTypes.constEnd(); ++it) { + const QString& name = it.key(); + const QString& type = it.value(); + + auto previous = previousValues.constFind(name); + if (previous == previousValues.constEnd()) { + continue; // Brand new parameter: the declared default stands. + } + if (previousTypes.value(name) != type) { + if (resetNames) { + resetNames->append(name); + } + continue; + } + if (type == QLatin1String("enumeration")) { + // Options can be rewritten without the type changing; a value + // that is no longer offered has to go back to the default. + bool stillOffered = false; + for (const auto& option : newEnumOptions.value(name)) { + QJsonObject entry = option.toObject(); + if (entry.isEmpty()) { + continue; + } + if (entry.constBegin().value().toVariant() == previous.value()) { + stillOffered = true; + break; + } + } + if (!stillOffered) { + if (resetNames) { + resetNames->append(name); + } + continue; + } + } + merged.insert(name, previous.value()); + } + + return merged; +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeDefinitionValidator.h b/tomviz/pipeline/NodeDefinitionValidator.h new file mode 100644 index 000000000..38832ac61 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionValidator.h @@ -0,0 +1,130 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeDefinitionValidator_h +#define tomvizPipelineNodeDefinitionValidator_h + +#include +#include +#include +#include +#include +#include + +namespace tomviz { +namespace pipeline { + +/// Which C++ shell owns the description being validated. A node's shell +/// class is fixed at construction (PythonSource never grows inputs, +/// PythonTransform always has them), so a candidate description that +/// would require the other shell is rejected rather than applied. +enum class NodeShape +{ + Source, + Transform +}; + +/// The descriptor schema the node was built against. Also frozen: v1 +/// descriptions run on LegacyPythonTransform, v2 on PythonSource / +/// PythonTransform, and a live node cannot migrate between them. +enum class DefinitionSchema +{ + V1, + V2 +}; + +/// What a candidate description is meant for. A live node's schema, +/// shape, ports and custom widget are frozen; a definition file on disk +/// is bound to no node class, so only the description itself is checked. +enum class DefinitionTarget +{ + LiveNode, + File +}; + +struct DefinitionIssue +{ + enum class Severity + { + Error, + Warning + }; + + Severity severity = Severity::Error; + QString message; +}; + +/// Outcome of checking a candidate description against the one a node +/// is currently running. Errors block the edit; warnings describe +/// consequences the user should see before committing. +struct DefinitionValidation +{ + QList issues; + + /// Parameters declared by the candidate but not the current + /// description. They will appear with their declared defaults. + QStringList addedParameters; + /// Parameters the candidate drops. Their current values are lost. + QStringList removedParameters; + /// Parameters whose declared type changed. Their current values + /// cannot be carried over and revert to the new defaults. + QStringList retypedParameters; + + bool hasErrors() const; + QStringList messages(DefinitionIssue::Severity severity) const; +}; + +/// The schema a description declares. Empty / unparseable / missing +/// ``schemaVersion`` all read as V1, matching the routing in +/// AddPythonTransformReaction. +DefinitionSchema definitionSchema(const QString& json); + +/// The shape a description implies: v2 with ``inputs`` is a transform, v2 +/// without is a source, and v1 is always a transform. Matches the routing +/// in AddPythonTransformReaction and tomviz._internal. +NodeShape definitionShape(const QString& json); + +/// Check @a candidateJson as a replacement for @a currentJson on a live +/// node of the given shape and schema. +/// +/// Rejects anything that would change the node's identity (schema, +/// source-vs-transform shape, custom widget) or its port topology — +/// ports are frozen because nothing in the pipeline can remove a port +/// or the links hanging off one. Everything else (parameters, labels, +/// help text, cancel/complete support, external-execution flags) is +/// free to change, with the parameter consequences reported as +/// warnings. +/// +/// With DefinitionTarget::File none of the identity or port checks +/// apply, and the parameter diff (phrased for a live node's values) is +/// skipped; the description still has to parse and declare its +/// parameters properly, and an existing one cannot be blanked. +DefinitionValidation validateNodeDefinition( + const QString& currentJson, const QString& candidateJson, NodeShape shape, + DefinitionSchema schema, + DefinitionTarget target = DefinitionTarget::LiveNode); + +/// Parse a description's ``parameters`` array into name → declared +/// type. Nameless layout entries (``xyz_header``) are skipped, as are +/// entries the node turns into ports rather than parameters (v1's +/// ``dataset``) unless @a includeDatasets is set. +QMap parameterDeclaredTypes(const QString& json, + bool includeDatasets = false); + +/// Carry previously-set parameter values onto a freshly re-parsed +/// default set. A value survives only when its name is still declared +/// AND its declared type is unchanged; an enumeration value must also +/// still be one of the declared options. Everything else keeps the new +/// default and is appended to @a resetNames. +QMap mergeParameterValues( + const QMap& previousValues, + const QMap& previousTypes, + const QMap& newDefaults, + const QMap& newTypes, + const QMap& newEnumOptions, + QStringList* resetNames = nullptr); + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeDefinitionWidget.cxx b/tomviz/pipeline/NodeDefinitionWidget.cxx new file mode 100644 index 000000000..1e59abed4 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionWidget.cxx @@ -0,0 +1,292 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "NodeDefinitionWidget.h" + +#include "NodeDefinitionFormWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class JsonSyntaxHighlighter : public QSyntaxHighlighter +{ +public: + explicit JsonSyntaxHighlighter(QTextDocument* document) + : QSyntaxHighlighter(document) + { + m_key.setForeground(QColor(0x8b, 0x4f, 0xbb)); + m_string.setForeground(QColor(0x2e, 0x8b, 0x57)); + m_number.setForeground(QColor(0x1e, 0x6f, 0xba)); + m_literal.setForeground(QColor(0xb2, 0x5c, 0x00)); + } + +protected: + void highlightBlock(const QString& text) override + { + // Keys first, then remaining strings, so `"a": "b"` colors both + // halves differently. + static const QRegularExpression key( + QStringLiteral("\"(?:[^\"\\\\]|\\\\.)*\"\\s*(?=:)")); + static const QRegularExpression string( + QStringLiteral("\"(?:[^\"\\\\]|\\\\.)*\"")); + static const QRegularExpression number( + QStringLiteral("-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b")); + static const QRegularExpression literal( + QStringLiteral("\\b(?:true|false|null)\\b")); + + applyRule(text, number, m_number); + applyRule(text, literal, m_literal); + applyRule(text, string, m_string); + applyRule(text, key, m_key); + } + +private: + void applyRule(const QString& text, const QRegularExpression& expression, + const QTextCharFormat& format) + { + auto it = expression.globalMatch(text); + while (it.hasNext()) { + auto match = it.next(); + setFormat(match.capturedStart(), match.capturedLength(), format); + } + } + + QTextCharFormat m_key; + QTextCharFormat m_string; + QTextCharFormat m_number; + QTextCharFormat m_literal; +}; + +} // namespace + +namespace tomviz { +namespace pipeline { + +NodeDefinitionWidget::NodeDefinitionWidget(const QString& json, + NodeShape shape, + DefinitionSchema schema, + QWidget* parent) + : NodeDefinitionWidget(json, shape, schema, DefinitionTarget::LiveNode, + parent) +{ +} + +NodeDefinitionWidget::NodeDefinitionWidget(const QString& json, + NodeShape shape, + DefinitionSchema schema, + DefinitionTarget target, + QWidget* parent) + : QWidget(parent), m_shape(shape), m_schema(schema), m_target(target), + m_appliedJson(json) +{ + m_renderedParameters = QJsonDocument::fromJson(json.toUtf8()) + .object() + .value(QStringLiteral("parameters")) + .toArray(); + + auto* layout = new QVBoxLayout(this); + + auto* headerRow = new QHBoxLayout; + auto* header = new QLabel( + m_target == DefinitionTarget::File + ? tr("Edits are written to the definition file when you save. Nodes " + "already in a pipeline keep the description they were created " + "with.") + : tr("Edits apply to this node only, and are saved with the state " + "file. Ports and the node's schema are fixed once it exists."), + this); + header->setWordWrap(true); + header->setStyleSheet("QLabel { color: palette(mid); }"); + headerRow->addWidget(header, 1); + + m_rawButton = new QPushButton(tr("Raw"), this); + m_rawButton->setCheckable(true); + m_rawButton->setToolTip( + tr("Edit the description as raw JSON, including the keys the form " + "doesn't show")); + headerRow->addWidget(m_rawButton, 0, Qt::AlignTop); + layout->addLayout(headerRow); + + m_stack = new QStackedWidget(this); + layout->addWidget(m_stack, 1); + + m_form = new NodeDefinitionFormWidget(shape, schema, m_target, m_stack); + m_stack->addWidget(m_form); + + m_editor = new QTextEdit(m_stack); + m_editor->setObjectName(QStringLiteral("definitionRawEditor")); + m_editor->setLineWrapMode(QTextEdit::NoWrap); + m_editor->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + m_editor->setPlainText(json); + new JsonSyntaxHighlighter(m_editor->document()); + m_stack->addWidget(m_editor); + + m_issueLabel = new QLabel(this); + m_issueLabel->setWordWrap(true); + m_issueLabel->setTextFormat(Qt::RichText); + m_issueLabel->hide(); + layout->addWidget(m_issueLabel); + + m_debounce = new QTimer(this); + m_debounce->setSingleShot(true); + m_debounce->setInterval(400); + + connect(m_editor, &QTextEdit::textChanged, this, + [this]() { m_debounce->start(); }); + connect(m_debounce, &QTimer::timeout, this, + &NodeDefinitionWidget::revalidate); + connect(m_form, &NodeDefinitionFormWidget::changed, this, + &NodeDefinitionWidget::onFormChanged); + connect(m_rawButton, &QPushButton::toggled, this, + &NodeDefinitionWidget::setRawMode); + + // The form is the default; raw is the escape hatch for the keys it + // doesn't render. + QSettings settings; + bool wantRaw = + settings.value(QStringLiteral("pipeline/nodeDefinitionRawMode"), false) + .toBool(); + // setChecked fires the handler only on an actual change, so silence it + // and drive the initial state once — letting both run would populate + // the form twice and leave a set of orphaned rows on screen. + m_rawButton->blockSignals(true); + m_rawButton->setChecked(wantRaw); + m_rawButton->blockSignals(false); + setRawMode(wantRaw); + + revalidate(); +} + +void NodeDefinitionWidget::setRawMode(bool raw) +{ + if (!raw) { + // The form is built from a parsed document, so it simply cannot + // represent text that doesn't parse. Stay in raw rather than + // silently discarding whatever the user was part-way through. + m_syncing = true; + bool loaded = m_form->setJson(definitionText()); + m_syncing = false; + if (!loaded) { + m_rawButton->setChecked(true); + m_issueLabel->setText( + tr("Fix the JSON before leaving Raw — the form can only show a " + "document that parses.")); + m_issueLabel->show(); + return; + } + } + m_stack->setCurrentWidget(raw ? static_cast(m_editor) + : static_cast(m_form)); + QSettings().setValue(QStringLiteral("pipeline/nodeDefinitionRawMode"), raw); +} + +void NodeDefinitionWidget::onFormChanged() +{ + if (m_syncing) { + return; + } + // The text buffer stays authoritative: writing the form's document + // into it is what kicks off validation and the Parameters-tab + // re-render, so both views go through exactly one code path. + m_syncing = true; + m_editor->setPlainText(m_form->json()); + m_syncing = false; +} + +QString NodeDefinitionWidget::definitionText() const +{ + return m_editor->toPlainText(); +} + +bool NodeDefinitionWidget::isValid() const +{ + return m_valid; +} + +void NodeDefinitionWidget::flushPendingValidation() +{ + if (!m_debounce->isActive()) { + return; + } + m_debounce->stop(); + revalidate(); +} + +void NodeDefinitionWidget::markApplied(const QString& json) +{ + m_appliedJson = json; + // The baseline moved, so warnings phrased against the old description + // ("dropping foo") are stale even though the text didn't change. + revalidate(); +} + +void NodeDefinitionWidget::revalidate() +{ + const QString text = definitionText(); + auto validation = validateNodeDefinition(m_appliedJson, text, m_shape, + m_schema, m_target); + + renderIssues(validation); + + bool valid = !validation.hasErrors(); + if (valid != m_valid) { + m_valid = valid; + emit validityChanged(m_valid); + } + if (!valid) { + return; + } + + auto parameters = QJsonDocument::fromJson(text.toUtf8()) + .object() + .value(QStringLiteral("parameters")) + .toArray(); + if (parameters != m_renderedParameters) { + m_renderedParameters = parameters; + emit parameterSchemaChanged(text); + } +} + +void NodeDefinitionWidget::renderIssues(const DefinitionValidation& validation) +{ + QStringList lines; + for (const auto& message : + validation.messages(DefinitionIssue::Severity::Error)) { + lines.append(QStringLiteral("%1").arg(message.toHtmlEscaped())); + } + for (const auto& message : + validation.messages(DefinitionIssue::Severity::Warning)) { + lines.append(message.toHtmlEscaped()); + } + + if (lines.isEmpty()) { + m_issueLabel->clear(); + m_issueLabel->hide(); + return; + } + + bool errors = validation.hasErrors(); + m_issueLabel->setStyleSheet( + errors ? "QLabel { color: palette(bright-text); background: #8b1a1a; " + "border-radius: 3px; padding: 5px; }" + : "QLabel { color: palette(text); background: palette(alternate-" + "base); border-radius: 3px; padding: 5px; }"); + m_issueLabel->setText(lines.join(QStringLiteral("
"))); + m_issueLabel->show(); +} + +} // namespace pipeline +} // namespace tomviz diff --git a/tomviz/pipeline/NodeDefinitionWidget.h b/tomviz/pipeline/NodeDefinitionWidget.h new file mode 100644 index 000000000..752a38df0 --- /dev/null +++ b/tomviz/pipeline/NodeDefinitionWidget.h @@ -0,0 +1,113 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizPipelineNodeDefinitionWidget_h +#define tomvizPipelineNodeDefinitionWidget_h + +#include "NodeDefinitionValidator.h" + +#include +#include + +class QLabel; +class QPushButton; +class QStackedWidget; +class QTextEdit; +class QTimer; + +namespace tomviz { +namespace pipeline { + +class NodeDefinitionFormWidget; + +/// Editor for a Python node's JSON description, shown as the +/// "Definition" tab of PythonNodeEditorWidget. +/// +/// Two interchangeable views over one buffer: a generated form and the +/// raw JSON text, switched by the "Raw" toggle. The QTextEdit is the +/// single source of truth — a form edit serializes straight back into it +/// — so validation and everything downstream have exactly one input +/// regardless of which view produced it. +/// +/// Validation runs on a short debounce after every keystroke rather than +/// on Apply, so the editor can report what a change will do (and refuse +/// the ones a live node can't survive) while it is being written. The +/// host gates its Apply/OK button on isValid(). Nothing here is written +/// to the node until Apply/OK, so cancelling the dialog discards the +/// edit — the same way parameter values already behave. +class NodeDefinitionWidget : public QWidget +{ + Q_OBJECT + +public: + /// For a live node: the identity and port checks apply. + NodeDefinitionWidget(const QString& json, NodeShape shape, + DefinitionSchema schema, QWidget* parent = nullptr); + /// @a target picks the validation rules; see DefinitionTarget. Shape and + /// schema seed the form either way. + NodeDefinitionWidget(const QString& json, NodeShape shape, + DefinitionSchema schema, DefinitionTarget target, + QWidget* parent = nullptr); + + /// Current editor text, whether or not it validates. + QString definitionText() const; + + bool isValid() const; + + /// Validate immediately instead of waiting out the debounce. The host + /// calls this before committing, so a keystroke typed a moment before + /// Apply can't slip past validation or leave the Parameters tab + /// rendering a description that is no longer on screen. + void flushPendingValidation(); + + /// Adopt @a json as the baseline that later edits are validated + /// against. Called by the host once the description has actually been + /// committed to the node. + void markApplied(const QString& json); + +signals: + /// Emitted whenever isValid() changes. + void validityChanged(bool valid); + + /// Emitted (debounced) when the text validates and its parameter + /// declarations differ from those the host last rendered. The host + /// rebuilds its Parameters tab from @a json so the form on screen + /// always matches the description being edited. + void parameterSchemaChanged(const QString& json); + +private: + void revalidate(); + void renderIssues(const DefinitionValidation& validation); + /// Switch views. Leaving raw re-syncs the form from the text; if the + /// text isn't valid JSON the form can't represent it, so the switch is + /// refused and the user stays in the raw editor. + void setRawMode(bool raw); + /// Push the form's serialization into the text buffer, which is what + /// actually triggers revalidation. + void onFormChanged(); + + NodeShape m_shape; + DefinitionSchema m_schema; + DefinitionTarget m_target = DefinitionTarget::LiveNode; + /// The description the node is actually running — the baseline every + /// candidate is validated and diffed against. + QString m_appliedJson; + /// The "parameters" array behind the currently-rendered form, so a + /// keystroke that leaves it alone doesn't tear the form down. + QJsonArray m_renderedParameters; + bool m_valid = true; + /// Guards the form → text → form path from re-entering itself. + bool m_syncing = false; + + QStackedWidget* m_stack = nullptr; + NodeDefinitionFormWidget* m_form = nullptr; + QTextEdit* m_editor = nullptr; + QPushButton* m_rawButton = nullptr; + QLabel* m_issueLabel = nullptr; + QTimer* m_debounce = nullptr; +}; + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/NodeEditDialog.cxx b/tomviz/pipeline/NodeEditDialog.cxx new file mode 100644 index 000000000..7f2a7d817 --- /dev/null +++ b/tomviz/pipeline/NodeEditDialog.cxx @@ -0,0 +1,398 @@ +/* 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 "CustomOperatorEditDialog.h" +#include "PythonNodeEditorWidget.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 +#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); + m_node->setHeld(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); + // That only covers the node's own trigger. A node that has never run + // (an eagerly spliced insertion, or one the strip linked up and handed + // to this dialog) is New, so any global execute would still pick it + // up and run it with default parameters; hold it until Apply commits. + if (m_isNewInsertion || m_node->state() == NodeState::New) { + m_node->setHeld(true); + } + + 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); + + QPushButton* saveAsButton = nullptr; + 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); }); + } + + // A Python node can become a custom operator in the user's directory, + // script and description as they stand in the editor. + if (qobject_cast(m_editWidget)) { + saveAsButton = new QPushButton(tr("Save as Custom Transform..."), this); + saveAsButton->setObjectName( + QStringLiteral("saveAsCustomOperatorButton")); + connect(saveAsButton, &QPushButton::clicked, this, + &NodeEditDialog::saveAsCustomOperator); + } + } + + // The button box keeps the platform's Apply/OK/Cancel arrangement to + // itself; the extra action sits at the left end of the same row. + auto* buttonRow = new QHBoxLayout; + if (saveAsButton) { + buttonRow->addWidget(saveAsButton); + } + buttonRow->addWidget(m_buttonBox, 1); + layout->addLayout(buttonRow); + + 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::saveAsCustomOperator() +{ + auto* python = qobject_cast(m_editWidget); + if (!python) { + return; + } + const QString directory = tomviz::userDataPath(); + if (directory.isEmpty()) { + return; // userDataPath() has already told the user why + } + + // The description's "name" is the natural file stem; a node without + // one is named after its label. + QString base = QJsonDocument::fromJson(python->definitionText().toUtf8()) + .object() + .value(QStringLiteral("name")) + .toString(); + if (base.isEmpty()) { + base = python->nodeLabel(); + } + + tomviz::CustomOperatorDraft draft; + draft.directory = directory; + draft.stem = tomviz::uniqueOperatorStem(directory, base); + draft.script = python->scriptText(); + draft.description = python->definitionText(); + + // Parented to the main window rather than this dialog, so closing the + // node editor does not take the draft with it. + auto* dialog = + new tomviz::CustomOperatorEditDialog(draft, tomviz::mainWidget()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); + dialog->raise(); + dialog->activateWindow(); + + // The draft has everything it needs; this editor is done, the same as + // a cancel (an insertion still in progress is rolled back). + reject(); +} + +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 || m_applying) { + return; + } + + m_applying = true; + if (m_editWidget) { + m_editWidget->applyChangesToOperator(); + } + m_applying = false; + + if (!m_node) { + // The node was removed while apply was blocked in a nested event + // loop; the deferred close was refused, so close now. + close(); + return; + } + + if (m_isNewInsertion && !m_insertionCompleted) { + completeInsertion(); + } + + m_node->setHeld(false); + m_node->markStale(); + m_pipeline->execute(); +} + +void NodeEditDialog::onOkay() +{ + if (!m_node || m_applying) { + return; + } + + m_applying = true; + if (m_editWidget) { + m_editWidget->applyChangesToOperator(); + } + m_applying = false; + + if (!m_node) { + close(); + return; + } + + if (m_isNewInsertion && !m_insertionCompleted) { + completeInsertion(); + } + + m_node->setHeld(false); + m_node->markStale(); + m_pipeline->execute(); + accept(); +} + +void NodeEditDialog::closeEvent(QCloseEvent* event) +{ + if (m_applying) { + event->ignore(); + return; + } + QDialog::closeEvent(event); +} + +void NodeEditDialog::reject() +{ + if (m_applying) { + return; + } + + 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() +{ + // No application core in test harnesses: nothing to remember into. + auto* core = pqApplicationCore::instance(); + if (!m_node || !core) { + return; + } + QSettings* settings = core->settings(); + QString key = + QString("Edit%1NodeDialogGeometry").arg(m_node->label()); + settings->setValue(key, QVariant(geometry())); +} + +void NodeEditDialog::restoreGeometry() +{ + if (!m_node) { + return; + } + auto* core = pqApplicationCore::instance(); + QVariant saved = core ? core->settings()->value( + QString("Edit%1NodeDialogGeometry") + .arg(m_node->label())) + : QVariant(); + 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..d23bdccb3 --- /dev/null +++ b/tomviz/pipeline/NodeEditDialog.h @@ -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". */ + +#ifndef tomvizPipelineNodeEditDialog_h +#define tomvizPipelineNodeEditDialog_h + +#include "DeferredLinkInfo.h" + +#include +#include + +class QDialogButtonBox; +class QCloseEvent; +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; + /// Close is refused while an apply is in progress: applying can + /// spin a nested event loop, and destroying the dialog under that + /// loop would unwind through freed objects. + void closeEvent(QCloseEvent* event) override; + +private: + bool m_applying = false; + void init(); + void refreshButtonEnablement(); + void saveGeometry(); + void restoreGeometry(); + + void completeInsertion(); + /// Hand the Python editor's script and description to a + /// CustomOperatorEditDialog draft in the user directory. + void saveAsCustomOperator(); + + 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..80aa17ee0 --- /dev/null +++ b/tomviz/pipeline/NodeExecutor.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 "NodeExecutor.h" + +#include "Node.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. +} + +bool NodeExecutor::shouldAutoExecute(Node* node) +{ + // Default: evaluate in-process. Out-of-process executors override to + // ask their external environment instead. + return node && node->queryShouldAutoExecute(); +} + +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..97a8a4720 --- /dev/null +++ b/tomviz/pipeline/NodeExecutor.h @@ -0,0 +1,74 @@ +/* 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); + + /// Ask whether @a node wants a periodic execution now (the + /// auto-execute poll). The hook runs in the same environment + /// execute() would use, so a node whose script only imports in its + /// external env still answers correctly. Blocks until done — call + /// from a worker thread. The default runs in-process via + /// Node::queryShouldAutoExecute(). + virtual bool shouldAutoExecute(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..f978892b9 --- /dev/null +++ b/tomviz/pipeline/NodeFactory.cxx @@ -0,0 +1,127 @@ +/* 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/LabelMapSink.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.labelMap")); + 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..73c850425 --- /dev/null +++ b/tomviz/pipeline/NodePropertiesPanel.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 "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); + + // Tells the properties dock not to wrap this panel in a scroll area of + // its own: the editor already scrolls below, and Apply is pinned under + // it. An outer scroll area would drag that pinned row into a viewport. + setProperty("providesOwnScrolling", true); + + 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..63b239e01 --- /dev/null +++ b/tomviz/pipeline/OutputPort.cxx @@ -0,0 +1,528 @@ +/* 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/LabelMapData.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) +{ + carryOverLabelTable(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); +} + +void OutputPort::notifyDataMutated() +{ + runOnThread(this, [this]() { emit intermediateDataApplied(); }); +} + +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; +} + +void OutputPort::carryOverLabelTable(const PortData& incoming) +{ + auto previous = m_weak.lock(); + if (!previous) { + return; + } + auto* oldVolume = std::any_cast(&previous->data()); + auto* newVolume = std::any_cast(&incoming.data()); + // A node that forwards its input untouched republishes the very same + // payload; there is nothing to carry over, and adopting from itself + // would force a needless rescan. + if (!oldVolume || !newVolume || *oldVolume == *newVolume) { + return; + } + auto oldLabels = labelMapData(*oldVolume); + auto newLabels = labelMapData(*newVolume); + if (oldLabels && newLabels) { + newLabels->adoptLabelsFrom(*oldLabels); + } +} + +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..35039b731 --- /dev/null +++ b/tomviz/pipeline/OutputPort.h @@ -0,0 +1,273 @@ +/* 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); + + /// Announce that the current payload changed in place (e.g. a + /// time-series step switch swapped the VolumeData's image) without + /// republishing it. Runs the same refresh path as an + /// intermediate-data push - linked sinks re-consume and views + /// re-render - but skips the payload replacement and deep copy. + /// Thread-safe: the emission is marshaled to the port's thread. + void notifyDataMutated(); + + 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); + + /// Hand the label table of the payload being replaced to @a incoming, + /// when both are label maps. A label map's per-label colors and + /// visibility are the user's choices, not the data's, and every + /// execution publishes a fresh payload that would otherwise start + /// from defaults. Data-only: no ParaView proxies are touched, so this + /// is safe on whichever thread published. + void carryOverLabelTable(const PortData& incoming); + + /// 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..41ffdd9e6 --- /dev/null +++ b/tomviz/pipeline/ParameterBindingUtils.cxx @@ -0,0 +1,291 @@ +/* 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/LabelMapSink.h" +#include "sinks/SliceSink.h" +#include "sinks/ThresholdSink.h" + +#include "DoubleSliderWidget.h" +#include "LabelSelectionWidget.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. Connections +/// are owned by the control, so they die with it. +void wireSliceSinkBinding(QSpinBox* spin, SliceSink* sink) +{ + { + QSignalBlocker b(spin); + spin->setValue(sink->slice()); + } + + QPointer sinkPtr(sink); + QObject::connect(sink, &SliceSink::sliceChanged, spin, + [spin, sinkPtr](int value) { + if (!sinkPtr || spin->value() == value) { + return; + } + QSignalBlocker b(spin); + spin->setValue(value); + }); + QObject::connect(spin, qOverload(&QSpinBox::valueChanged), spin, + [sinkPtr](int value) { + if (!sinkPtr || sinkPtr->slice() == value) { + return; + } + sinkPtr->setSlice(value); + }); +} + +/// Live link a DoubleSliderWidget to one end of a ThresholdSink's +/// range (@a lower picks which end). Same cycle guard as the slice +/// binding; the other end is left as the sink currently has it. +void wireThresholdSinkBinding(DoubleSliderWidget* slider, ThresholdSink* sink, + bool lower) +{ + auto end = [lower](ThresholdSink* s) { + return lower ? s->lowerThreshold() : s->upperThreshold(); + }; + { + QSignalBlocker b(slider); + slider->setValue(end(sink)); + } + + QPointer sinkPtr(sink); + QObject::connect(sink, &ThresholdSink::thresholdRangeChanged, slider, + [slider, lower](double lo, double hi) { + double value = lower ? lo : hi; + if (slider->value() == value) { + return; + } + QSignalBlocker b(slider); + slider->setValue(value); + }); + QObject::connect(slider, &DoubleSliderWidget::valueChanged, slider, + [sinkPtr, lower, end](double value) { + if (!sinkPtr || end(sinkPtr) == value) { + return; + } + if (lower) { + sinkPtr->setThresholdRange(value, + sinkPtr->upperThreshold()); + } else { + sinkPtr->setThresholdRange(sinkPtr->lowerThreshold(), + value); + } + }); +} + +/// Live link a LabelSelectionWidget's ticks to the labels hidden in a +/// LabelMapSink: a label hidden in the visualization is ticked for +/// removal, and ticking one hides it. Both setters compare before +/// changing anything, which is what breaks the cycle. +void wireLabelMapSinkBinding(LabelSelectionWidget* selection, + LabelMapSink* sink) +{ + // The visualization is what the user has been looking at, so it seeds + // the ticks; unless it hides nothing and the operator already has a + // selection, in which case the visualization shows that selection. + const auto hidden = sink->hiddenLabels(); + if (hidden.isEmpty() && !selection->selectedLabels().isEmpty()) { + sink->setHiddenLabels(selection->selectedLabels()); + } else { + selection->setSelectedLabels(hidden); + } + + QPointer sinkPtr(sink); + QObject::connect(sink, &LabelMapSink::labelVisibilityChanged, selection, + [selection, sinkPtr]() { + if (!sinkPtr) { + return; + } + auto nowHidden = sinkPtr->hiddenLabels(); + if (selection->selectedLabels() != nowHidden) { + selection->setSelectedLabels(nowHidden); + } + }); + QObject::connect(selection, &LabelSelectionWidget::selectionChanged, + selection, [selection, sinkPtr]() { + if (sinkPtr) { + sinkPtr->setHiddenLabels( + selection->selectedLabels()); + } + }); +} + +} // 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(spin, sink); + } else if (binding.sinkType == QStringLiteral("ThresholdSink") && + (binding.sinkProperty == QStringLiteral("lowerThreshold") || + binding.sinkProperty == QStringLiteral("upperThreshold"))) { + // A visible Threshold visualization is the one the user has been + // looking at; failing that, drive a hidden one. + auto* sink = findBindableSink( + node, [](ThresholdSink* s) { return s->visibility(); }); + if (!sink) { + sink = findBindableSink(node, + [](ThresholdSink*) { return true; }); + } + if (!sink) { + continue; + } + auto* slider = widget->findChild(paramName); + if (!slider) { + continue; + } + wireThresholdSinkBinding( + slider, sink, + binding.sinkProperty == QStringLiteral("lowerThreshold")); + } else if (binding.sinkType == QStringLiteral("LabelMapSink") && + binding.sinkProperty == QStringLiteral("hiddenLabels")) { + auto* sink = findBindableSink( + node, [](LabelMapSink* s) { return s->visibility(); }); + if (!sink) { + sink = findBindableSink( + node, [](LabelMapSink*) { return true; }); + } + if (!sink) { + continue; + } + auto* selection = widget->findChild(paramName); + if (!selection) { + continue; + } + wireLabelMapSinkBinding(selection, 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..333f31612 --- /dev/null +++ b/tomviz/pipeline/ParameterBindingUtils.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 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 the control — Qt auto-disconnects when +/// it is destroyed, so calling again after the controls are rebuilt +/// is safe. Controls that do not exist yet are skipped. +void wireParameterBindings(Node* node, QWidget* widget, + const QMap& bindings); + +} // namespace pipeline +} // namespace tomviz + +#endif diff --git a/tomviz/pipeline/ParameterInterfaceBuilder.cxx b/tomviz/pipeline/ParameterInterfaceBuilder.cxx new file mode 100644 index 000000000..c5cc74b88 --- /dev/null +++ b/tomviz/pipeline/ParameterInterfaceBuilder.cxx @@ -0,0 +1,1316 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ParameterInterfaceBuilder.h" + +#include +#include +#include "EnumOptions.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 + +namespace { + +using tomviz::pipeline::PortScalars; + +// --- Templated JSON helpers --- + +template +bool isType(const QJsonValue&) +{ + return false; +} + +template <> +bool isType(const QJsonValue& value) +{ + return value.isDouble(); +} + +template <> +bool isType(const QJsonValue& value) +{ + return value.isDouble(); +} + +template +T getAs(const QJsonValue&) +{ + return 0; +} + +template <> +int getAs(const QJsonValue& value) +{ + return value.toInt(); +} + +template <> +double getAs(const QJsonValue& value) +{ + return value.toDouble(); +} + +template <> +QString getAs(const QJsonValue& value) +{ + return value.toString(); +} + +// --- Numeric widget factory --- + +template +QWidget* getNumericWidget(T, T, T, int, T) +{ + return nullptr; +} + +template <> +QWidget* getNumericWidget(int defaultValue, int rangeMin, int rangeMax, + int /*precision*/, int step) +{ + auto* spinBox = new QSpinBox(); + spinBox->setSingleStep(step == -1 ? 1 : step); + spinBox->setMinimum(rangeMin); + spinBox->setMaximum(rangeMax); + spinBox->setValue(defaultValue); + return spinBox; +} + +template <> +QWidget* getNumericWidget(double defaultValue, double rangeMin, double rangeMax, + int precision, double step) +{ + 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; +} + +// --- Widget type helpers --- + +template +bool isWidgetType(const QWidget* widget) +{ + return qobject_cast(widget) != nullptr; +} + +bool isWidgetNumeric(const QWidget* widget) +{ + return isWidgetType(widget) || + isWidgetType(widget); +} + +// --- Widget changed signal --- + +template +auto changedSignal() +{ + if constexpr (std::is_same_v) { + return QOverload::of(&QComboBox::currentIndexChanged); + } else if constexpr (std::is_same_v) { + return &QCheckBox::toggled; + } else if constexpr (std::is_same_v) { + return QOverload::of(&QDoubleSpinBox::valueChanged); + } else if constexpr (std::is_same_v) { + return QOverload::of(&QSpinBox::valueChanged); + } else if constexpr (std::is_same_v) { + return &QLineEdit::textChanged; + } +} + +// --- Widget value extraction --- + +template +auto widgetValue(const T* w) +{ + if constexpr (std::is_same_v) { + return w->currentData().toString(); + } else if constexpr (std::is_same_v) { + return w->isChecked(); + } else if constexpr (std::is_same_v) { + return w->value(); + } else if constexpr (std::is_same_v) { + return w->value(); + } else if constexpr (std::is_same_v) { + return w->text(); + } +} + +// --- Widget builders --- + +void addBoolWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) +{ + QJsonValue nameValue = parameterNode.value("name"); + QJsonValue labelValue = parameterNode.value("label"); + + if (nameValue.isUndefined()) { + return; + } + + auto* label = new QLabel(nameValue.toString()); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + bool defaultValue = false; + if (parameterNode.contains("default")) { + QJsonValue defaultNode = parameterNode.value("default"); + if (defaultNode.isBool()) { + defaultValue = defaultNode.toBool(); + } + } + auto* checkBox = new QCheckBox(); + checkBox->setObjectName(nameValue.toString()); + checkBox->setCheckState(defaultValue ? Qt::Checked : Qt::Unchecked); + label->setBuddy(checkBox); + layout->addWidget(checkBox, row, 1, 1, 1); +} + +template +void addNumericWidget(QGridLayout* layout, int row, + QJsonObject& parameterNode) +{ + QJsonValue nameValue = parameterNode.value("name"); + QJsonValue labelValue = parameterNode.value("label"); + + if (nameValue.isUndefined()) { + return; + } + + auto* label = new QLabel(nameValue.toString()); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + std::vector defaultValues; + if (parameterNode.contains("default")) { + QJsonValue defaultNode = parameterNode.value("default"); + if (isType(defaultNode)) { + defaultValues.push_back(getAs(defaultNode)); + } else if (defaultNode.isArray()) { + QJsonArray defaultArray = defaultNode.toArray(); + for (QJsonObject::size_type i = 0; i < defaultArray.size(); ++i) { + defaultValues.push_back(getAs(defaultArray[i])); + } + } + } + + 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")) { + QJsonValue minNode = parameterNode.value("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 < (int)minValues.size(); ++i) { + minValues[i] = getAs(minArray[i]); + } + } + } + + std::vector maxValues(defaultValues.size(), + std::numeric_limits::max()); + if (parameterNode.contains("maximum")) { + QJsonValue maxNode = parameterNode.value("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 < (int)maxValues.size(); ++i) { + maxValues[i] = getAs(maxArray[i]); + } + } + } + + int precision = -1; + if (parameterNode.contains("precision")) { + QJsonValue precNode = parameterNode.value("precision"); + if (isType(precNode)) { + precision = getAs(precNode); + } + } + T step = -1; + if (parameterNode.contains("step")) { + QJsonValue stepNode = parameterNode.value("step"); + if (isType(stepNode)) { + step = getAs(stepNode); + } + } + + auto* horizontalLayout = new QHBoxLayout(); + horizontalLayout->setContentsMargins(0, 0, 0, 0); + auto* horizontalWidget = new QWidget; + horizontalWidget->setLayout(horizontalLayout); + label->setBuddy(horizontalWidget); + layout->addWidget(horizontalWidget, row, 1, 1, 1); + + for (size_t i = 0; i < defaultValues.size(); ++i) { + QString name = nameValue.toString(); + if (defaultValues.size() > 1) { + name.append("#%1"); + name = name.arg(i, 3, 10, QLatin1Char('0')); + } + + QWidget* spinBox = getNumericWidget(defaultValues[i], minValues[i], + maxValues[i], precision, step); + spinBox->setObjectName(name); + horizontalLayout->addWidget(spinBox); + } +} + +void addEnumerationWidget(QGridLayout* layout, int row, + QJsonObject& parameterNode) +{ + // Copies, not QJsonValueRefs: operator[] on a missing key ("label" is + // optional) inserts it, which invalidates every ref taken earlier and + // left the widget's objectName empty. + QJsonValue nameValue = parameterNode.value("name"); + QJsonValue labelValue = parameterNode.value("label"); + + if (nameValue.isUndefined()) { + return; + } + + auto* label = new QLabel(nameValue.toString()); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + auto* comboBox = new QComboBox(); + comboBox->setObjectName(nameValue.toString()); + label->setBuddy(comboBox); + QJsonValue optionsNode = parameterNode.value("options"); + if (!optionsNode.isUndefined()) { + QJsonArray optionsArray = optionsNode.toArray(); + for (QJsonObject::size_type i = 0; i < optionsArray.size(); ++i) { + QJsonObject optionNode = optionsArray[i].toObject(); + // An option is a single {"Label": value} pair. Anything else — a + // bare value, or the empty object you get from writing an + // undefined value into a QJsonObject — has no keys, and keys()[0] + // would read off the end of the list. Descriptions are hand- + // editable, so this has to be tolerated rather than trusted. + if (optionNode.isEmpty()) { + continue; + } + QString optionName = optionNode.keys().constFirst(); + QJsonValueRef optionValueNode = optionNode[optionName]; + QVariant optionValue; + if (isType(optionValueNode)) { + optionValue = optionValueNode.toInt(); + } else { + optionValue = optionValueNode.toVariant(); + } + comboBox->addItem(optionName, optionValue); + } + } + + QJsonValue defaultNode = parameterNode.value("default"); + if (!defaultNode.isUndefined()) { + if (isType(defaultNode)) { + comboBox->setCurrentIndex(getAs(defaultNode)); + } else if (defaultNode.isString()) { + int defaultIndex = comboBox->findData(getAs(defaultNode)); + if (defaultIndex >= 0) { + comboBox->setCurrentIndex(defaultIndex); + } + } + } + + layout->addWidget(comboBox, row, 1, 1, 1); +} + +void addXYZHeaderWidget(QGridLayout* layout, int row, const QJsonValue&) +{ + auto* horizontalLayout = new QHBoxLayout; + horizontalLayout->setContentsMargins(0, 0, 0, 0); + auto* horizontalWidget = new QWidget; + horizontalWidget->setLayout(horizontalLayout); + layout->addWidget(horizontalWidget, row, 1, 1, 1); + + auto* xLabel = new QLabel("X"); + xLabel->setAlignment(Qt::AlignCenter); + horizontalLayout->addWidget(xLabel); + auto* yLabel = new QLabel("Y"); + yLabel->setAlignment(Qt::AlignCenter); + horizontalLayout->addWidget(yLabel); + auto* zLabel = new QLabel("Z"); + zLabel->setAlignment(Qt::AlignCenter); + horizontalLayout->addWidget(zLabel); +} + +void addPathWidget(QGridLayout* layout, int row, QJsonObject& pathNode) +{ + auto* horizontalLayout = new QHBoxLayout; + horizontalLayout->setContentsMargins(0, 0, 0, 0); + auto* horizontalWidget = new QWidget; + horizontalWidget->setLayout(horizontalLayout); + layout->addWidget(horizontalWidget, row, 1, 1, 1); + + QJsonValueRef typeValue = pathNode["type"]; + if (typeValue.isUndefined()) { + return; + } + QString type = typeValue.toString(); + + QJsonValueRef nameValue = pathNode["name"]; + if (nameValue.isUndefined()) { + return; + } + + QJsonValueRef labelValue = pathNode["label"]; + auto* label = new QLabel(nameValue.toString()); + label->setBuddy(horizontalWidget); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + auto* pathField = new QLineEdit(); + pathField->setProperty("type", type); + pathField->setObjectName(nameValue.toString()); + pathField->setMinimumWidth(500); + + QJsonValueRef defaultNode = pathNode["default"]; + if (!defaultNode.isUndefined() && defaultNode.isString()) { + pathField->setText(getAs(defaultNode)); + } + + horizontalLayout->addWidget(pathField); + auto filter = pathNode["filter"].toString(); + + auto* browseButton = new QPushButton("Browse"); + horizontalLayout->addWidget(browseButton); + 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(); + } + } + + 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 (!path.isNull()) { + pathField->setText(path); + } + }); +} + +void addStringWidget(QGridLayout* layout, int row, QJsonObject& pathNode) +{ + auto* horizontalLayout = new QHBoxLayout; + horizontalLayout->setContentsMargins(0, 0, 0, 0); + auto* horizontalWidget = new QWidget; + horizontalWidget->setLayout(horizontalLayout); + layout->addWidget(horizontalWidget, row, 1, 1, 1); + + QJsonValueRef typeValue = pathNode["type"]; + if (typeValue.isUndefined()) { + return; + } + QString type = typeValue.toString(); + + QJsonValueRef nameValue = pathNode["name"]; + if (nameValue.isUndefined()) { + return; + } + + QJsonValueRef labelValue = pathNode["label"]; + auto* label = new QLabel(nameValue.toString()); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + auto* stringField = new QLineEdit(); + stringField->setProperty("type", type); + stringField->setObjectName(nameValue.toString()); + stringField->setMinimumWidth(500); + label->setBuddy(stringField); + horizontalLayout->addWidget(stringField); + + QJsonValueRef defaultNode = pathNode["default"]; + if (!defaultNode.isUndefined() && defaultNode.isString()) { + stringField->setText(getAs(defaultNode)); + } +} + +void addSelectScalarsWidget(QGridLayout* layout, int row, + QJsonObject& parameterNode, + const QList& portScalars) +{ + QJsonValue nameValue = parameterNode.value("name"); + QJsonValue labelValue = parameterNode.value("label"); + + if (nameValue.isUndefined()) { + QJsonDocument document(parameterNode); + qWarning() << QString("Parameter %1 has no name. Skipping.") + .arg(document.toJson().data()); + return; + } + + QString name = nameValue.toString(); + + // 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; + } + } + } + if (!resolved && !portScalars.isEmpty()) { + resolved = &portScalars.first(); + } + const QStringList availableScalars = + resolved ? resolved->scalarNames : QStringList{}; + const QString activeScalar = + resolved ? resolved->activeScalar : QString{}; + + auto* label = new QLabel(name); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + 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); + + auto* vLayout = new QVBoxLayout(); + vLayout->setContentsMargins(0, 0, 0, 0); + container->setLayout(vLayout); + + 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"); + auto* model = new QStandardItemModel(comboBox); + comboBox->setModel(model); + 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 + QJsonValue defaultNode = parameterNode.value("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); + + 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) { + 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(); + } + } + } + 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 updateComboEnabled = + [comboBox, selectAllCheckBox, selectActiveCheckBox]() { + comboBox->setEnabled(!selectAllCheckBox->isChecked() && + !selectActiveCheckBox->isChecked()); + }; + + // 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); + }); + + // 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(); + }); + + // 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 + { + if (event->type() == QEvent::MouseButtonPress) { + m_pressedOnViewport = true; + return true; + } + if (event->type() == QEvent::MouseButtonRelease) { + if (!m_pressedOnViewport) { + return true; + } + m_pressedOnViewport = false; + auto* view = m_combo->view(); + auto index = view->indexAt( + static_cast(event)->pos()); + if (index.isValid()) { + auto* model = qobject_cast(m_combo->model()); + if (model) { + auto* item = model->itemFromIndex(index); + if (item && (item->flags() & Qt::ItemIsUserCheckable)) { + auto state = item->checkState() == Qt::Checked + ? Qt::Unchecked : Qt::Checked; + item->setCheckState(state); + } + } + } + return true; + } + return false; + } + private: + QComboBox* m_combo; + bool m_pressedOnViewport = false; + }; + + auto* filter = new ComboEventFilter(comboBox, comboBox); + comboBox->view()->viewport()->installEventFilter(filter); + + layout->addWidget(container, row, 1, 1, 1); +} + +static const QStringList PATH_TYPES = { "file", "save_file", "directory" }; + +// --- enable_if / visible_if support --- + +template +bool compareGeneric(T value, T ref, const QString& comparator) +{ + if (comparator == "==") { + return value == ref; + } else if (comparator == "!=") { + return value != ref; + } + return false; +} + +template +bool compareNumbers(T value, T ref, const QString& comparator) +{ + 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; +} + +template +bool compare(const T* widget, const QVariant& compareValue, + const QString& comparator) +{ + 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; +} + +struct EnableCondition +{ + QWidget* refWidget = nullptr; + QString comparator; + QVariant compareValue; +}; + +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; +} + +static bool evaluateCompound( + const QList>& orGroups) +{ + for (auto& andGroup : orGroups) { + bool groupResult = true; + for (auto& cond : andGroup) { + if (!evaluateCondition(cond)) { + groupResult = false; + break; + } + } + if (groupResult) { + return true; + } + } + return false; +} + +QWidget* findRootInterfaceWidget(const QWidget* widget) +{ + 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; +} + +QLabel* findLabelForWidget(const QWidget* widget) +{ + auto* parent = findRootInterfaceWidget(widget); + if (!parent) { + return nullptr; + } + + for (auto* child : parent->findChildren()) { + if (child->buddy() == widget) { + return child; + } + } + return nullptr; +} + +void setWidgetProperty(QWidget* widget, const char* property, QVariant value) +{ + QWidget* field = widget; + if (isWidgetNumeric(widget) || isWidgetType(widget)) { + widget = widget->parentWidget(); + if (!widget) { + return; + } + } + + widget->setProperty(property, value); + + // The label is the buddy of the row's container for some parameter + // types and of the field itself for others (strings) + auto* label = findLabelForWidget(widget); + if (!label && field != widget) { + label = findLabelForWidget(field); + } + if (label) { + label->setProperty(property, value); + } +} + +static void connectWidgetChanged(QWidget* refWidget, QWidget* target, + std::function func) +{ + 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); + } +} + +} // end anonymous namespace + +namespace tomviz { +namespace pipeline { + +ParameterInterfaceBuilder::ParameterInterfaceBuilder(QObject* parentObject) + : QObject(parentObject) +{} + +void ParameterInterfaceBuilder::setJSONDescription(const QString& json) +{ + setJSONDescription(QJsonDocument::fromJson(json.toLatin1())); +} + +void ParameterInterfaceBuilder::setJSONDescription(const QJsonDocument& doc) +{ + if (!doc.isObject()) { + qCritical() << "Failed to parse operator JSON"; + m_json = QJsonDocument(); + } else { + m_json = doc; + } +} + +void ParameterInterfaceBuilder::setParameterValues( + const QMap& values) +{ + m_parameterValues = values; +} + +void ParameterInterfaceBuilder::setPortScalars( + const QList& ports) +{ + m_portScalars = ports; +} + +QWidget* ParameterInterfaceBuilder::buildWidget(QWidget* parent) const +{ + 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; + } + QJsonObject root = m_json.object(); + + // Description label. Nothing else renders the node's "description", + // and SAM2SeedWidget::wireForm() reaches in here to repair this + // label's size policy, so it has to stay. + 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; +} + +void ParameterInterfaceBuilder::buildParameterInterface( + QGridLayout* layout, QJsonArray& parameters) const +{ + 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]; + if (typeString == "enumeration") { + // Stored values are option values; the widget reads a numeric + // default as an option index, so translate + int index = PythonNodeUtils::enumOptionIndex( + parameterValue, parameterObject["options"].toArray()); + if (index >= 0) { + parameterValue = index; + } + } + parameterObject["default"] = + QJsonValue::fromVariant(parameterValue); + } + } + + 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). + } + + setupEnableAndVisibleStates(layout->parentWidget(), parameters); +} + +void ParameterInterfaceBuilder::setupEnableAndVisibleStates( + const QObject* parent, QJsonArray& parameters) const +{ + setupEnableStates(parent, parameters, true); + setupEnableStates(parent, parameters, false); +} + +void ParameterInterfaceBuilder::setupEnableStates( + const QObject* parent, QJsonArray& parameters, bool visible) const +{ + static const QStringList validComparators = { "==", "!=", ">", + ">=", "<", "<=" }; + + QJsonObject::size_type numParameters = parameters.size(); + for (QJsonObject::size_type i = 0; i < numParameters; ++i) { + QJsonValueRef parameterNode = parameters[i]; + QJsonObject parameterObject = parameterNode.toObject(); + + QString text = visible ? "visible_if" : "enable_if"; + QString enableIfValue = parameterObject[text].toString(""); + if (enableIfValue.isEmpty()) { + continue; + } + + QString widgetName = parameterObject["name"].toString(""); + if (widgetName.isEmpty()) { + continue; + } + auto* widget = parent->findChild(widgetName); + if (!widget) { + continue; + } + + 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); + QList andGroup; + for (auto& clause : andParts) { + auto tokens = clause.simplified().split(" "); + if (tokens.size() != 3) { + parseError = true; + break; + } + + auto refWidgetName = tokens[0]; + auto comparator = tokens[1]; + auto compareValue = tokens[2]; + + auto* refWidget = parent->findChild(refWidgetName); + if (!refWidget) { + parseError = true; + break; + } + + if (!validComparators.contains(comparator)) { + parseError = true; + break; + } + + EnableCondition cond; + cond.refWidget = refWidget; + cond.comparator = comparator; + cond.compareValue = compareValue; + andGroup.append(cond); + } + + if (parseError) { + break; + } + orGroups.append(andGroup); + } + + if (parseError) { + continue; + } + + const char* property = visible ? "visible" : "enabled"; + + auto evalFunc = [orGroups, widget, property]() { + bool result = evaluateCompound(orGroups); + setWidgetProperty(widget, property, result); + }; + + QSet connectedWidgets; + for (auto& andGroup : orGroups) { + for (auto& cond : andGroup) { + if (connectedWidgets.contains(cond.refWidget)) { + continue; + } + connectedWidgets.insert(cond.refWidget); + connectWidgetChanged(cond.refWidget, widget, evalFunc); + } + } + + // Evaluate once for the initial state + evalFunc(); + } +} + +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..e2dcb3fef --- /dev/null +++ b/tomviz/pipeline/PipelineStripWidget.cxx @@ -0,0 +1,3299 @@ +/* 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)); + connect(node, &Node::autoExecuteChanged, 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