// 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 . * * * ********************************************************************************/ #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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 renameModel(SessionState& session, QWidget& host, const QString& model_id) { const Federation::Model* model = session.federation()->findById(model_id); if (!model) return; bool ok = false; const QString name = QInputDialog::getText( &host, "Rename Model", "Model name:", QLineEdit::Normal, model->display_name, &ok); if (!ok) return; const QString trimmed = name.trimmed(); if (trimmed.isEmpty()) return; session.federation()->setModelDisplayName(model_id, trimmed); session.notifyFederationChanged(); session.setStatusMessage("Models", "Model 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"); } void viewModels(SessionState& session, ViewportWindow& viewport, const QStringList& model_ids) { // Federation ids are the panel's currency; the viewport speaks session // model ids. sessionModelIdForModelId returns 0 for a model the viewport // has never been given geometry for — skip those rather than framing id 0. std::vector session_model_ids; session_model_ids.reserve(std::size_t(model_ids.size())); for (const QString& model_id : model_ids) { const uint32_t session_model_id = session.sessionModelIdForModelId(model_id); if (session_model_id != 0) session_model_ids.push_back(session_model_id); } if (!viewport.viewModels(session_model_ids)) { session.setStatusMessage("Models", "Nothing to view — no loaded geometry"); return; } QString label = QString("%1 models").arg(model_ids.size()); if (model_ids.size() == 1) { const Federation::Model* model = session.federation()->findById(model_ids.front()); label = model ? model->display_name : model_ids.front(); } session.setStatusMessage("Models", QString("Viewing %1").arg(label)); } 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(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("listView")) { list->setSelectionMode(QAbstractItemView::ExtendedSelection); } if (auto* tree = database_dialog.findChild()) { 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 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 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 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(); timer->start(); auto error_message = std::make_shared(); 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 = 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(); timer->start(); auto error_message = std::make_shared(); 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 = 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