From 60cab3e7c43ec147f73c1c1a43fd9a15e249342e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 16:38:42 +1000 Subject: [PATCH] spatial hierarchy from IFC + active-model concept Build the spatial hierarchy panel from the loaded model's real IFC spatial structure instead of mock data: - helpers/element: add get_spatial_children (IsDecomposedBy -> RelatedObjects, filtered to spatial elements) to walk IfcProject -> IfcSite -> IfcBuilding -> IfcBuildingStorey -> IfcSpace. - SessionState: relay dataSourceReady as modelDataSourceReady (the .ifc for a sidecar hit loads asynchronously, so the tree can only build once it arrives). - spatial_hierarchy/View: walk the active model's IFC file into a TreeNode tree, naming nodes by Name (fallback to class), mapping site/building/storey kinds; siblings sorted with natural (numeric) collation. - spatial_hierarchy/Panel: tree now fills the panel height (setBodyExpanding + Expanding size policy); right-click menu for recursive Expand/Collapse Subtree and Expand/Collapse All. Add the concept of an active model: - SessionState: activeModelId / setActiveModelId / activeModelChanged; the first loaded model is active by default; reassigns/clears on removal. - Models panel: clicking a model makes it active; its cube icon is drawn with the accent colour (makeAccentSvgIcon) via FederationItemModel::setActiveModelId. - The spatial hierarchy reflects only the active model. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/SessionState.cpp | 21 +++++ src/bonsaiviewer/SessionState.h | 13 +++ .../modules/models/FederationItemModel.cpp | 16 +++- .../modules/models/FederationItemModel.h | 7 ++ src/bonsaiviewer/modules/models/Panel.cpp | 11 ++- src/bonsaiviewer/modules/models/View.cpp | 3 + .../modules/spatial_hierarchy/Panel.cpp | 29 ++++++ .../modules/spatial_hierarchy/View.cpp | 93 +++++++++++++++++-- .../modules/spatial_hierarchy/View.h | 1 + src/helpers/element.cpp | 33 +++++++ src/helpers/element.h | 5 + 11 files changed, 221 insertions(+), 11 deletions(-) diff --git a/src/bonsaiviewer/SessionState.cpp b/src/bonsaiviewer/SessionState.cpp index 418b8c2982..203c184583 100644 --- a/src/bonsaiviewer/SessionState.cpp +++ b/src/bonsaiviewer/SessionState.cpp @@ -94,6 +94,15 @@ void SessionState::createLoader(ViewportWindow* viewport) { connect(loader_, &SceneLoader::allLoadsFinished, this, [this]() { setStatusMessage("Loaded", QString("%1 model(s)").arg(loader_->modelCount())); }); + connect(loader_, &SceneLoader::dataSourceReady, this, [this](uint32_t session_model_id) { + emit modelDataSourceReady(session_model_id); + }); + // First model to load becomes the active model by default. + connect(this, &SessionState::modelGeometryReady, this, [this](uint32_t session_model_id) { + if (active_model_id_.isEmpty()) { + setActiveModelId(modelIdForSessionModelId(session_model_id)); + } + }); } void SessionState::setSelectedObjectId(uint32_t object_id) { @@ -129,12 +138,24 @@ void SessionState::removeModelMappingByModelId(const QString& model_id) { if (it == model_id_to_session_model_id_.end()) return; session_model_id_to_model_id_.remove(it.value()); model_id_to_session_model_id_.erase(it); + if (model_id == active_model_id_) { + setActiveModelId(model_id_to_session_model_id_.isEmpty() + ? QString() + : model_id_to_session_model_id_.keys().first()); + } } void SessionState::clearModelMappings() { model_id_to_session_model_id_.clear(); session_model_id_to_model_id_.clear(); cloud_metadata_.clear(); + setActiveModelId(QString()); +} + +void SessionState::setActiveModelId(const QString& model_id) { + if (model_id == active_model_id_) return; + active_model_id_ = model_id; + emit activeModelChanged(active_model_id_); } void SessionState::setCloudMetadata(const QString& model_id, const QVariantMap& metadata) { diff --git a/src/bonsaiviewer/SessionState.h b/src/bonsaiviewer/SessionState.h index 638e810129..672d5dc088 100644 --- a/src/bonsaiviewer/SessionState.h +++ b/src/bonsaiviewer/SessionState.h @@ -78,6 +78,12 @@ public: QString modelIdForSessionModelId(uint32_t session_model_id) const; QList sessionModelIds() const; + // The active model — the single model the spatial hierarchy (and other + // model-scoped views) operate on. Set by clicking a model in the models + // panel; defaults to the first loaded model. Empty when no model is loaded. + QString activeModelId() const { return active_model_id_; } + void setActiveModelId(const QString& model_id); + void notifySelectionChanged(); void notifyModelsChanged(); void notifyFederationChanged(); @@ -101,6 +107,12 @@ signals: // for both sidecar-cache and stream loads; subscribers that just need to // re-derive view state (e.g. ViewportView::refresh) listen to this. void modelGeometryReady(uint32_t session_model_id); + // Fires when a model's live IFC data source (the .ifc/.rdb, opened in the + // background after a sidecar-cache hit) becomes available for queries — + // e.g. so the spatial hierarchy can be built once the file is loaded. + void modelDataSourceReady(uint32_t session_model_id); + // Fires when the active model changes (empty model_id when cleared). + void activeModelChanged(const QString& model_id); // Fires when SceneLoader reports a load failure. SessionState turns the // raw loader signal into a session-level one so views (e.g. the MessageBox) // can subscribe without touching the loader directly. @@ -121,6 +133,7 @@ private: QString status_detail_; QHash model_id_to_session_model_id_; QHash session_model_id_to_model_id_; + QString active_model_id_; QHash cloud_metadata_; }; diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.cpp b/src/bonsaiviewer/modules/models/FederationItemModel.cpp index 2b9eda7ed3..9b30a654c1 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.cpp +++ b/src/bonsaiviewer/modules/models/FederationItemModel.cpp @@ -87,14 +87,28 @@ QStandardItem* FederationItemModel::makeGroupNameItem(const QString& group_id, c return item; } +QIcon FederationItemModel::modelIcon(const QString& model_id) const { + return model_id == active_model_id_ + ? components::icons::makeAccentSvgIcon(":/icons/cube.svg") + : components::icons::makeSvgIcon(":/icons/cube.svg"); +} + QStandardItem* FederationItemModel::makeModelNameItem(const QString& model_id, const QString& display_name) const { - auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/cube.svg"), display_name); + auto* item = new QStandardItem(modelIcon(model_id), display_name); item->setData(model_id, IdRole); item->setData(int(ItemKind::Model), KindRole); item->setEditable(false); return item; } +void FederationItemModel::setActiveModelId(const QString& model_id) { + if (model_id == active_model_id_) return; + const QString previous = active_model_id_; + active_model_id_ = model_id; + if (auto* item = id_to_name_item_.value(previous)) item->setIcon(modelIcon(previous)); + if (auto* item = id_to_name_item_.value(active_model_id_)) item->setIcon(modelIcon(active_model_id_)); +} + QStandardItem* FederationItemModel::makeVisibilityItem(ItemKind kind, bool visible) const { QString icon_path; if (kind == ItemKind::Group) { diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.h b/src/bonsaiviewer/modules/models/FederationItemModel.h index e2d007e681..5e6f00c534 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.h +++ b/src/bonsaiviewer/modules/models/FederationItemModel.h @@ -53,6 +53,10 @@ public: // preserving anyway). void rebuildAll(); + // The active model is drawn with an accent-coloured cube icon. Restyles the + // previously- and newly-active model rows. + void setActiveModelId(const QString& model_id); + private slots: void onGroupAdded(const QString& group_id); void onGroupRemoved(const QString& group_id); @@ -77,8 +81,11 @@ private: void appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id); void refreshSubtreeVisibility(QStandardItem* root); + QIcon modelIcon(const QString& model_id) const; // accent cube when active, else plain + Federation* federation_ = nullptr; QHash id_to_name_item_; // both group_ids and model_ids + QString active_model_id_; }; } // namespace bonsaiviewer::modules::models diff --git a/src/bonsaiviewer/modules/models/Panel.cpp b/src/bonsaiviewer/modules/models/Panel.cpp index 4a3a630a34..509abf6fac 100644 --- a/src/bonsaiviewer/modules/models/Panel.cpp +++ b/src/bonsaiviewer/modules/models/Panel.cpp @@ -249,8 +249,15 @@ ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state, addBodyWidget(section); connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) { - if (!index.isValid() || index.column() != 1) return; - commands::toggleVisibility(*session_state_, kindOf(index), idOf(index)); + if (!index.isValid()) return; + if (index.column() == 1) { + commands::toggleVisibility(*session_state_, kindOf(index), idOf(index)); + return; + } + // Clicking a model (its cube icon / row) makes it the active model. + if (kindOf(index) == ItemKind::Model) { + session_state_->setActiveModelId(idOf(index)); + } }); connect(tree_, &QTreeView::customContextMenuRequested, this, [this](const QPoint& pos) { diff --git a/src/bonsaiviewer/modules/models/View.cpp b/src/bonsaiviewer/modules/models/View.cpp index 9a8619c682..27ace0bbf6 100644 --- a/src/bonsaiviewer/modules/models/View.cpp +++ b/src/bonsaiviewer/modules/models/View.cpp @@ -70,6 +70,9 @@ ModelsPanelView::ModelsPanelView(ModelsPanel* widget, connect(&bonsaiviewer::ViewerSettings::instance(), &bonsaiviewer::ViewerSettings::themeChanged, this, rebuild); + connect(session_state_, &SessionState::activeModelChanged, this, [this](const QString& model_id) { + model_->setActiveModelId(model_id); + }); } } // namespace bonsaiviewer::modules::models diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp index 927f170e34..2aa30cc95e 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp +++ b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp @@ -24,17 +24,32 @@ #include "../../components/SvgIcon.h" #include +#include +#include #include #include namespace bonsaiviewer::modules::spatial_hierarchy { +namespace { + +void setSubtreeExpanded(QTreeWidgetItem* item, bool expanded) { + item->setExpanded(expanded); + for (int i = 0; i < item->childCount(); ++i) { + setSubtreeExpanded(item->child(i), expanded); + } +} + +} // namespace + SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent) : components::Panel("Spatial Hierarchy", nullptr, parent) { auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this); + section->setBodyExpanding(true); // let the tree fill the panel's height tree_ = new QTreeWidget(section); + tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); tree_->setColumnCount(2); tree_->setHeaderLabels({"Spatial Item", ""}); tree_->setIconSize(QSize(16, 16)); @@ -52,6 +67,20 @@ SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent) if (!item || column != 1) return; emit visibilityToggleRequested(itemPath(item)); }); + + // Right-click: recursive expand/collapse of a subtree or the whole tree. + tree_->setContextMenuPolicy(Qt::CustomContextMenu); + connect(tree_, &QTreeWidget::customContextMenuRequested, this, [this](const QPoint& pos) { + QMenu menu(tree_); + if (QTreeWidgetItem* item = tree_->itemAt(pos); item && item->childCount() > 0) { + menu.addAction("Expand Subtree", tree_, [item]() { setSubtreeExpanded(item, true); }); + menu.addAction("Collapse Subtree", tree_, [item]() { setSubtreeExpanded(item, false); }); + menu.addSeparator(); + } + menu.addAction("Expand All", tree_, [this]() { tree_->expandAll(); }); + menu.addAction("Collapse All", tree_, [this]() { tree_->collapseAll(); }); + menu.exec(tree_->viewport()->mapToGlobal(pos)); + }); } void SpatialHierarchyPanel::setNodes(const QList& nodes) { diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp b/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp index 353fecf7dc..6829e26628 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp +++ b/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp @@ -23,11 +23,33 @@ #include "Panel.h" #include "../../SessionState.h" +#include "../../../ifcviewer/SceneLoader.h" +#include "../../../ifcparse/file.h" +#include "../../../ifcparse/schema.h" + +#include "element.h" // helpers: get_spatial_children, get_string_attribute + +#include + +#include namespace bonsaiviewer::modules::spatial_hierarchy { namespace { +// Sort siblings by name with natural ordering (so "Level 2" precedes "Level 10"). +void sortByName(QList& nodes) { + static const QCollator collator = [] { + QCollator c; + c.setNumericMode(true); + c.setCaseSensitivity(Qt::CaseInsensitive); + return c; + }(); + std::sort(nodes.begin(), nodes.end(), [](const TreeNode& a, const TreeNode& b) { + return collator.compare(a.name, b.name) < 0; + }); +} + TreeNode* findNodeRecursive(QList& nodes, const NodePath& path, int depth) { for (auto& node : nodes) { if (node.name != path.at(depth)) continue; @@ -37,6 +59,33 @@ TreeNode* findNodeRecursive(QList& nodes, const NodePath& path, int de return nullptr; } +ItemKind kindOf(const express::Base& element) { + const auto& declaration = element.declaration(); + if (declaration.is("IfcSite")) return ItemKind::Site; + if (declaration.is("IfcBuilding")) return ItemKind::Building; + if (declaration.is("IfcBuildingStorey")) return ItemKind::Storey; + return ItemKind::Space; // IfcSpace, IfcSpatialZone, … +} + +QString displayName(const express::Base& element) { + if (auto name = get_string_attribute(element, "Name"); name && !name->empty()) { + return QString::fromStdString(*name); + } + return QString::fromStdString(element.declaration().name()); +} + +TreeNode buildNode(const express::Base& element) { + TreeNode node; + node.name = displayName(element); + node.kind = kindOf(element); + node.visible = true; + for (const auto& child : get_spatial_children(element)) { + node.children.append(buildNode(child)); + } + sortByName(node.children); + return node; +} + } // namespace SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widget, @@ -44,14 +93,6 @@ SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widg QObject* parent) : QObject(parent), widget_(widget), session_state_(session_state) { - nodes_ = { - {"Site A", ItemKind::Site, true, - {{"Building 01", ItemKind::Building, true, - {{"Level 02", ItemKind::Storey, true, - {{"Lobby", ItemKind::Space, true, {}}, - {"Core", ItemKind::Space, true, {}}}}}}}}, - }; - connect(widget_, &SpatialHierarchyPanel::visibilityToggleRequested, this, [this](const NodePath& path) { if (auto* node = findNode(path)) { node->visible = !node->visible; @@ -60,6 +101,42 @@ SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widg } }); + // The tree reflects the active model only. Rebuild when it changes, when its + // geometry or its live IFC data source arrives (the .ifc for a sidecar hit + // loads asynchronously), and on project open/reset. + connect(session_state_, &bonsaiviewer::SessionState::activeModelChanged, this, [this](const QString&) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::modelDataSourceReady, this, [this](uint32_t) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::modelGeometryReady, this, [this](uint32_t) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, [this](const QString&) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, [this]() { rebuild(); }); + + rebuild(); +} + +void SpatialHierarchyPanelView::rebuild() { + nodes_.clear(); + + auto* loader = session_state_->loader(); + const QString active_model_id = session_state_->activeModelId(); + if (loader != nullptr && !active_model_id.isEmpty()) { + const uint32_t session_model_id = session_state_->sessionModelIdForModelId(active_model_id); + ifcopenshell::file* file = session_model_id != 0 ? loader->ifcFile(session_model_id) : nullptr; + if (file != nullptr) { // null for a geometry-only model with no live IFC + try { + // IfcProject → IfcSite → … ; start the tree at the project's + // spatial children (the project itself has no ItemKind). + for (const auto& project : file->instances_by_type("IfcProject")) { + for (const auto& child : get_spatial_children(project)) { + nodes_.append(buildNode(child)); + } + } + } catch (const std::exception&) { + // Unsupported schema or malformed decomposition — show nothing. + } + } + } + + sortByName(nodes_); reload(); } diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/View.h b/src/bonsaiviewer/modules/spatial_hierarchy/View.h index 6e93e2b725..7fad4ed5e4 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/View.h +++ b/src/bonsaiviewer/modules/spatial_hierarchy/View.h @@ -38,6 +38,7 @@ public: QObject* parent = nullptr); private: + void rebuild(); // re-derive nodes_ from the loaded models' IFC spatial structure void reload(); TreeNode* findNode(const NodePath& path); diff --git a/src/helpers/element.cpp b/src/helpers/element.cpp index d6d854a283..820ce7a823 100644 --- a/src/helpers/element.cpp +++ b/src/helpers/element.cpp @@ -159,6 +159,25 @@ express::Base get_aggregate_s(const express::Base& element) { return relationship.RelatingObject(); } +// The spatial-structure children aggregated under this element (IsDecomposedBy → +// RelatedObjects, filtered to spatial elements). +template +std::vector get_spatial_children_s(const express::Base& element) { + std::vector children; + const auto object = element.template as(); + if (!object) { + return children; + } + for (const auto& relationship : object.IsDecomposedBy()) { + for (const auto& related : relationship.RelatedObjects()) { + if (related.template as()) { + children.push_back(related); + } + } + } + return children; +} + // ifcopenshell.util.element.get_container (should_get_direct=false, no // ifc_class): the directly containing spatial element, or the container of the // aggregate parent for an aggregated part. @@ -239,6 +258,20 @@ express::Base get_container(const express::Base& element) { unsupported_schema(name); } +std::vector get_spatial_children(const express::Base& element) { + if (!element) { + return {}; + } + const std::string name = schema_name(element); +#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \ + if (name == Identifier) { \ + return get_spatial_children_s(element); \ + } + IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH) +#undef IFCOPENSHELL_DISPATCH + unsupported_schema(name); +} + // getattr(element, name) for a string- or enum-valued attribute. Reads by name, // so it works uniformly across the various subtypes that carry a given // attribute (PredefinedType, Name, ...). diff --git a/src/helpers/element.h b/src/helpers/element.h index 2b15c3424b..3b477846c4 100644 --- a/src/helpers/element.h +++ b/src/helpers/element.h @@ -54,6 +54,11 @@ express::Base get_container(const express::Base& element); std::optional get_string_attribute(const express::Base& element, const std::string& name); +// The spatial-structure elements aggregated directly under `element` (its +// IsDecomposedBy → RelatedObjects, filtered to spatial elements). Used to walk +// the IfcProject → IfcSite → IfcBuilding → IfcBuildingStorey → IfcSpace tree. +std::vector get_spatial_children(const express::Base& element); + // The element's direct EXPRESS attributes that have a primitive scalar value // (string / enum / integer / real / boolean / logical), as (name, formatted // value) pairs in declaration order. Attributes that are entity references,