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:
Dion Moult
2026-05-04 17:39:28 +10:00
parent de5eb9641f
commit 095e4a1677
5 changed files with 936 additions and 28 deletions
+251
View File
@@ -31,7 +31,9 @@
#include <QJsonValue>
#include <QUuid>
#include <algorithm>
#include <cmath>
#include <functional>
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<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() {
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<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();
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<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;
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);
}
+76
View File
@@ -28,6 +28,7 @@
#include <QDateTime>
#include <QVector3D>
#include <memory>
#include <optional>
#include <string>
#include <vector>
@@ -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<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);
@@ -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<Model>& 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<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_; }
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<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 name_;
QDateTime created_;
QDateTime modified_;
std::vector<Model> models_;
std::vector<std::unique_ptr<Group>> root_groups_;
FederationConfig config_;
FederatedFalseOrigin federated_false_origin_;
bool has_home_view_ = false;
+231
View File
@@ -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.