Files
IfcOpenShell/src/bonsaiviewer/modules/models/Panel.cpp
T
Dion Moult 8074541057 Surface VRAM shortfall to the user and let them unload models
When the geometry in view needs more GPU memory than the cache can
hold, the viewer keeps the largest on-screen chunks resident and streams
the rest as the camera moves. That is the right degradation, but it was
invisible: nothing told the user the scene did not fit, and the only
lever was removing or hiding models, neither of which is "keep it in the
federation but stop spending GPU memory on it".

Viewer core:
- ModelGpuData::unloaded, with drawable() = !hidden && !unloaded now the
  test every cull / draw / pick / streaming pass uses. unloadModel evicts
  every chunk and releases the model's own buffers; loadModel recreates
  them from the CPU mirrors (no disk read) and lets chunks stream back.
  Recompose keeps the CPU instances current while a model is unloaded so
  a reload sees up-to-date transforms. The MeshGpu/InstanceGpu record
  builders are factored out so load and reload share them.
- FrameStats reports the camera's working set: chunks wanted, how many
  of those are not resident, and their bytes.
- modelVramBytes / isModelUnloaded accessors, forwarded by ViewportWindow.

BonsaiViewer:
- Models tree gains a memory column (name | MB | eye) refreshed once a
  second and on load-state changes; unloaded models read "unloaded" in
  italics. The viewport stays the single authority for the state;
  SessionState only carries the modelLoadStateChanged notification.
- Context menu: "Unload Model" / "Load Model", distinct from hide and
  remove, reporting the MB freed in the status bar.
- Status bar notice, independent of the perf-stats toggle, once the
  shortfall has persisted for 3 s (a moment of missing chunks after any
  camera move is normal): "GPU memory full: N of M visible chunks (X MB)
  not loaded", with a tooltip pointing at Unload. The perf label also
  shows "N/M chunks waiting".

Verified on the GPU: unloading a 497 MB model frees it immediately with
the others still rendering; reloading streams all 180 chunks back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 07:42:13 +10:00

445 lines
19 KiB
C++

// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of Bonsai. *
* *
* Bonsai is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* Bonsai is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "Commands.h"
#include "FederationItemModel.h"
#include "View.h"
#include "../../SessionState.h"
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QDataStream>
#include <QDrag>
#include <QDragEnterEvent>
#include <QDragMoveEvent>
#include <QDropEvent>
#include <QHeaderView>
#include <QMenu>
#include <QMimeData>
#include <QSizePolicy>
#include <QTreeView>
namespace bonsaiviewer::modules::models {
namespace {
constexpr auto kDragMimeType = "application/x-bonsaiviewer-model-items";
QString idOf(const QModelIndex& index) {
return index.sibling(index.row(), 0).data(FederationItemModel::IdRole).toString();
}
ItemKind kindOf(const QModelIndex& index) {
return static_cast<ItemKind>(
index.sibling(index.row(), 0).data(FederationItemModel::KindRole).toInt());
}
QStringList selectedModelIdsAt(QTreeView* tree, const QModelIndex& clicked_index) {
QStringList ids;
const QModelIndexList selection = tree->selectionModel()->selectedRows(0);
bool clicked_in_selection = false;
for (const QModelIndex& index : selection) {
if (index == clicked_index.sibling(clicked_index.row(), 0)) {
clicked_in_selection = true;
break;
}
}
if (clicked_in_selection) {
for (const QModelIndex& index : selection) {
if (kindOf(index) == ItemKind::Model) {
ids << idOf(index);
}
}
ids.removeDuplicates();
} else {
ids << idOf(clicked_index);
}
return ids;
}
constexpr int kVisibilityColumnWidth = 28;
constexpr int kMemoryColumnWidth = 72; // "1234 MB" / "unloaded"
// QTreeView subclass that handles drag-and-drop. Drop logic dispatches
// through commands (not directly into the model) so notifications + status
// messages happen the same way as menu-driven moves.
class ModelsTreeView : public QTreeView {
public:
explicit ModelsTreeView(bonsaiviewer::SessionState* session_state, QWidget* parent)
: QTreeView(parent), session_state_(session_state) {}
protected:
void startDrag(Qt::DropActions actions) override {
const QModelIndexList selection = selectionModel()->selectedRows(0);
if (selection.isEmpty()) return;
const auto first_kind = kindOf(selection.first());
if (first_kind == ItemKind::Group && selection.size() != 1) return;
QByteArray payload;
QDataStream stream(&payload, QIODevice::WriteOnly);
stream << static_cast<int>(first_kind);
if (first_kind == ItemKind::Group) {
stream << idOf(selection.first());
} else {
QStringList ids;
for (const QModelIndex& index : selection) {
if (kindOf(index) != first_kind) return;
ids.push_back(idOf(index));
}
ids.removeDuplicates();
stream << ids;
}
auto* mime = new QMimeData();
mime->setData(QString::fromUtf8(kDragMimeType), payload);
auto* drag = new QDrag(this);
drag->setMimeData(mime);
drag->exec(actions);
}
void dragEnterEvent(QDragEnterEvent* event) override {
if (event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
event->acceptProposedAction();
return;
}
QTreeView::dragEnterEvent(event);
}
void dragMoveEvent(QDragMoveEvent* event) override {
if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
QTreeView::dragMoveEvent(event);
return;
}
QString target_group_id;
if (!decodeTargetGroup(event->position().toPoint(), target_group_id) ||
!canAcceptDrop(event->mimeData(), indexAt(event->position().toPoint()), target_group_id)) {
event->ignore();
return;
}
event->acceptProposedAction();
}
void dropEvent(QDropEvent* event) override {
if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
QTreeView::dropEvent(event);
return;
}
QString target_group_id;
if (!decodeTargetGroup(event->position().toPoint(), target_group_id) ||
!canAcceptDrop(event->mimeData(), indexAt(event->position().toPoint()), target_group_id)) {
event->ignore();
return;
}
QByteArray payload = event->mimeData()->data(QString::fromUtf8(kDragMimeType));
QDataStream stream(&payload, QIODevice::ReadOnly);
int kind_int = 0;
stream >> kind_int;
const auto kind = static_cast<ItemKind>(kind_int);
if (kind == ItemKind::Group) {
QString group_id;
stream >> group_id;
commands::moveGroup(*session_state_, group_id, target_group_id);
} else {
QStringList ids;
stream >> ids;
ids.removeDuplicates();
if (!ids.isEmpty()) commands::moveModels(*session_state_, ids, target_group_id);
}
event->acceptProposedAction();
}
private:
bool decodeTargetGroup(const QPoint& pos, QString& out) const {
out.clear();
const QModelIndex index = indexAt(pos);
if (!index.isValid()) return true;
if (kindOf(index) != ItemKind::Group) return false;
out = idOf(index);
return true;
}
bool canAcceptDrop(const QMimeData* mime,
const QModelIndex& target_index,
const QString& target_group_id) const {
QByteArray payload = mime->data(QString::fromUtf8(kDragMimeType));
QDataStream stream(&payload, QIODevice::ReadOnly);
int kind_int = 0;
stream >> kind_int;
const auto kind = static_cast<ItemKind>(kind_int);
if (kind == ItemKind::Group) {
QString group_id;
stream >> group_id;
if (group_id.isEmpty()) return false;
if (!target_index.isValid()) return true;
if (kindOf(target_index) != ItemKind::Group) return false;
if (group_id == target_group_id) return false;
for (QModelIndex ancestor_index = target_index; ancestor_index.isValid();
ancestor_index = ancestor_index.parent()) {
if (idOf(ancestor_index) == group_id) return false;
}
return true;
}
if (kind == ItemKind::Model) {
QStringList ids;
stream >> ids;
ids.removeDuplicates();
if (ids.isEmpty()) return false;
return !target_index.isValid() || !target_group_id.isNull();
}
return false;
}
bonsaiviewer::SessionState* session_state_;
};
} // namespace
ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QWidget* parent)
: components::Panel("Models", nullptr, parent, true)
, session_state_(session_state)
, viewport_(viewport)
{
auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this);
section->setBodyExpanding(true);
tree_ = new ModelsTreeView(session_state_, section);
tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
tree_->setIconSize(QSize(16, 16));
tree_->setSelectionMode(QAbstractItemView::ExtendedSelection);
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
tree_->setDragEnabled(true);
tree_->viewport()->setAcceptDrops(true);
tree_->setDropIndicatorShown(true);
tree_->setDragDropMode(QAbstractItemView::DragDrop);
tree_->setDefaultDropAction(Qt::MoveAction);
tree_->setUniformRowHeights(true);
tree_->setExpandsOnDoubleClick(false);
tree_->setEditTriggers(QAbstractItemView::NoEditTriggers);
tree_->header()->hide();
section->addBodyWidget(tree_);
addBodyWidget(section);
connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) {
if (!index.isValid()) return;
if (index.column() == VisibilityColumn) {
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) {
const QModelIndex index = tree_->indexAt(pos);
QMenu menu(tree_);
if (!index.isValid()) {
QAction* add_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "Add Group");
connect(add_group_action, &QAction::triggered, this, [this]() {
commands::addGroup(*session_state_, *this, QString());
});
menu.exec(tree_->viewport()->mapToGlobal(pos));
return;
}
const auto kind = kindOf(index);
const QString id = idOf(index);
QAction* toggle_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/eye.svg"), "Toggle Visibility");
connect(toggle_action, &QAction::triggered, this, [this, kind, id]() {
commands::toggleVisibility(*session_state_, kind, id);
});
if (kind == ItemKind::Group) {
QAction* add_subgroup = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Subgroup");
connect(add_subgroup, &QAction::triggered, this, [this, id]() {
commands::addGroup(*session_state_, *this, id);
});
QAction* rename = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder.svg"), "Rename Group");
connect(rename, &QAction::triggered, this, [this, id]() {
commands::renameGroup(*session_state_, *this, id);
});
QMenu* move_menu = menu.addMenu("Move to Parent");
QAction* move_root = move_menu->addAction("(Root)");
connect(move_root, &QAction::triggered, this, [this, id]() {
commands::moveGroup(*session_state_, id, QString());
});
move_menu->addSeparator();
for (const auto& target : validMoveTargets(*session_state_->federation(), id)) {
QAction* action = move_menu->addAction(target.display_name);
const QString target_id = target.id;
connect(action, &QAction::triggered, this, [this, id, target_id]() {
commands::moveGroup(*session_state_, id, target_id);
});
}
QAction* remove = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-minus.svg"), "Remove Group");
connect(remove, &QAction::triggered, this, [this, id]() {
commands::removeGroup(*session_state_, *this, id);
});
} else {
QString parent_group_id;
const QModelIndex parent_index = index.parent();
if (parent_index.isValid() && kindOf(parent_index) == ItemKind::Group) {
parent_group_id = idOf(parent_index);
}
const QStringList selected_model_ids = selectedModelIdsAt(tree_, index);
// Frame the camera on just these models — View All, scoped. Right
// above Rename so the two "do something with this model" actions
// that need no dialog sit together at the top.
QAction* view_models = menu.addAction(
components::icons::makeSvgIcon(":/icons/cube-scan.svg"),
selected_model_ids.size() > 1 ? "View Selected Models"
: "View Selected Model");
connect(view_models, &QAction::triggered, this, [this, selected_model_ids]() {
commands::viewModels(*session_state_, *viewport_, selected_model_ids);
});
QAction* rename = menu.addAction(
components::icons::makeSvgIcon(":/icons/cube.svg"), "Rename");
connect(rename, &QAction::triggered, this, [this, id]() {
commands::renameModel(*session_state_, *this, id);
});
QAction* add_group = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Group");
connect(add_group, &QAction::triggered, this, [this, parent_group_id]() {
commands::addGroup(*session_state_, *this, parent_group_id);
});
QMenu* move_menu = menu.addMenu("Move to Group");
QAction* move_root = move_menu->addAction("(Root)");
connect(move_root, &QAction::triggered, this, [this, selected_model_ids]() {
commands::moveModels(*session_state_, selected_model_ids, QString());
});
move_menu->addSeparator();
for (const auto& target : validMoveTargets(*session_state_->federation(), QString())) {
QAction* action = move_menu->addAction(target.display_name);
const QString target_id = target.id;
connect(action, &QAction::triggered, this, [this, selected_model_ids, target_id]() {
commands::moveModels(*session_state_, selected_model_ids, target_id);
});
}
const Federation::Model* selected = session_state_->federation()->findById(id);
const bool has_cloud_source = selected && selected->source_connector != "local";
menu.addSeparator();
QAction* save_to_cloud = menu.addAction(
components::icons::makeSvgIcon(":/icons/cloud-square.svg"), "Save To Cloud");
save_to_cloud->setEnabled(has_cloud_source);
if (!has_cloud_source) {
save_to_cloud->setToolTip(
"This model has no cloud target yet. Use \"Save As To Cloud\" first.");
}
connect(save_to_cloud, &QAction::triggered, this, [this, id]() {
commands::saveModelToCloud(*session_state_, *this, id);
});
QAction* save_as_to_cloud = menu.addAction(
components::icons::makeSvgIcon(":/icons/cloud-square.svg"), "Save As To Cloud");
connect(save_as_to_cloud, &QAction::triggered, this, [this, id]() {
commands::saveModelAsToCloud(*session_state_, *this, id);
});
// GPU residency. Unload keeps the model in the federation (and
// its visibility) but frees everything it holds on the GPU — the
// lever when the scene does not fit in VRAM. Load brings it back.
menu.addSeparator();
const uint32_t session_model_id = session_state_->sessionModelIdForModelId(id);
const bool unloaded = session_model_id != 0 && viewport_->isModelUnloaded(session_model_id);
QAction* residency = menu.addAction(
components::icons::makeSvgIcon(":/icons/cube.svg"),
unloaded ? "Load Model" : "Unload Model");
residency->setEnabled(session_model_id != 0);
residency->setToolTip(unloaded
? "Allocate GPU memory for this model again and stream its geometry back in."
: "Free this model's GPU memory while keeping it in the federation.");
connect(residency, &QAction::triggered, this, [this, id, unloaded]() {
if (unloaded) commands::loadModel(*session_state_, *viewport_, id);
else commands::unloadModel(*session_state_, *viewport_, id);
});
menu.addSeparator();
QAction* remove = menu.addAction(
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
connect(remove, &QAction::triggered, this, [this, id]() {
commands::removeModel(*session_state_, *viewport_, *this, id);
});
}
menu.exec(tree_->viewport()->mapToGlobal(pos));
});
connect(this, &components::Panel::settingsRequested, this, [this]() {
commands::openSettings(*session_state_, *this);
});
}
void ModelsPanel::setModel(FederationItemModel* model) {
model_ = model;
tree_->setModel(model);
// QHeaderView resets per-section resize modes to Interactive whenever the
// column set is rebuilt — which FederationItemModel::rebuildAll() does on
// project open / theme change (clear() + setColumnCount()). Re-apply the
// layout every time the columns reappear.
connect(tree_->header(), &QHeaderView::sectionCountChanged,
this, [this]() { applyColumnLayout(); });
applyColumnLayout();
tree_->expandAll();
}
void ModelsPanel::applyColumnLayout() {
// The name stretches to fill; memory and visibility are fixed.
QHeaderView* header = tree_->header();
if (header->count() < ColumnCount) return;
header->setStretchLastSection(false);
header->setMinimumSectionSize(kVisibilityColumnWidth);
header->setSectionResizeMode(NameColumn, QHeaderView::Stretch);
header->setSectionResizeMode(MemoryColumn, QHeaderView::Fixed);
header->resizeSection(MemoryColumn, kMemoryColumnWidth);
header->setSectionResizeMode(VisibilityColumn, QHeaderView::Fixed);
header->resizeSection(VisibilityColumn, kVisibilityColumnWidth);
}
} // namespace bonsaiviewer::modules::models