diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 962de06..18f03f9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,7 @@ jobs: build: name: Build Docker image (cached) runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Checkout @@ -56,4 +57,8 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha - cache-to: type=gha,mode=max + # PR caches are scoped to the PR and never reused, so only branches and + # tags write. ignore-error keeps a flaky cache export (the GHA backend + # intermittently answers "error writing layer blob: not_found") from + # failing a build that already succeeded. + cache-to: ${{ github.event_name == 'pull_request' && '' || 'type=gha,mode=max,ignore-error=true' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 96664ae..60257e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# [1.4.0] - 2026-08-07 + +### Added + +- XDMF parser (`.xdmf` / `.xmf`) for solver outputs with HDF5 heavy data: + static topology/geometry plus a temporal collection of node/cell attributes. + Frames stream straight into the rendered array (no geometry re-read) and are + cached in memory under a 512 MB budget, so looped playback is zero-I/O. +- Annotation mode (`--annotate`): paint a per-cell `label` array with a surface + brush, undo, and save. +- Playback bar: previous/next frame buttons and a speed drop-up (1x/2x/4x/8x/12x). + +### Changed + +- Playback is clock-driven: each tick jumps to the frame the elapsed time calls + for, dropping frames instead of queueing them so high speeds stay real-time. +- `TemporalSource` is now an interface with VTKHDF and XDMF backends; parsers + expose their time series through `MeshParser::temporal()`. +- Mesh JSON accepts a `surfaces` key. + # [1.3.0] - 2026-07-07 ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index b3ec7ab..60b2302 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.14) -project(VtkViewer VERSION 1.3.0) +project(VtkViewer VERSION 1.4.0) set(CMAKE_AUTOMOC ON) @@ -80,6 +80,7 @@ find_package(VTK QUIET COMPONENTS IOImage IOHDF FiltersCore + FiltersExtraction RenderingCore RenderingOpenGL2 InteractionStyle @@ -126,6 +127,7 @@ set(VV_CORE_SOURCES src/VTKMeshParser.cpp src/ViewerWindow.cpp src/XMLMeshParser.cpp + src/XdmfMeshParser.cpp src/mesh_utils.cpp ) @@ -145,6 +147,7 @@ set(VV_HEADERS src/include/VTKMeshParser.h src/include/ViewerWindow.h src/include/XMLMeshParser.h + src/include/XdmfMeshParser.h src/include/mesh_utils.h ) @@ -188,7 +191,16 @@ if(VV_ENABLE_WARNINGS) endif() endif() -target_link_libraries(vv PRIVATE fmt::fmt) +# XDMF heavy data is read with the HDF5 C API. Prefer the copy VTK already links +# (VTK::hdf5), so no new dependency is introduced. +if(TARGET VTK::hdf5) + set(VV_HDF5_TARGET VTK::hdf5) +else() + find_package(HDF5 REQUIRED COMPONENTS C) + set(VV_HDF5_TARGET hdf5::hdf5) +endif() + +target_link_libraries(vv PRIVATE fmt::fmt ${VV_HDF5_TARGET}) target_link_libraries(vv PRIVATE nlohmann_json::nlohmann_json) target_link_libraries(vv PRIVATE VTK::CommonCore @@ -199,6 +211,7 @@ target_link_libraries(vv PRIVATE VTK::IOImage VTK::IOHDF VTK::FiltersCore + VTK::FiltersExtraction VTK::RenderingCore VTK::RenderingOpenGL2 VTK::InteractionStyle diff --git a/src/JsonMeshParser.cpp b/src/JsonMeshParser.cpp index 2220ac8..3602c08 100644 --- a/src/JsonMeshParser.cpp +++ b/src/JsonMeshParser.cpp @@ -28,31 +28,30 @@ bool isMeshPart(const json& value) { value["vertices"].is_array() && value["indices"].is_array(); } -bool looksLikeJsonMesh(const json& value) { - if (isMeshPart(value)) { - return true; - } - if (value.is_object() && value.contains("surface")) { - const auto& surface = value["surface"]; - return surface.is_array() && !surface.empty() && - std::all_of(surface.begin(), surface.end(), isMeshPart); - } - if (!value.is_array() || value.empty()) { - return false; - } - return std::all_of(value.begin(), value.end(), isMeshPart); -} - const json* meshPartArray(const json& root) { if (root.is_array()) { return &root; } - if (root.is_object() && root.contains("surface") && root["surface"].is_array()) { - return &root["surface"]; + if (!root.is_object()) { + return nullptr; + } + for (const char* key : {"surface", "surfaces"}) { + if (root.contains(key) && root[key].is_array()) { + return &root[key]; + } } return nullptr; } +bool looksLikeJsonMesh(const json& value) { + if (isMeshPart(value)) { + return true; + } + const json* parts = meshPartArray(value); + return parts != nullptr && !parts->empty() && + std::all_of(parts->begin(), parts->end(), isMeshPart); +} + std::string partName(const json& part) { if (part.contains("name") && part["name"].is_string()) { return part["name"].get(); diff --git a/src/MeshLoading.cpp b/src/MeshLoading.cpp index 8345492..c0ba263 100644 --- a/src/MeshLoading.cpp +++ b/src/MeshLoading.cpp @@ -9,6 +9,7 @@ #include "VTKHDFMeshParser.h" #include "VTKMeshParser.h" #include "XMLMeshParser.h" +#include "XdmfMeshParser.h" #include "mesh_utils.h" #include @@ -67,6 +68,7 @@ std::vector filesToProcessFromArgs(const std::vector& std::vector> buildParsers() { std::vector> parsers; parsers.emplace_back(std::make_unique()); + parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); parsers.emplace_back(std::make_unique()); @@ -143,11 +145,8 @@ MeshLoadResult loadMeshes(const std::vector& meshfiles, bool explod std::vector> parsedMeshes = selected->parse(realFilename); - // Capture temporal (playable) info if this file produced it. - if (const auto* hdfParser = dynamic_cast(selected)) { - if (auto temporal = hdfParser->temporal(); temporal && temporal->playable()) { - result.temporal = temporal; - } + if (auto temporal = selected->temporal(); temporal && temporal->playable()) { + result.temporal = temporal; } if (parsedMeshes.empty()) { diff --git a/src/MeshRenderer.cpp b/src/MeshRenderer.cpp index f791d5f..d97768c 100644 --- a/src/MeshRenderer.cpp +++ b/src/MeshRenderer.cpp @@ -365,6 +365,34 @@ bool MeshRenderer::setActiveScalar(const std::string& scalarName, FieldAssociati return true; } +void MeshRenderer::colorByFixedCategorical(const std::string& scalarName, + FieldAssociation association, + vtkLookupTable* lut, + const double range[2]) { + if (sceneMeshes.empty() || mappers.empty() || !sceneMeshes.front() || !lut) { + return; + } + activeScalarName = scalarName; + activeScalarAssociation = association; + vtkDataSet* mesh = sceneMeshes.front(); + vtkDataSetMapper* mapper = mappers.front(); + if (association == FieldAssociation::Cell) { + mesh->GetCellData()->SetActiveScalars(scalarName.c_str()); + mapper->SetScalarModeToUseCellFieldData(); + } else { + mesh->GetPointData()->SetActiveScalars(scalarName.c_str()); + mapper->SetScalarModeToUsePointFieldData(); + } + mapper->SelectColorArray(scalarName.c_str()); + mapper->SetColorModeToMapScalars(); + mapper->ScalarVisibilityOn(); + mapper->SetLookupTable(lut); + mapper->SetScalarRange(range[0], range[1]); + if (context.window) { + context.window->Render(); + } +} + void MeshRenderer::clearActiveScalar() { activeScalarName.clear(); activeScalarAnalysis = {}; diff --git a/src/PlaybackBar.cpp b/src/PlaybackBar.cpp index 0e1dc62..5682af0 100644 --- a/src/PlaybackBar.cpp +++ b/src/PlaybackBar.cpp @@ -1,10 +1,12 @@ #include "PlaybackBar.h" +#include +#include #include -#include #include #include #include +#include #include #include #include @@ -14,6 +16,7 @@ #include #include #include +#include #include namespace { @@ -24,7 +27,7 @@ namespace { constexpr int kIconBox = 24; const QColor kIconColor(232, 232, 232); // matches the bar's text color -enum class Glyph { Play, Pause, Loop }; +enum class Glyph { Play, Pause, Prev, Next, Loop }; QPainterPath glyphPath(Glyph glyph) { QPainterPath path; @@ -41,6 +44,19 @@ QPainterPath glyphPath(Glyph glyph) { path.addRoundedRect(QRectF(13.0, 6.0, 3.5, 12.0), 1.2, 1.2); break; } + case Glyph::Prev: + case Glyph::Next: { + // Triangle against a bar, mirrored for Prev. + QPainterPath shape; + shape.moveTo(17.5, 6.0); + shape.lineTo(17.5, 18.0); + shape.lineTo(8.5, 12.0); + shape.closeSubpath(); + shape.addRoundedRect(QRectF(5.5, 6.0, 2.6, 12.0), 1.0, 1.0); + path = glyph == Glyph::Next ? QTransform().translate(24.0, 0.0).scale(-1.0, 1.0).map(shape) + : shape; + break; + } case Glyph::Loop: { // Circular arrow: an open ring with an arrowhead at the top opening. QPainterPath ring; @@ -107,12 +123,8 @@ PlaybackBar::PlaybackBar(int numSteps, QWidget* parent) "QToolButton:hover { background: rgba(255,255,255,40); }" "QToolButton:checked { background: rgba(80,150,250,160); color: white; }" "QLabel { color: #D8D8D8; font-size: 12px; }" - "QComboBox {" - " background: rgba(255,255,255,18); color: #E8E8E8;" - " border: none; border-radius: 4px; padding: 2px 6px;" - "}" - "QComboBox QAbstractItemView { background: #202020; color: #E8E8E8; " - "selection-background-color: #5096FA; }" + "QMenu { background: #202020; color: #E8E8E8; border: 1px solid #3A3A3A; }" + "QMenu::item:selected { background: #5096FA; }" "QSlider::groove:horizontal { height: 4px; background: rgba(255,255,255,50); " "border-radius: 2px; }" "QSlider::handle:horizontal {" @@ -124,12 +136,19 @@ PlaybackBar::PlaybackBar(int numSteps, QWidget* parent) row->setContentsMargins(10, 6, 10, 6); row->setSpacing(8); - playButton_ = new QToolButton(this); - playButton_->setIcon(makeGlyphIcon(Glyph::Play)); - playButton_->setIconSize(QSize(18, 18)); - playButton_->setToolTip("Play/Pause"); - playButton_->setFocusPolicy(Qt::NoFocus); - row->addWidget(playButton_); + auto makeButton = [this, row](Glyph glyph, const char* tip) { + auto* button = new QToolButton(this); + button->setIcon(makeGlyphIcon(glyph)); + button->setIconSize(QSize(18, 18)); + button->setToolTip(QString::fromLatin1(tip)); + button->setFocusPolicy(Qt::NoFocus); + row->addWidget(button); + return button; + }; + + prevButton_ = makeButton(Glyph::Prev, "Previous frame"); + playButton_ = makeButton(Glyph::Play, "Play/Pause"); + nextButton_ = makeButton(Glyph::Next, "Next frame"); slider_ = new QSlider(Qt::Horizontal, this); slider_->setMinimum(0); @@ -140,26 +159,40 @@ PlaybackBar::PlaybackBar(int numSteps, QWidget* parent) row->addWidget(slider_, 1); readout_ = new QLabel(this); - readout_->setMinimumWidth(140); + readout_->setMinimumWidth(150); readout_->setAlignment(Qt::AlignCenter); row->addWidget(readout_); - speedBox_ = new QComboBox(this); - speedBox_->setFocusPolicy(Qt::NoFocus); - for (const char* label : {"0.25x", "0.5x", "1x", "2x", "4x", "8x"}) { - speedBox_->addItem(QString::fromLatin1(label)); + speedButton_ = new QToolButton(this); + speedButton_->setText(QStringLiteral("1x")); + speedButton_->setToolTip(QStringLiteral("Playback speed")); + speedButton_->setFocusPolicy(Qt::NoFocus); + row->addWidget(speedButton_); + + auto* speedMenu = new QMenu(speedButton_); + auto* speedGroup = new QActionGroup(speedMenu); + for (const double multiplier : {1.0, 2.0, 4.0, 8.0, 12.0}) { + const QString label = QStringLiteral("%1x").arg(multiplier); + QAction* action = speedMenu->addAction(label); + action->setCheckable(true); + action->setChecked(multiplier == speed_); + speedGroup->addAction(action); + connect(action, &QAction::triggered, this, [this, multiplier, label]() { + speed_ = multiplier; + speedButton_->setText(label); + emit speedChanged(speed_); + }); } - speedBox_->setCurrentIndex(2); // 1x - row->addWidget(speedBox_); + // Drop *up*: the bar sits at the bottom of the viewport, so a menu opened + // downwards would fall outside the window. + connect(speedButton_, &QToolButton::clicked, this, [this, speedMenu]() { + const QSize hint = speedMenu->sizeHint(); + speedMenu->popup(speedButton_->mapToGlobal(QPoint(0, -hint.height()))); + }); - loopButton_ = new QToolButton(this); - loopButton_->setIcon(makeGlyphIcon(Glyph::Loop)); - loopButton_->setIconSize(QSize(18, 18)); - loopButton_->setToolTip("Loop"); + loopButton_ = makeButton(Glyph::Loop, "Loop"); loopButton_->setCheckable(true); loopButton_->setChecked(true); - loopButton_->setFocusPolicy(Qt::NoFocus); - row->addWidget(loopButton_); updateReadout(0, 0.0); @@ -167,10 +200,9 @@ PlaybackBar::PlaybackBar(int numSteps, QWidget* parent) setPlaying(!playing_); emit playToggled(playing_); }); + connect(prevButton_, &QToolButton::clicked, this, [this]() { stepBy(-1); }); + connect(nextButton_, &QToolButton::clicked, this, [this]() { stepBy(1); }); connect(slider_, &QSlider::valueChanged, this, [this](int value) { emit stepRequested(value); }); - connect(speedBox_, &QComboBox::currentTextChanged, this, [this]() { - emit speedChanged(speedMultiplier()); - }); connect(loopButton_, &QToolButton::toggled, this, [this](bool on) { emit loopToggled(on); }); } @@ -179,21 +211,13 @@ int PlaybackBar::currentStep() const { } double PlaybackBar::speedMultiplier() const { - switch (speedBox_->currentIndex()) { - case 0: - return 0.25; - case 1: - return 0.5; - case 2: - return 1.0; - case 3: - return 2.0; - case 4: - return 4.0; - case 5: - return 8.0; - default: - return 1.0; + return speed_; +} + +void PlaybackBar::stepBy(int delta) { + const int target = std::clamp(currentStep() + delta, 0, numSteps_ - 1); + if (target != currentStep()) { + emit stepRequested(target); } } diff --git a/src/TemporalSource.cpp b/src/TemporalSource.cpp index 9092409..e9b54f8 100644 --- a/src/TemporalSource.cpp +++ b/src/TemporalSource.cpp @@ -9,14 +9,22 @@ #include #include -TemporalSource::TemporalSource() = default; TemporalSource::~TemporalSource() = default; -void TemporalSource::init(const vtkSmartPointer& reader, - std::vector timeValues) { +VTKHDFTemporalSource::VTKHDFTemporalSource() = default; +VTKHDFTemporalSource::~VTKHDFTemporalSource() = default; + +double TemporalSource::timeAt(int step) const { + if (step < 0 || step >= steps()) { + return 0.0; + } + return timeValues_[static_cast(step)]; +} + +void VTKHDFTemporalSource::init(const vtkSmartPointer& reader, + std::vector timeValues) { reader_ = reader; timeValues_ = std::move(timeValues); - numSteps_ = static_cast(timeValues_.size()); if (reader_) { // Cache the static geometry/topology so successive frames only re-read the // temporal point-data arrays (incompatible with MergeParts, which is off). @@ -28,7 +36,7 @@ void TemporalSource::init(const vtkSmartPointer& reader, } } -void TemporalSource::setActiveArray(const std::string& scalarName) { +void VTKHDFTemporalSource::setActiveArray(const std::string& scalarName) { if (!reader_ || scalarName.empty()) { return; } @@ -40,15 +48,8 @@ void TemporalSource::setActiveArray(const std::string& scalarName) { sel->EnableArray(scalarName.c_str()); } -double TemporalSource::timeAt(int step) const { - if (step < 0 || step >= numSteps_) { - return 0.0; - } - return timeValues_[static_cast(step)]; -} - -bool TemporalSource::updateToStep(int step) { - if (!reader_ || step < 0 || step >= numSteps_) { +bool VTKHDFTemporalSource::updateToStep(int step) { + if (!reader_ || step < 0 || step >= steps()) { return false; } // Drive the time-series pipeline via UPDATE_TIME_STEP: vtkHDFReader::RequestData @@ -63,7 +64,7 @@ bool TemporalSource::updateToStep(int step) { return true; } -bool TemporalSource::readStepInto(int step, vtkDataSet* target) { +bool VTKHDFTemporalSource::readStepInto(int step, vtkDataSet* target) { if (!target || !updateToStep(step)) { return false; } @@ -76,13 +77,13 @@ bool TemporalSource::readStepInto(int step, vtkDataSet* target) { return true; } -bool TemporalSource::sampledScalarRange(const std::string& scalarName, - double out[2], - int maxSamples) { - if (!reader_ || numSteps_ <= 0 || scalarName.empty()) { +bool VTKHDFTemporalSource::sampledScalarRange(const std::string& scalarName, + double out[2], + int maxSamples) { + if (!reader_ || steps() <= 0 || scalarName.empty()) { return false; } - const int sampleCount = std::min(numSteps_, std::max(1, maxSamples)); + const int sampleCount = std::min(steps(), std::max(1, maxSamples)); double lo = 0.0; double hi = 0.0; bool any = false; @@ -91,7 +92,7 @@ bool TemporalSource::sampledScalarRange(const std::string& scalarName, const int step = sampleCount == 1 ? 0 - : static_cast((static_cast(s) * (numSteps_ - 1)) / (sampleCount - 1)); + : static_cast((static_cast(s) * (steps() - 1)) / (sampleCount - 1)); if (!updateToStep(step)) { continue; } diff --git a/src/VTKHDFMeshParser.cpp b/src/VTKHDFMeshParser.cpp index e8523c8..54ebe55 100644 --- a/src/VTKHDFMeshParser.cpp +++ b/src/VTKHDFMeshParser.cpp @@ -82,8 +82,8 @@ std::vector> VTKHDFMeshParser::parse(const std::stri meshes.push_back(mesh); if (timeValues.size() > 1) { - temporal_ = std::make_shared(); - temporal_->init(reader, std::move(timeValues)); + temporal_ = std::make_shared(); + std::static_pointer_cast(temporal_)->init(reader, std::move(timeValues)); } return meshes; diff --git a/src/ViewerWindow.cpp b/src/ViewerWindow.cpp index 1c45745..04f7c74 100644 --- a/src/ViewerWindow.cpp +++ b/src/ViewerWindow.cpp @@ -8,18 +8,28 @@ #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 @@ -29,14 +39,25 @@ #include #include #include +#include #include #include +#include #include +#include +#include #include +#include +#include #include #include +#include +#include #include #include +#include +#include +#include namespace { @@ -54,6 +75,7 @@ constexpr int kTreeOverlayMaxHeight = 340; constexpr int kFacetBarMargin = 6; constexpr int kFacetBarMinWidth = 68; constexpr int kFacetBarMaxWidth = 140; +constexpr double kPlaybackBaseFps = 15.0; constexpr int kPlaybackBarMargin = 16; constexpr int kPlaybackBarMaxWidth = 760; constexpr int kPlaybackBarHeight = 44; @@ -152,6 +174,21 @@ QIcon partColorIcon(const std::array& rgb) { return QIcon(pix); } +// Small circle cursor for paint mode. ponytail: fixed size, not mapped to the +// ring-based brush (rings aren't pixels); it just signals "brush, not camera". +QCursor makeBrushCursor() { + constexpr int kSize = 20; + QPixmap pix(kSize, kSize); + pix.fill(Qt::transparent); + QPainter painter(&pix); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QPen(QColor(20, 20, 20, 200), 2.0)); + painter.drawEllipse(2, 2, kSize - 4, kSize - 4); + painter.setPen(QPen(QColor(255, 255, 255, 220), 1.0)); + painter.drawEllipse(2, 2, kSize - 4, kSize - 4); + return QCursor(pix, kSize / 2, kSize / 2); +} + // Swatch list for a categorical scalar: analysis unique values + LUT colors, // highest value first (top of the bar). std::vector> categoricalEntries(vtkLookupTable* lut, @@ -192,10 +229,13 @@ class VtkMouseFilter : public QObject { QWidget* overlayTree, std::function onSpaceCycle, std::function onViewportResize, + std::function onPointerEvent, + std::function onPaintHold, QObject* parent = nullptr) : QObject(parent), vtkRoot_(vtkRoot), overlayColorBar_(overlayColorBar), overlayTree_(overlayTree), onSpaceCycle_(std::move(onSpaceCycle)), - onViewportResize_(std::move(onViewportResize)) {} + onViewportResize_(std::move(onViewportResize)), onPointerEvent_(std::move(onPointerEvent)), + onPaintHold_(std::move(onPaintHold)) {} protected: bool eventFilter(QObject* watched, QEvent* event) override { @@ -225,6 +265,18 @@ class VtkMouseFilter : public QObject { return QObject::eventFilter(watched, event); } + // Annotation gets first dibs on pointer events so a paint stroke is not also + // interpreted as a camera rotate/dolly by VTK. Only the raw render surface + // paints — events on child overlays (the annotation bar's swatches, slider, + // save button) must pass through to those widgets. + if (onPointerEvent_ && widget == vtkRoot && + (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseMove || + event->type() == QEvent::MouseButtonRelease)) { + if (onPointerEvent_(static_cast(event))) { + return true; + } + } + switch (event->type()) { case QEvent::Resize: if (widget == vtkRoot) { @@ -302,11 +354,27 @@ class VtkMouseFilter : public QObject { QApplication::quit(); return true; } + // Hold 'a' to paint, 'e' to erase; ignore X11 auto-repeat so the hold sticks. + if (!ke->isAutoRepeat() && onPaintHold_ && + (ke->key() == Qt::Key_A || ke->key() == Qt::Key_E)) { + onPaintHold_(true, ke->key() == Qt::Key_E); + return true; + } + break; + } + case QEvent::KeyRelease: { + auto* ke = static_cast(event); + if (!ke->isAutoRepeat() && onPaintHold_ && + (ke->key() == Qt::Key_A || ke->key() == Qt::Key_E)) { + onPaintHold_(false, false); + return true; + } break; } case QEvent::ShortcutOverride: { auto* ke = static_cast(event); - if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Q) { + if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Q || ke->key() == Qt::Key_A || + ke->key() == Qt::Key_E) { ke->accept(); return true; } @@ -324,8 +392,97 @@ class VtkMouseFilter : public QObject { QPointer overlayTree_; std::function onSpaceCycle_; std::function onViewportResize_; + std::function onPointerEvent_; + std::function onPaintHold_; }; +// Number of paintable labels (1..kNumLabels); value 0 is "unlabeled". Kept at 9 +// so the categorical LUT uses the distinct tab10 palette. +constexpr int kNumLabels = 9; + +QRect annotationBarGeometry(const QWidget* viewport, const QWidget* bar) { + const int width = bar->sizeHint().width(); + const int height = bar->sizeHint().height(); + const int x = std::max(kPlaybackBarMargin, (viewport->width() - width) / 2); + const int y = std::max(kPlaybackBarMargin, viewport->height() - height - kPlaybackBarMargin); + return QRect(x, y, width, height); +} + +enum class AnnotGlyph { Pencil, Save }; + +QIcon makeAnnotIcon(AnnotGlyph glyph) { + QIcon icon; + for (const int scale : {1, 2}) { + const int px = 24 * scale; + QPixmap pix(px, px); + pix.fill(Qt::transparent); + QPainter painter(&pix); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.scale(scale, scale); + QPainterPath path; + if (glyph == AnnotGlyph::Pencil) { + path.moveTo(16.0, 4.0); // diagonal pencil body + path.lineTo(20.0, 8.0); + path.lineTo(8.0, 20.0); + path.lineTo(4.0, 16.0); + path.closeSubpath(); + QPainterPath tip; // graphite tip at the lower-left corner + tip.moveTo(4.0, 16.0); + tip.lineTo(8.0, 20.0); + tip.lineTo(4.0, 20.0); + tip.closeSubpath(); + path = path.united(tip); + } else { + path.addRect(QRectF(10.5, 4.0, 3.0, 7.0)); // down-arrow into a tray = save + QPainterPath head; + head.moveTo(7.0, 10.0); + head.lineTo(17.0, 10.0); + head.lineTo(12.0, 16.0); + head.closeSubpath(); + path = path.united(head); + path.addRect(QRectF(5.0, 17.5, 14.0, 2.5)); + } + painter.fillPath(path, QColor(232, 232, 232)); + painter.end(); + icon.addPixmap(pix); + } + return icon; +} + +// Grow a set of cells outward from `seed` over shared-vertex adjacency, up to +// `rings` expansion steps (ring 0 = just the seed cell). This walks the mesh +// surface: the far side of a thin sheet shares no vertices with the near side, +// so paint never jumps the gap the way a 3-D sphere brush would. +std::vector growCells(vtkDataSet* ds, vtkIdType seed, int rings) { + std::vector out; + if (!ds || seed < 0) { + return out; + } + std::set visited{seed}; + std::vector frontier{seed}; + out.push_back(seed); + auto cellPts = vtkSmartPointer::New(); + auto ptCells = vtkSmartPointer::New(); + for (int ring = 0; ring < rings && !frontier.empty(); ++ring) { + std::vector next; + for (vtkIdType cell : frontier) { + ds->GetCellPoints(cell, cellPts); + for (vtkIdType i = 0; i < cellPts->GetNumberOfIds(); ++i) { + ds->GetPointCells(cellPts->GetId(i), ptCells); + for (vtkIdType j = 0; j < ptCells->GetNumberOfIds(); ++j) { + const vtkIdType nb = ptCells->GetId(j); + if (visited.insert(nb).second) { + next.push_back(nb); + out.push_back(nb); + } + } + } + } + frontier.swap(next); + } + return out; +} + } // namespace // ═════════════════════════════════════════════════════════════════════ @@ -367,6 +524,12 @@ ViewerWindow::ViewerWindow(MeshLoadResult loadResult, const ViewerOptions& optio partsTree_, [this]() { cycleScalar(); }, [this]() { onViewportResize(); }, + options_.annotate ? std::function( + [this](QMouseEvent* e) { return handleAnnotatePointer(e); }) + : nullptr, + options_.annotate ? std::function( + [this](bool active, bool erase) { setPaintHold(active, erase); }) + : nullptr, this)); QTimer::singleShot(0, this, [this]() { @@ -378,6 +541,9 @@ ViewerWindow::ViewerWindow(MeshLoadResult loadResult, const ViewerOptions& optio setupFacetMode(); } else { setupNormalMode(); + if (options_.annotate) { + setupAnnotateMode(); + } } vtkWidget_->setFocus(); @@ -578,18 +744,29 @@ void ViewerWindow::setupPlayback() { playTimer_ = new QTimer(this); + // Playback is clock-driven, not tick-driven: each timeout jumps to the frame the + // elapsed time calls for. If decoding or rendering cannot keep up (high speeds, + // heavy meshes) frames are dropped instead of queueing up, so the animation runs + // at the requested rate rather than in slow motion. QObject::connect(playTimer_, &QTimer::timeout, this, [this, numSteps]() { - int next = playbackBar_->currentStep() + 1; + const double elapsed = double(playClock_.elapsed()) / 1000.0; + const int advance = + static_cast(elapsed * kPlaybackBaseFps * playbackBar_->speedMultiplier()); + int next = playAnchorStep_ + advance; if (next >= numSteps) { - if (playbackBar_->loopEnabled()) { - next = 0; - } else { + if (!playbackBar_->loopEnabled()) { playTimer_->stop(); playbackBar_->setPlaying(false); + showFrame(numSteps - 1); return; } + next %= numSteps; + playAnchorStep_ = next; + playClock_.restart(); + } + if (next != currentPlaybackStep_) { + showFrame(next); } - showFrame(next); }); QObject::connect(playbackBar_, &PlaybackBar::playToggled, this, [this, numSteps](bool playing) { @@ -598,6 +775,7 @@ void ViewerWindow::setupPlayback() { if (playbackBar_->currentStep() >= numSteps - 1) { showFrame(0); } + restartPlayClock(); applyPlayTimerInterval(); playTimer_->start(); } else { @@ -605,10 +783,13 @@ void ViewerWindow::setupPlayback() { } }); - QObject::connect( - playbackBar_, &PlaybackBar::stepRequested, this, [this](int step) { showFrame(step); }); + QObject::connect(playbackBar_, &PlaybackBar::stepRequested, this, [this](int step) { + showFrame(step); + restartPlayClock(); + }); QObject::connect(playbackBar_, &PlaybackBar::speedChanged, this, [this](double) { + restartPlayClock(); if (playTimer_->isActive()) { applyPlayTimerInterval(); } @@ -630,9 +811,391 @@ void ViewerWindow::showFrame(int step) { } } +void ViewerWindow::restartPlayClock() { + playAnchorStep_ = currentPlaybackStep_; + playClock_.restart(); +} + void ViewerWindow::applyPlayTimerInterval() { - const double fps = 15.0 * playbackBar_->speedMultiplier(); - playTimer_->setInterval(std::max(1, static_cast(std::round(1000.0 / fps)))); + const double fps = kPlaybackBaseFps * playbackBar_->speedMultiplier(); + playTimer_->setInterval(std::max(4, static_cast(std::round(1000.0 / fps)))); +} + +// ── annotation mode ───────────────────────────────────────────────────── +void ViewerWindow::ensureLabelArray() { + vtkDataSet* mesh = renderer_.getPrimaryMesh(); + if (!mesh) { + return; + } + labelArray_ = vtkIntArray::SafeDownCast(mesh->GetCellData()->GetArray("label")); + if (!labelArray_) { + auto arr = vtkSmartPointer::New(); + arr->SetName("label"); + arr->SetNumberOfComponents(1); + arr->SetNumberOfTuples(mesh->GetNumberOfCells()); + arr->FillComponent(0, 0.0); + mesh->GetCellData()->AddArray(arr); + labelArray_ = arr; + } + // GetPointCells (used by the surface brush BFS) needs the reverse links built. + if (auto* pd = vtkPolyData::SafeDownCast(mesh)) { + pd->BuildLinks(); + } else if (auto* ug = vtkUnstructuredGrid::SafeDownCast(mesh)) { + ug->BuildLinks(); + } +} + +void ViewerWindow::setupAnnotateMode() { + ensureLabelArray(); + picker_ = vtkSmartPointer::New(); + picker_->SetTolerance(0.0005); + + // Fixed value→color LUT so a value keeps its color while the array is edited. + std::set domain; + for (int v = 0; v <= kNumLabels; ++v) { + domain.insert(static_cast(v)); + } + labelLut_ = createCategoricalLookupTable(domain); + labelLut_->SetTableValue(0, 0.6, 0.6, 0.6, 1.0); // value 0 = unlabeled → grey + labelLut_->Modified(); + applyLabelColoring(); + + // Floating bar: pencil toggle, one color swatch per label, brush size, save. + annotationBar_ = new QWidget(vtkWidget_); + annotationBar_->setObjectName("annotationBar"); + annotationBar_->setAttribute(Qt::WA_StyledBackground, true); + annotationBar_->setFocusPolicy(Qt::NoFocus); + annotationBar_->setStyleSheet( + "QWidget#annotationBar { background: rgba(20,20,20,200); border-radius: 8px; }" + "QToolButton { background: rgba(255,255,255,18); color: #E8E8E8; border: none;" + " border-radius: 4px; padding: 3px; }" + "QToolButton:hover { background: rgba(255,255,255,40); }" + "QToolButton:checked { background: rgba(80,150,250,160); }" + "QLabel { color: #D8D8D8; font-size: 12px; }" + "QSlider::groove:horizontal { height: 4px; background: rgba(255,255,255,50);" + " border-radius: 2px; }" + "QSlider::handle:horizontal { width: 12px; margin: -5px 0; border-radius: 6px;" + " background: #E8E8E8; }" + "QSlider::sub-page:horizontal { background: #5096FA; border-radius: 2px; }"); + + auto* row = new QHBoxLayout(annotationBar_); + row->setContentsMargins(10, 6, 10, 6); + row->setSpacing(8); + + pencilButton_ = new QToolButton(annotationBar_); + pencilButton_->setIcon(makeAnnotIcon(AnnotGlyph::Pencil)); + pencilButton_->setIconSize(QSize(20, 20)); + pencilButton_->setCheckable(true); + pencilButton_->setChecked(false); // navigation by default; click (or 'a') to paint + pencilButton_->setFocusPolicy(Qt::NoFocus); + pencilButton_->setToolTip( + QStringLiteral("Paint (a) — off = rotate. 'e' toggles erase; right-drag also erases.")); + row->addWidget(pencilButton_); + const QCursor brushCursor = makeBrushCursor(); + QObject::connect(pencilButton_, &QToolButton::toggled, this, [this, brushCursor](bool on) { + vtkWidget_->setCursor(on ? brushCursor : QCursor(Qt::ArrowCursor)); + }); + vtkWidget_->setCursor(Qt::ArrowCursor); + // Toolbar is a child of vtkWidget_, so it inherits the brush cursor; force arrow. + annotationBar_->setCursor(Qt::ArrowCursor); + + auto* swatches = new QButtonGroup(this); + swatches->setExclusive(true); + for (int v = 0; v <= kNumLabels; ++v) { // v == 0 is the eraser (grey / unlabeled) + double rgb[3]; + labelLut_->GetColor(static_cast(v), rgb); + const QColor color = QColor::fromRgbF( + static_cast(rgb[0]), static_cast(rgb[1]), static_cast(rgb[2])); + auto* sw = new QToolButton(annotationBar_); + sw->setCheckable(true); + sw->setFixedSize(22, 22); + sw->setFocusPolicy(Qt::NoFocus); + sw->setToolTip(v == 0 ? QStringLiteral("Erase") : QString::number(v)); + sw->setStyleSheet(QStringLiteral("QToolButton { background: %1; border: 2px solid " + "rgba(0,0,0,0); border-radius: 4px; }" + "QToolButton:checked { border: 2px solid white; }") + .arg(color.name())); + swatches->addButton(sw, v); + row->addWidget(sw); + if (v == currentLabel_) { + sw->setChecked(true); + } + } + QObject::connect( + swatches, &QButtonGroup::idClicked, this, [this](int id) { currentLabel_ = id; }); + + row->addWidget(new QLabel(QStringLiteral("Brush"), annotationBar_)); + brushSlider_ = new QSlider(Qt::Horizontal, annotationBar_); + brushSlider_->setRange(0, 15); + brushSlider_->setValue(3); + brushSlider_->setFixedWidth(90); + brushSlider_->setFocusPolicy(Qt::NoFocus); + brushSlider_->setToolTip(QStringLiteral("Brush size: surface rings around the picked cell.")); + row->addWidget(brushSlider_); + + auto* saveButton = new QToolButton(annotationBar_); + saveButton->setIcon(makeAnnotIcon(AnnotGlyph::Save)); + saveButton->setIconSize(QSize(20, 20)); + saveButton->setFocusPolicy(Qt::NoFocus); + saveButton->setToolTip(QStringLiteral("Save annotations to .vtp/.vtu")); + row->addWidget(saveButton); + QObject::connect(saveButton, &QToolButton::clicked, this, [this]() { saveAnnotations(); }); + + annotationBar_->show(); + annotationBar_->raise(); + QTimer::singleShot(0, this, [this]() { + annotationBar_->setGeometry(annotationBarGeometry(vtkWidget_, annotationBar_)); + annotationBar_->raise(); + }); + + // 'a'/'e' are handled as press-and-hold in VtkMouseFilter, not QShortcuts. + auto bumpBrush = [this](int delta) { + if (brushSlider_) { + brushSlider_->setValue(brushSlider_->value() + delta); + } + }; + for (const auto key : {Qt::Key_Plus, Qt::Key_Equal}) { // '=' so + needs no Shift + QObject::connect(new QShortcut(QKeySequence(key), this), + &QShortcut::activated, + this, + [bumpBrush]() { bumpBrush(+1); }); + } + for (const auto key : {Qt::Key_Minus, Qt::Key_Underscore}) { + QObject::connect(new QShortcut(QKeySequence(key), this), + &QShortcut::activated, + this, + [bumpBrush]() { bumpBrush(-1); }); + } + auto* undo = new QShortcut(QKeySequence(QKeySequence::Undo), this); // Cmd/Ctrl+Z + QObject::connect(undo, &QShortcut::activated, this, [this]() { undoStroke(); }); +} + +// Press-and-hold from VtkMouseFilter: arm paint (erase = label 0) while held, +// disarm on release. Clicking pencilButton stays as a sticky lock. +void ViewerWindow::setPaintHold(bool active, bool erase) { + if (!pencilButton_) { + return; + } + eraseMode_ = active && erase; + pencilButton_->setChecked(active); + if (!active) { + clearBrushPreview(); + } +} + +void ViewerWindow::togglePaint() { + if (pencilButton_) { + pencilButton_->setChecked(!pencilButton_->isChecked()); + } +} + +bool ViewerWindow::handleAnnotatePointer(QMouseEvent* event) { + if (!pencilButton_ || !pencilButton_->isChecked()) { + return false; + } + switch (event->type()) { + case QEvent::MouseButtonPress: + if (event->button() == Qt::LeftButton || event->button() == Qt::RightButton) { + painting_ = true; + currentStroke_.clear(); + clearBrushPreview(); + paintAtWidgetPos(event->localPos(), eraseMode_ || event->button() == Qt::RightButton); + return true; + } + return false; + case QEvent::MouseMove: + if (painting_ && (event->buttons() & (Qt::LeftButton | Qt::RightButton))) { + paintAtWidgetPos(event->localPos(), eraseMode_ || (event->buttons() & Qt::RightButton) != 0); + return true; + } + updateBrushPreview(event->localPos()); // hover with no button: show the footprint + return false; + case QEvent::MouseButtonRelease: + if (painting_) { + painting_ = false; + if (!currentStroke_.empty()) { + undoStack_.push_back(std::move(currentStroke_)); + currentStroke_.clear(); + constexpr size_t kMaxUndo = 50; + if (undoStack_.size() > kMaxUndo) { + undoStack_.erase(undoStack_.begin()); + } + } + return true; + } + return false; + default: + return false; + } +} + +void ViewerWindow::paintAtWidgetPos(const QPointF& pos, bool erase) { + vtkRenderer* ren = renderer_.getRenderer(); + vtkDataSet* mesh = renderer_.getPrimaryMesh(); + if (!ren || !mesh || !labelArray_ || !picker_) { + return; + } + const double ratio = vtkWidget_->devicePixelRatioF(); + const double x = pos.x() * ratio; + const double y = (vtkWidget_->height() - pos.y()) * ratio; // Qt top-left → VTK bottom-left + if (picker_->Pick(x, y, 0.0, ren) == 0) { + return; + } + const vtkIdType cell = picker_->GetCellId(); + if (cell < 0) { + return; + } + const int value = erase ? 0 : currentLabel_; + bool changed = false; + for (vtkIdType c : growCells(mesh, cell, brushSlider_->value())) { + if (c < 0 || c >= labelArray_->GetNumberOfTuples()) { + continue; + } + const int prev = labelArray_->GetValue(c); + if (prev == value) { + continue; + } + currentStroke_.emplace_back(static_cast(c), prev); + labelArray_->SetValue(c, value); + changed = true; + } + if (!changed) { + return; + } + // Fixed LUT covers every value, so edits recolor live — no LUT rebuild needed. + labelArray_->Modified(); + if (renderer_.context.window) { + renderer_.context.window->Render(); + } +} + +void ViewerWindow::updateBrushPreview(const QPointF& pos) { + vtkRenderer* ren = renderer_.getRenderer(); + vtkDataSet* mesh = renderer_.getPrimaryMesh(); + if (!ren || !mesh || !picker_ || !labelLut_) { + clearBrushPreview(); + return; + } + const double ratio = vtkWidget_->devicePixelRatioF(); + const double x = pos.x() * ratio; + const double y = (vtkWidget_->height() - pos.y()) * ratio; + if (picker_->Pick(x, y, 0.0, ren) == 0 || picker_->GetCellId() < 0) { + clearBrushPreview(); + return; + } + + const std::vector ids = growCells(mesh, picker_->GetCellId(), brushSlider_->value()); + auto idList = vtkSmartPointer::New(); + idList->SetNumberOfIds(static_cast(ids.size())); + for (size_t i = 0; i < ids.size(); ++i) { + idList->SetId(static_cast(i), ids[i]); + } + auto extract = vtkSmartPointer::New(); + extract->SetInputData(mesh); + extract->SetCellList(idList); + extract->Update(); + + if (!previewActor_) { + previewActor_ = vtkSmartPointer::New(); + auto mapper = vtkSmartPointer::New(); + mapper->ScalarVisibilityOff(); + mapper->SetResolveCoincidentTopologyToPolygonOffset(); // draw over the mesh, no z-fight + previewActor_->SetMapper(mapper); + previewActor_->GetProperty()->SetOpacity(0.5); + previewActor_->GetProperty()->SetLighting(false); + ren->AddActor(previewActor_); + } + vtkDataSetMapper::SafeDownCast(previewActor_->GetMapper())->SetInputData(extract->GetOutput()); + + double rgb[3]; + labelLut_->GetColor(static_cast(eraseMode_ ? 0 : currentLabel_), rgb); + previewActor_->GetProperty()->SetColor(rgb); + previewActor_->VisibilityOn(); + if (renderer_.context.window) { + renderer_.context.window->Render(); + } +} + +void ViewerWindow::clearBrushPreview() { + if (previewActor_ && previewActor_->GetVisibility()) { + previewActor_->VisibilityOff(); + if (renderer_.context.window) { + renderer_.context.window->Render(); + } + } +} + +void ViewerWindow::undoStroke() { + if (!labelArray_ || undoStack_.empty()) { + return; + } + for (const auto& [cell, prev] : undoStack_.back()) { + if (cell >= 0 && cell < labelArray_->GetNumberOfTuples()) { + labelArray_->SetValue(static_cast(cell), prev); + } + } + undoStack_.pop_back(); + labelArray_->Modified(); + if (renderer_.context.window) { + renderer_.context.window->Render(); + } +} + +void ViewerWindow::applyLabelColoring() { + const double range[2] = {0.0, static_cast(kNumLabels)}; + renderer_.colorByFixedCategorical("label", FieldAssociation::Cell, labelLut_, range); + colorBar_->setVisible(false); // the swatches are the legend +} + +void ViewerWindow::saveAnnotations() { + vtkDataSet* mesh = renderer_.getPrimaryMesh(); + if (!mesh) { + return; + } + auto* pd = vtkPolyData::SafeDownCast(mesh); + auto* ug = vtkUnstructuredGrid::SafeDownCast(mesh); + if (!pd && !ug) { + QMessageBox::warning(this, + QStringLiteral("Save failed"), + QStringLiteral("Only polydata/unstructured-grid meshes can be saved.")); + return; + } + + const bool poly = pd != nullptr; + QString suggested; + if (!load_.meshes.names.empty()) { + QFileInfo fi(QStringFromUtf8(load_.meshes.names.front())); + suggested = fi.dir().filePath(fi.completeBaseName() + (poly ? QStringLiteral(".labeled.vtp") + : QStringLiteral(".labeled.vtu"))); + } + const QString filter = poly ? QStringLiteral("VTK PolyData (*.vtp)") + : QStringLiteral("VTK UnstructuredGrid (*.vtu)"); + const QString path = QFileDialog::getSaveFileName(this, "Save annotations", suggested, filter); + if (path.isEmpty()) { + return; + } + + const std::string p = path.toUtf8().toStdString(); + int ok = 0; + if (poly) { + auto w = vtkSmartPointer::New(); + w->SetFileName(p.c_str()); + w->SetInputData(pd); + w->SetDataModeToBinary(); // VTK 9.1's reader can't read its own appended-mode output + ok = w->Write(); + } else { + auto w = vtkSmartPointer::New(); + w->SetFileName(p.c_str()); + w->SetInputData(ug); + w->SetDataModeToBinary(); // VTK 9.1's reader can't read its own appended-mode output + ok = w->Write(); + } + if (ok == 0) { + QMessageBox::warning( + this, QStringLiteral("Save failed"), QStringLiteral("Could not write ") + path); + } else { + statusBar()->showMessage(QStringLiteral("Saved ") + path, 4000); + } } // ── scalar handling ──────────────────────────────────────────────────── @@ -728,6 +1291,10 @@ void ViewerWindow::onViewportResize() { playbackBar_->setGeometry(playbackBarGeometry(vtkWidget_)); playbackBar_->raise(); } + if (annotationBar_) { + annotationBar_->setGeometry(annotationBarGeometry(vtkWidget_, annotationBar_)); + annotationBar_->raise(); + } } void ViewerWindow::layoutFacetColorBars() { diff --git a/src/XdmfMeshParser.cpp b/src/XdmfMeshParser.cpp new file mode 100644 index 0000000..6cb137f --- /dev/null +++ b/src/XdmfMeshParser.cpp @@ -0,0 +1,456 @@ +#include "XdmfMeshParser.h" + +#include "TemporalSource.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Frames of one XDMF Attribute: an HDF5 dataset path per time step. +struct AttributeSeries { + bool cell = false; + std::vector datasets; +}; + +// Cap for the in-memory frame cache of the array being played. 302 steps of a +// 23k-node field is ~28 MB, so typical runs cache whole and loop without I/O. +constexpr size_t kCacheBudgetBytes = 512u * 1024u * 1024u; + +class H5File { +public: + explicit H5File(const std::string& path) + : id_(H5Fopen(path.c_str(), H5F_ACC_RDONLY, H5P_DEFAULT)) {} + ~H5File() { + if (id_ >= 0) { + H5Fclose(id_); + } + } + H5File(const H5File&) = delete; + H5File& operator=(const H5File&) = delete; + + bool valid() const { + return id_ >= 0; + } + + // Read a whole dataset, converting to `type` on the fly. Fails unless the + // dataset holds exactly `count` elements. + bool read(const std::string& path, hid_t type, void* buffer, hsize_t count) const { + if (id_ < 0) { + return false; + } + const hid_t dset = H5Dopen2(id_, path.c_str(), H5P_DEFAULT); + if (dset < 0) { + return false; + } + const hid_t space = H5Dget_space(dset); + const bool sizeOk = space >= 0 && H5Sget_simple_extent_npoints(space) == hssize_t(count); + const bool ok = sizeOk && H5Dread(dset, type, H5S_ALL, H5S_ALL, H5P_DEFAULT, buffer) >= 0; + if (space >= 0) { + H5Sclose(space); + } + H5Dclose(dset); + return ok; + } + + hssize_t datasetSize(const std::string& path) const { + if (id_ < 0) { + return -1; + } + const hid_t dset = H5Dopen2(id_, path.c_str(), H5P_DEFAULT); + if (dset < 0) { + return -1; + } + const hid_t space = H5Dget_space(dset); + const hssize_t n = space >= 0 ? H5Sget_simple_extent_npoints(space) : -1; + if (space >= 0) { + H5Sclose(space); + } + H5Dclose(dset); + return n; + } + +private: + hid_t id_; +}; + +class XdmfTemporalSource : public TemporalSource { +public: + XdmfTemporalSource(std::shared_ptr file, + std::map attributes, + std::vector times, + vtkIdType numPoints, + vtkIdType numCells) + : file_(std::move(file)), attributes_(std::move(attributes)), numPoints_(numPoints), + numCells_(numCells) { + timeValues_ = std::move(times); + } + + void setActiveArray(const std::string& scalarName) override { + if (scalarName == active_) { + return; + } + active_ = scalarName; + cache_.assign(attributes_.count(scalarName) != 0 ? timeValues_.size() : 0, {}); + cachedBytes_ = 0; + } + + bool readStepInto(int step, vtkDataSet* target) override { + const AttributeSeries* series = seriesFor(active_); + if (!series || !target || step < 0 || step >= steps()) { + return false; + } + vtkFloatArray* array = ensureArray(target, *series); + if (!array) { + return false; + } + const size_t index = static_cast(step); + const size_t count = static_cast(array->GetNumberOfTuples()); + float* out = array->GetPointer(0); + + if (index < cache_.size() && !cache_[index].empty()) { + std::memcpy(out, cache_[index].data(), count * sizeof(float)); + } else { + if (!file_->read(series->datasets[index], H5T_NATIVE_FLOAT, out, hsize_t(count))) { + return false; + } + const size_t bytes = count * sizeof(float); + if (index < cache_.size() && cachedBytes_ + bytes <= kCacheBudgetBytes) { + cache_[index].assign(out, out + count); + cachedBytes_ += bytes; + } + } + // Only the scalar values changed: touching the array (not the geometry) keeps + // the point/cell buffers off the per-frame upload path. + array->Modified(); + return true; + } + + bool sampledScalarRange(const std::string& scalarName, double out[2], int maxSamples) override { + const AttributeSeries* series = seriesFor(scalarName); + if (!series || steps() <= 0) { + return false; + } + const size_t count = static_cast(series->cell ? numCells_ : numPoints_); + std::vector buffer(count); + const int sampleCount = std::min(steps(), std::max(1, maxSamples)); + float lo = 0.0F; + float hi = 0.0F; + bool any = false; + for (int s = 0; s < sampleCount; ++s) { + const int step = + sampleCount == 1 + ? 0 + : static_cast((static_cast(s) * (steps() - 1)) / (sampleCount - 1)); + const bool cached = + static_cast(step) < cache_.size() && !cache_[static_cast(step)].empty(); + const float* data = cached ? cache_[static_cast(step)].data() : buffer.data(); + if (!cached && !file_->read(series->datasets[static_cast(step)], + H5T_NATIVE_FLOAT, + buffer.data(), + hsize_t(count))) { + continue; + } + const auto [minIt, maxIt] = std::minmax_element(data, data + count); + lo = any ? std::min(lo, *minIt) : *minIt; + hi = any ? std::max(hi, *maxIt) : *maxIt; + any = true; + } + if (!any) { + return false; + } + out[0] = double(lo); + out[1] = double(hi); + return true; + } + +private: + const AttributeSeries* seriesFor(const std::string& name) const { + const auto it = attributes_.find(name); + return it == attributes_.end() ? nullptr : &it->second; + } + + vtkFloatArray* ensureArray(vtkDataSet* target, const AttributeSeries& series) const { + vtkDataSetAttributes* data = series.cell + ? static_cast(target->GetCellData()) + : static_cast(target->GetPointData()); + if (!data) { + return nullptr; + } + auto* existing = vtkFloatArray::SafeDownCast(data->GetArray(active_.c_str())); + if (existing) { + return existing; + } + vtkNew array; + array->SetName(active_.c_str()); + array->SetNumberOfComponents(1); + array->SetNumberOfTuples(series.cell ? numCells_ : numPoints_); + data->AddArray(array); + return array; + } + + std::shared_ptr file_; + std::map attributes_; + std::string active_; + std::vector> cache_; + size_t cachedBytes_ = 0; + vtkIdType numPoints_ = 0; + vtkIdType numCells_ = 0; +}; + +vtkXMLDataElement* childNamed(vtkXMLDataElement* parent, const char* name) { + return parent ? parent->FindNestedElementWithName(name) : nullptr; +} + +std::string attributeOr(vtkXMLDataElement* element, const char* name, const std::string& fallback) { + const char* value = element ? element->GetAttribute(name) : nullptr; + return value ? std::string(value) : fallback; +} + +// "s1s2.h5:/Function/v/0" (heavy-data reference, relative to the XDMF file). +bool splitDataItem(vtkXMLDataElement* dataItem, + const std::filesystem::path& baseDir, + std::string& h5File, + std::string& h5Path) { + const char* text = dataItem ? dataItem->GetCharacterData() : nullptr; + if (!text) { + return false; + } + std::string reference(text); + const size_t begin = reference.find_first_not_of(" \t\r\n"); + const size_t end = reference.find_last_not_of(" \t\r\n"); + if (begin == std::string::npos) { + return false; + } + reference = reference.substr(begin, end - begin + 1); + const size_t colon = reference.rfind(':'); + if (colon == std::string::npos) { + return false; + } + h5File = (baseDir / reference.substr(0, colon)).string(); + h5Path = reference.substr(colon + 1); + return true; +} + +int nodesPerElement(const std::string& topologyType) { + if (topologyType == "Triangle") { + return 3; + } + if (topologyType == "Quadrilateral") { + return 4; + } + if (topologyType == "Polyline" || topologyType == "Line") { + return 2; + } + return 0; +} + +// Collect every Grid element under `root`, flattening collections. +void collectGrids(vtkXMLDataElement* root, std::vector& out) { + for (int i = 0; i < root->GetNumberOfNestedElements(); ++i) { + vtkXMLDataElement* child = root->GetNestedElement(i); + if (child && child->GetName() && std::string(child->GetName()) == "Grid") { + out.push_back(child); + collectGrids(child, out); + } + } +} + +} // namespace + +XdmfMeshParser::XdmfMeshParser() = default; +XdmfMeshParser::~XdmfMeshParser() = default; + +bool XdmfMeshParser::canParse(const std::string& filename) { + const std::string ext = std::filesystem::path(filename).extension().string(); + return ext == ".xdmf" || ext == ".xmf"; +} + +std::vector> XdmfMeshParser::parse(const std::string& filename) { + temporal_.reset(); + vtkSmartPointer root = vtkSmartPointer::Take( + vtkXMLUtilities::ReadElementFromFile(filename.c_str())); + vtkXMLDataElement* domain = childNamed(root, "Domain"); + if (!domain) { + std::cerr << "XDMF: no in " << filename << "\n"; + return {}; + } + + const std::filesystem::path baseDir = std::filesystem::path(filename).parent_path(); + std::vector grids; + collectGrids(domain, grids); + + // The topology/geometry are written once; temporal grids reference them by + // xpointer, which we do not resolve — take the first grid that carries them. + vtkXMLDataElement* topologyElem = nullptr; + vtkXMLDataElement* geometryElem = nullptr; + for (vtkXMLDataElement* grid : grids) { + if (!topologyElem) { + topologyElem = childNamed(grid, "Topology"); + geometryElem = childNamed(grid, "Geometry"); + if (topologyElem && geometryElem) { + break; + } + topologyElem = nullptr; + } + } + if (!topologyElem || !geometryElem) { + std::cerr << "XDMF: no / in " << filename << "\n"; + return {}; + } + + std::string h5File; + std::string topologyPath; + std::string geometryPath; + if (!splitDataItem(childNamed(topologyElem, "DataItem"), baseDir, h5File, topologyPath) || + !splitDataItem(childNamed(geometryElem, "DataItem"), baseDir, h5File, geometryPath)) { + std::cerr << "XDMF: only HDF5 heavy data is supported\n"; + return {}; + } + + auto file = std::make_shared(h5File); + if (!file->valid()) { + std::cerr << "XDMF: cannot open heavy data file " << h5File << "\n"; + return {}; + } + + const int perCell = nodesPerElement(attributeOr(topologyElem, "TopologyType", "")); + if (perCell == 0) { + std::cerr << "XDMF: unsupported TopologyType " << attributeOr(topologyElem, "TopologyType", "?") + << "\n"; + return {}; + } + const std::string geometryType = attributeOr(geometryElem, "GeometryType", "XYZ"); + const int perPoint = geometryType == "XY" ? 2 : (geometryType == "XYZ" ? 3 : 0); + if (perPoint == 0) { + std::cerr << "XDMF: unsupported GeometryType " << geometryType << "\n"; + return {}; + } + + const hssize_t coordCount = file->datasetSize(geometryPath); + const hssize_t connCount = file->datasetSize(topologyPath); + if (coordCount <= 0 || connCount <= 0 || coordCount % perPoint != 0 || connCount % perCell != 0) { + std::cerr << "XDMF: geometry/topology dimensions do not match the declared types\n"; + return {}; + } + const vtkIdType numPoints = vtkIdType(coordCount / perPoint); + const vtkIdType numCells = vtkIdType(connCount / perCell); + + std::vector coords(static_cast(coordCount)); + std::vector connectivity(static_cast(connCount)); + if (!file->read(geometryPath, H5T_NATIVE_FLOAT, coords.data(), hsize_t(coordCount)) || + !file->read(topologyPath, H5T_NATIVE_LLONG, connectivity.data(), hsize_t(connCount))) { + std::cerr << "XDMF: failed to read geometry/topology from " << h5File << "\n"; + return {}; + } + + vtkNew points; + points->SetDataTypeToFloat(); + points->SetNumberOfPoints(numPoints); + for (vtkIdType i = 0; i < numPoints; ++i) { + const size_t base = static_cast(i) * static_cast(perPoint); + points->SetPoint(i, + double(coords[base]), + double(coords[base + 1]), + perPoint == 3 ? double(coords[base + 2]) : 0.0); + } + + vtkNew cells; + cells->AllocateEstimate(numCells, perCell); + std::vector ids(static_cast(perCell)); + for (vtkIdType c = 0; c < numCells; ++c) { + const size_t base = static_cast(c) * static_cast(perCell); + for (int k = 0; k < perCell; ++k) { + ids[static_cast(k)] = vtkIdType(connectivity[base + static_cast(k)]); + } + cells->InsertNextCell(perCell, ids.data()); + } + + vtkNew mesh; + mesh->SetPoints(points); + if (perCell == 2) { + mesh->SetLines(cells); + } else { + mesh->SetPolys(cells); + } + + // Attributes: one entry per time step, in the order the grids appear. + std::map attributes; + std::vector times; + for (vtkXMLDataElement* grid : grids) { + std::vector attributeElems; + for (int i = 0; i < grid->GetNumberOfNestedElements(); ++i) { + vtkXMLDataElement* child = grid->GetNestedElement(i); + if (child && child->GetName() && std::string(child->GetName()) == "Attribute") { + attributeElems.push_back(child); + } + } + if (attributeElems.empty()) { + continue; + } + double timeValue = double(times.size()); + if (vtkXMLDataElement* timeElem = childNamed(grid, "Time")) { + timeElem->GetScalarAttribute("Value", timeValue); + } + times.push_back(timeValue); + for (vtkXMLDataElement* attributeElem : attributeElems) { + std::string ignored; + std::string path; + if (!splitDataItem(childNamed(attributeElem, "DataItem"), baseDir, ignored, path)) { + continue; + } + const std::string name = attributeOr(attributeElem, "Name", "attribute"); + AttributeSeries& series = attributes[name]; + series.cell = attributeOr(attributeElem, "Center", "Node") == "Cell"; + series.datasets.resize(times.size() - 1); + series.datasets.push_back(path); + } + } + + // Drop series that are not defined on every step; they cannot be played. + for (auto it = attributes.begin(); it != attributes.end();) { + it->second.datasets.resize(times.size()); + const bool complete = std::none_of(it->second.datasets.begin(), + it->second.datasets.end(), + [](const std::string& p) { return p.empty(); }); + it = complete ? std::next(it) : attributes.erase(it); + } + + std::vector> result{mesh}; + if (attributes.empty()) { + return result; + } + + auto source = std::make_shared(file, attributes, times, numPoints, numCells); + // Materialise every attribute's first frame so the scalar picker lists them all; + // playback then streams only whichever one is active. + for (const auto& [name, series] : attributes) { + source->setActiveArray(name); + source->readStepInto(0, mesh); + } + const std::string& first = attributes.begin()->first; + source->setActiveArray(first); + if (attributes.begin()->second.cell) { + mesh->GetCellData()->SetActiveScalars(first.c_str()); + } else { + mesh->GetPointData()->SetActiveScalars(first.c_str()); + } + temporal_ = source; + return result; +} diff --git a/src/include/MeshParser.h b/src/include/MeshParser.h index 1d14c32..166dd7d 100644 --- a/src/include/MeshParser.h +++ b/src/include/MeshParser.h @@ -1,9 +1,12 @@ #pragma once +#include #include #include #include #include +class TemporalSource; + class MeshParser { public: virtual ~MeshParser(); @@ -11,4 +14,8 @@ class MeshParser { virtual std::vector> parse(const std::string& filename) = 0; // Return true if this parser can handle the file virtual bool canParse(const std::string& filename) = 0; + // Valid after a successful parse(); null unless the file is a playable time series. + virtual std::shared_ptr temporal() const { + return nullptr; + } }; diff --git a/src/include/MeshRenderer.h b/src/include/MeshRenderer.h index 15bf61c..4cf8ae7 100644 --- a/src/include/MeshRenderer.h +++ b/src/include/MeshRenderer.h @@ -45,6 +45,13 @@ class MeshRenderer { void setSharedCatAnalysis(const ScalarAnalysis& shared); bool setActiveScalar(const std::string& scalarName, FieldAssociation association); + // Color the primary mesh by a categorical array using an externally-owned LUT, + // bypassing per-scalar analysis so the value→color mapping stays fixed while + // the array is edited (annotation mode). + void colorByFixedCategorical(const std::string& scalarName, + FieldAssociation association, + vtkLookupTable* lut, + const double range[2]); void clearActiveScalar(); // Re-apply the current scalar mapping after the underlying mesh data changed // (e.g. a new playback frame was shallow-copied in), keeping the color range @@ -71,6 +78,14 @@ class MeshRenderer { RendererContext context; + // Annotation mode needs the renderer (for picking) and the primary dataset. + vtkRenderer* getRenderer() const { + return renderer; + } + vtkDataSet* getPrimaryMesh() const { + return sceneMeshes.empty() ? nullptr : sceneMeshes.front(); + } + private: vtkSmartPointer renderer; vtkSmartPointer interactor; diff --git a/src/include/PlaybackBar.h b/src/include/PlaybackBar.h index 0cb304b..5dfcf9b 100644 --- a/src/include/PlaybackBar.h +++ b/src/include/PlaybackBar.h @@ -4,7 +4,6 @@ class QLabel; class QSlider; -class QComboBox; class QToolButton; // Bottom-overlay media bar for temporal (playable) meshes: play/pause, a scrub @@ -36,13 +35,17 @@ class PlaybackBar : public QWidget { private: void updateReadout(int step, double timeValue); + void stepBy(int delta); int numSteps_ = 0; bool playing_ = false; + double speed_ = 1.0; + QToolButton* prevButton_ = nullptr; QToolButton* playButton_ = nullptr; + QToolButton* nextButton_ = nullptr; QSlider* slider_ = nullptr; QLabel* readout_ = nullptr; - QComboBox* speedBox_ = nullptr; + QToolButton* speedButton_ = nullptr; QToolButton* loopButton_ = nullptr; }; diff --git a/src/include/TemporalSource.h b/src/include/TemporalSource.h index 8d04d14..6f7794c 100644 --- a/src/include/TemporalSource.h +++ b/src/include/TemporalSource.h @@ -7,43 +7,52 @@ class vtkHDFReader; -// Wraps a live vtkHDFReader for a temporal (time-series) VTKHDF file. Frames are -// streamed on demand — only the current step is held in memory — because result -// files routinely span thousands of steps and gigabytes on disk. +// A playable time series backing the rendered dataset. Frames are produced on +// demand — result files routinely span thousands of steps and gigabytes on disk, +// so only what is needed for the current frame is materialised. class TemporalSource { public: - TemporalSource(); - ~TemporalSource(); + virtual ~TemporalSource(); - // Number of time steps; >1 means the file is playable. int steps() const { - return numSteps_; + return static_cast(timeValues_.size()); } bool playable() const { - return numSteps_ > 1; + return timeValues_.size() > 1; } double timeAt(int step) const; - // Read the given step and shallow-copy its dataset into `target` (the rendered - // object the mappers point at). Returns false on out-of-range or read failure. - bool readStepInto(int step, vtkDataSet* target); + // Bring `target` (the rendered object the mappers point at) to the given step. + // Returns false on out-of-range or read failure. + virtual bool readStepInto(int step, vtkDataSet* target) = 0; - // Union of a point-data array's range across up to maxSamples evenly spaced - // steps. Used to fix a stable color range for the whole animation. - bool sampledScalarRange(const std::string& scalarName, double out[2], int maxSamples = 16); + // Union of an array's range across up to maxSamples evenly spaced steps. Used to + // fix a stable color range for the whole animation. + virtual bool + sampledScalarRange(const std::string& scalarName, double out[2], int maxSamples = 16) = 0; - // Restrict per-frame reads to a single point-data array. Static geometry is - // cached (UseCache) and the other arrays are skipped, so streaming a frame only - // touches the array actually being colored — the dominant playback speed-up. - void setActiveArray(const std::string& scalarName); + // Restrict per-frame work to a single array — the dominant playback speed-up. + virtual void setActiveArray(const std::string& scalarName) = 0; + +protected: + std::vector timeValues_; +}; + +// Wraps a live vtkHDFReader for a temporal VTKHDF file. +class VTKHDFTemporalSource : public TemporalSource { +public: + VTKHDFTemporalSource(); + ~VTKHDFTemporalSource() override; // Called by the parser once the reader is constructed and information is read. void init(const vtkSmartPointer& reader, std::vector timeValues); + bool readStepInto(int step, vtkDataSet* target) override; + bool sampledScalarRange(const std::string& scalarName, double out[2], int maxSamples) override; + void setActiveArray(const std::string& scalarName) override; + private: bool updateToStep(int step); vtkSmartPointer reader_; - std::vector timeValues_; - int numSteps_ = 0; }; diff --git a/src/include/VTKHDFMeshParser.h b/src/include/VTKHDFMeshParser.h index e48a38f..24f1b33 100644 --- a/src/include/VTKHDFMeshParser.h +++ b/src/include/VTKHDFMeshParser.h @@ -4,8 +4,6 @@ #include -class TemporalSource; - // Parses VTKHDF files (.vtkhdf — HDF5-backed VTK). Loads the first time step for // rendering; if the file is temporal, exposes a TemporalSource for playback. class VTKHDFMeshParser : public MeshParser { @@ -16,8 +14,7 @@ class VTKHDFMeshParser : public MeshParser { std::vector> parse(const std::string& filename) override; bool canParse(const std::string& filename) override; - // Valid after a successful parse(); null if the file is not temporal. - std::shared_ptr temporal() const { + std::shared_ptr temporal() const override { return temporal_; } diff --git a/src/include/ViewerWindow.h b/src/include/ViewerWindow.h index 0ce1471..680f426 100644 --- a/src/include/ViewerWindow.h +++ b/src/include/ViewerWindow.h @@ -4,12 +4,14 @@ #include "MeshRenderer.h" #include "ScalarVizUtils.h" +#include #include #include #include #include #include #include +#include #include class ColorBarWidget; @@ -22,8 +24,16 @@ class TemporalSource; struct ViewerOptions { bool explodeView = false; bool commonCatLut = false; + bool annotate = false; }; +class QSlider; +class QToolButton; +class QWidget; +class vtkCellPicker; +class vtkLookupTable; +class QMouseEvent; + // Main application window: owns the VTK viewport, the overlay widgets // (colorbar, parts tree, playback bar) and all viewer state previously held as // lambda captures in main(). @@ -40,6 +50,21 @@ class ViewerWindow : public QMainWindow { void buildPartsTree(); void setupPlayback(); + // ── annotation mode ─────────────────────────────────────────────── + void setupAnnotateMode(); + void ensureLabelArray(); + void togglePaint(); + void setPaintHold(bool active, bool erase); + // Handle a pointer event over the viewport while annotating; returns true if + // consumed (so the VTK camera does not also react). erase = paint value 0. + bool handleAnnotatePointer(QMouseEvent* event); + void paintAtWidgetPos(const QPointF& pos, bool erase); + void updateBrushPreview(const QPointF& pos); // faded overlay of cells a click would hit + void clearBrushPreview(); + void applyLabelColoring(); + void undoStroke(); + void saveAnnotations(); + // ── scalar handling ─────────────────────────────────────────────── void applyScalarAtIndex(int index); void applyNoScalar(); @@ -50,6 +75,7 @@ class ViewerWindow : public QMainWindow { void onViewportResize(); void showFrame(int step); void applyPlayTimerInterval(); + void restartPlayClock(); // ── state ───────────────────────────────────────────────────────── MeshLoadResult load_; @@ -72,5 +98,22 @@ class ViewerWindow : public QMainWindow { std::map> temporalRangeCache_; QPointer playbackBar_; QTimer* playTimer_ = nullptr; + QElapsedTimer playClock_; + int playAnchorStep_ = 0; int currentPlaybackStep_ = 0; + + // Annotation state (only populated in --annotate mode). + QWidget* annotationBar_ = nullptr; + QToolButton* pencilButton_ = nullptr; // checked = paint, unchecked = rotate + QSlider* brushSlider_ = nullptr; + int currentLabel_ = 1; + vtkSmartPointer picker_; + vtkSmartPointer labelLut_; // fixed value→color, stable while painting + class vtkIntArray* labelArray_ = nullptr; // owned by the mesh's cell data + bool painting_ = false; + bool eraseMode_ = false; // 'e' toggles: left-drag erases instead of painting + vtkSmartPointer previewActor_; // faded hover overlay of the brush footprint + // Undo: each stroke records the (cell, previous value) pairs it changed. + std::vector> currentStroke_; + std::vector>> undoStack_; }; diff --git a/src/include/XdmfMeshParser.h b/src/include/XdmfMeshParser.h new file mode 100644 index 0000000..6422ec9 --- /dev/null +++ b/src/include/XdmfMeshParser.h @@ -0,0 +1,24 @@ +#pragma once + +#include "MeshParser.h" + +#include + +// Parses XDMF (.xdmf/.xmf) light-data files whose heavy data lives in a companion +// HDF5 file — the usual output format of FEniCS/DOLFIN and similar solvers. +// Static geometry plus one temporal collection of node/cell attributes; the first +// frame is rendered and the rest are streamed for playback. +class XdmfMeshParser : public MeshParser { +public: + XdmfMeshParser(); + ~XdmfMeshParser() override; + + std::vector> parse(const std::string& filename) override; + bool canParse(const std::string& filename) override; + std::shared_ptr temporal() const override { + return temporal_; + } + +private: + std::shared_ptr temporal_; +}; diff --git a/src/main_qt.cpp b/src/main_qt.cpp index b018a90..c3f570b 100644 --- a/src/main_qt.cpp +++ b/src/main_qt.cpp @@ -40,6 +40,7 @@ struct Args { std::vector meshfiles; bool explode_view = false; bool common_cat_lut = false; + bool annotate = false; bool version = false; bool help = false; std::string thumbnail_output; // non-empty → offscreen render to PNG and exit @@ -55,6 +56,9 @@ Args parseArgs(int argc, char* argv[], bool requireFiles = true) { "C,common-cat-lut", "Share one categorical colormap across all categorical scalars for cross-scalar comparison", cxxopts::value(args.common_cat_lut))( + "a,annotate", + "Annotation mode: paint a per-cell 'label' array with a surface brush", + cxxopts::value(args.annotate))( "v,version", "Show version and exit", cxxopts::value(args.version))( "h,help", "Show help and exit", cxxopts::value(args.help))( "T,thumbnail", @@ -251,6 +255,10 @@ int main(int argc, char* argv[]) try { ViewerOptions viewerOptions; viewerOptions.explodeView = args.explode_view; viewerOptions.commonCatLut = args.common_cat_lut; + viewerOptions.annotate = args.annotate && !args.explode_view; + if (args.annotate && args.explode_view) { + std::cerr << "Warning: --annotate is not supported with --explode; ignoring --annotate.\n"; + } ViewerWindow window(std::move(loadResult), viewerOptions); window.show();