mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
e4f8475ce8
Right-clicking a model in the models panel now offers "View Selected Model",
which frames the camera on just that model's geometry — View All, scoped to
one model. With several models selected the action reads "View Selected
Models" and frames their union, matching how the panel's existing Move to
Group already treats a multi-selection.
The AABB fold behind viewAll moves into InstanceCompose, which exists so this
kind of logic is unit-testable without a Qt window or a wgpu device (populating
ViewportCore's model map needs a real GPU, so the fold was previously
untestable in place). It splits in two:
- sceneWorldAabb — every VISIBLE model, what viewAll frames.
- modelsWorldAabb — only the named models, hidden or not. A model the caller
named explicitly is framed even if hidden; second-guessing
that is worse than honouring it. Models with no loaded
geometry contribute nothing, and if none of them do the
camera is left alone rather than flying to the origin.
Both are covered by six new cases in test_instance_compose (131 total).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
898 lines
38 KiB
C++
898 lines
38 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 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<uint32_t> 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<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
|