mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 22:12:32 +00:00
ifcviewer: nested groups in federation, with cascading visibility
Federation gains a nested Group tree (id, display_name, visible, children); models reference a single group via Model::group_id. Visibility cascades: a model is effectively visible only when its own flag is on and every ancestor group is visible. Persistence nests groups directly in the JSON — no parent_id field. ifcviewer-full surfaces this in the element tree with right-click menus to create / rename / move / remove groups, move models between groups, and toggle group visibility. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,11 +28,14 @@
|
|||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMenuBar>
|
#include <QMenuBar>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
|
#include <QInputDialog>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QStatusBar>
|
#include <QStatusBar>
|
||||||
#include <QHeaderView>
|
#include <QHeaderView>
|
||||||
@@ -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) {
|
connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) {
|
||||||
setWindowModified(dirty);
|
setWindowModified(dirty);
|
||||||
});
|
});
|
||||||
@@ -314,11 +370,12 @@ void MainWindow::loadModelsFromPaths(const QStringList& paths,
|
|||||||
model_id_to_fed_id_[mid] = fed_id;
|
model_id_to_fed_id_[mid] = fed_id;
|
||||||
|
|
||||||
QString display = QFileInfo(paths[i]).fileName();
|
QString display = QFileInfo(paths[i]).fileName();
|
||||||
auto* root = new QTreeWidgetItem(element_tree_);
|
auto* root = new QTreeWidgetItem();
|
||||||
root->setText(0, display);
|
root->setText(0, display);
|
||||||
root->setText(1, "IFC Model");
|
root->setText(1, "IFC Model");
|
||||||
root->setData(0, Qt::UserRole, static_cast<uint32_t>(0));
|
root->setData(0, Qt::UserRole, static_cast<uint32_t>(0));
|
||||||
tree_roots_[mid] = root;
|
tree_roots_[mid] = root;
|
||||||
|
reparentModelTreeRoot(mid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +392,15 @@ bool MainWindow::openFederation(const QString& path) {
|
|||||||
|
|
||||||
clearScene();
|
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 paths;
|
||||||
QStringList fed_ids;
|
QStringList fed_ids;
|
||||||
QStringList missing;
|
QStringList missing;
|
||||||
@@ -449,6 +515,8 @@ void MainWindow::clearScene() {
|
|||||||
}
|
}
|
||||||
fed_id_to_model_id_.clear();
|
fed_id_to_model_id_.clear();
|
||||||
model_id_to_fed_id_.clear();
|
model_id_to_fed_id_.clear();
|
||||||
|
for (auto& kv : group_tree_items_) delete kv.second;
|
||||||
|
group_tree_items_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MainWindow::confirmDiscardIfDirty() {
|
bool MainWindow::confirmDiscardIfDirty() {
|
||||||
@@ -909,50 +977,160 @@ uint32_t MainWindow::modelIdForRoot(QTreeWidgetItem* item) const {
|
|||||||
void MainWindow::applyModelVisibilityToViewport(uint32_t mid) {
|
void MainWindow::applyModelVisibilityToViewport(uint32_t mid) {
|
||||||
auto fed_it = model_id_to_fed_id_.find(mid);
|
auto fed_it = model_id_to_fed_id_.find(mid);
|
||||||
if (fed_it == model_id_to_fed_id_.end()) return;
|
if (fed_it == model_id_to_fed_id_.end()) return;
|
||||||
const Federation::Model* m = federation_->findById(fed_it->second);
|
const QString& fed_id = fed_it->second;
|
||||||
if (!m) return;
|
const bool effective = federation_->isModelEffectivelyVisible(fed_id);
|
||||||
if (m->visible) viewport_->showModel(mid);
|
if (effective) viewport_->showModel(mid);
|
||||||
else viewport_->hideModel(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);
|
auto root_it = tree_roots_.find(mid);
|
||||||
if (root_it != tree_roots_.end()) {
|
if (root_it != tree_roots_.end()) {
|
||||||
QFont f = root_it->second->font(0);
|
QFont f = root_it->second->font(0);
|
||||||
f.setItalic(!m->visible);
|
f.setItalic(!effective);
|
||||||
for (int col = 0; col < element_tree_->columnCount(); ++col) {
|
for (int col = 0; col < element_tree_->columnCount(); ++col) {
|
||||||
root_it->second->setFont(col, f);
|
root_it->second->setFont(col, f);
|
||||||
root_it->second->setForeground(
|
root_it->second->setForeground(
|
||||||
col,
|
col,
|
||||||
m->visible ? element_tree_->palette().color(QPalette::Text)
|
effective ? element_tree_->palette().color(QPalette::Text)
|
||||||
: element_tree_->palette().color(QPalette::Disabled,
|
: element_tree_->palette().color(QPalette::Disabled,
|
||||||
QPalette::Text));
|
QPalette::Text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onTreeContextMenu(const QPoint& pos) {
|
void MainWindow::onTreeContextMenu(const QPoint& pos) {
|
||||||
QTreeWidgetItem* item = element_tree_->itemAt(pos);
|
QTreeWidgetItem* item = element_tree_->itemAt(pos);
|
||||||
uint32_t mid = modelIdForRoot(item);
|
const uint32_t mid = modelIdForRoot(item);
|
||||||
if (mid == 0) return; // not a model root — only roots get the menu
|
const QString group_id = groupIdForItem(item);
|
||||||
|
// Element rows (children of a model root) are excluded — only model
|
||||||
auto fed_it = model_id_to_fed_id_.find(mid);
|
// roots, group rows, and the empty area get a menu.
|
||||||
if (fed_it == model_id_to_fed_id_.end()) return;
|
const bool is_element_row =
|
||||||
const Federation::Model* m = federation_->findById(fed_it->second);
|
item != nullptr && mid == 0 && group_id.isEmpty();
|
||||||
if (!m) return;
|
if (is_element_row) return;
|
||||||
|
|
||||||
const bool currently_loading = loader_->isLoadingModel(mid);
|
|
||||||
|
|
||||||
QMenu menu(this);
|
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<std::pair<QAction*, QString>> 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<std::pair<QAction*, QString>> 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));
|
QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos));
|
||||||
if (!chosen) return;
|
if (!chosen) return;
|
||||||
if (chosen == hide_show) {
|
if (chosen == new_group) {
|
||||||
federation_->setModelVisible(fed_it->second, !m->visible);
|
bool ok = false;
|
||||||
} else if (chosen == remove) {
|
QString name = QInputDialog::getText(
|
||||||
removeModel(mid);
|
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);
|
if (!fed_id.isEmpty()) federation_->removeModel(fed_id);
|
||||||
updateWindowTitle();
|
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<QString> MainWindow::descendantGroupIds(const QString& group_id) const {
|
||||||
|
std::vector<QString> out;
|
||||||
|
auto walk = [&](auto&& self,
|
||||||
|
const std::vector<std::unique_ptr<Federation::Group>>& 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<QString> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "ViewportWindow.h"
|
#include "ViewportWindow.h"
|
||||||
#include "SceneLoader.h"
|
#include "SceneLoader.h"
|
||||||
@@ -102,8 +103,31 @@ private:
|
|||||||
void removeModelUi(uint32_t mid);
|
void removeModelUi(uint32_t mid);
|
||||||
void removeModel(uint32_t mid);
|
void removeModel(uint32_t mid);
|
||||||
// Returns the model_id whose tree root is `item`, or 0 if `item` is not
|
// 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;
|
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<QString> descendantGroupIds(const QString& group_id) const;
|
||||||
// Push the federation's `visible` flag for `mid` onto the viewport.
|
// Push the federation's `visible` flag for `mid` onto the viewport.
|
||||||
// No-op if `mid` is not in the federation map. Idempotent — safe to
|
// 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
|
// call before the model is finalised on the viewport (hideModel is a
|
||||||
@@ -151,8 +175,11 @@ private:
|
|||||||
QLabel* status_label_ = nullptr;
|
QLabel* status_label_ = nullptr;
|
||||||
QLabel* stats_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<uint32_t, QTreeWidgetItem*> tree_roots_;
|
std::map<uint32_t, QTreeWidgetItem*> tree_roots_;
|
||||||
|
// Per-group tree items, keyed by Federation group id.
|
||||||
|
std::unordered_map<QString, QTreeWidgetItem*> group_tree_items_;
|
||||||
|
|
||||||
// Bidirectional federation_id <-> model_id map. Federation owns the
|
// Bidirectional federation_id <-> model_id map. Federation owns the
|
||||||
// persistent ids; SceneLoader owns the runtime model_ids.
|
// persistent ids; SceneLoader owns the runtime model_ids.
|
||||||
|
|||||||
@@ -31,7 +31,9 @@
|
|||||||
#include <QJsonValue>
|
#include <QJsonValue>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr const char* kSchema = "ifcfed/1";
|
constexpr const char* kSchema = "ifcfed/1";
|
||||||
@@ -220,6 +222,7 @@ void Federation::clear() {
|
|||||||
created_ = QDateTime();
|
created_ = QDateTime();
|
||||||
modified_ = QDateTime();
|
modified_ = QDateTime();
|
||||||
models_.clear();
|
models_.clear();
|
||||||
|
root_groups_.clear();
|
||||||
config_ = FederationConfig{};
|
config_ = FederationConfig{};
|
||||||
federated_false_origin_ = FederatedFalseOrigin{};
|
federated_false_origin_ = FederatedFalseOrigin{};
|
||||||
has_home_view_ = false;
|
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<Group>();
|
||||||
|
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<QString> 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<QString> 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<Federation*>(this)->findGroupByIdMutable(group_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Federation::Group* Federation::findGroupByIdMutable(const QString& group_id) {
|
||||||
|
if (group_id.isEmpty()) return nullptr;
|
||||||
|
std::vector<Group*> 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<const Federation::Group*> Federation::allGroups() const {
|
||||||
|
std::vector<const Group*> out;
|
||||||
|
for (const auto& g : root_groups_) appendDfs(g.get(), out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Federation::appendDfs(const Group* g, std::vector<const Group*>& out) {
|
||||||
|
if (!g) return;
|
||||||
|
out.push_back(g);
|
||||||
|
for (const auto& c : g->children) appendDfs(c.get(), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Federation::Group> 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<Group>& up) { return up.get() == group; });
|
||||||
|
if (it == siblings.end()) return nullptr;
|
||||||
|
std::unique_ptr<Group> 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() {
|
void Federation::markClean() {
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
}
|
}
|
||||||
@@ -372,6 +562,41 @@ bool Federation::load(const QString& path,
|
|||||||
federated_false_origin_.rz_deg = oo.value("rz_deg").toDouble(0.0);
|
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<void(const QJsonArray&,
|
||||||
|
std::vector<std::unique_ptr<Group>>&,
|
||||||
|
Group*)> load_groups;
|
||||||
|
load_groups = [&](const QJsonArray& arr,
|
||||||
|
std::vector<std::unique_ptr<Group>>& 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<Group>();
|
||||||
|
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();
|
QJsonArray arr = root.value("models").toArray();
|
||||||
for (int i = 0; i < arr.size(); ++i) {
|
for (int i = 0; i < arr.size(); ++i) {
|
||||||
if (!arr[i].isObject()) {
|
if (!arr[i].isObject()) {
|
||||||
@@ -426,6 +651,14 @@ bool Federation::load(const QString& path,
|
|||||||
QJsonValue vv = mo.value("visible");
|
QJsonValue vv = mo.value("visible");
|
||||||
if (vv.isBool()) m.visible = vv.toBool();
|
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));
|
models_.push_back(std::move(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +714,23 @@ bool Federation::save(const QString& path, QString* err) {
|
|||||||
root["federated_false_origin"] = oo;
|
root["federated_false_origin"] = oo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!root_groups_.empty()) {
|
||||||
|
std::function<QJsonArray(const std::vector<std::unique_ptr<Group>>&)> dump;
|
||||||
|
dump = [&](const std::vector<std::unique_ptr<Group>>& 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;
|
QJsonArray arr;
|
||||||
for (const auto& m : models_) {
|
for (const auto& m : models_) {
|
||||||
QJsonObject mo;
|
QJsonObject mo;
|
||||||
@@ -520,6 +770,7 @@ bool Federation::save(const QString& path, QString* err) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!m.visible) mo["visible"] = false;
|
if (!m.visible) mo["visible"] = false;
|
||||||
|
if (!m.group_id.isEmpty()) mo["group_id"] = m.group_id;
|
||||||
|
|
||||||
arr.append(mo);
|
arr.append(mo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QVector3D>
|
#include <QVector3D>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -191,6 +192,29 @@ public:
|
|||||||
QString source_path; // resolved absolute when kind == "local"
|
QString source_path; // resolved absolute when kind == "local"
|
||||||
ModelTransformation model_transformation;
|
ModelTransformation model_transformation;
|
||||||
bool visible = true;
|
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<std::unique_ptr<Group>> 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);
|
explicit Federation(QObject* parent = nullptr);
|
||||||
@@ -211,10 +235,39 @@ public:
|
|||||||
void setFederatedFalseOrigin(const FederatedFalseOrigin&);
|
void setFederatedFalseOrigin(const FederatedFalseOrigin&);
|
||||||
void setModelTransformation(const QString& fed_id, const ModelTransformation&);
|
void setModelTransformation(const QString& fed_id, const ModelTransformation&);
|
||||||
void setModelVisible(const QString& fed_id, bool visible);
|
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
|
// Accessors
|
||||||
const std::vector<Model>& models() const { return models_; }
|
const std::vector<Model>& models() const { return models_; }
|
||||||
const Model* findById(const QString& fed_id) const;
|
const Model* findById(const QString& fed_id) const;
|
||||||
|
// Top-level groups in insertion order; descend via Group::children.
|
||||||
|
const std::vector<std::unique_ptr<Group>>& 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<const Group*> 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_; }
|
bool isDirty() const { return dirty_; }
|
||||||
void markClean();
|
void markClean();
|
||||||
QString filePath() const { return file_path_; }
|
QString filePath() const { return file_path_; }
|
||||||
@@ -234,17 +287,40 @@ signals:
|
|||||||
void federatedFalseOriginChanged();
|
void federatedFalseOriginChanged();
|
||||||
void modelTransformationChanged(const QString& fed_id);
|
void modelTransformationChanged(const QString& fed_id);
|
||||||
void modelVisibilityChanged(const QString& fed_id, bool visible);
|
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:
|
private:
|
||||||
void setDirty(bool d);
|
void setDirty(bool d);
|
||||||
static QString generateId();
|
static QString generateId();
|
||||||
static bool isFederationPath(const QString& path);
|
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<Group> 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<const Group*>& out);
|
||||||
|
|
||||||
QString file_path_;
|
QString file_path_;
|
||||||
QString name_;
|
QString name_;
|
||||||
QDateTime created_;
|
QDateTime created_;
|
||||||
QDateTime modified_;
|
QDateTime modified_;
|
||||||
std::vector<Model> models_;
|
std::vector<Model> models_;
|
||||||
|
std::vector<std::unique_ptr<Group>> root_groups_;
|
||||||
FederationConfig config_;
|
FederationConfig config_;
|
||||||
FederatedFalseOrigin federated_false_origin_;
|
FederatedFalseOrigin federated_false_origin_;
|
||||||
bool has_home_view_ = false;
|
bool has_home_view_ = false;
|
||||||
|
|||||||
@@ -457,6 +457,237 @@ TEST_CASE("composeFederatedFalseOrigin scales by federation unit",
|
|||||||
REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9);
|
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",
|
TEST_CASE("composeModelTransformation with pivot=B keeps A landing on B",
|
||||||
"[federation][compose]") {
|
"[federation][compose]") {
|
||||||
// A in ModelGlobal frame, federation in metres, identity CoordinateOperation.
|
// A in ModelGlobal frame, federation in metres, identity CoordinateOperation.
|
||||||
|
|||||||
Reference in New Issue
Block a user