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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-08 16:38:42 +10:00
parent ec285fc32c
commit 60cab3e7c4
11 changed files with 221 additions and 11 deletions
@@ -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) {
@@ -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<QString, QStandardItem*> id_to_name_item_; // both group_ids and model_ids
QString active_model_id_;
};
} // namespace bonsaiviewer::modules::models
+9 -2
View File
@@ -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) {
+3
View File
@@ -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
@@ -24,17 +24,32 @@
#include "../../components/SvgIcon.h"
#include <QHeaderView>
#include <QMenu>
#include <QSizePolicy>
#include <QTreeWidget>
#include <QTreeWidgetItem>
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<TreeNode>& nodes) {
@@ -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 <QCollator>
#include <algorithm>
namespace bonsaiviewer::modules::spatial_hierarchy {
namespace {
// Sort siblings by name with natural ordering (so "Level 2" precedes "Level 10").
void sortByName(QList<TreeNode>& 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<TreeNode>& nodes, const NodePath& path, int depth) {
for (auto& node : nodes) {
if (node.name != path.at(depth)) continue;
@@ -37,6 +59,33 @@ TreeNode* findNodeRecursive(QList<TreeNode>& 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();
}
@@ -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);