diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index bcff5b6890..c1d1220b15 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -28,11 +28,14 @@ #include #include + +#include #include #include #include #include #include +#include #include #include #include @@ -79,6 +82,59 @@ MainWindow::MainWindow(QWidget* parent) } }); + connect(federation_, &Federation::modelGroupChanged, + this, [this](const QString& fed_id, const QString& /*group_id*/) { + auto it = fed_id_to_model_id_.find(fed_id); + if (it == fed_id_to_model_id_.end()) return; + reparentModelTreeRoot(it->second); + // Effective visibility may have flipped because the new group's + // chain visibility differs from the old one. + applyModelVisibilityToViewport(it->second); + }); + + connect(federation_, &Federation::groupAdded, + this, [this](const QString& group_id) { + ensureGroupTreeItem(group_id); + reparentGroupTreeItem(group_id); + refreshGroupRowAppearance(group_id); + }); + + connect(federation_, &Federation::groupChanged, + this, [this](const QString& group_id) { + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return; + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + it->second->setText(0, g->display_name); + reparentGroupTreeItem(group_id); + // Reparenting a group changes the chain visibility for the moved + // group + its descendants — refresh both rows and viewport. + for (const QString& gid : descendantGroupIds(group_id)) { + refreshGroupRowAppearance(gid); + } + applyVisibilityCascadeFromGroup(group_id); + }); + + connect(federation_, &Federation::groupVisibilityChanged, + this, [this](const QString& group_id, bool /*visible*/) { + for (const QString& gid : descendantGroupIds(group_id)) { + refreshGroupRowAppearance(gid); + } + applyVisibilityCascadeFromGroup(group_id); + }); + + connect(federation_, &Federation::groupRemoved, + this, [this](const QString& group_id) { + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + // By now, the federation has fired groupChanged / modelGroupChanged + // for every direct child, and our slots have moved them out from + // under this item, so deleting it just removes the (now empty) + // group row. + delete it->second; + group_tree_items_.erase(it); + }); + connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { setWindowModified(dirty); }); @@ -314,11 +370,12 @@ void MainWindow::loadModelsFromPaths(const QStringList& paths, model_id_to_fed_id_[mid] = fed_id; QString display = QFileInfo(paths[i]).fileName(); - auto* root = new QTreeWidgetItem(element_tree_); + auto* root = new QTreeWidgetItem(); root->setText(0, display); root->setText(1, "IFC Model"); root->setData(0, Qt::UserRole, static_cast(0)); tree_roots_[mid] = root; + reparentModelTreeRoot(mid); } } @@ -335,6 +392,15 @@ bool MainWindow::openFederation(const QString& path) { clearScene(); + // Materialise group tree items. allGroups() walks parents-before- + // children, so reparenting in the same pass always finds the parent's + // tree item ready. + for (const Federation::Group* g : federation_->allGroups()) { + ensureGroupTreeItem(g->id); + reparentGroupTreeItem(g->id); + refreshGroupRowAppearance(g->id); + } + QStringList paths; QStringList fed_ids; QStringList missing; @@ -449,6 +515,8 @@ void MainWindow::clearScene() { } fed_id_to_model_id_.clear(); model_id_to_fed_id_.clear(); + for (auto& kv : group_tree_items_) delete kv.second; + group_tree_items_.clear(); } bool MainWindow::confirmDiscardIfDirty() { @@ -909,50 +977,160 @@ uint32_t MainWindow::modelIdForRoot(QTreeWidgetItem* item) const { void MainWindow::applyModelVisibilityToViewport(uint32_t mid) { auto fed_it = model_id_to_fed_id_.find(mid); if (fed_it == model_id_to_fed_id_.end()) return; - const Federation::Model* m = federation_->findById(fed_it->second); - if (!m) return; - if (m->visible) viewport_->showModel(mid); - else viewport_->hideModel(mid); + const QString& fed_id = fed_it->second; + const bool effective = federation_->isModelEffectivelyVisible(fed_id); + if (effective) viewport_->showModel(mid); + else viewport_->hideModel(mid); - // Tree-side cue: italicise + grey out the model root when hidden. + // Tree-side cue: italicise + grey out the model root when not + // effectively visible (own toggle off, or any ancestor group hidden). auto root_it = tree_roots_.find(mid); if (root_it != tree_roots_.end()) { QFont f = root_it->second->font(0); - f.setItalic(!m->visible); + f.setItalic(!effective); for (int col = 0; col < element_tree_->columnCount(); ++col) { root_it->second->setFont(col, f); root_it->second->setForeground( col, - m->visible ? element_tree_->palette().color(QPalette::Text) - : element_tree_->palette().color(QPalette::Disabled, - QPalette::Text)); + effective ? element_tree_->palette().color(QPalette::Text) + : element_tree_->palette().color(QPalette::Disabled, + QPalette::Text)); } } } void MainWindow::onTreeContextMenu(const QPoint& pos) { QTreeWidgetItem* item = element_tree_->itemAt(pos); - uint32_t mid = modelIdForRoot(item); - if (mid == 0) return; // not a model root — only roots get the menu - - auto fed_it = model_id_to_fed_id_.find(mid); - if (fed_it == model_id_to_fed_id_.end()) return; - const Federation::Model* m = federation_->findById(fed_it->second); - if (!m) return; - - const bool currently_loading = loader_->isLoadingModel(mid); + const uint32_t mid = modelIdForRoot(item); + const QString group_id = groupIdForItem(item); + // Element rows (children of a model root) are excluded — only model + // roots, group rows, and the empty area get a menu. + const bool is_element_row = + item != nullptr && mid == 0 && group_id.isEmpty(); + if (is_element_row) return; QMenu menu(this); - QAction* hide_show = menu.addAction(m->visible ? "Hide" : "Show"); - QAction* remove = menu.addAction("Remove"); - remove->setEnabled(!currently_loading); + if (mid != 0) { + // === Model row === + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it == model_id_to_fed_id_.end()) return; + const Federation::Model* m = federation_->findById(fed_it->second); + if (!m) return; + + const bool currently_loading = loader_->isLoadingModel(mid); + + QAction* hide_show = menu.addAction(m->visible ? "Hide" : "Show"); + + QMenu* move_menu = menu.addMenu("Move to Group"); + QAction* move_to_root = move_menu->addAction("(Root)"); + move_to_root->setEnabled(!m->group_id.isEmpty()); + move_menu->addSeparator(); + std::vector> move_targets; + for (const Federation::Group* g : federation_->allGroups()) { + QAction* a = move_menu->addAction(g->display_name); + a->setEnabled(g->id != m->group_id); + move_targets.emplace_back(a, g->id); + } + if (federation_->allGroups().empty()) { + QAction* none = move_menu->addAction("(no groups)"); + none->setEnabled(false); + } + + QAction* remove = menu.addAction("Remove"); + remove->setEnabled(!currently_loading); + + QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); + if (!chosen) return; + if (chosen == hide_show) { + federation_->setModelVisible(fed_it->second, !m->visible); + } else if (chosen == move_to_root) { + federation_->setModelGroup(fed_it->second, QString()); + } else if (chosen == remove) { + removeModel(mid); + } else { + for (const auto& [a, gid] : move_targets) { + if (chosen == a) { + federation_->setModelGroup(fed_it->second, gid); + break; + } + } + } + return; + } + + if (!group_id.isEmpty()) { + // === Group row === + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return; + + QAction* hide_show = menu.addAction(g->visible ? "Hide" : "Show"); + QAction* rename = menu.addAction("Rename..."); + QAction* new_sub = menu.addAction("New Subgroup"); + + QMenu* move_menu = menu.addMenu("Move to Parent"); + QAction* move_to_root = move_menu->addAction("(Root)"); + move_to_root->setEnabled(g->parent != nullptr); + move_menu->addSeparator(); + std::vector> move_targets; + for (const Federation::Group* og : federation_->allGroups()) { + QAction* a = move_menu->addAction(og->display_name); + // Disable self, current parent, and any descendant of g (the + // latter would create a cycle). Walk og's ancestor chain to + // detect descendants. + bool would_cycle = false; + for (const Federation::Group* cur = og; cur; cur = cur->parent) { + if (cur == g) { would_cycle = true; break; } + } + const bool is_current_parent = + g->parent != nullptr && og == g->parent; + a->setEnabled(!would_cycle && !is_current_parent); + move_targets.emplace_back(a, og->id); + } + + QAction* remove = menu.addAction("Remove Group"); + + QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); + if (!chosen) return; + if (chosen == hide_show) { + federation_->setGroupVisible(group_id, !g->visible); + } else if (chosen == rename) { + bool ok = false; + QString name = QInputDialog::getText( + this, "Rename Group", "Group name:", QLineEdit::Normal, + g->display_name, &ok); + if (ok && !name.isEmpty()) federation_->setGroupName(group_id, name); + } else if (chosen == new_sub) { + bool ok = false; + QString name = QInputDialog::getText( + this, "New Subgroup", "Group name:", QLineEdit::Normal, + "Group", &ok); + if (ok && !name.isEmpty()) federation_->addGroup(name, group_id); + } else if (chosen == move_to_root) { + federation_->setGroupParent(group_id, QString()); + } else if (chosen == remove) { + federation_->removeGroup(group_id); + } else { + for (const auto& [a, gid] : move_targets) { + if (chosen == a) { + federation_->setGroupParent(group_id, gid); + break; + } + } + } + return; + } + + // === Empty area === + QAction* new_group = menu.addAction("New Group"); QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); if (!chosen) return; - if (chosen == hide_show) { - federation_->setModelVisible(fed_it->second, !m->visible); - } else if (chosen == remove) { - removeModel(mid); + if (chosen == new_group) { + bool ok = false; + QString name = QInputDialog::getText( + this, "New Group", "Group name:", QLineEdit::Normal, + "Group", &ok); + if (ok && !name.isEmpty()) federation_->addGroup(name, QString()); } } @@ -969,3 +1147,148 @@ void MainWindow::removeModel(uint32_t mid) { if (!fed_id.isEmpty()) federation_->removeModel(fed_id); updateWindowTitle(); } + +QString MainWindow::groupIdForItem(QTreeWidgetItem* item) const { + if (!item) return {}; + for (const auto& kv : group_tree_items_) { + if (kv.second == item) return kv.first; + } + return {}; +} + +QTreeWidgetItem* MainWindow::ensureGroupTreeItem(const QString& group_id) { + if (group_id.isEmpty()) return nullptr; + auto it = group_tree_items_.find(group_id); + if (it != group_tree_items_.end()) return it->second; + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return nullptr; + + auto* item = new QTreeWidgetItem(); + item->setText(0, g->display_name); + item->setText(1, "Group"); + group_tree_items_[group_id] = item; + return item; +} + +void MainWindow::reparentGroupTreeItem(const QString& group_id) { + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + QTreeWidgetItem* item = it->second; + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return; + + QTreeWidgetItem* desired_parent = nullptr; + if (g->parent != nullptr) { + auto pit = group_tree_items_.find(g->parent->id); + if (pit != group_tree_items_.end()) desired_parent = pit->second; + } + + QTreeWidgetItem* current_parent = item->parent(); + if (current_parent == desired_parent && + (current_parent != nullptr || + element_tree_->indexOfTopLevelItem(item) >= 0)) { + return; + } + + // Detach from current location. + if (current_parent) { + current_parent->removeChild(item); + } else { + int idx = element_tree_->indexOfTopLevelItem(item); + if (idx >= 0) element_tree_->takeTopLevelItem(idx); + } + // Attach to desired location. + if (desired_parent) desired_parent->addChild(item); + else element_tree_->addTopLevelItem(item); +} + +void MainWindow::reparentModelTreeRoot(uint32_t mid) { + auto root_it = tree_roots_.find(mid); + if (root_it == tree_roots_.end()) return; + QTreeWidgetItem* item = root_it->second; + + auto fed_it = model_id_to_fed_id_.find(mid); + QString group_id; + if (fed_it != model_id_to_fed_id_.end()) { + if (const Federation::Model* m = federation_->findById(fed_it->second)) { + group_id = m->group_id; + } + } + + QTreeWidgetItem* desired_parent = nullptr; + if (!group_id.isEmpty()) { + auto pit = group_tree_items_.find(group_id); + if (pit != group_tree_items_.end()) desired_parent = pit->second; + } + + QTreeWidgetItem* current_parent = item->parent(); + if (current_parent == desired_parent && + (current_parent != nullptr || + element_tree_->indexOfTopLevelItem(item) >= 0)) { + return; + } + + if (current_parent) { + current_parent->removeChild(item); + } else { + int idx = element_tree_->indexOfTopLevelItem(item); + if (idx >= 0) element_tree_->takeTopLevelItem(idx); + } + if (desired_parent) desired_parent->addChild(item); + else element_tree_->addTopLevelItem(item); +} + +void MainWindow::refreshGroupRowAppearance(const QString& group_id) { + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + QTreeWidgetItem* item = it->second; + const bool effective = federation_->isGroupChainVisible(group_id); + + QFont f = item->font(0); + f.setItalic(!effective); + f.setBold(true); + for (int col = 0; col < element_tree_->columnCount(); ++col) { + item->setFont(col, f); + item->setForeground( + col, + effective ? element_tree_->palette().color(QPalette::Text) + : element_tree_->palette().color(QPalette::Disabled, + QPalette::Text)); + } +} + +std::vector MainWindow::descendantGroupIds(const QString& group_id) const { + std::vector out; + auto walk = [&](auto&& self, + const std::vector>& src) -> void { + for (const auto& g : src) { + out.push_back(g->id); + self(self, g->children); + } + }; + if (group_id.isEmpty()) { + walk(walk, federation_->rootGroups()); + } else { + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return out; + out.push_back(g->id); + walk(walk, g->children); + } + return out; +} + +void MainWindow::applyVisibilityCascadeFromGroup(const QString& group_id) { + const std::vector gids = descendantGroupIds(group_id); + // Models directly assigned to one of these groups need their viewport + // visibility re-pushed because chain visibility may have flipped. + for (const auto& m : federation_->models()) { + const bool affected = + (group_id.isEmpty()) || + std::find(gids.begin(), gids.end(), m.group_id) != gids.end(); + if (!affected) continue; + auto it = fed_id_to_model_id_.find(m.id); + if (it != fed_id_to_model_id_.end()) { + applyModelVisibilityToViewport(it->second); + } + } +} diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 99e694de53..ff395fe51f 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -30,6 +30,7 @@ #include #include +#include #include "ViewportWindow.h" #include "SceneLoader.h" @@ -102,8 +103,31 @@ private: void removeModelUi(uint32_t mid); void removeModel(uint32_t mid); // Returns the model_id whose tree root is `item`, or 0 if `item` is not - // a model root (i.e. an element row, or null). + // a model root (i.e. an element row, group, or null). uint32_t modelIdForRoot(QTreeWidgetItem* item) const; + // Returns the group_id whose tree item is `item`, or empty string when + // `item` is null or is not a group item. + QString groupIdForItem(QTreeWidgetItem* item) const; + // Place / reparent a model root under its group (or at top level when + // group_id is empty / unknown). Idempotent. No-op if there's no tree + // root yet for `mid`. + void reparentModelTreeRoot(uint32_t mid); + // Place / reparent a group item under its parent group (or at top + // level). Idempotent. No-op if there's no tree item for `group_id`. + void reparentGroupTreeItem(const QString& group_id); + // Lazily create the QTreeWidgetItem for `group_id` if not already in + // group_tree_items_. Returns the item. Sets text + flags but does + // not place it under a parent — call reparentGroupTreeItem afterwards. + QTreeWidgetItem* ensureGroupTreeItem(const QString& group_id); + // Recompute italic/grey on a group row from current effective visibility. + void refreshGroupRowAppearance(const QString& group_id); + // Walk descendants of `group_id` and re-push effective visibility to + // the viewport for every model under it. When `group_id` is empty, + // re-pushes every model in the federation. + void applyVisibilityCascadeFromGroup(const QString& group_id); + // Walk every group_id whose ancestor chain currently includes + // `group_id` (inclusive). + std::vector descendantGroupIds(const QString& group_id) const; // Push the federation's `visible` flag for `mid` onto the viewport. // No-op if `mid` is not in the federation map. Idempotent — safe to // call before the model is finalised on the viewport (hideModel is a @@ -151,8 +175,11 @@ private: QLabel* status_label_ = nullptr; QLabel* stats_label_ = nullptr; - // Per-model tree roots, keyed by model_id. + // Per-model tree roots, keyed by model_id. May live at the top level + // of element_tree_ or as a child of a group tree item. std::map tree_roots_; + // Per-group tree items, keyed by Federation group id. + std::unordered_map group_tree_items_; // Bidirectional federation_id <-> model_id map. Federation owns the // persistent ids; SceneLoader owns the runtime model_ids. diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index d32e066617..c6183adcf3 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -31,7 +31,9 @@ #include #include +#include #include +#include namespace { constexpr const char* kSchema = "ifcfed/1"; @@ -220,6 +222,7 @@ void Federation::clear() { created_ = QDateTime(); modified_ = QDateTime(); models_.clear(); + root_groups_.clear(); config_ = FederationConfig{}; federated_false_origin_ = FederatedFalseOrigin{}; has_home_view_ = false; @@ -265,6 +268,193 @@ void Federation::setModelVisible(const QString& fed_id, bool visible) { } } +void Federation::setModelGroup(const QString& fed_id, const QString& group_id) { + if (!group_id.isEmpty() && findGroupById(group_id) == nullptr) return; + for (auto& m : models_) { + if (m.id != fed_id) continue; + if (m.group_id == group_id) return; + m.group_id = group_id; + setDirty(true); + emit modelGroupChanged(fed_id, group_id); + return; + } +} + +QString Federation::addGroup(const QString& display_name, + const QString& parent_id) { + Group* parent = nullptr; + if (!parent_id.isEmpty()) { + parent = findGroupByIdMutable(parent_id); + if (!parent) return {}; + } + + auto g = std::make_unique(); + g->id = generateId(); + g->display_name = display_name.isEmpty() ? QString("Group") : display_name; + g->parent = parent; + const QString new_id = g->id; + + if (parent) parent->children.push_back(std::move(g)); + else root_groups_.push_back(std::move(g)); + + setDirty(true); + emit groupAdded(new_id); + return new_id; +} + +void Federation::removeGroup(const QString& group_id) { + Group* group = findGroupByIdMutable(group_id); + if (!group) return; + + Group* new_parent = group->parent; + const QString new_parent_id = new_parent ? new_parent->id : QString(); + auto& target_children = new_parent ? new_parent->children : root_groups_; + + // Move out of the to-be-removed group. Splice into target_children + // *before* the removed group's slot when possible to keep stable + // visual order. We don't bother with the precise position — append + // is fine and simpler. + std::vector moved_child_ids; + moved_child_ids.reserve(group->children.size()); + for (auto& child : group->children) { + moved_child_ids.push_back(child->id); + child->parent = new_parent; + target_children.push_back(std::move(child)); + } + group->children.clear(); + + // Reparent direct child models up one level. + std::vector moved_model_ids; + for (auto& m : models_) { + if (m.group_id == group_id) { + m.group_id = new_parent_id; + moved_model_ids.push_back(m.id); + } + } + + // Detach + drop the now-empty group. + auto owned = detachGroup(group); + owned.reset(); + + setDirty(true); + for (const auto& cid : moved_child_ids) emit groupChanged(cid); + for (const auto& mid : moved_model_ids) emit modelGroupChanged(mid, new_parent_id); + emit groupRemoved(group_id); +} + +void Federation::setGroupName(const QString& group_id, + const QString& display_name) { + Group* g = findGroupByIdMutable(group_id); + if (!g) return; + if (g->display_name == display_name) return; + g->display_name = display_name; + setDirty(true); + emit groupChanged(group_id); +} + +void Federation::setGroupParent(const QString& group_id, + const QString& parent_id) { + if (group_id.isEmpty()) return; + if (parent_id == group_id) return; + + Group* group = findGroupByIdMutable(group_id); + if (!group) return; + + Group* new_parent = nullptr; + if (!parent_id.isEmpty()) { + new_parent = findGroupByIdMutable(parent_id); + if (!new_parent) return; + if (isDescendantOrSelf(group, new_parent)) return; + } + + if (group->parent == new_parent) return; + + auto owned = detachGroup(group); + if (!owned) return; + owned->parent = new_parent; + if (new_parent) new_parent->children.push_back(std::move(owned)); + else root_groups_.push_back(std::move(owned)); + + setDirty(true); + emit groupChanged(group_id); +} + +void Federation::setGroupVisible(const QString& group_id, bool visible) { + Group* g = findGroupByIdMutable(group_id); + if (!g) return; + if (g->visible == visible) return; + g->visible = visible; + setDirty(true); + emit groupVisibilityChanged(group_id, visible); +} + +const Federation::Group* Federation::findGroupById(const QString& group_id) const { + return const_cast(this)->findGroupByIdMutable(group_id); +} + +Federation::Group* Federation::findGroupByIdMutable(const QString& group_id) { + if (group_id.isEmpty()) return nullptr; + std::vector stack; + for (auto& g : root_groups_) stack.push_back(g.get()); + while (!stack.empty()) { + Group* g = stack.back(); + stack.pop_back(); + if (g->id == group_id) return g; + for (auto& c : g->children) stack.push_back(c.get()); + } + return nullptr; +} + +std::vector Federation::allGroups() const { + std::vector out; + for (const auto& g : root_groups_) appendDfs(g.get(), out); + return out; +} + +void Federation::appendDfs(const Group* g, std::vector& out) { + if (!g) return; + out.push_back(g); + for (const auto& c : g->children) appendDfs(c.get(), out); +} + +std::unique_ptr Federation::detachGroup(Group* group) { + if (!group) return nullptr; + auto& siblings = group->parent ? group->parent->children : root_groups_; + auto it = std::find_if(siblings.begin(), siblings.end(), + [group](const std::unique_ptr& up) { return up.get() == group; }); + if (it == siblings.end()) return nullptr; + std::unique_ptr owned = std::move(*it); + siblings.erase(it); + return owned; +} + +bool Federation::isDescendantOrSelf(const Group* group, + const Group* candidate_descendant) { + if (!group || !candidate_descendant) return false; + if (group == candidate_descendant) return true; + for (const auto& c : group->children) { + if (isDescendantOrSelf(c.get(), candidate_descendant)) return true; + } + return false; +} + +bool Federation::isGroupChainVisible(const QString& group_id) const { + if (group_id.isEmpty()) return true; + const Group* g = findGroupById(group_id); + while (g != nullptr) { + if (!g->visible) return false; + g = g->parent; + } + return true; +} + +bool Federation::isModelEffectivelyVisible(const QString& fed_id) const { + const Model* m = findById(fed_id); + if (!m) return false; + if (!m->visible) return false; + return isGroupChainVisible(m->group_id); +} + void Federation::markClean() { setDirty(false); } @@ -372,6 +562,41 @@ bool Federation::load(const QString& path, federated_false_origin_.rz_deg = oo.value("rz_deg").toDouble(0.0); } + // Groups load before models so model.group_id can be validated. + { + // Recursive descend over the nested "groups" array. Each entry is + // {id, display_name, visible?, groups?: [...]}. Children inherit + // their parent pointer at construction time. + std::function>&, + Group*)> load_groups; + load_groups = [&](const QJsonArray& arr, + std::vector>& sink, + Group* parent) { + for (int i = 0; i < arr.size(); ++i) { + if (!arr[i].isObject()) { + if (warnings) + *warnings << QString("groups: entry %1 is not an object; skipping.").arg(i); + continue; + } + QJsonObject go = arr[i].toObject(); + auto g = std::make_unique(); + g->id = go.value("id").toString(); + if (g->id.isEmpty()) g->id = generateId(); + g->display_name = go.value("display_name").toString(); + if (QJsonValue vv = go.value("visible"); vv.isBool()) + g->visible = vv.toBool(); + g->parent = parent; + + if (QJsonValue cv = go.value("groups"); cv.isArray()) { + load_groups(cv.toArray(), g->children, g.get()); + } + sink.push_back(std::move(g)); + } + }; + load_groups(root.value("groups").toArray(), root_groups_, nullptr); + } + QJsonArray arr = root.value("models").toArray(); for (int i = 0; i < arr.size(); ++i) { if (!arr[i].isObject()) { @@ -426,6 +651,14 @@ bool Federation::load(const QString& path, QJsonValue vv = mo.value("visible"); if (vv.isBool()) m.visible = vv.toBool(); + m.group_id = mo.value("group_id").toString(); + if (!m.group_id.isEmpty() && findGroupById(m.group_id) == nullptr) { + if (warnings) + *warnings << QString("models[%1]: unknown group_id '%2'; moved to root.") + .arg(i).arg(m.group_id); + m.group_id.clear(); + } + models_.push_back(std::move(m)); } @@ -481,6 +714,23 @@ bool Federation::save(const QString& path, QString* err) { root["federated_false_origin"] = oo; } + if (!root_groups_.empty()) { + std::function>&)> dump; + dump = [&](const std::vector>& src) { + QJsonArray out; + for (const auto& g : src) { + QJsonObject go; + go["id"] = g->id; + go["display_name"] = g->display_name; + if (!g->visible) go["visible"] = false; + if (!g->children.empty()) go["groups"] = dump(g->children); + out.append(go); + } + return out; + }; + root["groups"] = dump(root_groups_); + } + QJsonArray arr; for (const auto& m : models_) { QJsonObject mo; @@ -520,6 +770,7 @@ bool Federation::save(const QString& path, QString* err) { } if (!m.visible) mo["visible"] = false; + if (!m.group_id.isEmpty()) mo["group_id"] = m.group_id; arr.append(mo); } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index ddc5173e3f..2377e337bf 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -191,6 +192,29 @@ public: QString source_path; // resolved absolute when kind == "local" ModelTransformation model_transformation; bool visible = true; + QString group_id; // empty = root level + }; + + // Group — a named container for sub-groups and models. Models are + // assigned via Model::group_id (one-to-one); sub-groups live in + // `children` (owning). Visibility is per-group and cascades: a + // model is effectively visible only when its `visible` is true and + // every ancestor group's `visible` is true. + // + // `parent` is a non-owning back pointer, kept in sync by Federation + // mutations. Group ownership tree is rooted at Federation::root_groups_. + struct Group { + QString id; // stable, persisted + QString display_name; + bool visible = true; + std::vector> children; + Group* parent = nullptr; // not owned; nullptr at root + + Group() = default; + Group(const Group&) = delete; + Group& operator=(const Group&) = delete; + Group(Group&&) = default; + Group& operator=(Group&&) = default; }; explicit Federation(QObject* parent = nullptr); @@ -211,10 +235,39 @@ public: void setFederatedFalseOrigin(const FederatedFalseOrigin&); void setModelTransformation(const QString& fed_id, const ModelTransformation&); void setModelVisible(const QString& fed_id, bool visible); + // Reassign a model to a group (or to root, when group_id is empty). + // No-op when fed_id is unknown or group_id is unknown-and-non-empty. + void setModelGroup(const QString& fed_id, const QString& group_id); + + // Group mutations. All return / accept stable group ids. + QString addGroup(const QString& display_name = QString(), + const QString& parent_id = QString()); + // Removes the group; child sub-groups + child models are reparented + // to the removed group's parent (i.e. up one level). No-op when + // group_id is unknown. + void removeGroup(const QString& group_id); + void setGroupName(const QString& group_id, const QString& display_name); + // Reparents a group. No-op if the move would create a cycle (new + // parent is the group itself or one of its descendants) or if either + // id is unknown. + void setGroupParent(const QString& group_id, const QString& parent_id); + void setGroupVisible(const QString& group_id, bool visible); // Accessors const std::vector& models() const { return models_; } const Model* findById(const QString& fed_id) const; + // Top-level groups in insertion order; descend via Group::children. + const std::vector>& rootGroups() const { return root_groups_; } + const Group* findGroupById(const QString& group_id) const; + // Depth-first flatten: every group in the tree, parents before + // children. Cheap, intended for UI iteration. + std::vector allGroups() const; + // True iff every ancestor of `group_id` (inclusive of `group_id` + // itself) has visible == true. Returns true for empty group_id (root). + bool isGroupChainVisible(const QString& group_id) const; + // True iff the model exists, its own `visible` is true, and every + // ancestor group is visible. + bool isModelEffectivelyVisible(const QString& fed_id) const; bool isDirty() const { return dirty_; } void markClean(); QString filePath() const { return file_path_; } @@ -234,17 +287,40 @@ signals: void federatedFalseOriginChanged(); void modelTransformationChanged(const QString& fed_id); void modelVisibilityChanged(const QString& fed_id, bool visible); + void modelGroupChanged(const QString& fed_id, const QString& group_id); + + void groupAdded(const QString& group_id); + void groupRemoved(const QString& group_id); + // Emitted on rename or reparent. + void groupChanged(const QString& group_id); + // Visibility flip on this group only. Effective visibility of + // descendant models also changes; consumers that care should walk + // descendants themselves. + void groupVisibilityChanged(const QString& group_id, bool visible); private: void setDirty(bool d); static QString generateId(); static bool isFederationPath(const QString& path); + Group* findGroupByIdMutable(const QString& group_id); + // Detach a group from its current parent's children vector, returning + // ownership. group->parent is left set to its former parent — the + // caller must update it before reattachment. Returns nullptr if the + // group can't be found in the expected parent. + std::unique_ptr detachGroup(Group* group); + // True iff `candidate_descendant` is `group` itself or any descendant. + static bool isDescendantOrSelf(const Group* group, + const Group* candidate_descendant); + // DFS append for allGroups() and similar walks. + static void appendDfs(const Group* g, std::vector& out); + QString file_path_; QString name_; QDateTime created_; QDateTime modified_; std::vector models_; + std::vector> root_groups_; FederationConfig config_; FederatedFalseOrigin federated_false_origin_; bool has_home_view_ = false; diff --git a/src/ifcviewer/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp index 0f90eb36e2..a93ddec56b 100644 --- a/src/ifcviewer/tests/test_federation.cpp +++ b/src/ifcviewer/tests/test_federation.cpp @@ -457,6 +457,237 @@ TEST_CASE("composeFederatedFalseOrigin scales by federation unit", REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9); } +TEST_CASE("addGroup creates a top-level group; addGroup with parent nests it", + "[federation][groups]") { + ensureQApp(); + Federation fed; + QSignalSpy added_spy(&fed, &Federation::groupAdded); + + QString a = fed.addGroup("Site A"); + REQUIRE_FALSE(a.isEmpty()); + REQUIRE(fed.rootGroups().size() == 1); + REQUIRE(fed.rootGroups()[0]->id == a); + REQUIRE(fed.findGroupById(a)->parent == nullptr); + REQUIRE(fed.isDirty()); + REQUIRE(added_spy.count() == 1); + + QString sub = fed.addGroup("Building 1", a); + REQUIRE_FALSE(sub.isEmpty()); + REQUIRE(fed.rootGroups().size() == 1); // still one root + REQUIRE(fed.rootGroups()[0]->children.size() == 1); + REQUIRE(fed.rootGroups()[0]->children[0]->id == sub); + REQUIRE(fed.findGroupById(sub)->parent == fed.findGroupById(a)); + + // Unknown parent_id is rejected. + QString bad = fed.addGroup("Orphan", "no-such-id"); + REQUIRE(bad.isEmpty()); + + // allGroups walks parents-before-children. + auto all = fed.allGroups(); + REQUIRE(all.size() == 2); + REQUIRE(all[0]->id == a); + REQUIRE(all[1]->id == sub); +} + +TEST_CASE("setModelGroup assigns and reassigns; rejects unknown group", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + Federation fed; + QString mid = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString gid = fed.addGroup("G"); + fed.markClean(); + + QSignalSpy spy(&fed, &Federation::modelGroupChanged); + fed.setModelGroup(mid, gid); + REQUIRE(fed.findById(mid)->group_id == gid); + REQUIRE(fed.isDirty()); + REQUIRE(spy.count() == 1); + + // Idempotent. + fed.markClean(); + spy.clear(); + fed.setModelGroup(mid, gid); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE(spy.count() == 0); + + // Unknown group is rejected. + fed.setModelGroup(mid, "no-such-group"); + REQUIRE(fed.findById(mid)->group_id == gid); + REQUIRE_FALSE(fed.isDirty()); + + // Reassign back to root. + fed.setModelGroup(mid, QString()); + REQUIRE(fed.findById(mid)->group_id.isEmpty()); + REQUIRE(spy.count() == 1); +} + +TEST_CASE("setGroupVisible affects effective visibility cascade", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + Federation fed; + QString mid = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString outer = fed.addGroup("Outer"); + QString inner = fed.addGroup("Inner", outer); + fed.setModelGroup(mid, inner); + + REQUIRE(fed.isModelEffectivelyVisible(mid)); + REQUIRE(fed.isGroupChainVisible(inner)); + + // Hide the outer group: inner chain visibility flips, model effective + // visibility flips, but the model's own visible flag is untouched. + fed.setGroupVisible(outer, false); + REQUIRE_FALSE(fed.isGroupChainVisible(outer)); + REQUIRE_FALSE(fed.isGroupChainVisible(inner)); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + REQUIRE(fed.findById(mid)->visible); + + // Hiding a model directly while its group is also hidden — still + // effectively hidden. + fed.setModelVisible(mid, false); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + + // Re-show the outer group; model is still hidden by its own flag. + fed.setGroupVisible(outer, true); + REQUIRE(fed.isGroupChainVisible(inner)); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + + fed.setModelVisible(mid, true); + REQUIRE(fed.isModelEffectivelyVisible(mid)); +} + +TEST_CASE("setGroupParent rejects cycles and self-parenting", + "[federation][groups]") { + ensureQApp(); + Federation fed; + QString a = fed.addGroup("A"); + QString b = fed.addGroup("B", a); + QString c = fed.addGroup("C", b); + + // Self-parent: rejected. + fed.setGroupParent(a, a); + REQUIRE(fed.findGroupById(a)->parent == nullptr); + + // Parenting an ancestor under its descendant: rejected. + fed.setGroupParent(a, c); + REQUIRE(fed.findGroupById(a)->parent == nullptr); + REQUIRE(fed.findGroupById(c)->parent->id == b); + + // Valid reparent: move b up to root. + fed.setGroupParent(b, QString()); + REQUIRE(fed.findGroupById(b)->parent == nullptr); + REQUIRE(fed.findGroupById(c)->parent->id == b); // c stays under b + REQUIRE(fed.rootGroups().size() == 2); // a + b at root +} + +TEST_CASE("removeGroup reparents direct children + models up one level", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + Federation fed; + QString outer = fed.addGroup("Outer"); + QString mid_outer = fed.addGroup("MidOuter", outer); + QString inner = fed.addGroup("Inner", mid_outer); + + QString m_outer = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString m_mid = fed.addModel(writeStubFile(tmp.filePath("b.ifc"))); + QString m_inner = fed.addModel(writeStubFile(tmp.filePath("c.ifc"))); + fed.setModelGroup(m_outer, outer); + fed.setModelGroup(m_mid, mid_outer); + fed.setModelGroup(m_inner, inner); + fed.markClean(); + + QSignalSpy gc_spy(&fed, &Federation::groupChanged); + QSignalSpy mg_spy(&fed, &Federation::modelGroupChanged); + QSignalSpy gr_spy(&fed, &Federation::groupRemoved); + + // Remove the middle group: its child group (inner) and child model + // (m_mid) should both move up to `outer`. + fed.removeGroup(mid_outer); + + REQUIRE(fed.findGroupById(mid_outer) == nullptr); + REQUIRE(fed.findGroupById(inner)->parent->id == outer); + REQUIRE(fed.findById(m_mid)->group_id == outer); + // Untouched siblings. + REQUIRE(fed.findById(m_outer)->group_id == outer); + REQUIRE(fed.findById(m_inner)->group_id == inner); + REQUIRE(fed.isDirty()); + + REQUIRE(gc_spy.count() == 1); + REQUIRE(gc_spy.takeFirst().at(0).toString() == inner); + REQUIRE(mg_spy.count() == 1); + REQUIRE(mg_spy.takeFirst().at(0).toString() == m_mid); + REQUIRE(gr_spy.count() == 1); + REQUIRE(gr_spy.takeFirst().at(0).toString() == mid_outer); +} + +TEST_CASE("groups + model.group_id round-trip through nested JSON save/load", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + QString fed_path = tmp.filePath("p.ifcfed"); + + QString site_id, bldg_id, m_root, m_bldg; + { + Federation src; + site_id = src.addGroup("Site"); + bldg_id = src.addGroup("Building 1", site_id); + m_root = src.addModel(writeStubFile(tmp.filePath("root.ifc"))); + m_bldg = src.addModel(writeStubFile(tmp.filePath("bldg.ifc"))); + src.setModelGroup(m_bldg, bldg_id); + src.setGroupVisible(bldg_id, false); + + QString err; + REQUIRE(src.save(fed_path, &err)); + } + + // Inspect raw JSON: groups should be nested under "groups" with no + // parent_id field anywhere, and model.group_id is present only when set. + { + QJsonObject root = readJsonFile(fed_path); + REQUIRE(root.contains("groups")); + QJsonArray grps = root.value("groups").toArray(); + REQUIRE(grps.size() == 1); + QJsonObject site = grps[0].toObject(); + REQUIRE(site.value("display_name").toString() == "Site"); + REQUIRE_FALSE(site.contains("parent_id")); + + QJsonArray site_children = site.value("groups").toArray(); + REQUIRE(site_children.size() == 1); + QJsonObject bldg = site_children[0].toObject(); + REQUIRE(bldg.value("display_name").toString() == "Building 1"); + REQUIRE(bldg.value("visible").toBool() == false); + REQUIRE_FALSE(bldg.contains("parent_id")); + + QJsonArray models = root.value("models").toArray(); + REQUIRE(models.size() == 2); + // m_root is at root: no group_id key. + REQUIRE_FALSE(models[0].toObject().contains("group_id")); + // m_bldg is inside the building. + REQUIRE(models[1].toObject().value("group_id").toString() == bldg_id); + } + + { + Federation dst; + QStringList warnings; + QString err; + REQUIRE(dst.load(fed_path, &warnings, &err)); + REQUIRE(warnings.isEmpty()); + REQUIRE(dst.rootGroups().size() == 1); + REQUIRE(dst.rootGroups()[0]->id == site_id); + REQUIRE(dst.rootGroups()[0]->children.size() == 1); + REQUIRE(dst.rootGroups()[0]->children[0]->id == bldg_id); + REQUIRE_FALSE(dst.rootGroups()[0]->children[0]->visible); + REQUIRE(dst.rootGroups()[0]->children[0]->parent != nullptr); + REQUIRE(dst.rootGroups()[0]->children[0]->parent->id == site_id); + + REQUIRE(dst.findById(m_root)->group_id.isEmpty()); + REQUIRE(dst.findById(m_bldg)->group_id == bldg_id); + REQUIRE_FALSE(dst.isModelEffectivelyVisible(m_bldg)); + } +} + TEST_CASE("composeModelTransformation with pivot=B keeps A landing on B", "[federation][compose]") { // A in ModelGlobal frame, federation in metres, identity CoordinateOperation.