Files
IfcOpenShell/src/bonsaiviewer/modules/models/Commands.cpp
T
Dion Moult 79d8408684 models: consume .rdbview bundles (extract at load time)
A .rdbview is a zip of model.rdb/ (the lossy IFC data DB — the rdb
serializer skips IfcRepresentationItem) + model.ifcview (baked geometry).
The viewer could produce them but not open them.

- extractRdbview(): unzip a .rdbview (QZipReader) into a session temp dir
  keyed by a hash of path+mtime+size (reused on re-open), returning the
  extracted model.rdb. The producer's layout means sidecarPath(model.rdb)
  resolves the sibling model.ifcview automatically, so it then loads exactly
  like any pure .rdb: geometry from the sidecar, data from the .rdb via
  ifcopenshell::file(FT_AUTODETECT). No SceneLoader/engine changes.
- detail::loadModels() resolves each source path through it before
  queueModels (both fresh-open and project reload go through here), so the
  Federation persists the .rdbview while the loader gets the extracted .rdb.
- cleanupRdbviewCache() clears stale extractions at startup.
- .rdbview is offered under "Add Geometry" (the file picker; "Add IFC
  Database" is a directory picker), not "Add IFC File" — it's a lossy viewer
  bundle, not a source IFC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:36:51 +10:00

858 lines
36 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 "Commands.h"
#include "AddModelDialog.h"
#include "SettingsDialog.h"
#include "../../ElementRegistry.h"
#include "../../SessionState.h"
#include "../connectors/PickerDialog.h"
#include "../connectors/Process.h"
#include "../connectors/Registry.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/SceneLoader.h"
#include "../../../ifcviewer/SidecarBuilder.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include "../../../ifcgeom/Serializer.h"
#include "../../../serializers/document_serializer_plugin.h"
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QElapsedTimer>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QInputDialog>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QLineEdit>
#include <QListView>
#include <QMessageBox>
#include <QPointer>
#include <QStandardPaths>
#include <QThread>
#include <QTreeView>
#include <QUuid>
#include <QtCore/private/qzipwriter_p.h>
#include <QtCore/private/qzipreader_p.h>
#include <QCryptographicHash>
#include <Eigen/Dense>
#include <memory>
namespace bonsaiviewer::modules::models {
namespace {
bool should_guess_federated_false_origin_ = false;
} // namespace
void armFederatedFalseOriginGuess() {
should_guess_federated_false_origin_ = true;
}
bool consumeFederatedFalseOriginGuess() {
const bool armed = should_guess_federated_false_origin_;
should_guess_federated_false_origin_ = false;
return armed;
}
} // namespace bonsaiviewer::modules::models
namespace bonsaiviewer::modules::models::commands {
namespace {
QString formatElapsed(qint64 ms) {
return (ms >= 1000)
? QString::number(ms / 1000.0, 'f', 2) + " s"
: QString::number(ms) + " ms";
}
// Session-scoped scratch root where .rdbview bundles are unzipped for loading.
QString rdbviewCacheRoot() {
return QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation))
.filePath("ifcviewer-rdbview");
}
// A .rdbview is a zip of `model.rdb/` (the data DB) + `model.ifcview` (geometry
// sidecar). Unzip it into a per-source subdir (hashed from path + mtime + size,
// so a re-open reuses an existing extraction) and return the extracted
// `model.rdb` path — from there it loads exactly like any pure .rdb (geometry
// from the co-extracted sibling .ifcview). Returns empty on failure.
QString extractRdbview(const QString& rdbview_path) {
const QFileInfo info(rdbview_path);
const QString key = rdbview_path + '|'
+ QString::number(info.lastModified().toMSecsSinceEpoch()) + '|'
+ QString::number(info.size());
const QString hash = QString::fromLatin1(
QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha1).toHex());
const QString dir = QDir(rdbviewCacheRoot()).filePath(hash);
const QString rdb = QDir(dir).filePath("model.rdb");
if (QFileInfo::exists(rdb)) return rdb; // already extracted this session
QZipReader reader(rdbview_path);
if (reader.status() != QZipReader::NoError) return {};
QDir().mkpath(dir);
for (const QZipReader::FileInfo& entry : reader.fileInfoList()) {
if (!entry.isFile) continue; // dirs recreated below as needed
const QString out = QDir(dir).filePath(entry.filePath);
QDir().mkpath(QFileInfo(out).absolutePath());
QFile f(out);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(reader.fileData(entry.filePath));
}
return QFileInfo::exists(rdb) ? rdb : QString(); // empty if the bundle lacked model.rdb
}
// Map a source path to the path the loader should open: a .rdbview is unzipped
// to its extracted .rdb; everything else passes through unchanged.
QString resolveLoadPath(const QString& path) {
if (path.endsWith(".rdbview", Qt::CaseInsensitive)) return extractRdbview(path);
return path;
}
} // namespace
void cleanupRdbviewCache() {
QDir(rdbviewCacheRoot()).removeRecursively();
}
void toggleVisibility(SessionState& session, ItemKind kind, const QString& id) {
Federation* federation = session.federation();
if (kind == ItemKind::Group) {
const Federation::Group* group = federation->findGroupById(id);
if (!group) return;
federation->setGroupVisible(id, !group->visible);
session.notifyVisibilityChanged();
session.setStatusMessage("Models", group->visible ? "Group hidden" : "Group shown");
} else {
const Federation::Model* model = federation->findById(id);
if (!model) return;
federation->setModelVisible(id, !model->visible);
session.notifyVisibilityChanged();
session.setStatusMessage("Models", model->visible ? "Model hidden" : "Model shown");
}
}
void addGroup(SessionState& session, QWidget& host, const QString& parent_group_id) {
bool ok = false;
const QString name = QInputDialog::getText(
&host, "New Group", "Group name:", QLineEdit::Normal, "Group", &ok);
if (!ok) return;
const QString trimmed = name.trimmed();
if (trimmed.isEmpty()) return;
session.federation()->addGroup(trimmed, parent_group_id);
session.notifyFederationChanged();
session.setStatusMessage("Models", "Group added");
}
void renameGroup(SessionState& session, QWidget& host, const QString& group_id) {
const Federation::Group* group = session.federation()->findGroupById(group_id);
if (!group) return;
bool ok = false;
const QString name = QInputDialog::getText(
&host, "Rename Group", "Group name:", QLineEdit::Normal, group->display_name, &ok);
if (!ok) return;
const QString trimmed = name.trimmed();
if (trimmed.isEmpty()) return;
session.federation()->setGroupName(group_id, trimmed);
session.notifyFederationChanged();
session.setStatusMessage("Models", "Group renamed");
}
void moveGroup(SessionState& session, const QString& id, const QString& parent_group_id) {
session.federation()->setGroupParent(id, parent_group_id);
session.notifyFederationChanged();
session.setStatusMessage("Models", parent_group_id.isEmpty() ? "Group moved to root" : "Group moved");
}
void moveModels(SessionState& session, const QStringList& ids, const QString& parent_group_id) {
for (const auto& id : ids) {
session.federation()->setModelGroup(id, parent_group_id);
}
session.notifyFederationChanged();
session.setStatusMessage("Models", parent_group_id.isEmpty() ? "Model(s) moved to root" : "Model(s) moved");
}
void removeGroup(SessionState& session, QWidget& host, const QString& group_id) {
const Federation::Group* group = session.federation()->findGroupById(group_id);
if (!group) return;
const auto choice = QMessageBox::question(
&host, "Remove Group",
QString("Remove group '%1'? Models inside it will move to the parent.").arg(group->display_name),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (choice != QMessageBox::Yes) return;
session.federation()->removeGroup(group_id);
session.notifyFederationChanged();
session.setStatusMessage("Models", "Group removed");
}
void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& model_id) {
const Federation::Model* model = session.federation()->findById(model_id);
const QString label = model ? model->display_name : model_id;
const auto choice = QMessageBox::question(
&host, "Remove Model",
QString("Remove model '%1' from the federation?").arg(label),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (choice != QMessageBox::Yes) return;
const uint32_t session_model_id = session.sessionModelIdForModelId(model_id);
if (session_model_id == 0) {
session.federation()->removeModel(model_id);
session.notifyFederationChanged();
session.setStatusMessage("Models", "Model removed");
return;
}
if (session.loader()->isLoadingModel(session_model_id)) return;
viewport.setSelectedObjectId(0);
session.setSelectedObjectId(0);
session.federation()->removeModel(model_id);
viewport.removeModel(session_model_id);
session.loader()->removeModel(session_model_id);
session.elementRegistry()->removeModel(session_model_id);
session.removeModelMappingByModelId(model_id);
session.notifySelectionChanged();
session.notifyModelsChanged();
session.setStatusMessage("Models", "Model removed");
}
namespace detail {
void loadModels(SessionState& session, const QStringList& paths, const QStringList& model_ids) {
if (paths.isEmpty()) return;
// The Federation stores the source paths (e.g. a .rdbview); the loader gets
// the resolved load path (a .rdbview is unzipped at load time to its .rdb).
// Keep model_ids aligned with the paths that actually resolve.
QStringList load_paths;
QStringList load_model_ids;
for (int i = 0; i < paths.size(); ++i) {
const QString resolved = resolveLoadPath(paths[i]);
if (resolved.isEmpty()) {
session.setStatusMessage("Error",
QString("Could not open %1").arg(QFileInfo(paths[i]).fileName()));
continue;
}
load_paths.push_back(resolved);
load_model_ids.push_back(i < model_ids.size() ? model_ids[i] : QString());
}
if (load_paths.isEmpty()) return;
const auto session_model_ids = session.loader()->queueModels(load_paths);
for (int i = 0; i < load_paths.size()
&& i < static_cast<int>(session_model_ids.size())
&& i < load_model_ids.size(); ++i) {
session.setModelMapping(load_model_ids[i], session_model_ids[i]);
}
}
} // namespace detail
void addModel(SessionState& session, QWidget& host) {
AddModelDialog dialog(&host);
if (dialog.exec() != QDialog::Accepted) return;
QStringList paths;
switch (dialog.selectedMode()) {
case SourceMode::IfcFile: {
QFileDialog file_dialog(&host, "Add IFC Files");
file_dialog.setFileMode(QFileDialog::ExistingFiles);
file_dialog.setNameFilter("IFC Files (*.ifc);;All Files (*)");
file_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (file_dialog.exec() == QDialog::Accepted) {
paths = file_dialog.selectedFiles();
}
break;
}
case SourceMode::IfcDatabase: {
QFileDialog database_dialog(&host, "Add IFC Databases");
database_dialog.setFileMode(QFileDialog::Directory);
database_dialog.setOption(QFileDialog::ShowDirsOnly, true);
database_dialog.setOption(QFileDialog::DontResolveSymlinks, true);
database_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (auto* list = database_dialog.findChild<QListView*>("listView")) {
list->setSelectionMode(QAbstractItemView::ExtendedSelection);
}
if (auto* tree = database_dialog.findChild<QTreeView*>()) {
tree->setSelectionMode(QAbstractItemView::ExtendedSelection);
}
if (database_dialog.exec() == QDialog::Accepted) {
paths = database_dialog.selectedFiles();
}
break;
}
case SourceMode::GeometryOnly: {
QFileDialog file_dialog(&host, "Add Geometry");
file_dialog.setFileMode(QFileDialog::ExistingFiles);
file_dialog.setNameFilter(
"Viewer Model (*.ifcview *.rdbview);;IFC Viewer Cache (*.ifcview);;"
"Geometry Database (*.rdbview);;All Files (*)");
file_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (file_dialog.exec() == QDialog::Accepted) {
paths = file_dialog.selectedFiles();
}
break;
}
case SourceMode::CloudModel:
addModelFromCloud(session, host);
return;
case SourceMode::ConvertToDatabase:
convertIfcToDatabase(session, host);
return;
case SourceMode::ExportGeometryDatabase:
exportGeometryDatabase(session, host);
return;
case SourceMode::None:
return;
}
// Arm the false-origin guess if we're adding into a session with no
// models yet — the first model that finishes loading will set the
// origin via ViewportView. Checked here (before federation->addModel)
// because federation->addModel doesn't yet populate SessionState's
// model mapping; sessionModelIds() reflects pre-add state at this point.
if (session.sessionModelIds().isEmpty()) {
armFederatedFalseOriginGuess();
}
QStringList accepted_paths;
QStringList accepted_model_ids;
for (const auto& path : paths) {
const QString model_id =
session.federation()->addModel(path, QFileInfo(path).fileName());
if (model_id.isEmpty()) continue;
accepted_paths << path;
accepted_model_ids << model_id;
}
detail::loadModels(session, accepted_paths, accepted_model_ids);
session.notifyModelsChanged();
}
void addModelFromCloud(SessionState& session, QWidget& host) {
auto* registry = session.connectorRegistry();
const auto& manifests = registry->available();
if (manifests.empty()) {
QMessageBox::information(&host, "Add From Cloud",
"No connectors are installed.");
return;
}
modules::connectors::ConnectorPickerDialog picker(
manifests, "Add From Cloud",
"Pick a connector to browse models on.", &host);
if (picker.exec() != QDialog::Accepted) return;
const QString connector_id = picker.selectedId();
if (connector_id.isEmpty()) return;
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Add From Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return;
}
session.setStatusMessage("Cloud", QString("Browsing %1...").arg(connector_id));
QPointer<SessionState> sguard(&session);
proc->call("pull_models_interactive", QJsonValue(),
[sguard, connector_id](const QJsonValue& result) {
if (!sguard) return;
// Arm before the first addCloudModel — sessionModelIds() reflects the
// session state at the moment the connector returns, which is
// when the user's "add into empty session" intent applies.
if (sguard->sessionModelIds().isEmpty()) {
armFederatedFalseOriginGuess();
}
const QJsonArray arr = result.toArray();
QStringList paths;
QStringList model_ids;
int added = 0;
for (const QJsonValue& value : arr) {
if (value.isNull() || !value.isObject()) continue;
const QJsonObject entry = value.toObject();
const QString display_name = entry.value("display_name").toString();
const QString path = entry.value("path").toString();
if (path.isEmpty()) continue;
const QJsonObject source = entry.value("source").toObject();
QString src_connector = source.value("connector").toString();
if (src_connector.isEmpty()) src_connector = connector_id;
const QString model_id = sguard->federation()->addCloudModel(
display_name, src_connector, source);
if (model_id.isEmpty()) continue;
const QJsonObject meta = entry.value("metadata").toObject();
sguard->setCloudMetadata(model_id, meta.toVariantMap());
paths << path;
model_ids << model_id;
++added;
}
if (!paths.isEmpty()) {
detail::loadModels(*sguard, paths, model_ids);
sguard->notifyModelsChanged();
}
sguard->setStatusMessage("Cloud",
QString("Added %1 model(s) from %2").arg(added).arg(connector_id));
},
[sguard, connector_id](int code, const QString& message) {
qWarning() << "pull_models_interactive from" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
}
namespace {
// Shared "local path on disk" lookup for the right-click cloud commands:
// the loader keeps the path keyed by session_model_id (set when a file or pull_models
// path was queued). Both local-sourced and resolved cloud-sourced models
// have one; only un-resolved cloud models (where pull_models hasn't
// returned yet) won't.
QString localPathForModel(SessionState& session, const QString& model_id) {
const uint32_t session_model_id = session.sessionModelIdForModelId(model_id);
if (session_model_id == 0 || !session.loader()) return {};
return session.loader()->filePath(session_model_id);
}
} // namespace
void saveModelToCloud(SessionState& session, QWidget& host, const QString& model_id) {
auto* federation = session.federation();
const Federation::Model* model = federation->findById(model_id);
if (!model) return;
if (model->source_connector == "local") {
QMessageBox::information(&host, "Save Model To Cloud",
"This model has no cloud target. Use \"Save As To Cloud\" first.");
return;
}
const QString local_path = localPathForModel(session, model_id);
if (local_path.isEmpty()) {
QMessageBox::warning(&host, "Save Model To Cloud",
"Cannot find a local copy of this model to push.");
return;
}
const QString connector_id = model->source_connector;
auto* registry = session.connectorRegistry();
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Save Model To Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return;
}
QJsonObject source = model->source_data;
source["connector"] = connector_id;
QJsonObject params;
params["path"] = local_path;
params["source"] = source;
session.setStatusMessage("Cloud",
QString("Saving %1 to %2...").arg(model->display_name, connector_id));
QPointer<SessionState> sguard(&session);
proc->call("push_model", params,
[sguard, model_id, connector_id](const QJsonValue& result) {
if (!sguard) return;
const QJsonObject obj = result.toObject();
const QJsonObject new_source = obj.value("source").toObject();
QString new_connector = new_source.value("connector").toString();
if (new_connector.isEmpty()) new_connector = connector_id;
sguard->federation()->setModelSource(model_id, new_connector, new_source);
const QJsonObject meta = obj.value("metadata").toObject();
sguard->setCloudMetadata(model_id, meta.toVariantMap());
sguard->setStatusMessage("Cloud",
QString("Saved to %1").arg(new_connector));
},
[sguard, connector_id](int code, const QString& message) {
qWarning() << "push_model to" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
}
void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& model_id) {
const Federation::Model* model = session.federation()->findById(model_id);
if (!model) return;
const QString local_path = localPathForModel(session, model_id);
if (local_path.isEmpty()) {
QMessageBox::warning(&host, "Save Model As To Cloud",
"Cannot find a local copy of this model to push.");
return;
}
auto* registry = session.connectorRegistry();
const auto& manifests = registry->available();
if (manifests.empty()) {
QMessageBox::information(&host, "Save Model As To Cloud",
"No connectors are installed.");
return;
}
modules::connectors::ConnectorPickerDialog picker(
manifests, "Save Model As To Cloud",
QString("Pick a connector to push '%1' to.").arg(model->display_name),
&host);
if (picker.exec() != QDialog::Accepted) return;
const QString connector_id = picker.selectedId();
if (connector_id.isEmpty()) return;
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Save Model As To Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return;
}
QJsonObject params;
params["path"] = local_path;
session.setStatusMessage("Cloud",
QString("Pushing %1 to %2...").arg(model->display_name, connector_id));
QPointer<SessionState> sguard(&session);
proc->call("push_model_interactive", params,
[sguard, model_id, connector_id](const QJsonValue& result) {
if (!sguard) return;
const QJsonObject obj = result.toObject();
const QJsonObject new_source = obj.value("source").toObject();
QString new_connector = new_source.value("connector").toString();
if (new_connector.isEmpty()) new_connector = connector_id;
sguard->federation()->setModelSource(model_id, new_connector, new_source);
const QString new_name = obj.value("display_name").toString();
if (!new_name.isEmpty()) {
sguard->federation()->setModelDisplayName(model_id, new_name);
}
const QJsonObject meta = obj.value("metadata").toObject();
sguard->setCloudMetadata(model_id, meta.toVariantMap());
sguard->setStatusMessage("Cloud",
QString("Pushed to %1").arg(new_connector));
},
[sguard, connector_id](int code, const QString& message) {
qWarning() << "push_model_interactive to" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
}
void convertIfcToDatabase(SessionState& session, QWidget& host) {
QFileDialog input_dialog(&host, "Select IFC File to Convert");
input_dialog.setFileMode(QFileDialog::ExistingFile);
input_dialog.setNameFilter("IFC Files (*.ifc);;All Files (*)");
input_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (input_dialog.exec() != QDialog::Accepted) return;
const QStringList inputs = input_dialog.selectedFiles();
if (inputs.isEmpty()) return;
const QString input_path = inputs.first();
const QFileInfo input_info(input_path);
const QString default_output = input_info.absoluteDir().filePath(input_info.completeBaseName() + ".rdb");
QFileDialog output_dialog(&host, "Save IFC Database As");
output_dialog.setAcceptMode(QFileDialog::AcceptSave);
output_dialog.setFileMode(QFileDialog::AnyFile);
output_dialog.setNameFilter("IFC Database (*.rdb);;All Files (*)");
output_dialog.setDefaultSuffix("rdb");
output_dialog.selectFile(default_output);
output_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
output_dialog.setOption(QFileDialog::DontConfirmOverwrite, true);
if (output_dialog.exec() != QDialog::Accepted) return;
const QStringList outputs = output_dialog.selectedFiles();
if (outputs.isEmpty()) return;
QString output_path = outputs.first();
if (!output_path.endsWith(".rdb", Qt::CaseInsensitive)) {
output_path += ".rdb";
}
const QFileInfo output_info(output_path);
if (output_info.exists()) {
const QString message = QString("'%1' already exists. Overwrite?").arg(output_info.fileName());
if (QMessageBox::question(&host, "Convert IFC to Database", message,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) {
return;
}
}
session.beginProgress(QString("Converting %1 to %2…")
.arg(QFileInfo(input_path).fileName(),
QFileInfo(output_path).fileName()));
session.setStatusMessage("Converting",
QString("%1 → %2").arg(QFileInfo(input_path).fileName(), QFileInfo(output_path).fileName()));
auto timer = std::make_shared<QElapsedTimer>();
timer->start();
auto error_message = std::make_shared<QString>();
QThread* thread = QThread::create([input_path, output_path, error_message]() {
try {
ifcopenshell::serializers::document_serializer_context context;
context.file = nullptr;
context.input_filename = input_path.toStdString();
context.output_filename = output_path.toStdString();
context.stream = true;
auto& registry = ifcopenshell::serializers::document_serializer_registry_instance();
const auto* info = registry.find("rdb");
if (!info) {
throw ifcopenshell::exception(
"No 'rdb' document serializer is registered. The RocksDB serializer plugin may not be installed.");
}
if (!info->supports_input_filename) {
throw ifcopenshell::exception("RDB serializer does not support streaming from an input filename");
}
boost::shared_ptr<Serializer> serializer = registry.create("rdb", context);
serializer->finalize();
} catch (const std::exception& e) {
*error_message = QString::fromUtf8(e.what());
} catch (...) {
*error_message = "Unknown error during IFC to RDB conversion";
}
});
QObject::connect(thread, &QThread::finished, &host,
[&session, host_ptr = &host, thread, timer, error_message, input_path, output_path]() {
const qint64 elapsed = timer->elapsed();
session.endProgress();
thread->deleteLater();
if (!error_message->isEmpty()) {
session.setStatusMessage("Error", *error_message);
QMessageBox::warning(host_ptr, "Convert IFC to Database",
QString("Conversion failed:\n%1").arg(*error_message));
return;
}
session.setStatusMessage(
"Converted",
QString("%1 → %2 in %3")
.arg(QFileInfo(input_path).fileName(),
QFileInfo(output_path).fileName(),
formatElapsed(elapsed)));
QMessageBox::information(host_ptr, "Convert IFC to Database",
QString("Database written to:\n%1").arg(output_path));
});
thread->start();
}
void exportGeometryDatabase(SessionState& session, QWidget& host) {
QFileDialog input_dialog(&host, "Select IFC File to Export");
input_dialog.setFileMode(QFileDialog::ExistingFile);
input_dialog.setNameFilter("IFC Files (*.ifc);;All Files (*)");
input_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (input_dialog.exec() != QDialog::Accepted) return;
const QStringList inputs = input_dialog.selectedFiles();
if (inputs.isEmpty()) return;
const QString input_path = inputs.first();
const QFileInfo input_info(input_path);
const QString default_output = input_info.absoluteDir().filePath(input_info.completeBaseName() + ".rdbview");
QFileDialog output_dialog(&host, "Save Geometry Database As");
output_dialog.setAcceptMode(QFileDialog::AcceptSave);
output_dialog.setFileMode(QFileDialog::AnyFile);
output_dialog.setNameFilter("Geometry Database (*.rdbview);;All Files (*)");
output_dialog.setDefaultSuffix("rdbview");
output_dialog.selectFile(default_output);
output_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (output_dialog.exec() != QDialog::Accepted) return;
const QStringList outputs = output_dialog.selectedFiles();
if (outputs.isEmpty()) return;
QString output_path = outputs.first();
if (!output_path.endsWith(".rdbview", Qt::CaseInsensitive)) {
output_path += ".rdbview";
}
session.beginProgress(QString("Exporting %1 to %2…")
.arg(QFileInfo(input_path).fileName(),
QFileInfo(output_path).fileName()));
session.setStatusMessage("Exporting",
QString("%1 → %2").arg(QFileInfo(input_path).fileName(), QFileInfo(output_path).fileName()));
auto timer = std::make_shared<QElapsedTimer>();
timer->start();
auto error_message = std::make_shared<QString>();
QThread* thread = QThread::create([input_path, output_path, error_message]() {
// Scratch dir holds the intermediate .ifcview and .rdb directory
// until they're zipped into the .rdbview. RAII-like cleanup at the
// bottom of this lambda; on early exception we leak it (cheap
// tradeoff to keep the failure log around for the user).
const QString tmp_root = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation))
.filePath(QString("ifcviewer-export-%1")
.arg(QUuid::createUuid().toString(QUuid::Id128)));
QDir().mkpath(tmp_root);
const QString tmp_anchor = QDir(tmp_root).filePath("model.ifc");
const QString tmp_sidecar = QDir(tmp_root).filePath("model.ifcview");
const QString tmp_rdb_dir = QDir(tmp_root).filePath("model.rdb");
try {
ifcopenshell::serializers::document_serializer_context context;
context.file = nullptr;
context.input_filename = input_path.toStdString();
context.output_filename = tmp_rdb_dir.toStdString();
context.stream = true;
context.skip_supertypes = { "IfcRepresentationItem" };
auto& registry = ifcopenshell::serializers::document_serializer_registry_instance();
const auto* info = registry.find("rdb");
if (!info) {
throw ifcopenshell::exception(
"No 'rdb' document serializer is registered. The RocksDB serializer plugin may not be installed.");
}
if (!info->supports_input_filename) {
throw ifcopenshell::exception("RDB serializer does not support streaming from an input filename");
}
boost::shared_ptr<Serializer> serializer = registry.create("rdb", context);
serializer->finalize();
serializer.reset();
SidecarBuilder builder;
if (!builder.build(input_path, tmp_anchor)) {
throw ifcopenshell::exception(
("Sidecar build failed: " + builder.lastError()).toStdString());
}
if (!QFileInfo::exists(tmp_sidecar)) {
throw ifcopenshell::exception(
("Sidecar build reported success but " + tmp_sidecar + " is missing").toStdString());
}
// Write to a sibling `.tmp` then rename so a partial file never
// appears at the destination (matters for cloud-sync folders).
const QString temporary_zip = output_path + ".tmp";
QFile::remove(temporary_zip);
{
QZipWriter writer(temporary_zip);
if (writer.status() != QZipWriter::NoError) {
throw ifcopenshell::exception(
("Failed to open " + temporary_zip + " for writing").toStdString());
}
writer.setCompressionPolicy(QZipWriter::AutoCompress);
{
QFile sf(tmp_sidecar);
if (!sf.open(QIODevice::ReadOnly)) {
throw ifcopenshell::exception(
("Failed to read sidecar " + tmp_sidecar).toStdString());
}
writer.addFile("model.ifcview", sf.readAll());
}
QDirIterator it(tmp_rdb_dir, QDir::Files | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories);
const QDir rdb_root(tmp_rdb_dir);
while (it.hasNext()) {
const QString file_path = it.next();
const QString rel = rdb_root.relativeFilePath(file_path);
QFile f(file_path);
if (!f.open(QIODevice::ReadOnly)) {
throw ifcopenshell::exception(
("Failed to read " + file_path + " for zip").toStdString());
}
writer.addFile(QString("model.rdb/%1").arg(rel), f.readAll());
}
writer.close();
if (writer.status() != QZipWriter::NoError) {
throw ifcopenshell::exception(
("Failed to finalize " + temporary_zip).toStdString());
}
}
QFile::remove(output_path);
if (!QFile::rename(temporary_zip, output_path)) {
QFile::remove(temporary_zip);
throw ifcopenshell::exception(
("Failed to move " + temporary_zip + " to " + output_path).toStdString());
}
} catch (const std::exception& e) {
*error_message = QString::fromUtf8(e.what());
} catch (...) {
*error_message = "Unknown error during geometry database export";
}
QDir(tmp_root).removeRecursively();
});
QObject::connect(thread, &QThread::finished, &host,
[&session, host_ptr = &host, thread, timer, error_message, input_path, output_path]() {
const qint64 elapsed = timer->elapsed();
session.endProgress();
thread->deleteLater();
if (!error_message->isEmpty()) {
session.setStatusMessage("Error", *error_message);
QMessageBox::warning(host_ptr, "Export Geometry Database",
QString("Export failed:\n%1").arg(*error_message));
return;
}
session.setStatusMessage(
"Exported",
QString("%1 → %2 in %3")
.arg(QFileInfo(input_path).fileName(),
QFileInfo(output_path).fileName(),
formatElapsed(elapsed)));
QMessageBox::information(host_ptr, "Export Geometry Database",
QString("Geometry database written to:\n%1").arg(output_path));
});
thread->start();
}
void openSettings(SessionState& session, QWidget& host) {
SettingsDialog dialog(&session, &host);
dialog.exec();
}
} // namespace bonsaiviewer::modules::models::commands