From 5f467cd8fae34c8b2a31960eaeb3181f9da8b87d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 16 May 2026 17:04:23 +1000 Subject: [PATCH] Interface mockup 19 --- src/ifcviewer-full/CMakeLists.txt | 6 +- src/ifcviewer-full/MainWindow.cpp | 55 ++- src/ifcviewer-full/MainWindow.h | 2 - src/ifcviewer-full/SessionState.cpp | 23 +- src/ifcviewer-full/SessionState.h | 20 +- .../modules/models/Commands.cpp | 48 +-- src/ifcviewer-full/modules/models/Commands.h | 6 + .../modules/models/FederationItemModel.cpp | 265 ++++++++++++++ .../modules/models/FederationItemModel.h | 85 +++++ src/ifcviewer-full/modules/models/Panel.cpp | 344 ++++++++---------- src/ifcviewer-full/modules/models/Panel.h | 32 +- src/ifcviewer-full/modules/models/View.cpp | 84 +---- src/ifcviewer-full/modules/models/View.h | 21 +- .../project/{Controller.cpp => Commands.cpp} | 193 +++++----- .../project/{Controller.h => Commands.h} | 41 +-- src/ifcviewer/Federation.cpp | 5 +- src/ifcviewer/Federation.h | 2 + 17 files changed, 739 insertions(+), 493 deletions(-) create mode 100644 src/ifcviewer-full/modules/models/FederationItemModel.cpp create mode 100644 src/ifcviewer-full/modules/models/FederationItemModel.h rename src/ifcviewer-full/modules/project/{Controller.cpp => Commands.cpp} (53%) rename src/ifcviewer-full/modules/project/{Controller.h => Commands.h} (64%) diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt index 0ca381ef84..3a1ac36ab0 100644 --- a/src/ifcviewer-full/CMakeLists.txt +++ b/src/ifcviewer-full/CMakeLists.txt @@ -60,6 +60,8 @@ set(IFCVIEWER_FULL_FILES ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Types.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Commands.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Commands.h + ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/FederationItemModel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/FederationItemModel.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Panel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/Panel.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/models/View.cpp @@ -71,8 +73,8 @@ set(IFCVIEWER_FULL_FILES ${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/Panel.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.h - ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Controller.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Controller.h + ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.h ${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Types.h diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 7f6070b9a7..4a789fecc6 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -22,7 +22,6 @@ #include "../ifcviewer/AppSettings.h" #include "../ifcviewer/Federation.h" -#include "../ifcviewer/SceneLoader.h" #include "../ifcviewer/ViewportWindow.h" #include "SessionState.h" #include "components/Buttons.h" @@ -33,7 +32,7 @@ #include "modules/todo/Panel.h" #include "modules/models/View.h" #include "modules/models/Panel.h" -#include "modules/project/Controller.h" +#include "modules/project/Commands.h" #include "modules/properties/View.h" #include "modules/properties/Panel.h" #include "modules/settings/Dialog.h" @@ -150,11 +149,13 @@ QWidget* MainWindow::buildHomeRibbonPage() { auto* new_project = components::buttons::makeButton("New Project", ":/icons/plus-square.svg", this); connect(new_project, &QToolButton::clicked, this, [this]() { - project_controller_->newProject(); + modules::project::commands::newProject( + *session_state_, *this, *viewport_widget_->viewport()); }); auto* open_project = components::buttons::makeButton("Open Project", ":/icons/download-square.svg", this); connect(open_project, &QToolButton::clicked, this, [this]() { - project_controller_->openProject(); + modules::project::commands::openProject( + *session_state_, *this, *viewport_widget_->viewport()); }); auto* open_cloud = components::buttons::makeButton("Open Cloud", ":/icons/cloud-square.svg", this); connect(open_cloud, &QToolButton::clicked, this, [this]() { @@ -166,11 +167,11 @@ QWidget* MainWindow::buildHomeRibbonPage() { }); auto* save_project = components::buttons::makeButton("Save Project", ":/icons/floppy-disk.svg", this); connect(save_project, &QToolButton::clicked, this, [this]() { - project_controller_->saveProject(); + modules::project::commands::saveProject(*session_state_, *this); }); auto* save_project_as = components::buttons::makeButton("Save As", ":/icons/floppy-disk-arrow-in.svg", this); connect(save_project_as, &QToolButton::clicked, this, [this]() { - project_controller_->saveProjectAs(); + modules::project::commands::saveProjectAs(*session_state_, *this); }); auto* add_model = components::buttons::makeButton("Add Model", ":/icons/cube.svg", this); @@ -449,43 +450,33 @@ void MainWindow::setupStatus() { status_mode_label_->setText(mode); status_selection_label_->setText(detail); }); + connect(session_state_, &ifcviewerfull::SessionState::progressBegan, this, [this](const QString&) { + // Start in indeterminate mode (spinning bar). The first concrete + // setProgress(...) call below switches it to a determinate 0-100 bar. + status_progress_bar_->setRange(0, 0); + status_progress_bar_->setVisible(true); + }); + connect(session_state_, &ifcviewerfull::SessionState::progressChanged, this, [this](int percent) { + if (status_progress_bar_->maximum() == 0) status_progress_bar_->setRange(0, 100); + status_progress_bar_->setValue(percent); + }); + connect(session_state_, &ifcviewerfull::SessionState::progressEnded, this, [this]() { + status_progress_bar_->setVisible(false); + }); session_state_->setStatusMessage("Ready", "No selection"); } void MainWindow::setupLoader() { session_state_->createLoader(viewport_widget_->viewport()); - auto* loader = session_state_->loader(); viewport_view_ = new modules::viewport::ViewportView( session_state_, viewport_widget_->viewport(), this); - project_controller_ = new modules::project::ProjectController( - this, session_state_, viewport_widget_->viewport(), this); - - // Progress bar — tightly coupled to loader by nature, fine to subscribe direct. - connect(loader, &SceneLoader::loadStarted, this, - [this](uint32_t, const QString&) { - status_progress_bar_->setValue(0); - status_progress_bar_->setVisible(true); - }); - connect(loader, &SceneLoader::progressChanged, this, - [this](int percent) { status_progress_bar_->setValue(percent); }); - - auto hide_progress = [this]() { status_progress_bar_->setVisible(false); }; - connect(loader, &SceneLoader::loadedFromSidecar, this, - [hide_progress](uint32_t, qint64) { hide_progress(); }); - connect(loader, &SceneLoader::loadedFromStream, this, - [this, hide_progress](uint32_t mid, qint64) { - modules::models::commands::writeSidecarForLoadedModel( - *session_state_, *viewport_widget_->viewport(), mid); - hide_progress(); - }); - connect(loader, &SceneLoader::loadCancelled, this, - [hide_progress](uint32_t) { hide_progress(); }); + modules::models::commands::addHandlers( + *session_state_, *viewport_widget_->viewport(), *this); // Load errors surface through SessionState as a session-level signal; the - // status text is already set there, we only show the modal here. + // status text + progress are already cleared there, we only show the modal. connect(session_state_, &ifcviewerfull::SessionState::loadError, this, [this](const QString& message) { - status_progress_bar_->setVisible(false); QMessageBox::warning(this, "IfcViewer", message); }); diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 53e4d21b92..0ff2eec5c9 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -34,7 +34,6 @@ namespace ifcviewerfull { class SessionState; } namespace ifcviewerfull::components { class TabBar; } namespace ifcviewerfull::modules::models { class ModelsPanel; } namespace ifcviewerfull::modules::models { class ModelsPanelView; } -namespace ifcviewerfull::modules::project { class ProjectController; } namespace ifcviewerfull::modules::spatial_hierarchy { class SpatialHierarchyPanel; } namespace ifcviewerfull::modules::spatial_hierarchy { class SpatialHierarchyPanelView; } namespace ifcviewerfull::modules::properties { class PropertiesPanel; } @@ -84,7 +83,6 @@ private: QDockWidget* clash_panel_ = nullptr; QDockWidget* issues_panel_ = nullptr; ifcviewerfull::modules::models::ModelsPanelView* models_view_ = nullptr; - ifcviewerfull::modules::project::ProjectController* project_controller_ = nullptr; ifcviewerfull::modules::spatial_hierarchy::SpatialHierarchyPanelView* spatial_view_ = nullptr; ifcviewerfull::modules::properties::PropertiesPanelView* properties_view_ = nullptr; }; diff --git a/src/ifcviewer-full/SessionState.cpp b/src/ifcviewer-full/SessionState.cpp index 87636a6cff..aa569b9801 100644 --- a/src/ifcviewer-full/SessionState.cpp +++ b/src/ifcviewer-full/SessionState.cpp @@ -44,18 +44,21 @@ void SessionState::createLoader(ViewportWindow* viewport) { : QString::number(ms) + " ms"; }; - // Translate low-level loader events into session-level signals + status - // text so views don't need to subscribe to the loader directly. + // Translate low-level loader events into session-level signals, status + // text, and progress so views don't need to subscribe to the loader. connect(loader_, &SceneLoader::loadStarted, this, [this](uint32_t, const QString& display_name) { setStatusMessage("Loading", display_name); + beginProgress(display_name); }); + connect(loader_, &SceneLoader::progressChanged, this, &SessionState::setProgress); connect(loader_, &SceneLoader::loadedFromSidecar, this, [this, format_elapsed](uint32_t mid, qint64 elapsed_ms) { setStatusMessage("Loaded", QString("%1 from cache in %2") .arg(loader_->displayName(mid)) .arg(format_elapsed(elapsed_ms))); + endProgress(); emit modelGeometryReady(mid); }); connect(loader_, &SceneLoader::loadedFromStream, this, @@ -64,14 +67,18 @@ void SessionState::createLoader(ViewportWindow* viewport) { QString("%1 streamed in %2") .arg(loader_->displayName(mid)) .arg(format_elapsed(elapsed_ms))); + endProgress(); emit modelGeometryReady(mid); + emit modelGeometryStreamed(mid); }); connect(loader_, &SceneLoader::loadCancelled, this, [this](uint32_t mid) { setStatusMessage("Cancelled", loader_->displayName(mid)); + endProgress(); }); connect(loader_, &SceneLoader::loadError, this, [this](uint32_t, const QString& message) { setStatusMessage("Error", message); + endProgress(); emit loadError(message); }); connect(loader_, &SceneLoader::allLoadsFinished, this, [this]() { @@ -89,6 +96,18 @@ void SessionState::setStatusMessage(const QString& mode, const QString& detail) emit statusMessageChanged(status_mode_, status_detail_); } +void SessionState::beginProgress(const QString& label) { + emit progressBegan(label); +} + +void SessionState::setProgress(int percent) { + emit progressChanged(percent); +} + +void SessionState::endProgress() { + emit progressEnded(); +} + void SessionState::setModelMapping(const QString& fed_id, uint32_t model_id) { fed_id_to_model_id_[fed_id] = model_id; model_id_to_fed_id_[model_id] = fed_id; diff --git a/src/ifcviewer-full/SessionState.h b/src/ifcviewer-full/SessionState.h index de615bcd33..5602a25ad4 100644 --- a/src/ifcviewer-full/SessionState.h +++ b/src/ifcviewer-full/SessionState.h @@ -53,6 +53,13 @@ public: uint32_t selectedObjectId() const { return selected_object_id_; } void setStatusMessage(const QString& mode, const QString& detail); + // Generic progress reporting for any long-running operation (load, + // convert, export, ...). Subscribers (the status bar) react to the + // signals; they do not need to know which operation is running. + void beginProgress(const QString& label); + void setProgress(int percent); + void endProgress(); + void setModelMapping(const QString& fed_id, uint32_t model_id); void removeModelMappingByFedId(const QString& fed_id); void clearModelMappings(); @@ -79,16 +86,23 @@ signals: // emit this on save/load/reset — projectSaved/Opened/Reset cover those. void federationChanged(); void visibilityChanged(); - // Fires when a model's geometry has been pushed to the viewport. SessionState - // emits this internally in response to SceneLoader signals — callers should - // not need to fire it themselves. + // Fires when a model's geometry has been pushed to the viewport. Fires + // for both sidecar-cache and stream loads; subscribers that just need to + // re-derive view state (e.g. ViewportView::refresh) listen to this. void modelGeometryReady(uint32_t model_id); + // Fires only after a stream load — i.e. when no sidecar cache existed + // yet. The sidecar-write subscriber listens to this so it doesn't + // re-persist a cache that was just read from disk. + void modelGeometryStreamed(uint32_t model_id); // Fires when SceneLoader reports a load failure. SessionState turns the // raw loader signal into a session-level one so views (e.g. the MessageBox) // can subscribe without touching the loader directly. void loadError(const QString& message); void selectionChanged(uint32_t object_id); void statusMessageChanged(const QString& mode, const QString& detail); + void progressBegan(const QString& label); + void progressChanged(int percent); + void progressEnded(); private: Federation* federation_ = nullptr; diff --git a/src/ifcviewer-full/modules/models/Commands.cpp b/src/ifcviewer-full/modules/models/Commands.cpp index 9b426484e4..b44daf4504 100644 --- a/src/ifcviewer-full/modules/models/Commands.cpp +++ b/src/ifcviewer-full/modules/models/Commands.cpp @@ -45,7 +45,6 @@ #include #include #include -#include #include #include #include @@ -174,6 +173,11 @@ void removeModel(SessionState& s, ViewportWindow& vp, QWidget& host, const QStri s.setStatusMessage("Models", "Model removed"); } +void addHandlers(SessionState& s, ViewportWindow& vp, QObject& context) { + QObject::connect(&s, &SessionState::modelGeometryStreamed, &context, + [&s, &vp](uint32_t mid) { writeSidecarForLoadedModel(s, vp, mid); }); +} + namespace detail { void loadModels(SessionState& s, const QStringList& paths, const QStringList& fed_ids) { @@ -290,19 +294,9 @@ void convertIfcToDatabase(SessionState& s, QWidget& host) { } } - auto* progress = new QProgressDialog(&host); - progress->setWindowTitle("Convert IFC to Database"); - progress->setLabelText(QString("Converting %1 to %2…") - .arg(QFileInfo(input_path).fileName(), - QFileInfo(output_path).fileName())); - progress->setRange(0, 0); - progress->setCancelButton(nullptr); - progress->setMinimumDuration(0); - progress->setWindowModality(Qt::ApplicationModal); - progress->setAutoClose(false); - progress->setAutoReset(false); - progress->show(); - + s.beginProgress(QString("Converting %1 to %2…") + .arg(QFileInfo(input_path).fileName(), + QFileInfo(output_path).fileName())); s.setStatusMessage("Converting", QString("%1 → %2").arg(QFileInfo(input_path).fileName(), QFileInfo(output_path).fileName())); @@ -338,11 +332,10 @@ void convertIfcToDatabase(SessionState& s, QWidget& host) { }); QObject::connect(thread, &QThread::finished, &host, - [&s, host_ptr = &host, thread, progress, timer, error_message, input_path, output_path]() { + [&s, host_ptr = &host, thread, timer, error_message, input_path, output_path]() { const qint64 elapsed = timer->elapsed(); - progress->close(); - progress->deleteLater(); + s.endProgress(); thread->deleteLater(); if (!error_message->isEmpty()) { @@ -393,19 +386,9 @@ void exportGeometryDatabase(SessionState& s, QWidget& host) { output_path += ".rdbview"; } - auto* progress = new QProgressDialog(&host); - progress->setWindowTitle("Export Geometry Database"); - progress->setLabelText(QString("Exporting %1 to %2…") - .arg(QFileInfo(input_path).fileName(), - QFileInfo(output_path).fileName())); - progress->setRange(0, 0); - progress->setCancelButton(nullptr); - progress->setMinimumDuration(0); - progress->setWindowModality(Qt::ApplicationModal); - progress->setAutoClose(false); - progress->setAutoReset(false); - progress->show(); - + s.beginProgress(QString("Exporting %1 to %2…") + .arg(QFileInfo(input_path).fileName(), + QFileInfo(output_path).fileName())); s.setStatusMessage("Exporting", QString("%1 → %2").arg(QFileInfo(input_path).fileName(), QFileInfo(output_path).fileName())); @@ -517,11 +500,10 @@ void exportGeometryDatabase(SessionState& s, QWidget& host) { }); QObject::connect(thread, &QThread::finished, &host, - [&s, host_ptr = &host, thread, progress, timer, error_message, input_path, output_path]() { + [&s, host_ptr = &host, thread, timer, error_message, input_path, output_path]() { const qint64 elapsed = timer->elapsed(); - progress->close(); - progress->deleteLater(); + s.endProgress(); thread->deleteLater(); if (!error_message->isEmpty()) { diff --git a/src/ifcviewer-full/modules/models/Commands.h b/src/ifcviewer-full/modules/models/Commands.h index 2bb923f493..4638b60cc7 100644 --- a/src/ifcviewer-full/modules/models/Commands.h +++ b/src/ifcviewer-full/modules/models/Commands.h @@ -52,6 +52,12 @@ void openSettings(SessionState& s, QWidget& host); // SceneLoader::loadedFromStream so subsequent loads can skip the stream phase. void writeSidecarForLoadedModel(SessionState& s, ViewportWindow& vp, uint32_t mid); +// Persistent post-load handler. Call once at app startup. Whenever the +// session reports a model was streamed (not loaded from cache), persists a +// sidecar so the next load skips the stream phase. Connection lifetime is +// tied to `context`. +void addHandlers(SessionState& s, ViewportWindow& vp, QObject& context); + // Internal building blocks shared by commands here and by ProjectController. // These NEVER call notify*() — the caller is responsible for emitting once // at the end of its execution. diff --git a/src/ifcviewer-full/modules/models/FederationItemModel.cpp b/src/ifcviewer-full/modules/models/FederationItemModel.cpp new file mode 100644 index 0000000000..25879309e5 --- /dev/null +++ b/src/ifcviewer-full/modules/models/FederationItemModel.cpp @@ -0,0 +1,265 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "FederationItemModel.h" + +#include "../../ViewerSettings.h" +#include "../../components/SvgIcon.h" +#include "../../../ifcviewer/Federation.h" + +#include +#include + +namespace ifcviewerfull::modules::models { + +namespace { + +QStandardItem* siblingVisibilityItem(QStandardItem* name_item) { + QStandardItem* parent = name_item->parent(); + if (!parent) parent = name_item->model()->invisibleRootItem(); + return parent->child(name_item->row(), 1); +} + +template +void walkSubtree(QStandardItem* root, F visit) { + visit(root); + for (int i = 0; i < root->rowCount(); ++i) { + walkSubtree(root->child(i, 0), visit); + } +} + +} // namespace + +FederationItemModel::FederationItemModel(Federation* federation, QObject* parent) + : QStandardItemModel(parent) + , federation_(federation) +{ + setColumnCount(2); + rebuildAll(); + + connect(federation_, &Federation::groupAdded, this, &FederationItemModel::onGroupAdded); + connect(federation_, &Federation::groupRemoved, this, &FederationItemModel::onGroupRemoved); + connect(federation_, &Federation::groupChanged, this, &FederationItemModel::onGroupChanged); + connect(federation_, &Federation::groupVisibilityChanged, this, &FederationItemModel::onGroupVisibilityChanged); + connect(federation_, &Federation::modelAdded, this, &FederationItemModel::onModelAdded); + connect(federation_, &Federation::modelRemoved, this, &FederationItemModel::onModelRemoved); + connect(federation_, &Federation::modelVisibilityChanged, this, &FederationItemModel::onModelVisibilityChanged); + connect(federation_, &Federation::modelGroupChanged, this, &FederationItemModel::onModelGroupChanged); +} + +void FederationItemModel::rebuildAll() { + clear(); + setColumnCount(2); + id_to_name_item_.clear(); + + for (const auto& root_group : federation_->rootGroups()) { + appendGroupSubtreeTo(invisibleRootItem(), root_group->id); + } + for (const auto& model : federation_->models()) { + if (!model.group_id.isEmpty()) continue; + appendModelTo(invisibleRootItem(), model.id); + } +} + +QStandardItem* FederationItemModel::makeGroupNameItem(const QString& group_id, const QString& display_name) const { + auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/folder.svg"), display_name); + item->setData(group_id, IdRole); + item->setData(int(ItemKind::Group), KindRole); + item->setEditable(false); + return item; +} + +QStandardItem* FederationItemModel::makeModelNameItem(const QString& fed_id, const QString& display_name) const { + auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/cube.svg"), display_name); + item->setData(fed_id, IdRole); + item->setData(int(ItemKind::Model), KindRole); + item->setEditable(false); + return item; +} + +QStandardItem* FederationItemModel::makeVisibilityItem(ItemKind kind, bool visible) const { + QString icon_path; + if (kind == ItemKind::Group) { + icon_path = visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg"; + } else { + icon_path = visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg"; + } + auto* item = new QStandardItem(components::icons::makeSvgIcon(icon_path), QString()); + item->setEditable(false); + return item; +} + +void FederationItemModel::styleRowVisibility(QStandardItem* name_item, bool visible) const { + QStandardItem* vis_item = siblingVisibilityItem(name_item); + if (visible) { + name_item->setData(QVariant(), Qt::ForegroundRole); + if (vis_item) vis_item->setData(QVariant(), Qt::ForegroundRole); + } else { + const QBrush disabled(QColor(ifcviewerfull::ViewerSettings::instance().color("disabled_text"))); + name_item->setForeground(disabled); + if (vis_item) vis_item->setForeground(disabled); + } +} + +QStandardItem* FederationItemModel::findItem(const QString& id) const { + return id_to_name_item_.value(id, nullptr); +} + +QStandardItem* FederationItemModel::parentItemForGroup(const QString& parent_group_id) const { + if (parent_group_id.isEmpty()) return invisibleRootItem(); + QStandardItem* found = findItem(parent_group_id); + return found ? found : invisibleRootItem(); +} + +void FederationItemModel::appendModelTo(QStandardItem* parent_item, const QString& fed_id) { + const Federation::Model* model = federation_->findById(fed_id); + if (!model) return; + auto* name_item = makeModelNameItem(fed_id, model->display_name); + auto* vis_item = makeVisibilityItem(ItemKind::Model, federation_->isModelEffectivelyVisible(fed_id)); + parent_item->appendRow({name_item, vis_item}); + id_to_name_item_.insert(fed_id, name_item); + styleRowVisibility(name_item, federation_->isModelEffectivelyVisible(fed_id)); +} + +void FederationItemModel::appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id) { + const Federation::Group* group = federation_->findGroupById(group_id); + if (!group) return; + auto* name_item = makeGroupNameItem(group_id, group->display_name); + auto* vis_item = makeVisibilityItem(ItemKind::Group, group->visible); + parent_item->appendRow({name_item, vis_item}); + id_to_name_item_.insert(group_id, name_item); + styleRowVisibility(name_item, group->visible); + + for (const auto& child : group->children) { + appendGroupSubtreeTo(name_item, child->id); + } + for (const auto& model : federation_->models()) { + if (model.group_id != group_id) continue; + appendModelTo(name_item, model.id); + } +} + +void FederationItemModel::refreshSubtreeVisibility(QStandardItem* root) { + walkSubtree(root, [this](QStandardItem* item) { + const QString id = item->data(IdRole).toString(); + if (id.isEmpty()) return; + const auto kind = static_cast(item->data(KindRole).toInt()); + bool visible = true; + if (kind == ItemKind::Group) { + const Federation::Group* g = federation_->findGroupById(id); + visible = g && g->visible; + } else { + visible = federation_->isModelEffectivelyVisible(id); + } + QStandardItem* vis_item = siblingVisibilityItem(item); + if (vis_item) { + const QString icon_path = (kind == ItemKind::Group) + ? (visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg") + : (visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg"); + vis_item->setIcon(components::icons::makeSvgIcon(icon_path)); + } + styleRowVisibility(item, visible); + }); +} + +void FederationItemModel::onGroupAdded(const QString& group_id) { + const Federation::Group* group = federation_->findGroupById(group_id); + if (!group) return; + QStandardItem* parent_item = parentItemForGroup(group->parent ? group->parent->id : QString()); + appendGroupSubtreeTo(parent_item, group_id); +} + +void FederationItemModel::onGroupRemoved(const QString& group_id) { + QStandardItem* item = findItem(group_id); + if (!item) return; + walkSubtree(item, [this](QStandardItem* descendant) { + const QString id = descendant->data(IdRole).toString(); + if (!id.isEmpty()) id_to_name_item_.remove(id); + }); + QStandardItem* parent_item = item->parent(); + if (!parent_item) parent_item = invisibleRootItem(); + parent_item->removeRow(item->row()); +} + +void FederationItemModel::onGroupChanged(const QString& group_id) { + QStandardItem* item = findItem(group_id); + if (!item) return; + const Federation::Group* group = federation_->findGroupById(group_id); + if (!group) return; + + QStandardItem* current_parent = item->parent(); + if (!current_parent) current_parent = invisibleRootItem(); + QStandardItem* target_parent = parentItemForGroup(group->parent ? group->parent->id : QString()); + + if (current_parent == target_parent) { + item->setText(group->display_name); + return; + } + + // Reparent: take row from current parent, append at target. Pointers + // survive — id_to_name_item_ entries remain valid. + QList taken = current_parent->takeRow(item->row()); + taken.first()->setText(group->display_name); + target_parent->appendRow(taken); + refreshSubtreeVisibility(taken.first()); +} + +void FederationItemModel::onGroupVisibilityChanged(const QString& group_id, bool /*visible*/) { + QStandardItem* item = findItem(group_id); + if (!item) return; + refreshSubtreeVisibility(item); +} + +void FederationItemModel::onModelAdded(const QString& fed_id) { + const Federation::Model* model = federation_->findById(fed_id); + if (!model) return; + QStandardItem* parent_item = parentItemForGroup(model->group_id); + appendModelTo(parent_item, fed_id); +} + +void FederationItemModel::onModelRemoved(const QString& fed_id) { + QStandardItem* item = findItem(fed_id); + if (!item) return; + id_to_name_item_.remove(fed_id); + QStandardItem* parent_item = item->parent(); + if (!parent_item) parent_item = invisibleRootItem(); + parent_item->removeRow(item->row()); +} + +void FederationItemModel::onModelVisibilityChanged(const QString& fed_id, bool /*visible*/) { + QStandardItem* item = findItem(fed_id); + if (!item) return; + refreshSubtreeVisibility(item); +} + +void FederationItemModel::onModelGroupChanged(const QString& fed_id, const QString& new_group_id) { + QStandardItem* item = findItem(fed_id); + if (!item) return; + QStandardItem* current_parent = item->parent(); + if (!current_parent) current_parent = invisibleRootItem(); + QStandardItem* target_parent = parentItemForGroup(new_group_id); + if (current_parent == target_parent) return; + + QList taken = current_parent->takeRow(item->row()); + target_parent->appendRow(taken); + refreshSubtreeVisibility(taken.first()); +} + +} // namespace ifcviewerfull::modules::models diff --git a/src/ifcviewer-full/modules/models/FederationItemModel.h b/src/ifcviewer-full/modules/models/FederationItemModel.h new file mode 100644 index 0000000000..779839500b --- /dev/null +++ b/src/ifcviewer-full/modules/models/FederationItemModel.h @@ -0,0 +1,85 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_MODULES_MODELS_FEDERATIONITEMMODEL_H +#define IFCINTERFACE_MODULES_MODELS_FEDERATIONITEMMODEL_H + +#include "Types.h" + +#include +#include + +class Federation; + +namespace ifcviewerfull::modules::models { + +// QStandardItemModel that mirrors the Federation tree (groups + models in +// two columns: name + visibility icon). Subscribes directly to Federation's +// granular signals so each mutation only touches the affected rows — view +// state (expansion, selection, scroll) is preserved automatically. +// +// Coarse session events (project open/reset, theme change) are not the +// model's concern: the owning View calls rebuildAll() in those cases. +class FederationItemModel : public QStandardItemModel { + Q_OBJECT +public: + enum Role { + IdRole = Qt::UserRole + 1, + KindRole = Qt::UserRole + 2, + }; + + explicit FederationItemModel(Federation* federation, QObject* parent = nullptr); + + // Discard everything and rebuild from current Federation state. Loses + // expansion/selection — caller is the only one that knows whether that's + // acceptable (e.g. project reset, where there's no prior state worth + // preserving anyway). + void rebuildAll(); + +private slots: + void onGroupAdded(const QString& group_id); + void onGroupRemoved(const QString& group_id); + void onGroupChanged(const QString& group_id); + void onGroupVisibilityChanged(const QString& group_id, bool visible); + void onModelAdded(const QString& fed_id); + void onModelRemoved(const QString& fed_id); + void onModelVisibilityChanged(const QString& fed_id, bool visible); + void onModelGroupChanged(const QString& fed_id, const QString& new_group_id); + +private: + QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const; + QStandardItem* makeModelNameItem(const QString& fed_id, const QString& display_name) const; + QStandardItem* makeVisibilityItem(ItemKind kind, bool visible) const; + void styleRowVisibility(QStandardItem* name_item, bool visible) const; + + QStandardItem* findItem(const QString& id) const; + QStandardItem* parentItemForGroup(const QString& parent_group_id) const; + + void appendModelTo(QStandardItem* parent_item, const QString& fed_id); + void appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id); + void refreshSubtreeVisibility(QStandardItem* root); + + Federation* federation_ = nullptr; + QHash id_to_name_item_; // both group_ids and fed_ids +}; + +} // namespace ifcviewerfull::modules::models + +#endif diff --git a/src/ifcviewer-full/modules/models/Panel.cpp b/src/ifcviewer-full/modules/models/Panel.cpp index a866d97eec..62d5fce679 100644 --- a/src/ifcviewer-full/modules/models/Panel.cpp +++ b/src/ifcviewer-full/modules/models/Panel.cpp @@ -21,25 +21,24 @@ #include "Panel.h" #include "Commands.h" +#include "FederationItemModel.h" +#include "View.h" -#include "../../ViewerSettings.h" +#include "../../SessionState.h" #include "../../components/Section.h" #include "../../components/SvgIcon.h" +#include "../../../ifcviewer/Federation.h" -#include -#include #include +#include #include #include +#include #include #include #include -#include #include -#include -#include - -#include +#include namespace ifcviewerfull::modules::models { @@ -47,38 +46,75 @@ namespace { constexpr auto kDragMimeType = "application/x-ifcviewerfull-model-items"; -class ModelsTreeWidget : public QTreeWidget { -public: - explicit ModelsTreeWidget(QWidget* parent = nullptr) : QTreeWidget(parent) {} +QString idOf(const QModelIndex& index) { + return index.sibling(index.row(), 0).data(FederationItemModel::IdRole).toString(); +} - std::function on_model_drop; - std::function on_group_drop; +ItemKind kindOf(const QModelIndex& index) { + return static_cast( + index.sibling(index.row(), 0).data(FederationItemModel::KindRole).toInt()); +} + +QStringList selectedModelIdsAt(QTreeView* tree, const QModelIndex& clicked_index) { + QStringList ids; + const QModelIndexList selection = tree->selectionModel()->selectedRows(0); + bool clicked_in_selection = false; + for (const QModelIndex& index : selection) { + if (index == clicked_index.sibling(clicked_index.row(), 0)) { + clicked_in_selection = true; + break; + } + } + if (clicked_in_selection) { + for (const QModelIndex& index : selection) { + if (kindOf(index) == ItemKind::Model) { + ids << idOf(index); + } + } + ids.removeDuplicates(); + } else { + ids << idOf(clicked_index); + } + return ids; +} + +constexpr int kVisibilityColumnWidth = 28; + +// QTreeView subclass that handles drag-and-drop. Drop logic dispatches +// through commands (not directly into the model) so notifications + status +// messages happen the same way as menu-driven moves. +class ModelsTreeView : public QTreeView { +public: + explicit ModelsTreeView(ifcviewerfull::SessionState* session_state, QWidget* parent) + : QTreeView(parent), session_state_(session_state) {} protected: - QStringList mimeTypes() const override { - return {QString::fromUtf8(kDragMimeType)}; + void resizeEvent(QResizeEvent* event) override { + QTreeView::resizeEvent(event); + if (model() && model()->columnCount() >= 2) { + const int vw = viewport()->width(); + setColumnWidth(0, std::max(40, vw - kVisibilityColumnWidth)); + setColumnWidth(1, kVisibilityColumnWidth); + } } - QMimeData* mimeData(const QList& items) const override { - Q_UNUSED(items); - const QList selected = selectedItems(); - if (selected.isEmpty()) return nullptr; + void startDrag(Qt::DropActions actions) override { + const QModelIndexList selection = selectionModel()->selectedRows(0); + if (selection.isEmpty()) return; - const int first_kind = selected.first()->data(0, Qt::UserRole).toInt(); - if (first_kind == static_cast(ItemKind::Group) && selected.size() != 1) { - return nullptr; - } + const auto first_kind = kindOf(selection.first()); + if (first_kind == ItemKind::Group && selection.size() != 1) return; QByteArray payload; QDataStream stream(&payload, QIODevice::WriteOnly); - stream << first_kind; - if (first_kind == static_cast(ItemKind::Group)) { - stream << selected.first()->data(0, Qt::UserRole + 1).toString(); + stream << static_cast(first_kind); + if (first_kind == ItemKind::Group) { + stream << idOf(selection.first()); } else { QStringList ids; - for (QTreeWidgetItem* item : selected) { - if (item->data(0, Qt::UserRole).toInt() != first_kind) return nullptr; - ids.push_back(item->data(0, Qt::UserRole + 1).toString()); + for (const QModelIndex& index : selection) { + if (kindOf(index) != first_kind) return; + ids.push_back(idOf(index)); } ids.removeDuplicates(); stream << ids; @@ -86,11 +122,9 @@ protected: auto* mime = new QMimeData(); mime->setData(QString::fromUtf8(kDragMimeType), payload); - return mime; - } - - Qt::DropActions supportedDropActions() const override { - return Qt::MoveAction; + auto* drag = new QDrag(this); + drag->setMimeData(mime); + drag->exec(actions); } void dragEnterEvent(QDragEnterEvent* event) override { @@ -98,129 +132,100 @@ protected: event->acceptProposedAction(); return; } - QTreeWidget::dragEnterEvent(event); + QTreeView::dragEnterEvent(event); } void dragMoveEvent(QDragMoveEvent* event) override { if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) { - QTreeWidget::dragMoveEvent(event); + QTreeView::dragMoveEvent(event); return; } - - QString group_id; - if (!decodeDropTarget(event->position().toPoint(), group_id)) { + QString target_group_id; + if (!decodeTargetGroup(event->position().toPoint(), target_group_id) || + !canAcceptDrop(event->mimeData(), indexAt(event->position().toPoint()), target_group_id)) { event->ignore(); return; } - - if (canAcceptDrop(event->mimeData(), itemAt(event->position().toPoint()), group_id)) { - event->acceptProposedAction(); - } else { - event->ignore(); - } + event->acceptProposedAction(); } void dropEvent(QDropEvent* event) override { if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) { - QTreeWidget::dropEvent(event); + QTreeView::dropEvent(event); return; } QString target_group_id; - if (!decodeDropTarget(event->position().toPoint(), target_group_id)) { - event->ignore(); - return; - } - - QTreeWidgetItem* target_item = itemAt(event->position().toPoint()); - if (!canAcceptDrop(event->mimeData(), target_item, target_group_id)) { + if (!decodeTargetGroup(event->position().toPoint(), target_group_id) || + !canAcceptDrop(event->mimeData(), indexAt(event->position().toPoint()), target_group_id)) { event->ignore(); return; } QByteArray payload = event->mimeData()->data(QString::fromUtf8(kDragMimeType)); QDataStream stream(&payload, QIODevice::ReadOnly); - int kind = 0; - stream >> kind; - if (kind == static_cast(ItemKind::Group)) { + int kind_int = 0; + stream >> kind_int; + const auto kind = static_cast(kind_int); + if (kind == ItemKind::Group) { QString group_id; stream >> group_id; - if (on_group_drop) on_group_drop(group_id, target_group_id); - } else if (kind == static_cast(ItemKind::Model)) { + commands::moveGroup(*session_state_, group_id, target_group_id); + } else { QStringList ids; stream >> ids; ids.removeDuplicates(); - if (!ids.isEmpty() && on_model_drop) on_model_drop(ids, target_group_id); - } else { - event->ignore(); - return; + if (!ids.isEmpty()) commands::moveModels(*session_state_, ids, target_group_id); } - event->acceptProposedAction(); } private: - bool decodeDropTarget(const QPoint& pos, QString& target_group_id) const { - target_group_id.clear(); - QTreeWidgetItem* target_item = itemAt(pos); - if (!target_item) return true; - - const int kind = target_item->data(0, Qt::UserRole).toInt(); - if (kind != static_cast(ItemKind::Group)) return false; - - target_group_id = target_item->data(0, Qt::UserRole + 1).toString(); + bool decodeTargetGroup(const QPoint& pos, QString& out) const { + out.clear(); + const QModelIndex index = indexAt(pos); + if (!index.isValid()) return true; + if (kindOf(index) != ItemKind::Group) return false; + out = idOf(index); return true; } bool canAcceptDrop(const QMimeData* mime, - QTreeWidgetItem* target_item, + const QModelIndex& target_index, const QString& target_group_id) const { QByteArray payload = mime->data(QString::fromUtf8(kDragMimeType)); QDataStream stream(&payload, QIODevice::ReadOnly); - int kind = 0; - stream >> kind; + int kind_int = 0; + stream >> kind_int; + const auto kind = static_cast(kind_int); - if (kind == static_cast(ItemKind::Group)) { + if (kind == ItemKind::Group) { QString group_id; stream >> group_id; if (group_id.isEmpty()) return false; - if (!target_item) return true; - if (target_item->data(0, Qt::UserRole).toInt() != static_cast(ItemKind::Group)) return false; + if (!target_index.isValid()) return true; + if (kindOf(target_index) != ItemKind::Group) return false; if (group_id == target_group_id) return false; - for (QTreeWidgetItem* cur = target_item; cur != nullptr; cur = cur->parent()) { - if (cur->data(0, Qt::UserRole + 1).toString() == group_id) return false; + for (QModelIndex cur = target_index; cur.isValid(); cur = cur.parent()) { + if (idOf(cur) == group_id) return false; } return true; } - if (kind == static_cast(ItemKind::Model)) { + if (kind == ItemKind::Model) { QStringList ids; stream >> ids; ids.removeDuplicates(); if (ids.isEmpty()) return false; - return target_item == nullptr || !target_group_id.isNull(); + return !target_index.isValid() || !target_group_id.isNull(); } return false; } + + ifcviewerfull::SessionState* session_state_; }; -QString itemId(QTreeWidgetItem* item) { - return item ? item->data(0, Qt::UserRole + 1).toString() : QString(); -} - -ItemKind itemKind(QTreeWidgetItem* item) { - return static_cast(item->data(0, Qt::UserRole).toInt()); -} - -QList selectedItemsOfKind(QTreeWidget* tree, ItemKind kind) { - QList matches; - for (QTreeWidgetItem* item : tree->selectedItems()) { - if (itemKind(item) == kind) matches.push_back(item); - } - return matches; -} - } // namespace ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, @@ -232,11 +237,9 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, { auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this); section->setBodyExpanding(true); - auto* tree = new ModelsTreeWidget(section); - tree_ = tree; + + tree_ = new ModelsTreeView(session_state_, section); tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - tree_->setColumnCount(2); - tree_->setHeaderLabels({"Model", ""}); tree_->setIconSize(QSize(16, 16)); tree_->setSelectionMode(QAbstractItemView::ExtendedSelection); tree_->setContextMenuPolicy(Qt::CustomContextMenu); @@ -246,29 +249,23 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, tree_->setDragDropMode(QAbstractItemView::DragDrop); tree_->setDefaultDropAction(Qt::MoveAction); tree_->setUniformRowHeights(true); - tree_->header()->setStretchLastSection(false); - tree_->header()->setSectionResizeMode(0, QHeaderView::Stretch); - tree_->header()->setSectionResizeMode(1, QHeaderView::Fixed); - tree_->header()->resizeSection(1, 28); + tree_->setExpandsOnDoubleClick(false); + tree_->setEditTriggers(QAbstractItemView::NoEditTriggers); tree_->header()->hide(); - tree->on_model_drop = [this](const QStringList& ids, const QString& target_group_id) { - commands::moveModels(*session_state_, ids, target_group_id); - }; - tree->on_group_drop = [this](const QString& id, const QString& target_group_id) { - commands::moveGroup(*session_state_, id, target_group_id); - }; + section->addBodyWidget(tree_); addBodyWidget(section); - connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { - if (!item || column != 1) return; - commands::toggleVisibility(*session_state_, itemKind(item), itemId(item)); + connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) { + if (!index.isValid() || index.column() != 1) return; + commands::toggleVisibility(*session_state_, kindOf(index), idOf(index)); }); - connect(tree_, &QTreeWidget::customContextMenuRequested, this, [this](const QPoint& pos) { - auto* item = tree_->itemAt(pos); + connect(tree_, &QTreeView::customContextMenuRequested, this, [this](const QPoint& pos) { + const QModelIndex index = tree_->indexAt(pos); QMenu menu(tree_); - if (!item) { + + if (!index.isValid()) { QAction* add_group_action = menu.addAction( components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "Add Group"); connect(add_group_action, &QAction::triggered, this, [this]() { @@ -278,36 +275,34 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, return; } - const auto kind = itemKind(item); - const QString id = itemId(item); + const auto kind = kindOf(index); + const QString id = idOf(index); - QAction* toggle_visibility_action = menu.addAction( + QAction* toggle_action = menu.addAction( components::icons::makeSvgIcon(":/icons/eye.svg"), "Toggle Visibility"); - connect(toggle_visibility_action, &QAction::triggered, this, [this, kind, id]() { + connect(toggle_action, &QAction::triggered, this, [this, kind, id]() { commands::toggleVisibility(*session_state_, kind, id); }); if (kind == ItemKind::Group) { - QAction* add_group_action = menu.addAction( + QAction* add_subgroup = menu.addAction( components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Subgroup"); - connect(add_group_action, &QAction::triggered, this, [this, id]() { + connect(add_subgroup, &QAction::triggered, this, [this, id]() { commands::addGroup(*session_state_, *this, id); }); - QAction* rename_group_action = menu.addAction( + QAction* rename = menu.addAction( components::icons::makeSvgIcon(":/icons/folder.svg"), "Rename Group"); - connect(rename_group_action, &QAction::triggered, this, [this, id]() { + connect(rename, &QAction::triggered, this, [this, id]() { commands::renameGroup(*session_state_, *this, id); }); QMenu* move_menu = menu.addMenu("Move to Parent"); - QAction* move_root_action = move_menu->addAction("(Root)"); - connect(move_root_action, &QAction::triggered, this, [this, id]() { + QAction* move_root = move_menu->addAction("(Root)"); + connect(move_root, &QAction::triggered, this, [this, id]() { commands::moveGroup(*session_state_, id, QString()); }); move_menu->addSeparator(); - const QList targets = - group_list_provider_ ? group_list_provider_(id) : QList{}; - for (const auto& target : targets) { + for (const auto& target : validMoveTargets(*session_state_->federation(), id)) { QAction* action = move_menu->addAction(target.display_name); const QString target_id = target.id; connect(action, &QAction::triggered, this, [this, id, target_id]() { @@ -315,45 +310,33 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, }); } - QAction* remove_group_action = menu.addAction( + QAction* remove = menu.addAction( components::icons::makeSvgIcon(":/icons/folder-minus.svg"), "Remove Group"); - connect(remove_group_action, &QAction::triggered, this, [this, id]() { + connect(remove, &QAction::triggered, this, [this, id]() { commands::removeGroup(*session_state_, *this, id); }); } else { QString parent_group_id; - if (auto* parent_item = item->parent()) { - if (itemKind(parent_item) == ItemKind::Group) { - parent_group_id = itemId(parent_item); - } + const QModelIndex parent_index = index.parent(); + if (parent_index.isValid() && kindOf(parent_index) == ItemKind::Group) { + parent_group_id = idOf(parent_index); } - QAction* add_group_action = menu.addAction( + QAction* add_group = menu.addAction( components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Group"); - connect(add_group_action, &QAction::triggered, this, [this, parent_group_id]() { + connect(add_group, &QAction::triggered, this, [this, parent_group_id]() { commands::addGroup(*session_state_, *this, parent_group_id); }); - QStringList selected_model_ids; - const QList selected_models = selectedItemsOfKind(tree_, ItemKind::Model); - if (selected_models.contains(item)) { - for (QTreeWidgetItem* selected : selected_models) { - selected_model_ids.push_back(itemId(selected)); - } - selected_model_ids.removeDuplicates(); - } else { - selected_model_ids = {id}; - } + const QStringList selected_model_ids = selectedModelIdsAt(tree_, index); QMenu* move_menu = menu.addMenu("Move to Group"); - QAction* move_root_action = move_menu->addAction("(Root)"); - connect(move_root_action, &QAction::triggered, this, [this, selected_model_ids]() { + QAction* move_root = move_menu->addAction("(Root)"); + connect(move_root, &QAction::triggered, this, [this, selected_model_ids]() { commands::moveModels(*session_state_, selected_model_ids, QString()); }); move_menu->addSeparator(); - const QList targets = - group_list_provider_ ? group_list_provider_(QString()) : QList{}; - for (const auto& target : targets) { + for (const auto& target : validMoveTargets(*session_state_->federation(), QString())) { QAction* action = move_menu->addAction(target.display_name); const QString target_id = target.id; connect(action, &QAction::triggered, this, [this, selected_model_ids, target_id]() { @@ -361,9 +344,9 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, }); } - QAction* remove_model_action = menu.addAction( + QAction* remove = menu.addAction( components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model"); - connect(remove_model_action, &QAction::triggered, this, [this, id]() { + connect(remove, &QAction::triggered, this, [this, id]() { commands::removeModel(*session_state_, *viewport_, *this, id); }); } @@ -375,46 +358,17 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state, }); } -void ModelsPanel::setNodes(const QList& nodes) { - tree_->clear(); - const bool has_hierarchy = std::any_of(nodes.begin(), nodes.end(), [](const TreeNode& node) { - return !node.children.isEmpty() || node.kind == ItemKind::Group; - }); - tree_->setRootIsDecorated(has_hierarchy); - for (const auto& node : nodes) { - addNode(tree_->invisibleRootItem(), node); - } +void ModelsPanel::setModel(FederationItemModel* model) { + model_ = model; + tree_->setModel(model); + // Columns are sized by ModelsTreeView::resizeEvent — header is hidden so + // there's no user-facing resize affordance, and Stretch mode on + // non-last sections proved unreliable here. Manual sizing is simpler. + tree_->header()->setMinimumSectionSize(16); + tree_->header()->setStretchLastSection(false); + tree_->setColumnWidth(0, std::max(40, tree_->viewport()->width() - kVisibilityColumnWidth)); + tree_->setColumnWidth(1, kVisibilityColumnWidth); tree_->expandAll(); } -void ModelsPanel::setGroupListProvider(GroupListProvider provider) { - group_list_provider_ = std::move(provider); -} - -void ModelsPanel::addNode(QTreeWidgetItem* parent, const TreeNode& node) { - auto* item = new QTreeWidgetItem(parent, {node.name, ""}); - item->setData(0, Qt::UserRole, static_cast(node.kind)); - item->setData(0, Qt::UserRole + 1, node.id); - item->setSizeHint(0, QSize(0, 24)); - - if (node.kind == ItemKind::Group) { - item->setIcon(0, components::icons::makeSvgIcon(":/icons/folder.svg")); - item->setIcon(1, components::icons::makeSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); - } else { - item->setIcon(0, components::icons::makeSvgIcon(":/icons/cube.svg")); - item->setIcon(1, components::icons::makeSvgIcon(node.visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg")); - } - - if (!node.visible) { - const QBrush disabled_brush( - QColor(ifcviewerfull::ViewerSettings::instance().color("disabled_text"))); - item->setForeground(0, disabled_brush); - item->setForeground(1, disabled_brush); - } - - for (const auto& child : node.children) { - addNode(item, child); - } -} - } // namespace ifcviewerfull::modules::models diff --git a/src/ifcviewer-full/modules/models/Panel.h b/src/ifcviewer-full/modules/models/Panel.h index 4f7c934f58..3fc65c9ad6 100644 --- a/src/ifcviewer-full/modules/models/Panel.h +++ b/src/ifcviewer-full/modules/models/Panel.h @@ -25,42 +25,34 @@ #include "../../components/Panel.h" -#include - -class QTreeWidget; -class QTreeWidgetItem; +class QTreeView; class ViewportWindow; namespace ifcviewerfull { class SessionState; } namespace ifcviewerfull::modules::models { -// The widget for the Models dock. Owns no domain state; its click handlers -// call commands directly. The View tells it what to render (setNodes) and -// supplies derived data for the right-click menu (setGroupListProvider). +class FederationItemModel; + +// The widget for the Models dock. Owns no domain state; click handlers call +// commands directly. The QTreeView reads from a FederationItemModel which +// subscribes to Federation's granular signals — view state (expansion, +// selection, scroll) is preserved across mutations automatically. class ModelsPanel : public components::Panel { Q_OBJECT public: - // Returns the groups a move operation may target. If exclude_subtree_root - // is non-empty, that group + its descendants are excluded so a group can't - // be moved into its own subtree. For model moves the panel passes an empty - // string and gets every group back. - using GroupListProvider = - std::function(const QString& exclude_subtree_root)>; - explicit ModelsPanel(ifcviewerfull::SessionState* session_state, ViewportWindow* viewport, QWidget* parent = nullptr); - void setNodes(const QList& nodes); - void setGroupListProvider(GroupListProvider provider); + // Owned externally (the View constructs and owns the model). The panel + // assigns it to the tree view; same model can outlive setModel calls. + void setModel(FederationItemModel* model); private: - void addNode(QTreeWidgetItem* parent, const TreeNode& node); - ifcviewerfull::SessionState* session_state_ = nullptr; ViewportWindow* viewport_ = nullptr; - QTreeWidget* tree_ = nullptr; - GroupListProvider group_list_provider_; + QTreeView* tree_ = nullptr; + FederationItemModel* model_ = nullptr; }; } // namespace ifcviewerfull::modules::models diff --git a/src/ifcviewer-full/modules/models/View.cpp b/src/ifcviewer-full/modules/models/View.cpp index e7924e2e85..3261367aeb 100644 --- a/src/ifcviewer-full/modules/models/View.cpp +++ b/src/ifcviewer-full/modules/models/View.cpp @@ -20,6 +20,7 @@ #include "View.h" +#include "FederationItemModel.h" #include "Panel.h" #include "../../ViewerSettings.h" @@ -30,32 +31,6 @@ namespace ifcviewerfull::modules::models { namespace { -TreeNode makeGroupNode(const Federation* federation, const Federation::Group* group) { - TreeNode node; - node.id = group->id; - node.name = group->display_name; - node.kind = ItemKind::Group; - node.visible = group->visible; - - for (const auto& child_group : group->children) { - node.children.append(makeGroupNode(federation, child_group.get())); - } - - for (const auto& model : federation->models()) { - if (model.group_id != group->id) continue; - node.children.append({ - model.id, - model.display_name, - ItemKind::Model, - federation->isModelEffectivelyVisible(model.id), - {} - }); - } - return node; -} - -// Walks the federation's group tree, appending every group except those under -// exclude_subtree_root (used to prevent a group from being moved into itself). void collectGroupsRecursive(const Federation::Group* group, const QString& exclude_subtree_root, QList& out) { @@ -68,54 +43,33 @@ void collectGroupsRecursive(const Federation::Group* group, } // namespace +QList validMoveTargets(const Federation& federation, + const QString& exclude_subtree_root) { + QList out; + for (const auto& root : federation.rootGroups()) { + collectGroupsRecursive(root.get(), exclude_subtree_root, out); + } + return out; +} + ModelsPanelView::ModelsPanelView(ModelsPanel* widget, ifcviewerfull::SessionState* session_state, QObject* parent) : QObject(parent) , widget_(widget) , session_state_(session_state) + , model_(new FederationItemModel(session_state->federation(), this)) { - connect(session_state_, &SessionState::modelsChanged, this, &ModelsPanelView::refresh); - connect(session_state_, &SessionState::federationChanged, this, &ModelsPanelView::refresh); - connect(session_state_, &SessionState::visibilityChanged, this, &ModelsPanelView::refresh); - connect(session_state_, &SessionState::projectReset, this, &ModelsPanelView::refresh); - connect(session_state_, &SessionState::projectOpened, this, [this](const QString&) { refresh(); }); + widget_->setModel(model_); + + // Coarse signals: full rebuild + re-style. The granular Federation + // signals are handled inside FederationItemModel and don't reach here. + auto rebuild = [this]() { model_->rebuildAll(); }; + connect(session_state_, &SessionState::projectReset, this, rebuild); + connect(session_state_, &SessionState::projectOpened, this, rebuild); connect(&ifcviewerfull::ViewerSettings::instance(), &ifcviewerfull::ViewerSettings::themeChanged, - this, &ModelsPanelView::refresh); - - widget_->setGroupListProvider([this](const QString& exclude_subtree_root) { - return groupListForMove(exclude_subtree_root); - }); - - refresh(); -} - -void ModelsPanelView::refresh() { - Federation* federation = session_state_->federation(); - QList nodes; - for (const auto& root_group : federation->rootGroups()) { - nodes.append(makeGroupNode(federation, root_group.get())); - } - for (const auto& model : federation->models()) { - if (!model.group_id.isEmpty()) continue; - nodes.append({ - model.id, - model.display_name, - ItemKind::Model, - federation->isModelEffectivelyVisible(model.id), - {} - }); - } - widget_->setNodes(nodes); -} - -QList ModelsPanelView::groupListForMove(const QString& exclude_subtree_root) const { - QList out; - for (const auto& root : session_state_->federation()->rootGroups()) { - collectGroupsRecursive(root.get(), exclude_subtree_root, out); - } - return out; + this, rebuild); } } // namespace ifcviewerfull::modules::models diff --git a/src/ifcviewer-full/modules/models/View.h b/src/ifcviewer-full/modules/models/View.h index 4cf3375b13..1ec87e5d5a 100644 --- a/src/ifcviewer-full/modules/models/View.h +++ b/src/ifcviewer-full/modules/models/View.h @@ -25,15 +25,26 @@ #include +class Federation; namespace ifcviewerfull { class SessionState; } namespace ifcviewerfull::modules::models { +class FederationItemModel; class ModelsPanel; -// Subscribes to SessionState and re-derives panel state (tree nodes, valid -// move targets) from the federation. The panel calls commands directly for -// input, so this object is purely state→view; it has no command knowledge. +// Pure derivation used by ModelsPanel when building its "move to..." menus. +// Walks the federation's group tree and returns every group except those in +// the subtree rooted at exclude_subtree_root (skip a group's own subtree to +// prevent a cyclic move). Pass an empty exclude_subtree_root to get every +// group back. +QList validMoveTargets(const Federation& federation, + const QString& exclude_subtree_root); + +// Owns the FederationItemModel, hands it to the panel, and listens to the +// coarse session signals (project open/reset, theme change) — those are the +// "rebuild from scratch" cases the model itself doesn't subscribe to. +// Granular Federation events are handled inside the model. class ModelsPanelView : public QObject { Q_OBJECT public: @@ -42,11 +53,9 @@ public: QObject* parent = nullptr); private: - void refresh(); - QList groupListForMove(const QString& exclude_subtree_root) const; - ModelsPanel* widget_ = nullptr; ifcviewerfull::SessionState* session_state_ = nullptr; + FederationItemModel* model_ = nullptr; }; } // namespace ifcviewerfull::modules::models diff --git a/src/ifcviewer-full/modules/project/Controller.cpp b/src/ifcviewer-full/modules/project/Commands.cpp similarity index 53% rename from src/ifcviewer-full/modules/project/Controller.cpp rename to src/ifcviewer-full/modules/project/Commands.cpp index 98a0a2e799..d5ecb74cd4 100644 --- a/src/ifcviewer-full/modules/project/Controller.cpp +++ b/src/ifcviewer-full/modules/project/Commands.cpp @@ -18,7 +18,7 @@ * * ********************************************************************************/ -#include "Controller.h" +#include "Commands.h" #include "../../ElementRegistry.h" #include "../../SessionState.h" @@ -31,71 +31,61 @@ #include #include -namespace ifcviewerfull::modules::project { +namespace ifcviewerfull::modules::project::commands { -ProjectController::ProjectController(QWidget* host, - ifcviewerfull::SessionState* session_state, - ViewportWindow* viewport, - QObject* parent) - : QObject(parent) - , host_(host) - , session_state_(session_state) - , viewport_(viewport) -{ +namespace { + +// Pure helper — clears the loaded scene without emitting any signals. The +// caller (newProject / openProject) emits projectReset / projectOpened once +// the whole flow finishes. +void clearScene(SessionState& s, ViewportWindow& vp) { + vp.setSelectedObjectId(0); + s.setSelectedObjectId(0); + for (uint32_t mid : s.modelIds()) { + vp.removeModel(mid); + s.loader()->removeModel(mid); + } + s.clearModelMappings(); + s.elementRegistry()->clear(); } -bool ProjectController::newProject() { - SceneLoader* loader = session_state_->loader(); - if (loader && loader->isLoading()) { - QMessageBox::information( - host_, "New Project", - "Wait until the current model load finishes before creating a new project."); - return false; - } - if (!confirmDiscardIfDirty()) return false; - - clearScene(); - session_state_->federation()->clear(); - session_state_->setStatusMessage("Project", "Untitled"); - session_state_->notifyProjectReset(); +// Returns false if the user cancelled (i.e. don't proceed with the destructive +// op). Handles the Save → Discard → Cancel branch including a follow-on save. +bool confirmDiscardIfDirty(SessionState& s, QWidget& host) { + if (!s.federation()->isDirty()) return true; + const auto result = QMessageBox::question( + &host, "Unsaved Project", + "The current project has unsaved changes. Save before continuing?", + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, + QMessageBox::Save); + if (result == QMessageBox::Cancel) return false; + if (result == QMessageBox::Save) return saveProject(s, host); return true; } -bool ProjectController::openProject() { - QFileDialog file_dialog(host_, "Open Project"); - file_dialog.setFileMode(QFileDialog::ExistingFile); - file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)"); - file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); - if (file_dialog.exec() != QDialog::Accepted) return false; - - const QString path = file_dialog.selectedFiles().value(0); - if (path.isEmpty()) return false; - return openProject(path); -} - -bool ProjectController::openProject(const QString& path) { - SceneLoader* loader = session_state_->loader(); +bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QString& path) { + SceneLoader* loader = s.loader(); if (loader && loader->isLoading()) { QMessageBox::information( - host_, "Open Project", + &host, "Open Project", "Wait until the current model load finishes before opening another project."); return false; } - if (!confirmDiscardIfDirty()) return false; + if (!confirmDiscardIfDirty(s, host)) return false; QStringList warnings; QString err; - if (!session_state_->federation()->load(path, &warnings, &err)) { - QMessageBox::warning(host_, "Open Project", + if (!s.federation()->load(path, &warnings, &err)) { + QMessageBox::warning(&host, "Open Project", QString("Could not open project:\n%1").arg(err)); return false; } - clearScene(); + clearScene(s, vp); QStringList paths; QStringList fed_ids; - for (const auto& model : session_state_->federation()->models()) { + for (const auto& model : s.federation()->models()) { if (model.source_kind != "local") continue; if (!QFileInfo::exists(model.source_path)) { warnings << QString("Source not found, kept in project: %1").arg(model.source_path); @@ -104,43 +94,77 @@ bool ProjectController::openProject(const QString& path) { paths << model.source_path; fed_ids << model.id; } - ifcviewerfull::modules::models::commands::detail::loadModels(*session_state_, paths, fed_ids); + modules::models::commands::detail::loadModels(s, paths, fed_ids); if (!warnings.isEmpty()) { - QMessageBox::warning(host_, "Open Project", + QMessageBox::warning(&host, "Open Project", "Project opened with warnings:\n\n" + warnings.join("\n")); } - session_state_->federation()->markClean(); - if (session_state_->federation()->hasHomeView()) { - const auto& hv = session_state_->federation()->homeView(); - viewport_->setCamera( - hv.target.x(), hv.target.y(), hv.target.z(), hv.distance, hv.yaw, hv.pitch); + s.federation()->markClean(); + if (s.federation()->hasHomeView()) { + const auto& hv = s.federation()->homeView(); + vp.setCamera(hv.target.x(), hv.target.y(), hv.target.z(), + hv.distance, hv.yaw, hv.pitch); } - session_state_->setStatusMessage("Project", QFileInfo(path).fileName()); - session_state_->notifyProjectOpened(path); + s.setStatusMessage("Project", QFileInfo(path).fileName()); + s.notifyProjectOpened(path); return true; } -bool ProjectController::saveProject() { - if (session_state_->federation()->filePath().isEmpty()) return saveProjectAs(); - +bool saveProjectTo(SessionState& s, QWidget& host, const QString& path) { QString err; - if (!session_state_->federation()->save(session_state_->federation()->filePath(), &err)) { - QMessageBox::warning(host_, "Save Project", + if (!s.federation()->save(path, &err)) { + QMessageBox::warning(&host, "Save Project", QString("Could not save project:\n%1").arg(err)); return false; } - session_state_->setStatusMessage("Project", QFileInfo(session_state_->federation()->filePath()).fileName()); - session_state_->notifyProjectSaved(session_state_->federation()->filePath()); + s.setStatusMessage("Project", QFileInfo(path).fileName()); + s.notifyProjectSaved(path); return true; } -bool ProjectController::saveProjectAs() { - QString suggested = session_state_->federation()->filePath(); +} // namespace + +bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp) { + SceneLoader* loader = s.loader(); + if (loader && loader->isLoading()) { + QMessageBox::information( + &host, "New Project", + "Wait until the current model load finishes before creating a new project."); + return false; + } + if (!confirmDiscardIfDirty(s, host)) return false; + + clearScene(s, vp); + s.federation()->clear(); + s.setStatusMessage("Project", "Untitled"); + s.notifyProjectReset(); + return true; +} + +bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp) { + QFileDialog file_dialog(&host, "Open Project"); + file_dialog.setFileMode(QFileDialog::ExistingFile); + file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)"); + file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (file_dialog.exec() != QDialog::Accepted) return false; + + const QString path = file_dialog.selectedFiles().value(0); + if (path.isEmpty()) return false; + return openProjectAt(s, host, vp, path); +} + +bool saveProject(SessionState& s, QWidget& host) { + if (s.federation()->filePath().isEmpty()) return saveProjectAs(s, host); + return saveProjectTo(s, host, s.federation()->filePath()); +} + +bool saveProjectAs(SessionState& s, QWidget& host) { + QString suggested = s.federation()->filePath(); if (suggested.isEmpty()) suggested = "project.ifcfed"; - QFileDialog file_dialog(host_, "Save Project As", suggested); + QFileDialog file_dialog(&host, "Save Project As", suggested); file_dialog.setAcceptMode(QFileDialog::AcceptSave); file_dialog.setFileMode(QFileDialog::AnyFile); file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)"); @@ -150,44 +174,7 @@ bool ProjectController::saveProjectAs() { QString path = file_dialog.selectedFiles().value(0); if (path.isEmpty()) return false; if (!path.endsWith(".ifcfed", Qt::CaseInsensitive)) path += ".ifcfed"; - return saveProjectAs(path); + return saveProjectTo(s, host, path); } -bool ProjectController::saveProjectAs(const QString& path) { - QString err; - if (!session_state_->federation()->save(path, &err)) { - QMessageBox::warning(host_, "Save Project", - QString("Could not save project:\n%1").arg(err)); - return false; - } - session_state_->setStatusMessage("Project", QFileInfo(path).fileName()); - session_state_->notifyProjectSaved(path); - return true; -} - -void ProjectController::clearScene() { - // Helper for newProject / openProject. Does not emit any notifies; the - // caller emits projectReset / projectOpened once at the end of its flow. - viewport_->setSelectedObjectId(0); - session_state_->setSelectedObjectId(0); - for (uint32_t mid : session_state_->modelIds()) { - viewport_->removeModel(mid); - session_state_->loader()->removeModel(mid); - } - session_state_->clearModelMappings(); - session_state_->elementRegistry()->clear(); -} - -bool ProjectController::confirmDiscardIfDirty() { - if (!session_state_->federation()->isDirty()) return true; - const auto result = QMessageBox::question( - host_, "Unsaved Project", - "The current project has unsaved changes. Save before continuing?", - QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, - QMessageBox::Save); - if (result == QMessageBox::Cancel) return false; - if (result == QMessageBox::Save) return saveProject(); - return true; -} - -} // namespace ifcviewerfull::modules::project +} // namespace ifcviewerfull::modules::project::commands diff --git a/src/ifcviewer-full/modules/project/Controller.h b/src/ifcviewer-full/modules/project/Commands.h similarity index 64% rename from src/ifcviewer-full/modules/project/Controller.h rename to src/ifcviewer-full/modules/project/Commands.h index 5382d68a84..58df26f551 100644 --- a/src/ifcviewer-full/modules/project/Controller.h +++ b/src/ifcviewer-full/modules/project/Commands.h @@ -18,42 +18,25 @@ * * ********************************************************************************/ -#ifndef IFCINTERFACE_PANELS_PROJECT_CONTROLLER_H -#define IFCINTERFACE_PANELS_PROJECT_CONTROLLER_H +#ifndef IFCINTERFACE_MODULES_PROJECT_COMMANDS_H +#define IFCINTERFACE_MODULES_PROJECT_COMMANDS_H -#include +#include class QWidget; class ViewportWindow; namespace ifcviewerfull { class SessionState; } -namespace ifcviewerfull::modules::project { +namespace ifcviewerfull::modules::project::commands { -class ProjectController : public QObject { - Q_OBJECT +// User-facing commands. Each owns its own dialogs and confirmations; each +// emits exactly one notify() at the end (projectReset / projectOpened / +// projectSaved) so views refresh once per command. +bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp); +bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp); +bool saveProject(SessionState& s, QWidget& host); +bool saveProjectAs(SessionState& s, QWidget& host); -public: - explicit ProjectController(QWidget* host, - ifcviewerfull::SessionState* session_state, - ViewportWindow* viewport, - QObject* parent = nullptr); - - bool newProject(); - bool openProject(); - bool saveProject(); - bool saveProjectAs(); - -private: - bool openProject(const QString& path); - bool saveProjectAs(const QString& path); - void clearScene(); - bool confirmDiscardIfDirty(); - - QWidget* host_ = nullptr; - ifcviewerfull::SessionState* session_state_ = nullptr; - ViewportWindow* viewport_ = nullptr; -}; - -} // namespace ifcviewerfull::modules::project +} // namespace ifcviewerfull::modules::project::commands #endif diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 4cf83fd5ba..478c636de0 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -483,8 +483,10 @@ QString Federation::addModel(const QString& source_path, m.source_kind = "local"; m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); models_.push_back(std::move(m)); + const QString new_id = models_.back().id; setDirty(true); - return models_.back().id; + emit modelAdded(new_id); + return new_id; } void Federation::removeModel(const QString& fed_id) { @@ -492,6 +494,7 @@ void Federation::removeModel(const QString& fed_id) { if (it->id == fed_id) { models_.erase(it); setDirty(true); + emit modelRemoved(fed_id); return; } } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 9fd61b3072..6102b7cb60 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -284,6 +284,8 @@ signals: // to dirtyChanged from the corresponding setters. void configChanged(); void federatedFalseOriginChanged(); + void modelAdded(const QString& fed_id); + void modelRemoved(const QString& fed_id); void modelTransformationChanged(const QString& fed_id); void modelVisibilityChanged(const QString& fed_id, bool visible); void modelGroupChanged(const QString& fed_id, const QString& group_id);