Rename IfcViewerFull to Bonsai Viewer

Directory src/ifcviewer-full -> src/bonsaiviewer, CMake target
IfcViewerFull -> BonsaiViewer, namespace ifcviewerfull -> bonsaiviewer,
QApplication / window titles / connector path now use the new brand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-20 09:38:10 +10:00
parent fcae3d90af
commit 59e5b2b1b8
125 changed files with 308 additions and 308 deletions
@@ -0,0 +1,114 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Discovery.h"
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#include <QStandardPaths>
namespace bonsaiviewer::modules::connectors {
namespace {
QString resolveExec(const QString& exec_field, const QString& folder) {
QString resolved = QDir(folder).absoluteFilePath(exec_field);
#if defined(Q_OS_WIN)
if (!QFileInfo::exists(resolved) && QFileInfo::exists(resolved + ".exe")) {
resolved += ".exe";
}
#endif
return QDir::cleanPath(resolved);
}
bool parseManifest(const QString& folder, ConnectorManifest& out) {
const QString manifest_path = QDir(folder).filePath("connector.json");
QFile file(manifest_path);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "ifcviewer connectors: cannot read" << manifest_path
<< ":" << file.errorString();
return false;
}
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "ifcviewer connectors: malformed manifest"
<< manifest_path << ":" << err.errorString();
return false;
}
const QJsonObject obj = doc.object();
const QString id = obj.value("id").toString();
const QString name = obj.value("name").toString();
const QString version = obj.value("version").toString();
const QString exec_field = obj.value("exec").toString();
if (id.isEmpty() || name.isEmpty() || exec_field.isEmpty()) {
qWarning() << "ifcviewer connectors: missing required field (id/name/exec) in"
<< manifest_path;
return false;
}
out.id = id;
out.name = name;
out.version = version;
out.folder = QDir::cleanPath(folder);
out.exec_path = resolveExec(exec_field, out.folder);
return true;
}
} // namespace
QString userConnectorsDir() {
// GenericDataLocation maps to the platform's per-user data root:
// Linux -> ~/.local/share
// macOS -> ~/Library/Application Support
// Win -> %APPDATA% (Roaming)
// matching CLOUD_SYNC_PROTOCOL.md's listed locations.
const QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
return QDir(base).filePath("IfcOpenShell/BonsaiViewer/connectors");
}
std::vector<ConnectorManifest> discoverConnectors() {
std::vector<ConnectorManifest> result;
QSet<QString> seen_ids;
QDir dir(userConnectorsDir());
if (!dir.exists()) return result;
const QFileInfoList entries = dir.entryInfoList(
QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
for (const QFileInfo& entry : entries) {
if (!QFileInfo(QDir(entry.absoluteFilePath()).filePath("connector.json")).exists()) continue;
ConnectorManifest m;
if (!parseManifest(entry.absoluteFilePath(), m)) continue;
if (seen_ids.contains(m.id)) {
qWarning() << "ifcviewer connectors: duplicate id" << m.id
<< "at" << entry.absoluteFilePath()
<< "ignored (earlier match wins)";
continue;
}
seen_ids.insert(m.id);
result.push_back(std::move(m));
}
return result;
}
} // namespace bonsaiviewer::modules::connectors
@@ -0,0 +1,50 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_CONNECTORS_DISCOVERY_H
#define IFCINTERFACE_MODULES_CONNECTORS_DISCOVERY_H
#include <QString>
#include <vector>
namespace bonsaiviewer::modules::connectors {
struct ConnectorManifest {
QString id; // from connector.json; stable identifier
QString name; // human-readable label
QString version; // informational
QString folder; // directory containing connector.json
QString exec_path; // resolved executable; absolute when possible,
// otherwise a bare name for PATH lookup at launch
};
// Scans the per-user connectors directory per CLOUD_SYNC_PROTOCOL.md.
// First match wins for any given id; duplicates and malformed manifests
// are skipped with a qWarning. Returned in discovery order so first-wins
// is observable to callers.
std::vector<ConnectorManifest> discoverConnectors();
// Per-user connectors directory (platform-specific). Exposed for tests and
// for "open user connectors dir" affordances.
QString userConnectorsDir();
} // namespace bonsaiviewer::modules::connectors
#endif
@@ -0,0 +1,79 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "PickerDialog.h"
#include "../../components/Buttons.h"
#include "../../components/Section.h"
#include "../../components/Style.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::modules::connectors {
ConnectorPickerDialog::ConnectorPickerDialog(const std::vector<ConnectorManifest>& manifests,
const QString& title,
const QString& description,
QWidget* parent)
: components::Dialog(parent)
{
setObjectName("appDialog");
setWindowTitle(title);
setModal(true);
if (auto* root = qobject_cast<QVBoxLayout*>(layout())) {
root->setSizeConstraint(QLayout::SetFixedSize);
}
auto* description_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* description_label = new QLabel(description, description_section);
description_label->setProperty("textRole", "secondary");
description_label->setWordWrap(true);
description_label->setAlignment(Qt::AlignCenter);
description_label->setMinimumWidth((90 * 3) + (components::style::metrics::padding * 2));
description_section->addBodyWidget(description_label);
auto* choices_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* choices = new QWidget(choices_section);
auto* row = new QHBoxLayout(choices);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(components::style::metrics::padding);
QList<QToolButton*> buttons;
for (const auto& m : manifests) {
auto* button = components::buttons::makeButton(m.name, ":/icons/cloud-square.svg", choices);
const QString id = m.id;
connect(button, &QToolButton::clicked, this, [this, id]() {
selected_id_ = id;
accept();
});
buttons.push_back(button);
}
row->addWidget(components::buttons::makeButtonGroup("CONNECTORS", buttons, choices, true, 8));
choices_section->addBodyWidget(choices);
addBodyWidget(description_section);
addBodyWidget(choices_section);
}
} // namespace bonsaiviewer::modules::connectors
@@ -0,0 +1,54 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_CONNECTORS_PICKERDIALOG_H
#define IFCINTERFACE_MODULES_CONNECTORS_PICKERDIALOG_H
#include "Discovery.h"
#include "../../components/Dialog.h"
#include <QString>
#include <vector>
namespace bonsaiviewer::modules::connectors {
// Connector chooser, modelled on AddModelDialog: one button per available
// connector. Always shown (even when only one connector is installed), per
// project UX direction.
class ConnectorPickerDialog : public components::Dialog {
Q_OBJECT
public:
// `title` and `description` adapt the dialog to the calling workflow
// (e.g. "Open from Cloud", "Save Model to Cloud").
ConnectorPickerDialog(const std::vector<ConnectorManifest>& manifests,
const QString& title,
const QString& description,
QWidget* parent = nullptr);
QString selectedId() const { return selected_id_; }
private:
QString selected_id_;
};
} // namespace bonsaiviewer::modules::connectors
#endif
@@ -0,0 +1,197 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Process.h"
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QStringList>
#include <QTimer>
namespace bonsaiviewer::modules::connectors {
namespace {
constexpr int kStartTimeoutMs = 5000;
constexpr int kShutdownTimeoutMs = 3000;
// Reserved out-of-band error codes used by the host (not by the connector).
// JSON-RPC reserves -32000..-32099 for "implementation-defined server
// errors"; we co-opt three for transport failures.
constexpr int kErrLaunchFailed = -32000;
constexpr int kErrShutdown = -32001;
constexpr int kErrProcessExited = -32002;
} // namespace
ConnectorProcess::ConnectorProcess(ConnectorManifest manifest, QObject* parent)
: QObject(parent)
, manifest_(std::move(manifest))
{
}
ConnectorProcess::~ConnectorProcess() {
shutdown();
}
bool ConnectorProcess::isRunning() const {
return process_ && process_->state() != QProcess::NotRunning;
}
bool ConnectorProcess::ensureStarted() {
if (isRunning()) return true;
if (manifest_.exec_path.isEmpty()) {
last_error_ = QString("Connector '%1' has no executable configured.")
.arg(manifest_.id);
return false;
}
process_ = new QProcess(this);
process_->setWorkingDirectory(manifest_.folder);
process_->setProcessChannelMode(QProcess::SeparateChannels);
connect(process_, &QProcess::readyReadStandardOutput,
this, &ConnectorProcess::onReadyReadStdout);
connect(process_, &QProcess::finished,
this, &ConnectorProcess::onProcessFinished);
process_->start(manifest_.exec_path, QStringList{});
if (!process_->waitForStarted(kStartTimeoutMs)) {
last_error_ = QString("Failed to launch connector '%1': %2")
.arg(manifest_.id, process_->errorString());
process_->deleteLater();
process_ = nullptr;
return false;
}
last_error_.clear();
shutting_down_ = false;
return true;
}
void ConnectorProcess::call(const QString& method,
const QJsonValue& params,
ResultHandler on_result,
ErrorHandler on_error) {
if (!ensureStarted()) {
const QString err = last_error_;
// Queue rather than invoke inline so callers see uniform async order
// regardless of whether the launch succeeded.
QTimer::singleShot(0, this, [on_error = std::move(on_error), err]() {
if (on_error) on_error(kErrLaunchFailed, err);
});
return;
}
const QString id = QString::number(next_request_id_++);
pending_.insert(id, Pending{std::move(on_result), std::move(on_error)});
QJsonObject msg;
msg["jsonrpc"] = "2.0";
msg["id"] = id;
msg["method"] = method;
if (!params.isUndefined() && !params.isNull()) {
msg["params"] = params;
}
const QByteArray line = QJsonDocument(msg).toJson(QJsonDocument::Compact) + '\n';
process_->write(line);
}
void ConnectorProcess::shutdown() {
if (!process_) return;
shutting_down_ = true;
if (process_->state() == QProcess::Running) {
process_->closeWriteChannel();
if (!process_->waitForFinished(kShutdownTimeoutMs)) {
qWarning() << "ifcviewer connectors:" << manifest_.id
<< "did not exit within" << kShutdownTimeoutMs
<< "ms after stdin close; killing.";
process_->kill();
process_->waitForFinished(1000);
}
}
failPendingAndClear(kErrShutdown,
QString("Connector '%1' was shut down.").arg(manifest_.id));
process_->deleteLater();
process_ = nullptr;
}
void ConnectorProcess::onReadyReadStdout() {
if (!process_) return;
buffer_.append(process_->readAllStandardOutput());
int newline = buffer_.indexOf('\n');
while (newline >= 0) {
const QByteArray line = buffer_.left(newline);
buffer_.remove(0, newline + 1);
if (!line.isEmpty()) dispatchLine(line);
newline = buffer_.indexOf('\n');
}
}
void ConnectorProcess::onProcessFinished(int exit_code, QProcess::ExitStatus status) {
const bool unexpected = !shutting_down_;
const QString reason = (status == QProcess::CrashExit)
? QString("Connector '%1' crashed (exit %2).").arg(manifest_.id).arg(exit_code)
: QString("Connector '%1' exited (code %2).").arg(manifest_.id).arg(exit_code);
failPendingAndClear(kErrProcessExited, reason);
if (unexpected) emit crashed(reason);
}
void ConnectorProcess::dispatchLine(const QByteArray& line) {
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(line, &err);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "ifcviewer connectors:" << manifest_.id
<< "emitted non-JSON line:" << line << err.errorString();
return;
}
const QJsonObject obj = doc.object();
if (obj.value("jsonrpc").toString() != "2.0") {
qWarning() << "ifcviewer connectors:" << manifest_.id
<< "ignored message missing jsonrpc=2.0:" << line;
return;
}
// Connector echoes our id verbatim (we always send strings), but accept
// numeric ids too in case a connector parses our "0" as an int.
const QString id = obj.value("id").toVariant().toString();
if (!pending_.contains(id)) {
qWarning() << "ifcviewer connectors:" << manifest_.id
<< "ignored response with unknown id:" << id;
return;
}
Pending pending = pending_.take(id);
if (obj.contains("error")) {
const QJsonObject err_obj = obj.value("error").toObject();
const int code = err_obj.value("code").toInt();
const QString message = err_obj.value("message").toString();
if (pending.on_error) pending.on_error(code, message);
} else {
if (pending.on_result) pending.on_result(obj.value("result"));
}
}
void ConnectorProcess::failPendingAndClear(int code, const QString& message) {
QHash<QString, Pending> snapshot;
snapshot.swap(pending_);
for (const auto& p : snapshot) {
if (p.on_error) p.on_error(code, message);
}
}
} // namespace bonsaiviewer::modules::connectors
@@ -0,0 +1,98 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_CONNECTORS_PROCESS_H
#define IFCINTERFACE_MODULES_CONNECTORS_PROCESS_H
#include "Discovery.h"
#include <QByteArray>
#include <QHash>
#include <QJsonValue>
#include <QObject>
#include <QProcess>
#include <QString>
#include <functional>
namespace bonsaiviewer::modules::connectors {
// One running connector subprocess. Owns the QProcess, frames stdio as
// newline-delimited JSON-RPC 2.0, and routes responses back to per-call
// callbacks. Per the protocol, one process per session: launch lazily on
// first call(), keep alive until shutdown().
class ConnectorProcess : public QObject {
Q_OBJECT
public:
using ResultHandler = std::function<void(const QJsonValue& result)>;
using ErrorHandler = std::function<void(int code, const QString& message)>;
explicit ConnectorProcess(ConnectorManifest manifest, QObject* parent = nullptr);
~ConnectorProcess() override;
const ConnectorManifest& manifest() const { return manifest_; }
bool isRunning() const;
QString lastError() const { return last_error_; }
// Lazy-launches the underlying process. Returns false on launch failure;
// the user-facing reason is in lastError().
bool ensureStarted();
// Async JSON-RPC 2.0 call. Exactly one of on_result / on_error fires
// exactly once, on the same thread (queued). Launch failure synthesizes
// an immediate on_error via singleShot so callers see consistent
// async ordering.
void call(const QString& method,
const QJsonValue& params,
ResultHandler on_result,
ErrorHandler on_error);
// Closes stdin and waits a few seconds for clean exit; SIGKILLs on
// timeout. Any in-flight calls fail via on_error. Safe to call when
// not running.
void shutdown();
signals:
// Process exited unexpectedly (not via shutdown()). In-flight calls have
// already been failed via on_error before this fires.
void crashed(const QString& message);
private:
void onReadyReadStdout();
void onProcessFinished(int exit_code, QProcess::ExitStatus status);
void dispatchLine(const QByteArray& line);
void failPendingAndClear(int code, const QString& message);
ConnectorManifest manifest_;
QProcess* process_ = nullptr;
QByteArray buffer_;
QString last_error_;
bool shutting_down_ = false;
quint64 next_request_id_ = 0;
struct Pending {
ResultHandler on_result;
ErrorHandler on_error;
};
QHash<QString, Pending> pending_;
};
} // namespace bonsaiviewer::modules::connectors
#endif
@@ -0,0 +1,109 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Registry.h"
#include "Process.h"
#include <QDebug>
namespace bonsaiviewer::modules::connectors {
ConnectorRegistry::ConnectorRegistry(QObject* parent)
: QObject(parent)
{
}
ConnectorRegistry::~ConnectorRegistry() {
shutdownAll();
}
const std::vector<ConnectorManifest>& ConnectorRegistry::available() {
if (!discovered_) refresh();
return manifests_;
}
void ConnectorRegistry::refresh() {
manifests_ = discoverConnectors();
discovered_ = true;
// Cull processes whose connector is no longer discoverable, or whose
// exec changed under us. Surviving entries keep their running process.
for (auto it = processes_.begin(); it != processes_.end();) {
const QString id = it.key();
ConnectorProcess* p = it.value();
const ConnectorManifest* now = manifestFor(id);
const bool stale = !now || !p ||
p->manifest().exec_path != now->exec_path;
if (stale) {
if (p) {
p->shutdown();
p->deleteLater();
}
it = processes_.erase(it);
} else {
++it;
}
}
}
const ConnectorManifest* ConnectorRegistry::manifestFor(const QString& id) const {
for (const auto& m : manifests_) {
if (m.id == id) return &m;
}
return nullptr;
}
ConnectorProcess* ConnectorRegistry::get(const QString& id) {
if (!discovered_) refresh();
if (auto* existing = processes_.value(id, nullptr)) return existing;
const ConnectorManifest* manifest = manifestFor(id);
if (!manifest) {
last_error_ = QString("Unknown connector '%1'.").arg(id);
return nullptr;
}
auto* proc = new ConnectorProcess(*manifest, this);
if (!proc->ensureStarted()) {
last_error_ = proc->lastError();
delete proc;
return nullptr;
}
last_error_.clear();
processes_.insert(id, proc);
connect(proc, &ConnectorProcess::crashed, this, [this, id](const QString& message) {
qWarning() << "ifcviewer connectors:" << message;
if (auto* p = processes_.take(id)) p->deleteLater();
});
return proc;
}
void ConnectorRegistry::shutdownAll() {
const auto procs = processes_;
processes_.clear();
for (auto it = procs.begin(); it != procs.end(); ++it) {
if (auto* p = it.value()) {
p->shutdown();
p->deleteLater();
}
}
}
} // namespace bonsaiviewer::modules::connectors
@@ -0,0 +1,74 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_CONNECTORS_REGISTRY_H
#define IFCINTERFACE_MODULES_CONNECTORS_REGISTRY_H
#include "Discovery.h"
#include <QHash>
#include <QObject>
#include <QString>
#include <vector>
namespace bonsaiviewer::modules::connectors {
class ConnectorProcess;
// Per-session catalog of available connectors plus their lazily-launched
// subprocesses. One instance lives on SessionState; processes are launched
// on first get() and shut down on ~Registry / shutdownAll().
class ConnectorRegistry : public QObject {
Q_OBJECT
public:
explicit ConnectorRegistry(QObject* parent = nullptr);
~ConnectorRegistry() override;
// Discovered connectors in discovery order. First call triggers a scan;
// call refresh() to force a rescan.
const std::vector<ConnectorManifest>& available();
// Re-scans the filesystem. Live processes for ids that have disappeared
// (or whose exec changed) are shut down; surviving connectors keep their
// running process.
void refresh();
// Returns the manifest for `id`, or nullptr.
const ConnectorManifest* manifestFor(const QString& id) const;
// Lazily-launched process. Returns nullptr if `id` is unknown or launch
// failed; the reason is in lastError().
ConnectorProcess* get(const QString& id);
QString lastError() const { return last_error_; }
// Close stdin on every live connector. Idempotent.
void shutdownAll();
private:
bool discovered_ = false;
std::vector<ConnectorManifest> manifests_;
QHash<QString, ConnectorProcess*> processes_;
QString last_error_;
};
} // namespace bonsaiviewer::modules::connectors
#endif
@@ -0,0 +1,160 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "AddModelDialog.h"
#include "../../components/Dialog.h"
#include "../../components/Buttons.h"
#include "../../components/Section.h"
#include "../../components/Style.h"
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::modules::models {
namespace {
class HoverDescriptionFilter : public QObject {
public:
HoverDescriptionFilter(QLabel* label, QString hover_text, QString default_text)
: label_(label), hover_text_(std::move(hover_text)), default_text_(std::move(default_text)) {}
protected:
bool eventFilter(QObject* watched, QEvent* event) override {
Q_UNUSED(watched);
if (event->type() == QEvent::Enter) {
label_->setText(hover_text_);
} else if (event->type() == QEvent::Leave) {
label_->setText(default_text_);
}
return false;
}
private:
QLabel* label_ = nullptr;
QString hover_text_;
QString default_text_;
};
} // namespace
AddModelDialog::AddModelDialog(QWidget* parent)
: components::Dialog(parent)
{
setObjectName("appDialog");
setWindowTitle("Add Model");
setModal(true);
setupUi();
}
void AddModelDialog::setupUi() {
if (auto* root = qobject_cast<QVBoxLayout*>(layout())) {
root->setSizeConstraint(QLayout::SetFixedSize);
}
const QString default_description = "Choose what to add to the project";
auto* description_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* description = new QLabel(default_description, description_section);
description->setProperty("textRole", "secondary");
description->setWordWrap(true);
description->setAlignment(Qt::AlignCenter);
description->setMinimumWidth((90 * 4) + (components::style::metrics::padding * 3));
description->setMinimumHeight(description->fontMetrics().lineSpacing() * 2 + 4);
description_section->addBodyWidget(description);
auto* choices_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* choices = new QWidget(choices_section);
auto* row = new QHBoxLayout(choices);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(components::style::metrics::padding);
auto* add_ifc = components::buttons::makeButton("Add IFC File", ":/icons/cube.svg", choices);
connect(add_ifc, &QToolButton::clicked, this, [this]() {
selected_mode_ = SourceMode::IfcFile;
accept();
});
add_ifc->installEventFilter(new HoverDescriptionFilter(
description,
"Add IFC files and load both geometry and data.",
default_description));
auto* add_database = components::buttons::makeButton("Add IFC\nDatabase", ":/icons/database.svg", choices);
connect(add_database, &QToolButton::clicked, this, [this]() {
selected_mode_ = SourceMode::IfcDatabase;
accept();
});
add_database->installEventFilter(new HoverDescriptionFilter(
description,
"Add IFC RDB databases for optimised performance",
default_description));
auto* add_geometry = components::buttons::makeButton("Add Geometry", ":/icons/cube-bandage.svg", choices);
connect(add_geometry, &QToolButton::clicked, this, [this]() {
selected_mode_ = SourceMode::GeometryOnly;
accept();
});
add_geometry->installEventFilter(new HoverDescriptionFilter(
description,
"Add pure geometry for fast visualisation",
default_description));
auto* add_cloud = components::buttons::makeButton("Add From\nCloud", ":/icons/cloud-square.svg", choices);
connect(add_cloud, &QToolButton::clicked, this, [this]() {
selected_mode_ = SourceMode::CloudModel;
accept();
});
add_cloud->installEventFilter(new HoverDescriptionFilter(
description,
"Browse a cloud connector and add one or more models from there.",
default_description));
auto* convert_database = components::buttons::makeButton("Convert IFC File\nto Database", ":/icons/database-restore.svg", choices);
connect(convert_database, &QToolButton::clicked, this, [this]() {
selected_mode_ = SourceMode::ConvertToDatabase;
accept();
});
convert_database->installEventFilter(new HoverDescriptionFilter(
description,
"Convert IFC files to databases for smaller filesizes, reduced memory, and faster access. No data is lost.",
default_description));
auto* export_geometry_database = components::buttons::makeButton("Export Geometry\nDatabase", ":/icons/database-restore.svg", choices);
connect(export_geometry_database, &QToolButton::clicked, this, [this]() {
selected_mode_ = SourceMode::ExportGeometryDatabase;
accept();
});
export_geometry_database->installEventFilter(new HoverDescriptionFilter(
description,
"Convert IFC files to a read-only geometry database for smaller filesizes, reduced memory, and faster access. Ideal for cloud read-only coordination workflows. Only parametric geometry editing capabilities are lost.",
default_description));
row->addWidget(components::buttons::makeButtonGroup("ADD", {add_ifc, add_database, add_geometry, add_cloud}, choices, true, 8));
row->addWidget(components::buttons::makeButtonGroup("TOOLS", {convert_database, export_geometry_database}, choices, false, 8));
choices_section->addBodyWidget(choices);
addBodyWidget(description_section);
addBodyWidget(choices_section);
}
} // namespace bonsaiviewer::modules::models
@@ -0,0 +1,53 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_ADDMODELDIALOG_H
#define IFCINTERFACE_PANELS_ADDMODELDIALOG_H
#include "../../components/Dialog.h"
namespace bonsaiviewer::modules::models {
enum class SourceMode {
None,
IfcFile,
IfcDatabase,
GeometryOnly,
CloudModel,
ConvertToDatabase,
ExportGeometryDatabase,
};
class AddModelDialog : public components::Dialog {
Q_OBJECT
public:
explicit AddModelDialog(QWidget* parent = nullptr);
SourceMode selectedMode() const { return selected_mode_; }
private:
void setupUi();
SourceMode selected_mode_ = SourceMode::None;
};
} // namespace bonsaiviewer::modules::models
#endif
@@ -0,0 +1,752 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser 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 <Eigen/Dense>
#include <memory>
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";
}
} // namespace
void toggleVisibility(SessionState& s, ItemKind kind, const QString& id) {
Federation* fed = s.federation();
if (kind == ItemKind::Group) {
const Federation::Group* group = fed->findGroupById(id);
if (!group) return;
fed->setGroupVisible(id, !group->visible);
s.notifyVisibilityChanged();
s.setStatusMessage("Models", group->visible ? "Group hidden" : "Group shown");
} else {
const Federation::Model* model = fed->findById(id);
if (!model) return;
fed->setModelVisible(id, !model->visible);
s.notifyVisibilityChanged();
s.setStatusMessage("Models", model->visible ? "Model hidden" : "Model shown");
}
}
void addGroup(SessionState& s, 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;
s.federation()->addGroup(trimmed, parent_group_id);
s.notifyFederationChanged();
s.setStatusMessage("Models", "Group added");
}
void renameGroup(SessionState& s, QWidget& host, const QString& group_id) {
const Federation::Group* group = s.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;
s.federation()->setGroupName(group_id, trimmed);
s.notifyFederationChanged();
s.setStatusMessage("Models", "Group renamed");
}
void moveGroup(SessionState& s, const QString& id, const QString& parent_group_id) {
s.federation()->setGroupParent(id, parent_group_id);
s.notifyFederationChanged();
s.setStatusMessage("Models", parent_group_id.isEmpty() ? "Group moved to root" : "Group moved");
}
void moveModels(SessionState& s, const QStringList& ids, const QString& parent_group_id) {
for (const auto& id : ids) {
s.federation()->setModelGroup(id, parent_group_id);
}
s.notifyFederationChanged();
s.setStatusMessage("Models", parent_group_id.isEmpty() ? "Model(s) moved to root" : "Model(s) moved");
}
void removeGroup(SessionState& s, QWidget& host, const QString& group_id) {
const Federation::Group* group = s.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;
s.federation()->removeGroup(group_id);
s.notifyFederationChanged();
s.setStatusMessage("Models", "Group removed");
}
void removeModel(SessionState& s, ViewportWindow& vp, QWidget& host, const QString& fed_id) {
const Federation::Model* model = s.federation()->findById(fed_id);
const QString label = model ? model->display_name : fed_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 mid = s.modelIdForFedId(fed_id);
if (mid == 0) {
s.federation()->removeModel(fed_id);
s.notifyFederationChanged();
s.setStatusMessage("Models", "Model removed");
return;
}
if (s.loader()->isLoadingModel(mid)) return;
vp.setSelectedObjectId(0);
s.setSelectedObjectId(0);
s.federation()->removeModel(fed_id);
vp.removeModel(mid);
s.loader()->removeModel(mid);
s.elementRegistry()->removeModel(mid);
s.removeModelMappingByFedId(fed_id);
s.notifySelectionChanged();
s.notifyModelsChanged();
s.setStatusMessage("Models", "Model removed");
}
namespace detail {
void loadModels(SessionState& s, const QStringList& paths, const QStringList& fed_ids) {
if (paths.isEmpty()) return;
const auto ids = s.loader()->addFiles(paths);
for (int i = 0; i < paths.size() && i < static_cast<int>(ids.size()) && i < fed_ids.size(); ++i) {
s.setModelMapping(fed_ids[i], ids[i]);
}
}
} // namespace detail
void addModel(SessionState& s, 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 Only");
file_dialog.setFileMode(QFileDialog::ExistingFiles);
file_dialog.setNameFilter("IFC Viewer Cache (*.ifcview);;All Files (*)");
file_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (file_dialog.exec() == QDialog::Accepted) {
paths = file_dialog.selectedFiles();
}
break;
}
case SourceMode::CloudModel:
addModelFromCloud(s, host);
return;
case SourceMode::ConvertToDatabase:
convertIfcToDatabase(s, host);
return;
case SourceMode::ExportGeometryDatabase:
exportGeometryDatabase(s, host);
return;
case SourceMode::None:
return;
}
QStringList accepted_paths;
QStringList accepted_fed_ids;
for (const auto& path : paths) {
const QString fed_id = s.federation()->addModel(path);
if (fed_id.isEmpty()) continue;
accepted_paths << path;
accepted_fed_ids << fed_id;
}
detail::loadModels(s, accepted_paths, accepted_fed_ids);
s.notifyModelsChanged();
}
void addModelFromCloud(SessionState& s, QWidget& host) {
auto* registry = s.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;
}
s.setStatusMessage("Cloud", QString("Browsing %1...").arg(connector_id));
QPointer<SessionState> sguard(&s);
proc->call("pull_models_interactive", QJsonValue(),
[sguard, connector_id](const QJsonValue& result) {
if (!sguard) return;
const QJsonArray arr = result.toArray();
QStringList paths;
QStringList fed_ids;
int added = 0;
for (const QJsonValue& v : arr) {
if (v.isNull() || !v.isObject()) continue;
const QJsonObject entry = v.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 fed_id = sguard->federation()->addCloudModel(
display_name, src_connector, source);
if (fed_id.isEmpty()) continue;
const QJsonObject meta = entry.value("metadata").toObject();
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
paths << path;
fed_ids << fed_id;
++added;
}
if (!paths.isEmpty()) {
detail::loadModels(*sguard, paths, fed_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 mid (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& s, const QString& fed_id) {
const uint32_t mid = s.modelIdForFedId(fed_id);
if (mid == 0 || !s.loader()) return {};
return s.loader()->filePath(mid);
}
} // namespace
void saveModelToCloud(SessionState& s, QWidget& host, const QString& fed_id) {
auto* fed = s.federation();
const Federation::Model* model = fed->findById(fed_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(s, fed_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 = s.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;
s.setStatusMessage("Cloud",
QString("Saving %1 to %2...").arg(model->display_name, connector_id));
QPointer<SessionState> sguard(&s);
proc->call("push_model", params,
[sguard, fed_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(fed_id, new_connector, new_source);
const QJsonObject meta = obj.value("metadata").toObject();
sguard->setCloudMetadata(fed_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& s, QWidget& host, const QString& fed_id) {
const Federation::Model* model = s.federation()->findById(fed_id);
if (!model) return;
const QString local_path = localPathForModel(s, fed_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 = s.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;
s.setStatusMessage("Cloud",
QString("Pushing %1 to %2...").arg(model->display_name, connector_id));
QPointer<SessionState> sguard(&s);
proc->call("push_model_interactive", params,
[sguard, fed_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(fed_id, new_connector, new_source);
const QString new_name = obj.value("display_name").toString();
if (!new_name.isEmpty()) {
sguard->federation()->setModelDisplayName(fed_id, new_name);
}
const QJsonObject meta = obj.value("metadata").toObject();
sguard->setCloudMetadata(fed_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& s, 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;
}
}
s.beginProgress(QString("Converting %1 to %2…")
.arg(QFileInfo(input_path).fileName(),
QFileInfo(output_path).fileName()));
s.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,
[&s, host_ptr = &host, thread, timer, error_message, input_path, output_path]() {
const qint64 elapsed = timer->elapsed();
s.endProgress();
thread->deleteLater();
if (!error_message->isEmpty()) {
s.setStatusMessage("Error", *error_message);
QMessageBox::warning(host_ptr, "Convert IFC to Database",
QString("Conversion failed:\n%1").arg(*error_message));
return;
}
s.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& s, 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";
}
s.beginProgress(QString("Exporting %1 to %2…")
.arg(QFileInfo(input_path).fileName(),
QFileInfo(output_path).fileName()));
s.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 tmp_zip = output_path + ".tmp";
QFile::remove(tmp_zip);
{
QZipWriter writer(tmp_zip);
if (writer.status() != QZipWriter::NoError) {
throw ifcopenshell::exception(
("Failed to open " + tmp_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 " + tmp_zip).toStdString());
}
}
QFile::remove(output_path);
if (!QFile::rename(tmp_zip, output_path)) {
QFile::remove(tmp_zip);
throw ifcopenshell::exception(
("Failed to move " + tmp_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,
[&s, host_ptr = &host, thread, timer, error_message, input_path, output_path]() {
const qint64 elapsed = timer->elapsed();
s.endProgress();
thread->deleteLater();
if (!error_message->isEmpty()) {
s.setStatusMessage("Error", *error_message);
QMessageBox::warning(host_ptr, "Export Geometry Database",
QString("Export failed:\n%1").arg(*error_message));
return;
}
s.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& s, QWidget& host) {
SettingsDialog dialog(&s, &host);
dialog.exec();
}
} // namespace bonsaiviewer::modules::models::commands
@@ -0,0 +1,72 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_MODELS_COMMANDS_H
#define IFCINTERFACE_MODULES_MODELS_COMMANDS_H
#include "Types.h"
#include <QString>
#include <QStringList>
#include <cstdint>
class QWidget;
class ViewportWindow;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::models::commands {
// User-facing commands. Each one is responsible for emitting any notify()
// signals exactly once, at the end of its execution.
void toggleVisibility(SessionState& s, ItemKind kind, const QString& id);
void addGroup(SessionState& s, QWidget& host, const QString& parent_group_id);
void renameGroup(SessionState& s, QWidget& host, const QString& group_id);
void moveGroup(SessionState& s, const QString& id, const QString& parent_group_id);
void moveModels(SessionState& s, const QStringList& ids, const QString& parent_group_id);
void removeGroup(SessionState& s, QWidget& host, const QString& group_id);
void removeModel(SessionState& s, ViewportWindow& vp, QWidget& host, const QString& fed_id);
void addModel(SessionState& s, QWidget& host);
// Connector picker → pull_models_interactive → addCloudModel + load.
// Reachable from AddModelDialog's CloudModel button; the underlying call
// is async, so addModelFromCloud returns immediately after kicking it off.
void addModelFromCloud(SessionState& s, QWidget& host);
// push_model: push a cloud-sourced model back to its existing target.
// Only valid when model.source_connector != "local". Async.
void saveModelToCloud(SessionState& s, QWidget& host, const QString& fed_id);
// push_model_interactive: pick a connector and push to a fresh cloud
// target. Valid for any model (local or already cloud-sourced). Async.
void saveModelAsToCloud(SessionState& s, QWidget& host, const QString& fed_id);
void convertIfcToDatabase(SessionState& s, QWidget& host);
void exportGeometryDatabase(SessionState& s, QWidget& host);
void openSettings(SessionState& s, QWidget& host);
// Internal building blocks shared by commands here and by ProjectController.
// These NEVER call notify*() — the caller is responsible for emitting once
// at the end of its execution.
namespace detail {
// Queues already-federated models on the loader and maps their fed-ids to mids.
void loadModels(SessionState& s, const QStringList& paths, const QStringList& fed_ids);
} // namespace detail
} // namespace bonsaiviewer::modules::models::commands
#endif
@@ -0,0 +1,274 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "FederationItemModel.h"
#include "../../ViewerSettings.h"
#include "../../components/SvgIcon.h"
#include "../../../ifcviewer/Federation.h"
#include <QBrush>
#include <QColor>
namespace bonsaiviewer::modules::models {
namespace {
QStandardItem* siblingVisibilityItem(QStandardItem* name_item) {
QStandardItem* parent = name_item->parent();
if (!parent) parent = name_item->model()->invisibleRootItem();
return parent->child(name_item->row(), 1);
}
template <typename F>
void walkSubtree(QStandardItem* root, F visit) {
visit(root);
for (int i = 0; i < root->rowCount(); ++i) {
walkSubtree(root->child(i, 0), visit);
}
}
} // namespace
FederationItemModel::FederationItemModel(Federation* federation, QObject* parent)
: QStandardItemModel(parent)
, federation_(federation)
{
setColumnCount(2);
rebuildAll();
connect(federation_, &Federation::groupAdded, this, &FederationItemModel::onGroupAdded);
connect(federation_, &Federation::groupRemoved, this, &FederationItemModel::onGroupRemoved);
connect(federation_, &Federation::groupChanged, this, &FederationItemModel::onGroupChanged);
connect(federation_, &Federation::groupVisibilityChanged, this, &FederationItemModel::onGroupVisibilityChanged);
connect(federation_, &Federation::modelAdded, this, &FederationItemModel::onModelAdded);
connect(federation_, &Federation::modelRemoved, this, &FederationItemModel::onModelRemoved);
connect(federation_, &Federation::modelVisibilityChanged, this, &FederationItemModel::onModelVisibilityChanged);
connect(federation_, &Federation::modelGroupChanged, this, &FederationItemModel::onModelGroupChanged);
connect(federation_, &Federation::modelChanged, this, &FederationItemModel::onModelChanged);
}
void FederationItemModel::rebuildAll() {
clear();
setColumnCount(2);
id_to_name_item_.clear();
for (const auto& root_group : federation_->rootGroups()) {
appendGroupSubtreeTo(invisibleRootItem(), root_group->id);
}
for (const auto& model : federation_->models()) {
if (!model.group_id.isEmpty()) continue;
appendModelTo(invisibleRootItem(), model.id);
}
}
QStandardItem* FederationItemModel::makeGroupNameItem(const QString& group_id, const QString& display_name) const {
auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/folder.svg"), display_name);
item->setData(group_id, IdRole);
item->setData(int(ItemKind::Group), KindRole);
item->setEditable(false);
return item;
}
QStandardItem* FederationItemModel::makeModelNameItem(const QString& fed_id, const QString& display_name) const {
auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/cube.svg"), display_name);
item->setData(fed_id, IdRole);
item->setData(int(ItemKind::Model), KindRole);
item->setEditable(false);
return item;
}
QStandardItem* FederationItemModel::makeVisibilityItem(ItemKind kind, bool visible) const {
QString icon_path;
if (kind == ItemKind::Group) {
icon_path = visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg";
} else {
icon_path = visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg";
}
auto* item = new QStandardItem(components::icons::makeSvgIcon(icon_path), QString());
item->setEditable(false);
return item;
}
void FederationItemModel::styleRowVisibility(QStandardItem* name_item, bool visible) const {
QStandardItem* vis_item = siblingVisibilityItem(name_item);
if (visible) {
name_item->setData(QVariant(), Qt::ForegroundRole);
if (vis_item) vis_item->setData(QVariant(), Qt::ForegroundRole);
} else {
const QBrush disabled(QColor(bonsaiviewer::ViewerSettings::instance().color("disabled_text")));
name_item->setForeground(disabled);
if (vis_item) vis_item->setForeground(disabled);
}
}
QStandardItem* FederationItemModel::findItem(const QString& id) const {
return id_to_name_item_.value(id, nullptr);
}
QStandardItem* FederationItemModel::parentItemForGroup(const QString& parent_group_id) const {
if (parent_group_id.isEmpty()) return invisibleRootItem();
QStandardItem* found = findItem(parent_group_id);
return found ? found : invisibleRootItem();
}
void FederationItemModel::appendModelTo(QStandardItem* parent_item, const QString& fed_id) {
const Federation::Model* model = federation_->findById(fed_id);
if (!model) return;
auto* name_item = makeModelNameItem(fed_id, model->display_name);
auto* vis_item = makeVisibilityItem(ItemKind::Model, federation_->isModelEffectivelyVisible(fed_id));
parent_item->appendRow({name_item, vis_item});
id_to_name_item_.insert(fed_id, name_item);
styleRowVisibility(name_item, federation_->isModelEffectivelyVisible(fed_id));
}
void FederationItemModel::appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id) {
const Federation::Group* group = federation_->findGroupById(group_id);
if (!group) return;
auto* name_item = makeGroupNameItem(group_id, group->display_name);
auto* vis_item = makeVisibilityItem(ItemKind::Group, group->visible);
parent_item->appendRow({name_item, vis_item});
id_to_name_item_.insert(group_id, name_item);
styleRowVisibility(name_item, group->visible);
for (const auto& child : group->children) {
appendGroupSubtreeTo(name_item, child->id);
}
for (const auto& model : federation_->models()) {
if (model.group_id != group_id) continue;
appendModelTo(name_item, model.id);
}
}
void FederationItemModel::refreshSubtreeVisibility(QStandardItem* root) {
walkSubtree(root, [this](QStandardItem* item) {
const QString id = item->data(IdRole).toString();
if (id.isEmpty()) return;
const auto kind = static_cast<ItemKind>(item->data(KindRole).toInt());
bool visible = true;
if (kind == ItemKind::Group) {
const Federation::Group* g = federation_->findGroupById(id);
visible = g && g->visible;
} else {
visible = federation_->isModelEffectivelyVisible(id);
}
QStandardItem* vis_item = siblingVisibilityItem(item);
if (vis_item) {
const QString icon_path = (kind == ItemKind::Group)
? (visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")
: (visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg");
vis_item->setIcon(components::icons::makeSvgIcon(icon_path));
}
styleRowVisibility(item, visible);
});
}
void FederationItemModel::onGroupAdded(const QString& group_id) {
const Federation::Group* group = federation_->findGroupById(group_id);
if (!group) return;
QStandardItem* parent_item = parentItemForGroup(group->parent ? group->parent->id : QString());
appendGroupSubtreeTo(parent_item, group_id);
}
void FederationItemModel::onGroupRemoved(const QString& group_id) {
QStandardItem* item = findItem(group_id);
if (!item) return;
walkSubtree(item, [this](QStandardItem* descendant) {
const QString id = descendant->data(IdRole).toString();
if (!id.isEmpty()) id_to_name_item_.remove(id);
});
QStandardItem* parent_item = item->parent();
if (!parent_item) parent_item = invisibleRootItem();
parent_item->removeRow(item->row());
}
void FederationItemModel::onGroupChanged(const QString& group_id) {
QStandardItem* item = findItem(group_id);
if (!item) return;
const Federation::Group* group = federation_->findGroupById(group_id);
if (!group) return;
QStandardItem* current_parent = item->parent();
if (!current_parent) current_parent = invisibleRootItem();
QStandardItem* target_parent = parentItemForGroup(group->parent ? group->parent->id : QString());
if (current_parent == target_parent) {
item->setText(group->display_name);
return;
}
// Reparent: take row from current parent, append at target. Pointers
// survive — id_to_name_item_ entries remain valid.
QList<QStandardItem*> taken = current_parent->takeRow(item->row());
taken.first()->setText(group->display_name);
target_parent->appendRow(taken);
refreshSubtreeVisibility(taken.first());
}
void FederationItemModel::onGroupVisibilityChanged(const QString& group_id, bool /*visible*/) {
QStandardItem* item = findItem(group_id);
if (!item) return;
refreshSubtreeVisibility(item);
}
void FederationItemModel::onModelAdded(const QString& fed_id) {
const Federation::Model* model = federation_->findById(fed_id);
if (!model) return;
QStandardItem* parent_item = parentItemForGroup(model->group_id);
appendModelTo(parent_item, fed_id);
}
void FederationItemModel::onModelRemoved(const QString& fed_id) {
QStandardItem* item = findItem(fed_id);
if (!item) return;
id_to_name_item_.remove(fed_id);
QStandardItem* parent_item = item->parent();
if (!parent_item) parent_item = invisibleRootItem();
parent_item->removeRow(item->row());
}
void FederationItemModel::onModelVisibilityChanged(const QString& fed_id, bool /*visible*/) {
QStandardItem* item = findItem(fed_id);
if (!item) return;
refreshSubtreeVisibility(item);
}
void FederationItemModel::onModelChanged(const QString& fed_id) {
QStandardItem* item = findItem(fed_id);
if (!item) return;
const Federation::Model* model = federation_->findById(fed_id);
if (!model) return;
item->setText(model->display_name);
}
void FederationItemModel::onModelGroupChanged(const QString& fed_id, const QString& new_group_id) {
QStandardItem* item = findItem(fed_id);
if (!item) return;
QStandardItem* current_parent = item->parent();
if (!current_parent) current_parent = invisibleRootItem();
QStandardItem* target_parent = parentItemForGroup(new_group_id);
if (current_parent == target_parent) return;
QList<QStandardItem*> taken = current_parent->takeRow(item->row());
target_parent->appendRow(taken);
refreshSubtreeVisibility(taken.first());
}
} // namespace bonsaiviewer::modules::models
@@ -0,0 +1,86 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_MODELS_FEDERATIONITEMMODEL_H
#define IFCINTERFACE_MODULES_MODELS_FEDERATIONITEMMODEL_H
#include "Types.h"
#include <QHash>
#include <QStandardItemModel>
class Federation;
namespace bonsaiviewer::modules::models {
// QStandardItemModel that mirrors the Federation tree (groups + models in
// two columns: name + visibility icon). Subscribes directly to Federation's
// granular signals so each mutation only touches the affected rows — view
// state (expansion, selection, scroll) is preserved automatically.
//
// Coarse session events (project open/reset, theme change) are not the
// model's concern: the owning View calls rebuildAll() in those cases.
class FederationItemModel : public QStandardItemModel {
Q_OBJECT
public:
enum Role {
IdRole = Qt::UserRole + 1,
KindRole = Qt::UserRole + 2,
};
explicit FederationItemModel(Federation* federation, QObject* parent = nullptr);
// Discard everything and rebuild from current Federation state. Loses
// expansion/selection — caller is the only one that knows whether that's
// acceptable (e.g. project reset, where there's no prior state worth
// preserving anyway).
void rebuildAll();
private slots:
void onGroupAdded(const QString& group_id);
void onGroupRemoved(const QString& group_id);
void onGroupChanged(const QString& group_id);
void onGroupVisibilityChanged(const QString& group_id, bool visible);
void onModelAdded(const QString& fed_id);
void onModelRemoved(const QString& fed_id);
void onModelVisibilityChanged(const QString& fed_id, bool visible);
void onModelGroupChanged(const QString& fed_id, const QString& new_group_id);
void onModelChanged(const QString& fed_id);
private:
QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const;
QStandardItem* makeModelNameItem(const QString& fed_id, const QString& display_name) const;
QStandardItem* makeVisibilityItem(ItemKind kind, bool visible) const;
void styleRowVisibility(QStandardItem* name_item, bool visible) const;
QStandardItem* findItem(const QString& id) const;
QStandardItem* parentItemForGroup(const QString& parent_group_id) const;
void appendModelTo(QStandardItem* parent_item, const QString& fed_id);
void appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id);
void refreshSubtreeVisibility(QStandardItem* root);
Federation* federation_ = nullptr;
QHash<QString, QStandardItem*> id_to_name_item_; // both group_ids and fed_ids
};
} // namespace bonsaiviewer::modules::models
#endif
+395
View File
@@ -0,0 +1,395 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "Commands.h"
#include "FederationItemModel.h"
#include "View.h"
#include "../../SessionState.h"
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include "../../../ifcviewer/Federation.h"
#include <QDataStream>
#include <QDrag>
#include <QDragEnterEvent>
#include <QDragMoveEvent>
#include <QDropEvent>
#include <QHeaderView>
#include <QMenu>
#include <QMimeData>
#include <QSizePolicy>
#include <QTreeView>
namespace bonsaiviewer::modules::models {
namespace {
constexpr auto kDragMimeType = "application/x-bonsaiviewer-model-items";
QString idOf(const QModelIndex& index) {
return index.sibling(index.row(), 0).data(FederationItemModel::IdRole).toString();
}
ItemKind kindOf(const QModelIndex& index) {
return static_cast<ItemKind>(
index.sibling(index.row(), 0).data(FederationItemModel::KindRole).toInt());
}
QStringList selectedModelIdsAt(QTreeView* tree, const QModelIndex& clicked_index) {
QStringList ids;
const QModelIndexList selection = tree->selectionModel()->selectedRows(0);
bool clicked_in_selection = false;
for (const QModelIndex& index : selection) {
if (index == clicked_index.sibling(clicked_index.row(), 0)) {
clicked_in_selection = true;
break;
}
}
if (clicked_in_selection) {
for (const QModelIndex& index : selection) {
if (kindOf(index) == ItemKind::Model) {
ids << idOf(index);
}
}
ids.removeDuplicates();
} else {
ids << idOf(clicked_index);
}
return ids;
}
constexpr int kVisibilityColumnWidth = 28;
// QTreeView subclass that handles drag-and-drop. Drop logic dispatches
// through commands (not directly into the model) so notifications + status
// messages happen the same way as menu-driven moves.
class ModelsTreeView : public QTreeView {
public:
explicit ModelsTreeView(bonsaiviewer::SessionState* session_state, QWidget* parent)
: QTreeView(parent), session_state_(session_state) {}
protected:
void resizeEvent(QResizeEvent* event) override {
QTreeView::resizeEvent(event);
if (model() && model()->columnCount() >= 2) {
const int vw = viewport()->width();
setColumnWidth(0, std::max(40, vw - kVisibilityColumnWidth));
setColumnWidth(1, kVisibilityColumnWidth);
}
}
void startDrag(Qt::DropActions actions) override {
const QModelIndexList selection = selectionModel()->selectedRows(0);
if (selection.isEmpty()) return;
const auto first_kind = kindOf(selection.first());
if (first_kind == ItemKind::Group && selection.size() != 1) return;
QByteArray payload;
QDataStream stream(&payload, QIODevice::WriteOnly);
stream << static_cast<int>(first_kind);
if (first_kind == ItemKind::Group) {
stream << idOf(selection.first());
} else {
QStringList ids;
for (const QModelIndex& index : selection) {
if (kindOf(index) != first_kind) return;
ids.push_back(idOf(index));
}
ids.removeDuplicates();
stream << ids;
}
auto* mime = new QMimeData();
mime->setData(QString::fromUtf8(kDragMimeType), payload);
auto* drag = new QDrag(this);
drag->setMimeData(mime);
drag->exec(actions);
}
void dragEnterEvent(QDragEnterEvent* event) override {
if (event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
event->acceptProposedAction();
return;
}
QTreeView::dragEnterEvent(event);
}
void dragMoveEvent(QDragMoveEvent* event) override {
if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
QTreeView::dragMoveEvent(event);
return;
}
QString target_group_id;
if (!decodeTargetGroup(event->position().toPoint(), target_group_id) ||
!canAcceptDrop(event->mimeData(), indexAt(event->position().toPoint()), target_group_id)) {
event->ignore();
return;
}
event->acceptProposedAction();
}
void dropEvent(QDropEvent* event) override {
if (!event->mimeData()->hasFormat(QString::fromUtf8(kDragMimeType))) {
QTreeView::dropEvent(event);
return;
}
QString target_group_id;
if (!decodeTargetGroup(event->position().toPoint(), target_group_id) ||
!canAcceptDrop(event->mimeData(), indexAt(event->position().toPoint()), target_group_id)) {
event->ignore();
return;
}
QByteArray payload = event->mimeData()->data(QString::fromUtf8(kDragMimeType));
QDataStream stream(&payload, QIODevice::ReadOnly);
int kind_int = 0;
stream >> kind_int;
const auto kind = static_cast<ItemKind>(kind_int);
if (kind == ItemKind::Group) {
QString group_id;
stream >> group_id;
commands::moveGroup(*session_state_, group_id, target_group_id);
} else {
QStringList ids;
stream >> ids;
ids.removeDuplicates();
if (!ids.isEmpty()) commands::moveModels(*session_state_, ids, target_group_id);
}
event->acceptProposedAction();
}
private:
bool decodeTargetGroup(const QPoint& pos, QString& out) const {
out.clear();
const QModelIndex index = indexAt(pos);
if (!index.isValid()) return true;
if (kindOf(index) != ItemKind::Group) return false;
out = idOf(index);
return true;
}
bool canAcceptDrop(const QMimeData* mime,
const QModelIndex& target_index,
const QString& target_group_id) const {
QByteArray payload = mime->data(QString::fromUtf8(kDragMimeType));
QDataStream stream(&payload, QIODevice::ReadOnly);
int kind_int = 0;
stream >> kind_int;
const auto kind = static_cast<ItemKind>(kind_int);
if (kind == ItemKind::Group) {
QString group_id;
stream >> group_id;
if (group_id.isEmpty()) return false;
if (!target_index.isValid()) return true;
if (kindOf(target_index) != ItemKind::Group) return false;
if (group_id == target_group_id) return false;
for (QModelIndex cur = target_index; cur.isValid(); cur = cur.parent()) {
if (idOf(cur) == group_id) return false;
}
return true;
}
if (kind == ItemKind::Model) {
QStringList ids;
stream >> ids;
ids.removeDuplicates();
if (ids.isEmpty()) return false;
return !target_index.isValid() || !target_group_id.isNull();
}
return false;
}
bonsaiviewer::SessionState* session_state_;
};
} // namespace
ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QWidget* parent)
: components::Panel("Models", nullptr, parent, true)
, session_state_(session_state)
, viewport_(viewport)
{
auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this);
section->setBodyExpanding(true);
tree_ = new ModelsTreeView(session_state_, section);
tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
tree_->setIconSize(QSize(16, 16));
tree_->setSelectionMode(QAbstractItemView::ExtendedSelection);
tree_->setContextMenuPolicy(Qt::CustomContextMenu);
tree_->setDragEnabled(true);
tree_->viewport()->setAcceptDrops(true);
tree_->setDropIndicatorShown(true);
tree_->setDragDropMode(QAbstractItemView::DragDrop);
tree_->setDefaultDropAction(Qt::MoveAction);
tree_->setUniformRowHeights(true);
tree_->setExpandsOnDoubleClick(false);
tree_->setEditTriggers(QAbstractItemView::NoEditTriggers);
tree_->header()->hide();
section->addBodyWidget(tree_);
addBodyWidget(section);
connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) {
if (!index.isValid() || index.column() != 1) return;
commands::toggleVisibility(*session_state_, kindOf(index), idOf(index));
});
connect(tree_, &QTreeView::customContextMenuRequested, this, [this](const QPoint& pos) {
const QModelIndex index = tree_->indexAt(pos);
QMenu menu(tree_);
if (!index.isValid()) {
QAction* add_group_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "Add Group");
connect(add_group_action, &QAction::triggered, this, [this]() {
commands::addGroup(*session_state_, *this, QString());
});
menu.exec(tree_->viewport()->mapToGlobal(pos));
return;
}
const auto kind = kindOf(index);
const QString id = idOf(index);
QAction* toggle_action = menu.addAction(
components::icons::makeSvgIcon(":/icons/eye.svg"), "Toggle Visibility");
connect(toggle_action, &QAction::triggered, this, [this, kind, id]() {
commands::toggleVisibility(*session_state_, kind, id);
});
if (kind == ItemKind::Group) {
QAction* add_subgroup = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Subgroup");
connect(add_subgroup, &QAction::triggered, this, [this, id]() {
commands::addGroup(*session_state_, *this, id);
});
QAction* rename = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder.svg"), "Rename Group");
connect(rename, &QAction::triggered, this, [this, id]() {
commands::renameGroup(*session_state_, *this, id);
});
QMenu* move_menu = menu.addMenu("Move to Parent");
QAction* move_root = move_menu->addAction("(Root)");
connect(move_root, &QAction::triggered, this, [this, id]() {
commands::moveGroup(*session_state_, id, QString());
});
move_menu->addSeparator();
for (const auto& target : validMoveTargets(*session_state_->federation(), id)) {
QAction* action = move_menu->addAction(target.display_name);
const QString target_id = target.id;
connect(action, &QAction::triggered, this, [this, id, target_id]() {
commands::moveGroup(*session_state_, id, target_id);
});
}
QAction* remove = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-minus.svg"), "Remove Group");
connect(remove, &QAction::triggered, this, [this, id]() {
commands::removeGroup(*session_state_, *this, id);
});
} else {
QString parent_group_id;
const QModelIndex parent_index = index.parent();
if (parent_index.isValid() && kindOf(parent_index) == ItemKind::Group) {
parent_group_id = idOf(parent_index);
}
QAction* add_group = menu.addAction(
components::icons::makeSvgIcon(":/icons/folder-plus.svg"), "New Group");
connect(add_group, &QAction::triggered, this, [this, parent_group_id]() {
commands::addGroup(*session_state_, *this, parent_group_id);
});
const QStringList selected_model_ids = selectedModelIdsAt(tree_, index);
QMenu* move_menu = menu.addMenu("Move to Group");
QAction* move_root = move_menu->addAction("(Root)");
connect(move_root, &QAction::triggered, this, [this, selected_model_ids]() {
commands::moveModels(*session_state_, selected_model_ids, QString());
});
move_menu->addSeparator();
for (const auto& target : validMoveTargets(*session_state_->federation(), QString())) {
QAction* action = move_menu->addAction(target.display_name);
const QString target_id = target.id;
connect(action, &QAction::triggered, this, [this, selected_model_ids, target_id]() {
commands::moveModels(*session_state_, selected_model_ids, target_id);
});
}
const Federation::Model* selected = session_state_->federation()->findById(id);
const bool has_cloud_source = selected && selected->source_connector != "local";
menu.addSeparator();
QAction* save_to_cloud = menu.addAction(
components::icons::makeSvgIcon(":/icons/cloud-square.svg"), "Save To Cloud");
save_to_cloud->setEnabled(has_cloud_source);
if (!has_cloud_source) {
save_to_cloud->setToolTip(
"This model has no cloud target yet. Use \"Save As To Cloud\" first.");
}
connect(save_to_cloud, &QAction::triggered, this, [this, id]() {
commands::saveModelToCloud(*session_state_, *this, id);
});
QAction* save_as_to_cloud = menu.addAction(
components::icons::makeSvgIcon(":/icons/cloud-square.svg"), "Save As To Cloud");
connect(save_as_to_cloud, &QAction::triggered, this, [this, id]() {
commands::saveModelAsToCloud(*session_state_, *this, id);
});
menu.addSeparator();
QAction* remove = menu.addAction(
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
connect(remove, &QAction::triggered, this, [this, id]() {
commands::removeModel(*session_state_, *viewport_, *this, id);
});
}
menu.exec(tree_->viewport()->mapToGlobal(pos));
});
connect(this, &components::Panel::settingsRequested, this, [this]() {
commands::openSettings(*session_state_, *this);
});
}
void ModelsPanel::setModel(FederationItemModel* model) {
model_ = model;
tree_->setModel(model);
// Columns are sized by ModelsTreeView::resizeEvent — header is hidden so
// there's no user-facing resize affordance, and Stretch mode on
// non-last sections proved unreliable here. Manual sizing is simpler.
tree_->header()->setMinimumSectionSize(16);
tree_->header()->setStretchLastSection(false);
tree_->setColumnWidth(0, std::max(40, tree_->viewport()->width() - kVisibilityColumnWidth));
tree_->setColumnWidth(1, kVisibilityColumnWidth);
tree_->expandAll();
}
} // namespace bonsaiviewer::modules::models
+60
View File
@@ -0,0 +1,60 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_MODELS_PANEL_H
#define IFCINTERFACE_MODULES_MODELS_PANEL_H
#include "Types.h"
#include "../../components/Panel.h"
class QTreeView;
class ViewportWindow;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::models {
class FederationItemModel;
// The widget for the Models dock. Owns no domain state; click handlers call
// commands directly. The QTreeView reads from a FederationItemModel which
// subscribes to Federation's granular signals — view state (expansion,
// selection, scroll) is preserved across mutations automatically.
class ModelsPanel : public components::Panel {
Q_OBJECT
public:
explicit ModelsPanel(bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QWidget* parent = nullptr);
// Owned externally (the View constructs and owns the model). The panel
// assigns it to the tree view; same model can outlive setModel calls.
void setModel(FederationItemModel* model);
private:
bonsaiviewer::SessionState* session_state_ = nullptr;
ViewportWindow* viewport_ = nullptr;
QTreeView* tree_ = nullptr;
FederationItemModel* model_ = nullptr;
};
} // namespace bonsaiviewer::modules::models
#endif
@@ -0,0 +1,485 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SettingsDialog.h"
#include "SettingsView.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/SceneLoader.h"
#include "../../components/Section.h"
#include "../../components/Style.h"
#include "../../components/SvgIcon.h"
#include "../../components/Tabs.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QHeaderView>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QRegularExpression>
#include <QSizePolicy>
#include <QShowEvent>
#include <QSignalBlocker>
#include <QScrollBar>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QVBoxLayout>
namespace bonsaiviewer::modules::models {
namespace {
struct UnitChoice {
const char* label;
const char* prefix;
const char* name;
};
const UnitChoice kUnitChoices[] = {
{"Metres (m)", "", "METRE"},
{"Millimetres (mm)", "MILLI", "METRE"},
{"Centimetres (cm)", "CENTI", "METRE"},
{"Kilometres (km)", "KILO", "METRE"},
{"Feet (ft)", "", "foot"},
{"Inches (in)", "", "inch"},
{"Yards (yd)", "", "yard"},
{"Miles (mi)", "", "mile"},
};
QLineEdit* makeNumericField(QWidget* parent, const QString& placeholder = {}) {
auto* field = new QLineEdit(parent);
field->setPlaceholderText(placeholder);
field->setMaximumWidth(96);
return field;
}
QWidget* makeEqualThirdsRow(QWidget* parent, QLineEdit* a, QLineEdit* b, QLineEdit* c) {
auto* row = new QWidget(parent);
auto* layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(components::style::metrics::padding);
for (QLineEdit* field : {a, b, c}) {
field->setMaximumWidth(QWIDGETSIZE_MAX);
field->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
layout->addWidget(field, 1);
}
return row;
}
QString formatNumber(double value) {
return QString::number(value, 'f', 6);
}
double parseNumber(QLineEdit* field) {
bool ok = false;
const double value = field->text().toDouble(&ok);
return ok ? value : 0.0;
}
QString formatVector3(const Eigen::Vector3d& value) {
return QString("%1, %2, %3").arg(formatNumber(value.x()), formatNumber(value.y()), formatNumber(value.z()));
}
Eigen::Vector3d parseVector3(const QString& text) {
const QStringList parts = text.split(QRegularExpression("[,\\s]+"), Qt::SkipEmptyParts);
if (parts.size() != 3) return Eigen::Vector3d::Zero();
bool ok_x = false;
bool ok_y = false;
bool ok_z = false;
const double x = parts[0].toDouble(&ok_x);
const double y = parts[1].toDouble(&ok_y);
const double z = parts[2].toDouble(&ok_z);
if (!ok_x || !ok_y || !ok_z) return Eigen::Vector3d::Zero();
return Eigen::Vector3d(x, y, z);
}
QLabel* makeReadOnlyValue(QWidget* parent) {
auto* label = new QLabel(parent);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
label->setWordWrap(true);
return label;
}
} // namespace
SettingsDialog::SettingsDialog(bonsaiviewer::SessionState* session_state, QWidget* parent)
: components::TabbedDialog(parent)
, session_state_(session_state)
, federation_(session_state ? session_state->federation() : nullptr)
, loader_(session_state ? session_state->loader() : nullptr)
, settings_view_(new SettingsView(this, session_state))
{
setObjectName("appDialog");
setWindowTitle("Model Settings");
setModal(true);
resize(980, 560);
setupUi();
}
void SettingsDialog::showEvent(QShowEvent* event) {
federation_ = session_state_ ? session_state_->federation() : federation_;
loader_ = session_state_ ? session_state_->loader() : loader_;
syncFromFederation();
populateModelTable();
QDialog::showEvent(event);
}
void SettingsDialog::setupUi() {
auto* federation_tab = new QWidget(this);
auto* federation_layout = new QVBoxLayout(federation_tab);
federation_layout->setContentsMargins(0, 0, 0, 0);
federation_layout->setSpacing(components::style::metrics::padding);
federation_layout->setAlignment(Qt::AlignTop);
auto* federation_unit_section =
new components::Section("Federation Unit", components::SectionHeaderMode::Visible, federation_tab);
auto* unit_hint = new QLabel(
"All measurements, federated false origins, and destination model transforms will be interpreted in this unit.",
federation_unit_section);
unit_hint->setProperty("textRole", "secondary");
unit_hint->setWordWrap(true);
auto* federation_unit_body = new QWidget(federation_unit_section);
auto* federation_unit_form = new QFormLayout(federation_unit_body);
federation_unit_form->setContentsMargins(0, 0, 0, 0);
federation_unit_form->setHorizontalSpacing(16);
federation_unit_form->setVerticalSpacing(10);
unit_combo_ = new QComboBox(federation_unit_body);
for (const auto& uc : kUnitChoices) {
QStringList data;
data << QString::fromUtf8(uc.prefix) << QString::fromUtf8(uc.name);
unit_combo_->addItem(uc.label, data);
}
federation_unit_form->addRow("Unit", unit_combo_);
federation_unit_section->addBodyWidget(unit_hint);
federation_unit_section->addBodyWidget(federation_unit_body);
auto* origin_section =
new components::Section("Federated False Origin", components::SectionHeaderMode::Visible, federation_tab);
auto* origin_hint = new QLabel(
"Nominate a false origin and project north to use when viewing the federation of models.",
origin_section);
origin_hint->setProperty("textRole", "secondary");
origin_hint->setWordWrap(true);
auto* origin_body = new QWidget(origin_section);
auto* origin_form = new QFormLayout(origin_body);
origin_form->setContentsMargins(0, 0, 0, 0);
origin_form->setHorizontalSpacing(16);
origin_form->setVerticalSpacing(10);
xyz_x_ = makeNumericField(origin_body, "X");
xyz_y_ = makeNumericField(origin_body, "Y");
xyz_z_ = makeNumericField(origin_body, "Z");
rz_deg_ = makeNumericField(origin_body, "deg");
origin_form->addRow("XYZ", makeEqualThirdsRow(origin_body, xyz_x_, xyz_y_, xyz_z_));
origin_form->addRow("Z rotation (°)", rz_deg_);
origin_section->addBodyWidget(origin_hint);
origin_section->addBodyWidget(origin_body);
federation_layout->addWidget(federation_unit_section);
federation_layout->addWidget(origin_section);
federation_layout->addStretch(1);
auto* model_tab = new QWidget(this);
auto* model_layout = new QVBoxLayout(model_tab);
model_layout->setContentsMargins(0, 0, 0, 0);
model_layout->setSpacing(components::style::metrics::padding);
model_layout->setAlignment(Qt::AlignTop);
auto* georef_section =
new components::Section("Selected Model Georeferencing", components::SectionHeaderMode::Visible, model_tab);
auto* georef_body = new QWidget(georef_section);
auto* georef_layout = new QHBoxLayout(georef_body);
georef_layout->setContentsMargins(0, 0, 0, 0);
georef_layout->setSpacing(16);
georef_layout->setAlignment(Qt::AlignTop);
georef_present_value_ = makeReadOnlyValue(georef_body);
georef_type_value_ = makeReadOnlyValue(georef_body);
georef_project_unit_value_ = makeReadOnlyValue(georef_body);
georef_map_unit_value_ = makeReadOnlyValue(georef_body);
georef_easting_value_ = makeReadOnlyValue(georef_body);
georef_northing_value_ = makeReadOnlyValue(georef_body);
georef_height_value_ = makeReadOnlyValue(georef_body);
georef_x_axis_abscissa_value_ = makeReadOnlyValue(georef_body);
georef_x_axis_ordinate_value_ = makeReadOnlyValue(georef_body);
georef_rotation_dd_value_ = makeReadOnlyValue(georef_body);
georef_rotation_dms_value_ = makeReadOnlyValue(georef_body);
georef_scale_value_ = makeReadOnlyValue(georef_body);
georef_factor_x_value_ = makeReadOnlyValue(georef_body);
georef_factor_y_value_ = makeReadOnlyValue(georef_body);
georef_factor_z_value_ = makeReadOnlyValue(georef_body);
auto* georef_col_1 = new QFormLayout();
georef_col_1->setContentsMargins(0, 0, 0, 0);
georef_col_1->setHorizontalSpacing(12);
georef_col_1->setVerticalSpacing(8);
georef_col_1->addRow("Georeferenced", georef_present_value_);
georef_col_1->addRow("Coordinate Operation", georef_type_value_);
georef_col_1->addRow("Project Unit", georef_project_unit_value_);
georef_col_1->addRow("Map Unit", georef_map_unit_value_);
georef_col_1->addRow("Easting", georef_easting_value_);
georef_col_1->addRow("Northing", georef_northing_value_);
georef_col_1->addRow("OrthogonalHeight", georef_height_value_);
auto* georef_col_2 = new QFormLayout();
georef_col_2->setContentsMargins(0, 0, 0, 0);
georef_col_2->setHorizontalSpacing(12);
georef_col_2->setVerticalSpacing(8);
georef_col_2->addRow("XAxisAbscissa", georef_x_axis_abscissa_value_);
georef_col_2->addRow("XAxisOrdinate", georef_x_axis_ordinate_value_);
georef_col_2->addRow("Rotation (DD)", georef_rotation_dd_value_);
georef_col_2->addRow("Rotation (DMS)", georef_rotation_dms_value_);
auto* georef_col_3 = new QFormLayout();
georef_col_3->setContentsMargins(0, 0, 0, 0);
georef_col_3->setHorizontalSpacing(12);
georef_col_3->setVerticalSpacing(8);
georef_col_3->addRow("Scale", georef_scale_value_);
georef_col_3->addRow("FactorX", georef_factor_x_value_);
georef_col_3->addRow("FactorY", georef_factor_y_value_);
georef_col_3->addRow("FactorZ", georef_factor_z_value_);
auto* georef_col_1_widget = new QWidget(georef_body);
georef_col_1_widget->setLayout(georef_col_1);
auto* georef_col_2_widget = new QWidget(georef_body);
georef_col_2_widget->setLayout(georef_col_2);
auto* georef_col_3_widget = new QWidget(georef_body);
georef_col_3_widget->setLayout(georef_col_3);
georef_layout->addWidget(georef_col_1_widget, 1);
georef_layout->addWidget(georef_col_2_widget, 1);
georef_layout->addWidget(georef_col_3_widget, 1);
georef_section->addBodyWidget(georef_body);
auto* table_section =
new components::Section("Model Transformations", components::SectionHeaderMode::Visible, model_tab);
auto* table_body = new QWidget(table_section);
auto* table_layout = new QVBoxLayout(table_body);
table_layout->setContentsMargins(0, 0, 0, 0);
table_layout->setSpacing(components::style::metrics::padding);
model_table_ = new QTableWidget(table_body);
model_table_->setObjectName("modelCoordinatesTable");
model_table_->setColumnCount(6);
model_table_->setHorizontalHeaderLabels(
{"Model", "From", "From Point", "To Point", "Rotate", "Pivot Point"});
model_table_->verticalHeader()->setVisible(false);
model_table_->horizontalHeader()->setStretchLastSection(false);
model_table_->horizontalHeader()->setSectionsMovable(false);
model_table_->horizontalHeader()->setSectionsClickable(true);
model_table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive);
model_table_->horizontalHeader()->resizeSection(0, 180);
model_table_->horizontalHeader()->resizeSection(1, 150);
model_table_->horizontalHeader()->resizeSection(2, 180);
model_table_->horizontalHeader()->resizeSection(3, 180);
model_table_->horizontalHeader()->resizeSection(4, 180);
model_table_->horizontalHeader()->resizeSection(5, 180);
model_table_->setSelectionBehavior(QAbstractItemView::SelectRows);
model_table_->setSelectionMode(QAbstractItemView::SingleSelection);
model_table_->setShowGrid(true);
model_table_->setWordWrap(false);
model_table_->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
model_table_->setAlternatingRowColors(false);
model_table_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto* table_hint = new QLabel(
"This section lets you override model coordinates by specifying an optional translation from a point to "
"another desired point, and an optional rotation.",
table_section);
table_hint->setProperty("textRole", "secondary");
table_hint->setWordWrap(true);
table_layout->addWidget(model_table_, 1);
table_section->addBodyWidget(table_hint);
table_section->addBodyWidget(table_body);
model_layout->addWidget(georef_section);
model_layout->addWidget(table_section);
addTab("Federation", federation_tab);
addTab("Model", model_tab);
connect(model_table_, &QTableWidget::currentCellChanged, this,
[this](int /*current_row*/, int /*current_column*/, int /*previous_row*/, int /*previous_column*/) {
updateSelectedModelGeoref();
});
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
if (auto* ok = buttons->button(QDialogButtonBox::Ok)) {
ok->setText("OK");
ok->setIcon(components::icons::makeSvgIcon(":/icons/check.svg"));
}
if (auto* cancel = buttons->button(QDialogButtonBox::Cancel)) {
cancel->setText("Cancel");
cancel->setIcon(components::icons::makeSvgIcon(":/icons/xmark-circle.svg"));
}
connect(buttons, &QDialogButtonBox::accepted, this, [this]() { onAccepted(); });
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
addFooterWidget(buttons);
}
void SettingsDialog::syncFromFederation() {
if (!federation_) return;
const auto& cfg = federation_->config();
int idx = -1;
for (int i = 0; i < unit_combo_->count(); ++i) {
const QStringList data = unit_combo_->itemData(i).toStringList();
if (data.size() == 2 &&
data[0].toStdString() == cfg.unit_prefix &&
data[1].toStdString() == cfg.unit_name) {
idx = i;
break;
}
}
unit_combo_->setCurrentIndex(idx >= 0 ? idx : 0);
const auto& origin = federation_->federatedFalseOrigin();
xyz_x_->setText(formatNumber(origin.xyz.x()));
xyz_y_->setText(formatNumber(origin.xyz.y()));
xyz_z_->setText(formatNumber(origin.xyz.z()));
rz_deg_->setText(formatNumber(origin.rz_deg));
}
void SettingsDialog::populateModelTable() {
model_rows_.clear();
const QSignalBlocker blocker(model_table_);
model_table_->clearContents();
model_table_->setRowCount(0);
if (!federation_) return;
int row = 0;
for (const auto& model : federation_->models()) {
const auto& xf = model.model_transformation;
model_table_->insertRow(row);
auto* model_item = new QTableWidgetItem(model.display_name.isEmpty() ? model.id : model.display_name);
model_item->setData(Qt::UserRole, model.id);
model_table_->setItem(row, 0, model_item);
ModelRowWidgets widgets;
widgets.fed_id = model.id;
widgets.frame = new QComboBox(model_table_);
widgets.frame->addItem("Local", static_cast<int>(AFrame::ModelLocal));
widgets.frame->addItem("Global", static_cast<int>(AFrame::ModelGlobal));
widgets.frame->setCurrentIndex(xf.a_frame == AFrame::ModelGlobal ? 1 : 0);
model_table_->setCellWidget(row, 1, widgets.frame);
widgets.from_point = new QTableWidgetItem(formatVector3(xf.a));
widgets.to_point = new QTableWidgetItem(formatVector3(xf.b));
widgets.rotate = new QTableWidgetItem(formatVector3(xf.rxyz_deg));
widgets.pivot = new QTableWidgetItem(formatVector3(xf.pivot));
model_table_->setItem(row, 2, widgets.from_point);
model_table_->setItem(row, 3, widgets.to_point);
model_table_->setItem(row, 4, widgets.rotate);
model_table_->setItem(row, 5, widgets.pivot);
model_rows_.push_back(widgets);
model_table_->setRowHeight(row, 42);
++row;
}
int table_height = model_table_->frameWidth() * 2 + model_table_->horizontalHeader()->height();
for (int i = 0; i < model_table_->rowCount(); ++i) {
table_height += model_table_->rowHeight(i);
}
if (model_table_->horizontalScrollBar()->isVisible()) {
table_height += model_table_->horizontalScrollBar()->sizeHint().height();
}
model_table_->setMinimumHeight(table_height);
model_table_->setMaximumHeight(table_height);
if (model_table_->rowCount() > 0) {
model_table_->setCurrentCell(0, 0);
}
updateSelectedModelGeoref();
}
void SettingsDialog::renderSelectedModelGeoref(const SelectedModelGeorefState& state) {
georef_present_value_->setText(state.georef_present);
georef_type_value_->setText(state.coordinate_operation_type);
georef_project_unit_value_->setText(state.project_unit);
georef_map_unit_value_->setText(state.map_unit);
georef_easting_value_->setText(state.easting);
georef_northing_value_->setText(state.northing);
georef_height_value_->setText(state.height);
georef_x_axis_abscissa_value_->setText(state.x_axis_abscissa);
georef_x_axis_ordinate_value_->setText(state.x_axis_ordinate);
georef_rotation_dd_value_->setText(state.rotation_dd);
georef_rotation_dms_value_->setText(state.rotation_dms);
georef_scale_value_->setText(state.scale);
georef_factor_x_value_->setText(state.factor_x);
georef_factor_y_value_->setText(state.factor_y);
georef_factor_z_value_->setText(state.factor_z);
}
void SettingsDialog::updateSelectedModelGeoref() {
const int row = model_table_->currentRow();
if (row < 0 || row >= static_cast<int>(model_rows_.size())) {
renderSelectedModelGeoref(
{"No model selected", "", "", "", "", "", "", "", "", "", "", "", "", "", ""});
return;
}
if (!settings_view_) {
renderSelectedModelGeoref(
{"Unavailable", "No settings view", "", "", "", "", "", "", "", "", "", "", "", "", ""});
return;
}
settings_view_->refresh(model_rows_[row].fed_id);
}
void SettingsDialog::onAccepted() {
if (federation_) {
const QStringList data = unit_combo_->currentData().toStringList();
FederationConfig cfg;
if (data.size() == 2) {
cfg.unit_prefix = data[0].toStdString();
cfg.unit_name = data[1].toStdString();
}
federation_->setConfig(cfg);
FederatedFalseOrigin origin;
origin.xyz = Eigen::Vector3d(parseNumber(xyz_x_), parseNumber(xyz_y_), parseNumber(xyz_z_));
origin.rz_deg = parseNumber(rz_deg_);
federation_->setFederatedFalseOrigin(origin);
for (const auto& row : model_rows_) {
ModelTransformation xf;
xf.a_frame = static_cast<AFrame>(row.frame->currentData().toInt());
xf.a = parseVector3(row.from_point->text());
xf.b = parseVector3(row.to_point->text());
xf.rxyz_deg = parseVector3(row.rotate->text());
xf.pivot = parseVector3(row.pivot->text());
federation_->setModelTransformation(row.fed_id, xf);
}
if (session_state_) {
session_state_->notifyFederationChanged();
}
}
accept();
}
} // namespace bonsaiviewer::modules::models
@@ -0,0 +1,105 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_MODELS_SETTINGSDIALOG_H
#define IFCINTERFACE_MODULES_MODELS_SETTINGSDIALOG_H
#include "../../components/Dialog.h"
#include "Types.h"
#include <QString>
#include <vector>
class Federation;
class SceneLoader;
class QComboBox;
class QLabel;
class QLineEdit;
class QShowEvent;
class QTableWidget;
class QTableWidgetItem;
namespace bonsaiviewer {
class SessionState;
}
namespace bonsaiviewer::modules::models {
class SettingsView;
class SettingsDialog : public components::TabbedDialog {
Q_OBJECT
public:
explicit SettingsDialog(bonsaiviewer::SessionState* session_state, QWidget* parent = nullptr);
void renderSelectedModelGeoref(const SelectedModelGeorefState& state);
protected:
void showEvent(QShowEvent* event) override;
private:
struct ModelRowWidgets {
QString fed_id;
QComboBox* frame = nullptr;
QTableWidgetItem* from_point = nullptr;
QTableWidgetItem* to_point = nullptr;
QTableWidgetItem* rotate = nullptr;
QTableWidgetItem* pivot = nullptr;
};
void setupUi();
void syncFromFederation();
void populateModelTable();
void updateSelectedModelGeoref();
void onAccepted();
bonsaiviewer::SessionState* session_state_ = nullptr;
Federation* federation_ = nullptr;
SceneLoader* loader_ = nullptr;
QComboBox* unit_combo_ = nullptr;
QLineEdit* xyz_x_ = nullptr;
QLineEdit* xyz_y_ = nullptr;
QLineEdit* xyz_z_ = nullptr;
QLineEdit* rz_deg_ = nullptr;
QLabel* georef_present_value_ = nullptr;
QLabel* georef_type_value_ = nullptr;
QLabel* georef_project_unit_value_ = nullptr;
QLabel* georef_map_unit_value_ = nullptr;
QLabel* georef_easting_value_ = nullptr;
QLabel* georef_northing_value_ = nullptr;
QLabel* georef_height_value_ = nullptr;
QLabel* georef_x_axis_abscissa_value_ = nullptr;
QLabel* georef_x_axis_ordinate_value_ = nullptr;
QLabel* georef_rotation_dd_value_ = nullptr;
QLabel* georef_rotation_dms_value_ = nullptr;
QLabel* georef_scale_value_ = nullptr;
QLabel* georef_factor_x_value_ = nullptr;
QLabel* georef_factor_y_value_ = nullptr;
QLabel* georef_factor_z_value_ = nullptr;
QTableWidget* model_table_ = nullptr;
std::vector<ModelRowWidgets> model_rows_;
SettingsView* settings_view_ = nullptr;
};
} // namespace bonsaiviewer::modules::models
#endif
@@ -0,0 +1,246 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SettingsView.h"
#include "SettingsDialog.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/Geolocation.h"
#include "../../../ifcviewer/SceneLoader.h"
#include "../../../ifcviewer/Unit.h"
#include <cmath>
namespace bonsaiviewer::modules::models {
namespace {
QString formatNumber(double value) {
return QString::number(value, 'f', 6);
}
QString formatAngleDms(double degrees) {
const double absolute = std::fabs(degrees);
const int d = static_cast<int>(absolute);
const double minutes_total = (absolute - static_cast<double>(d)) * 60.0;
const int m = static_cast<int>(minutes_total);
const double s = (minutes_total - static_cast<double>(m)) * 60.0;
const QString sign = degrees < 0.0 ? "-" : "";
return QString("%1%2° %3' %4\"").arg(sign).arg(d).arg(m, 2, 10, QChar('0')).arg(formatNumber(s));
}
SelectedModelGeorefState unknownState(const QString& georef, const QString& type) {
return {
georef,
type,
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
};
}
QString formatCachedUnitScale(double meters_per_unit) {
return QString("Cached scale: 1 unit = %1 m").arg(formatNumber(meters_per_unit));
}
std::string enumString(const attribute_value& av) {
if (av.isNull()) return {};
if (av.type() != ifcopenshell::Argument_ENUMERATION) return {};
enumeration_reference er = av;
return std::string(er.value() ? er.value() : "");
}
QString formatNamedUnit(const express::Base& unit) {
if (!unit) return "";
auto entity = unit.as<express::Entity>();
if (unit.declaration().is("IfcSIUnit")) {
const std::string prefix = enumString(entity.get("Prefix"));
const std::string name = enumString(entity.get("Name"));
QString text;
if (!prefix.empty()) {
text += QString::fromStdString(prefix) + " ";
}
text += QString::fromStdString(name);
auto symbol_it = kUnitSymbols.find(name);
if (symbol_it != kUnitSymbols.end()) {
text += " (" + QString::fromStdString(symbol_it->second) + ")";
}
return text;
}
auto name_attr = entity.get("Name");
if (name_attr.isNull()) return "";
const std::string name = static_cast<std::string>(name_attr);
QString text = QString::fromStdString(name);
auto symbol_it = kUnitSymbols.find(name);
if (symbol_it != kUnitSymbols.end()) {
text += " (" + QString::fromStdString(symbol_it->second) + ")";
}
return text;
}
std::optional<QString> coordinateOperationType(ifcopenshell::file* ifc_file) {
if (!ifc_file) return std::nullopt;
try {
const auto coordops = ifc_file->instances_by_type("IfcCoordinateOperation");
if (!coordops.empty()) {
return QString::fromStdString(coordops[0].declaration().name());
}
} catch (...) {
return std::nullopt;
}
if (ifc_file->schema()->name() == "IFC2X3") {
if (getHelmertTransformationParameters(ifc_file)) {
return QStringLiteral("ePSet_MapConversion");
}
}
return QStringLiteral("None");
}
SelectedModelGeorefState stateFromLiveFile(ifcopenshell::file* ifc_file) {
if (!ifc_file) return unknownState("Not available yet", "No data source");
const auto params = getHelmertTransformationParameters(ifc_file);
const auto coordop_type = coordinateOperationType(ifc_file);
const auto project_unit_value = getProjectUnit(ifc_file, "LENGTHUNIT");
const auto map_unit_value = getMapUnit(ifc_file);
const QString project_unit = project_unit_value ? formatNamedUnit(*project_unit_value) : QString("");
const QString map_unit = map_unit_value ? formatNamedUnit(*map_unit_value) : project_unit;
if (!params) {
auto state = unknownState("No", coordop_type.value_or("Unknown"));
state.project_unit = project_unit;
state.map_unit = map_unit;
return state;
}
const double rotation_dd = xaxis2angleDeg(params->xaa, params->xao);
return {
"Yes",
coordop_type.value_or("Unknown"),
project_unit,
map_unit,
formatNumber(params->e),
formatNumber(params->n),
formatNumber(params->h),
formatNumber(params->xaa),
formatNumber(params->xao),
formatNumber(rotation_dd),
formatAngleDms(rotation_dd),
formatNumber(params->scale),
formatNumber(params->factor_x),
formatNumber(params->factor_y),
formatNumber(params->factor_z),
};
}
SelectedModelGeorefState stateFromCachedGeoref(const ModelGeoref& georef) {
if (!georef.has_coordinate_operation) {
return unknownState("No", "None");
}
const Eigen::Matrix4d& m = georef.coordinate_operation_meters;
const Eigen::Vector3d translation = m.block<3, 1>(0, 3);
const Eigen::Vector3d x_axis = m.block<3, 1>(0, 0);
const Eigen::Vector3d y_axis = m.block<3, 1>(0, 1);
const double factor_x = x_axis.norm();
const double factor_y = y_axis.norm();
const double factor_z = m.block<3, 1>(0, 2).norm();
const double scale = (factor_x + factor_y) * 0.5;
const double x_axis_abscissa = factor_x > 0.0 ? x_axis.x() / factor_x : 1.0;
const double x_axis_ordinate = factor_x > 0.0 ? x_axis.y() / factor_x : 0.0;
constexpr double kRadiansToDegrees = 57.29577951308232;
const double rotation_dd = std::atan2(x_axis_ordinate, x_axis_abscissa) * kRadiansToDegrees;
return {
"Yes",
"Cached coordinate operation",
formatCachedUnitScale(georef.units.project_length_to_meters),
formatCachedUnitScale(georef.units.map_unit_to_meters),
formatNumber(translation.x()),
formatNumber(translation.y()),
formatNumber(translation.z()),
formatNumber(x_axis_abscissa),
formatNumber(x_axis_ordinate),
formatNumber(rotation_dd),
formatAngleDms(rotation_dd),
formatNumber(scale),
formatNumber(factor_x),
formatNumber(factor_y),
formatNumber(factor_z),
};
}
} // namespace
SettingsView::SettingsView(SettingsDialog* widget,
bonsaiviewer::SessionState* session_state)
: widget_(widget), session_state_(session_state)
{
}
void SettingsView::refresh(const QString& fed_id) const {
if (!widget_) {
return;
}
if (!session_state_) {
widget_->renderSelectedModelGeoref(unknownState("Unavailable", "No session state"));
return;
}
SceneLoader* loader = session_state_->loader();
if (!loader) {
widget_->renderSelectedModelGeoref(unknownState("Unavailable", "No loader"));
return;
}
const uint32_t mid = session_state_->modelIdForFedId(fed_id);
if (mid == 0) {
widget_->renderSelectedModelGeoref(unknownState("Not loaded", "No live model"));
return;
}
if (auto* ifc_file = loader->ifcFile(mid)) {
widget_->renderSelectedModelGeoref(stateFromLiveFile(ifc_file));
return;
}
const ModelGeoref* georef = loader->modelGeoref(mid);
if (!georef) {
widget_->renderSelectedModelGeoref(unknownState("Not available yet", "No data source"));
return;
}
widget_->renderSelectedModelGeoref(stateFromCachedGeoref(*georef));
}
} // namespace bonsaiviewer::modules::models
@@ -0,0 +1,48 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_MODELS_SETTINGSVIEW_H
#define IFCINTERFACE_MODULES_MODELS_SETTINGSVIEW_H
#include "Types.h"
namespace bonsaiviewer {
class SessionState;
}
namespace bonsaiviewer::modules::models {
class SettingsDialog;
class SettingsView {
public:
explicit SettingsView(SettingsDialog* widget,
bonsaiviewer::SessionState* session_state);
void refresh(const QString& fed_id) const;
private:
SettingsDialog* widget_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
};
} // namespace bonsaiviewer::modules::models
#endif
+69
View File
@@ -0,0 +1,69 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_MODELSPANELTYPES_H
#define IFCINTERFACE_PANELS_MODELSPANELTYPES_H
#include <QList>
#include <QString>
namespace bonsaiviewer::modules::models {
enum class ItemKind {
Group,
Model,
};
struct TreeNode {
QString id;
QString name;
ItemKind kind = ItemKind::Group;
bool visible = true;
QList<TreeNode> children;
};
// One entry in a "move to..." menu. Computed by the View from federation state
// and passed into the Panel so menu construction has no domain knowledge.
struct GroupOption {
QString id;
QString display_name;
};
struct SelectedModelGeorefState {
QString georef_present;
QString coordinate_operation_type;
QString project_unit;
QString map_unit;
QString easting;
QString northing;
QString height;
QString x_axis_abscissa;
QString x_axis_ordinate;
QString rotation_dd;
QString rotation_dms;
QString scale;
QString factor_x;
QString factor_y;
QString factor_z;
};
} // namespace bonsaiviewer::modules::models
#endif
+75
View File
@@ -0,0 +1,75 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "View.h"
#include "FederationItemModel.h"
#include "Panel.h"
#include "../../ViewerSettings.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
namespace bonsaiviewer::modules::models {
namespace {
void collectGroupsRecursive(const Federation::Group* group,
const QString& exclude_subtree_root,
QList<GroupOption>& out) {
if (group->id == exclude_subtree_root) return;
out.append({group->id, group->display_name});
for (const auto& child : group->children) {
collectGroupsRecursive(child.get(), exclude_subtree_root, out);
}
}
} // namespace
QList<GroupOption> validMoveTargets(const Federation& federation,
const QString& exclude_subtree_root) {
QList<GroupOption> out;
for (const auto& root : federation.rootGroups()) {
collectGroupsRecursive(root.get(), exclude_subtree_root, out);
}
return out;
}
ModelsPanelView::ModelsPanelView(ModelsPanel* widget,
bonsaiviewer::SessionState* session_state,
QObject* parent)
: QObject(parent)
, widget_(widget)
, session_state_(session_state)
, model_(new FederationItemModel(session_state->federation(), this))
{
widget_->setModel(model_);
// Coarse signals: full rebuild + re-style. The granular Federation
// signals are handled inside FederationItemModel and don't reach here.
auto rebuild = [this]() { model_->rebuildAll(); };
connect(session_state_, &SessionState::projectReset, this, rebuild);
connect(session_state_, &SessionState::projectOpened, this, rebuild);
connect(&bonsaiviewer::ViewerSettings::instance(),
&bonsaiviewer::ViewerSettings::themeChanged,
this, rebuild);
}
} // namespace bonsaiviewer::modules::models
+63
View File
@@ -0,0 +1,63 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_MODELSPANELVIEW_H
#define IFCINTERFACE_PANELS_MODELSPANELVIEW_H
#include "Types.h"
#include <QObject>
class Federation;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::models {
class FederationItemModel;
class ModelsPanel;
// Pure derivation used by ModelsPanel when building its "move to..." menus.
// Walks the federation's group tree and returns every group except those in
// the subtree rooted at exclude_subtree_root (skip a group's own subtree to
// prevent a cyclic move). Pass an empty exclude_subtree_root to get every
// group back.
QList<GroupOption> validMoveTargets(const Federation& federation,
const QString& exclude_subtree_root);
// Owns the FederationItemModel, hands it to the panel, and listens to the
// coarse session signals (project open/reset, theme change) — those are the
// "rebuild from scratch" cases the model itself doesn't subscribe to.
// Granular Federation events are handled inside the model.
class ModelsPanelView : public QObject {
Q_OBJECT
public:
explicit ModelsPanelView(ModelsPanel* widget,
bonsaiviewer::SessionState* session_state,
QObject* parent = nullptr);
private:
ModelsPanel* widget_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
FederationItemModel* model_ = nullptr;
};
} // namespace bonsaiviewer::modules::models
#endif
@@ -0,0 +1,671 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Commands.h"
#include "SaveProjectDialog.h"
#include "../../ElementRegistry.h"
#include "../../SessionState.h"
#include "../connectors/PickerDialog.h"
#include "../connectors/Process.h"
#include "../connectors/Registry.h"
#include "../models/Commands.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/SceneLoader.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QDebug>
#include <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QHash>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonValue>
#include <QMessageBox>
#include <QPointer>
#include <QTemporaryDir>
#include <memory>
namespace bonsaiviewer::modules::project::commands {
namespace {
// Pure helper — clears the loaded scene without emitting any signals. The
// caller (newProject / openProject) emits projectReset / projectOpened once
// the whole flow finishes.
void clearScene(SessionState& s, ViewportWindow& vp) {
vp.setSelectedObjectId(0);
s.setSelectedObjectId(0);
for (uint32_t mid : s.modelIds()) {
vp.removeModel(mid);
s.loader()->removeModel(mid);
}
s.clearModelMappings();
s.elementRegistry()->clear();
}
// Returns false if the user cancelled (i.e. don't proceed with the destructive
// op). Handles the Save → Discard → Cancel branch including a follow-on save.
bool confirmDiscardIfDirty(SessionState& s, QWidget& host) {
if (!s.federation()->isDirty()) return true;
const auto result = QMessageBox::question(
&host, "Unsaved Project",
"The current project has unsaved changes. Save before continuing?",
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel,
QMessageBox::Save);
if (result == QMessageBox::Cancel) return false;
if (result == QMessageBox::Save) return saveProject(s, host);
return true;
}
// Fire-and-forget async resolution of any non-local models in the
// federation. Groups by source_connector and issues one pull_models per
// group. For each returned entry:
// - if the fed_id already has a scene entry pointed at the same path,
// just refresh cloud metadata (no reload, preserves view state);
// - if the path differs, tear down the stale scene entry and queue a
// fresh load (federation entry is preserved either way);
// - if no scene entry exists yet (initial open), queue a load.
// Per spec, connector errors are not surfaced to the user; the connector
// has already shown its own UI.
void resolveCloudModels(SessionState& s, ViewportWindow& vp) {
auto* fed = s.federation();
QHash<QString, QStringList> connector_to_fed_ids;
for (const auto& m : fed->models()) {
if (m.source_connector == "local") continue;
connector_to_fed_ids[m.source_connector].push_back(m.id);
}
if (connector_to_fed_ids.isEmpty()) return;
auto* registry = s.connectorRegistry();
QPointer<SessionState> sguard(&s);
QPointer<ViewportWindow> vguard(&vp);
for (auto it = connector_to_fed_ids.constBegin();
it != connector_to_fed_ids.constEnd(); ++it) {
const QString connector_id = it.key();
const QStringList fed_ids = it.value();
auto* proc = registry->get(connector_id);
if (!proc) {
qWarning() << "resolveCloudModels: cannot launch connector"
<< connector_id << ":" << registry->lastError();
continue;
}
QJsonArray params;
for (const QString& fed_id : fed_ids) {
const Federation::Model* m = fed->findById(fed_id);
if (!m) continue;
QJsonObject source = m->source_data;
source["connector"] = m->source_connector;
QJsonObject entry;
entry["display_name"] = m->display_name;
entry["id"] = m->id;
entry["source"] = source;
params.append(entry);
}
proc->call("pull_models", params,
[sguard, vguard, fed_ids](const QJsonValue& result) {
if (!sguard) return;
const QJsonArray arr = result.toArray();
QStringList paths_to_load;
QStringList fed_ids_to_load;
bool any_detached = false;
for (int i = 0; i < arr.size() && i < fed_ids.size(); ++i) {
if (arr[i].isNull()) continue;
const QJsonObject obj = arr[i].toObject();
const QString new_path = obj.value("path").toString();
if (new_path.isEmpty()) continue;
const QString fed_id = fed_ids[i];
const QJsonObject meta = obj.value("metadata").toObject();
const uint32_t existing_mid = sguard->modelIdForFedId(fed_id);
if (existing_mid != 0 && sguard->loader()) {
const QString existing_path = sguard->loader()->filePath(existing_mid);
if (QDir::cleanPath(existing_path) == QDir::cleanPath(new_path)) {
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
continue;
}
// Path changed (new revision lives in a fresh cache dir).
// Detach the stale scene entry; federation entry stays.
if (vguard) vguard->removeModel(existing_mid);
sguard->loader()->removeModel(existing_mid);
sguard->elementRegistry()->removeModel(existing_mid);
sguard->removeModelMappingByFedId(fed_id);
any_detached = true;
}
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
paths_to_load << new_path;
fed_ids_to_load << fed_id;
}
if (any_detached) {
if (vguard) vguard->setSelectedObjectId(0);
sguard->setSelectedObjectId(0);
sguard->notifySelectionChanged();
}
if (!paths_to_load.isEmpty()) {
modules::models::commands::detail::loadModels(
*sguard, paths_to_load, fed_ids_to_load);
sguard->notifyModelsChanged();
}
},
[sguard, connector_id](int code, const QString& message) {
qWarning() << "pull_models from" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
}
int total = 0;
for (auto it = connector_to_fed_ids.constBegin();
it != connector_to_fed_ids.constEnd(); ++it) {
total += it.value().size();
}
s.setStatusMessage("Cloud", QString("Resolving %1 model(s)...").arg(total));
}
// Byte-equality check for "is this .ifcfed the same as what we have loaded?"
// — used by Sync From Cloud to skip the full reload when the connector
// served an unchanged revision.
bool isIfcfedUnchanged(const QString& current_path, const QString& candidate_path) {
if (current_path.isEmpty() || candidate_path.isEmpty()) return false;
if (QDir::cleanPath(current_path) == QDir::cleanPath(candidate_path)) return true;
QFile a(current_path);
QFile b(candidate_path);
if (!a.open(QIODevice::ReadOnly) || !b.open(QIODevice::ReadOnly)) return false;
if (a.size() != b.size()) return false;
return a.readAll() == b.readAll();
}
bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QString& path) {
SceneLoader* loader = s.loader();
if (loader && loader->isLoading()) {
QMessageBox::information(
&host, "Open Project",
"Wait until the current model load finishes before opening another project.");
return false;
}
if (!confirmDiscardIfDirty(s, host)) return false;
QStringList warnings;
QString err;
if (!s.federation()->load(path, &warnings, &err)) {
QMessageBox::warning(&host, "Open Project",
QString("Could not open project:\n%1").arg(err));
return false;
}
clearScene(s, vp);
QStringList paths;
QStringList fed_ids;
for (const auto& model : s.federation()->models()) {
if (model.source_connector != "local") continue;
if (!QFileInfo::exists(model.source_path)) {
warnings << QString("Source not found, kept in project: %1").arg(model.source_path);
continue;
}
paths << model.source_path;
fed_ids << model.id;
}
modules::models::commands::detail::loadModels(s, paths, fed_ids);
if (!warnings.isEmpty()) {
QMessageBox::warning(&host, "Open Project",
"Project opened with warnings:\n\n" + warnings.join("\n"));
}
s.federation()->markClean();
if (s.federation()->hasHomeView()) {
const auto& hv = s.federation()->homeView();
vp.setCamera(hv.target.x(), hv.target.y(), hv.target.z(),
hv.distance, hv.yaw, hv.pitch);
}
s.setStatusMessage("Project", QFileInfo(path).fileName());
s.notifyProjectOpened(path);
resolveCloudModels(s, vp);
return true;
}
bool saveProjectTo(SessionState& s, QWidget& host, const QString& path) {
QString err;
if (!s.federation()->save(path, &err)) {
QMessageBox::warning(&host, "Save Project",
QString("Could not save project:\n%1").arg(err));
return false;
}
s.setStatusMessage("Project", QFileInfo(path).fileName());
s.notifyProjectSaved(path);
return true;
}
} // namespace
bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
SceneLoader* loader = s.loader();
if (loader && loader->isLoading()) {
QMessageBox::information(
&host, "New Project",
"Wait until the current model load finishes before creating a new project.");
return false;
}
if (!confirmDiscardIfDirty(s, host)) return false;
clearScene(s, vp);
s.federation()->clear();
s.setStatusMessage("Project", "Untitled");
s.notifyProjectReset();
return true;
}
bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
QFileDialog file_dialog(&host, "Open Project");
file_dialog.setFileMode(QFileDialog::ExistingFile);
file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)");
file_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (file_dialog.exec() != QDialog::Accepted) return false;
const QString path = file_dialog.selectedFiles().value(0);
if (path.isEmpty()) return false;
return openProjectAt(s, host, vp, path);
}
bool openCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
SceneLoader* loader = s.loader();
if (loader && loader->isLoading()) {
QMessageBox::information(
&host, "Open from Cloud",
"Wait until the current model load finishes before opening another project.");
return false;
}
if (!confirmDiscardIfDirty(s, host)) return false;
auto* registry = s.connectorRegistry();
const auto& manifests = registry->available();
if (manifests.empty()) {
QMessageBox::information(&host, "Open from Cloud",
"No connectors are installed. Install one under your user connectors "
"directory.");
return false;
}
modules::connectors::ConnectorPickerDialog picker(
manifests, "Open from Cloud",
"Pick a connector to browse a project on.", &host);
if (picker.exec() != QDialog::Accepted) return false;
const QString connector_id = picker.selectedId();
if (connector_id.isEmpty()) return false;
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Open from Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return false;
}
s.beginProgress(QString("Opening project from %1...").arg(connector_id));
s.setStatusMessage("Cloud", QString("Browsing %1...").arg(connector_id));
QPointer<SessionState> sguard(&s);
QPointer<QWidget> hguard(&host);
QPointer<ViewportWindow> vguard(&vp);
proc->call("pull_ifcfed_interactive", QJsonValue(),
[sguard, hguard, vguard, connector_id](const QJsonValue& result) {
if (!sguard) return;
sguard->endProgress();
const QString path = result.toObject().value("path").toString();
if (path.isEmpty()) {
if (hguard) {
QMessageBox::warning(hguard, "Open from Cloud",
QString("Connector '%1' returned no path.").arg(connector_id));
}
return;
}
if (hguard && vguard) {
openProjectAt(*sguard, *hguard, *vguard, path);
}
},
[sguard, connector_id](int code, const QString& message) {
qWarning() << "pull_ifcfed_interactive from" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->endProgress();
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
return true;
}
bool syncCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
auto* fed = s.federation();
// Per spec, sync has two independent phases — refreshing the .ifcfed
// (requires manifest) and refreshing cloud models (requires any
// non-local source). Either is sufficient.
const bool has_manifest = fed->hasManifest();
bool has_cloud_models = false;
for (const auto& m : fed->models()) {
if (m.source_connector != "local") { has_cloud_models = true; break; }
}
if (!has_manifest && !has_cloud_models) {
QMessageBox::information(&host, "Sync From Cloud",
"Nothing to sync — this project has no cloud resources.");
return false;
}
SceneLoader* loader = s.loader();
if (loader && loader->isLoading()) {
QMessageBox::information(&host, "Sync From Cloud",
"Wait until the current model load finishes before syncing.");
return false;
}
// No manifest: skip the pull_ifcfed phase entirely (spec step 2-4
// skipped). Just refresh cloud-sourced models against the .ifcfed
// already on disk. Federation state is preserved, so no dirty prompt.
if (!has_manifest) {
s.setStatusMessage("Cloud", "Refreshing cloud models...");
resolveCloudModels(s, vp);
return true;
}
// Manifest path: the .ifcfed itself may be replaced. Confirm dirty —
// even though we'll attempt to preserve the session if the returned
// .ifcfed is byte-equal, that's not known until after the round-trip.
if (!confirmDiscardIfDirty(s, host)) return false;
const QString connector_id = fed->manifestConnectorId();
if (connector_id.isEmpty()) {
QMessageBox::warning(&host, "Sync From Cloud",
"The project's manifest does not name a connector.");
return false;
}
auto* registry = s.connectorRegistry();
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Sync From Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return false;
}
s.beginProgress(QString("Syncing from %1...").arg(connector_id));
s.setStatusMessage("Cloud", QString("Syncing from %1...").arg(connector_id));
QPointer<SessionState> sguard(&s);
QPointer<QWidget> hguard(&host);
QPointer<ViewportWindow> vguard(&vp);
const QString current_path = fed->filePath();
proc->call("pull_ifcfed", fed->manifest(),
[sguard, hguard, vguard, connector_id, current_path](const QJsonValue& result) {
if (!sguard) return;
sguard->endProgress();
const QString new_path = result.toObject().value("path").toString();
if (new_path.isEmpty()) {
if (hguard) {
QMessageBox::warning(hguard, "Sync From Cloud",
QString("Connector '%1' returned no path.").arg(connector_id));
}
return;
}
// Per spec: if the returned .ifcfed is unchanged from the one
// already loaded, preserve the current session — just repoint
// if the cache path moved and resync models.
if (isIfcfedUnchanged(current_path, new_path)) {
if (QDir::cleanPath(current_path) != QDir::cleanPath(new_path)) {
sguard->federation()->repointTo(new_path);
}
sguard->setStatusMessage("Cloud",
"Project up to date; refreshing cloud models...");
if (vguard) resolveCloudModels(*sguard, *vguard);
return;
}
// Content differs — fall through to the standard open path,
// which clears the scene and triggers resolveCloudModels for
// any cloud-sourced models in the freshly loaded federation.
if (hguard && vguard) {
openProjectAt(*sguard, *hguard, *vguard, new_path);
}
},
[sguard, connector_id](int code, const QString& message) {
qWarning() << "pull_ifcfed from" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->endProgress();
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
return true;
}
bool saveProject(SessionState& s, QWidget& host) {
if (s.federation()->filePath().isEmpty()) return saveProjectAs(s, host);
return saveProjectTo(s, host, s.federation()->filePath());
}
bool saveProjectAs(SessionState& s, QWidget& host) {
QString suggested = s.federation()->filePath();
if (suggested.isEmpty()) suggested = "project.ifcfed";
QFileDialog file_dialog(&host, "Save Project As", suggested);
file_dialog.setAcceptMode(QFileDialog::AcceptSave);
file_dialog.setFileMode(QFileDialog::AnyFile);
file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)");
file_dialog.setOption(QFileDialog::DontUseNativeDialog, true);
if (file_dialog.exec() != QDialog::Accepted) return false;
QString path = file_dialog.selectedFiles().value(0);
if (path.isEmpty()) return false;
if (!path.endsWith(".ifcfed", Qt::CaseInsensitive)) path += ".ifcfed";
return saveProjectTo(s, host, path);
}
namespace {
// Writes the current federation to a fresh QTemporaryDir as a `.ifcfed`
// without touching Federation's state, returning the temp dir (so callers
// can keep it alive through the async RPC) and the resolved file path.
struct TempProjectFile {
std::shared_ptr<QTemporaryDir> dir;
QString path;
};
TempProjectFile writeProjectToTemp(SessionState& s, QWidget& host, const QString& op_title) {
TempProjectFile out;
out.dir = std::make_shared<QTemporaryDir>();
if (!out.dir->isValid()) {
QMessageBox::warning(&host, op_title,
QString("Could not create a temporary directory:\n%1")
.arg(out.dir->errorString()));
out.dir.reset();
return out;
}
const QString name = s.federation()->filePath().isEmpty()
? "project.ifcfed"
: QFileInfo(s.federation()->filePath()).fileName();
const QString tmp_path = QDir(out.dir->path()).filePath(name);
QString err;
if (!s.federation()->writeCopyTo(tmp_path, &err)) {
QMessageBox::warning(&host, op_title,
QString("Failed to write temporary project:\n%1").arg(err));
out.dir.reset();
return out;
}
out.path = tmp_path;
return out;
}
// Shared continuation for push_ifcfed[_interactive]: on success, repoint
// Federation to the returned path and notify; on error, log + status.
void onPushIfcfedResult(SessionState& s,
QWidget& host,
const QString& op_title,
const QString& connector_id,
const QJsonValue& result) {
s.endProgress();
const QString new_path = result.toObject().value("path").toString();
if (new_path.isEmpty()) {
QMessageBox::warning(&host, op_title,
QString("Connector '%1' returned no path.").arg(connector_id));
return;
}
QStringList warnings;
s.federation()->repointTo(new_path, &warnings);
s.setStatusMessage("Cloud",
QString("Saved to %1 via %2")
.arg(QFileInfo(new_path).fileName(), connector_id));
s.notifyProjectSaved(new_path);
}
} // namespace
bool saveCloudProject(SessionState& s, QWidget& host) {
auto* fed = s.federation();
if (!fed->hasManifest()) {
QMessageBox::information(&host, "Save To Cloud",
"This project has no cloud target. Use \"Save As To Cloud\" first.");
return false;
}
const QString connector_id = fed->manifestConnectorId();
auto* registry = s.connectorRegistry();
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Save To Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return false;
}
auto tmp = writeProjectToTemp(s, host, "Save To Cloud");
if (!tmp.dir) return false;
QJsonObject params;
params["path"] = tmp.path;
params["manifest"] = fed->manifest();
s.beginProgress(QString("Saving to %1...").arg(connector_id));
s.setStatusMessage("Cloud", QString("Saving to %1...").arg(connector_id));
QPointer<SessionState> sguard(&s);
QPointer<QWidget> hguard(&host);
proc->call("push_ifcfed", params,
[sguard, hguard, connector_id, tmp_keepalive = tmp.dir](const QJsonValue& result) {
(void)tmp_keepalive;
if (!sguard || !hguard) return;
onPushIfcfedResult(*sguard, *hguard, "Save To Cloud", connector_id, result);
},
[sguard, connector_id, tmp_keepalive = tmp.dir](int code, const QString& message) {
(void)tmp_keepalive;
qWarning() << "push_ifcfed to" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->endProgress();
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
return true;
}
bool saveAsCloudProject(SessionState& s, QWidget& host) {
auto* registry = s.connectorRegistry();
const auto& manifests = registry->available();
if (manifests.empty()) {
QMessageBox::information(&host, "Save As To Cloud",
"No connectors are installed.");
return false;
}
modules::connectors::ConnectorPickerDialog picker(
manifests, "Save As To Cloud",
"Pick a connector to push this project to.", &host);
if (picker.exec() != QDialog::Accepted) return false;
const QString connector_id = picker.selectedId();
if (connector_id.isEmpty()) return false;
auto* proc = registry->get(connector_id);
if (!proc) {
QMessageBox::warning(&host, "Save As To Cloud",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id, registry->lastError()));
return false;
}
auto tmp = writeProjectToTemp(s, host, "Save As To Cloud");
if (!tmp.dir) return false;
QJsonObject params;
params["path"] = tmp.path;
s.beginProgress(QString("Pushing to %1...").arg(connector_id));
s.setStatusMessage("Cloud", QString("Pushing to %1...").arg(connector_id));
QPointer<SessionState> sguard(&s);
QPointer<QWidget> hguard(&host);
proc->call("push_ifcfed_interactive", params,
[sguard, hguard, connector_id, tmp_keepalive = tmp.dir](const QJsonValue& result) {
(void)tmp_keepalive;
if (!sguard || !hguard) return;
onPushIfcfedResult(*sguard, *hguard, "Save As To Cloud", connector_id, result);
},
[sguard, connector_id, tmp_keepalive = tmp.dir](int code, const QString& message) {
(void)tmp_keepalive;
qWarning() << "push_ifcfed_interactive to" << connector_id
<< "failed:" << code << message;
if (sguard) {
sguard->endProgress();
sguard->setStatusMessage("Cloud",
QString("%1 reported an error (see connector UI)").arg(connector_id));
}
});
return true;
}
bool saveProjectDialog(SessionState& s, QWidget& host) {
SaveProjectDialog dialog(s.federation()->hasManifest(), &host);
if (dialog.exec() != QDialog::Accepted) return false;
switch (dialog.selectedTarget()) {
case SaveTarget::Local: return saveProject(s, host);
case SaveTarget::LocalAs: return saveProjectAs(s, host);
case SaveTarget::Cloud: return saveCloudProject(s, host);
case SaveTarget::CloudAs: return saveAsCloudProject(s, host);
case SaveTarget::None: return false;
}
return false;
}
} // namespace bonsaiviewer::modules::project::commands
@@ -0,0 +1,63 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_PROJECT_COMMANDS_H
#define IFCINTERFACE_MODULES_PROJECT_COMMANDS_H
#include <QString>
class QWidget;
class ViewportWindow;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::project::commands {
// User-facing commands. Each owns its own dialogs and confirmations; each
// emits exactly one notify() at the end (projectReset / projectOpened /
// projectSaved) so views refresh once per command.
bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp);
bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp);
// Pick a connector, then call pull_ifcfed_interactive and open the resulting
// .ifcfed as a fresh project. Non-local models in the loaded federation are
// resolved asynchronously via pull_models.
bool openCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp);
// pull_ifcfed using the current project's .ifcfed.manifest. Re-downloads
// the .ifcfed from the same cloud target it came from (typically without
// user interaction), then opens it like a fresh project — discarding any
// local edits after the usual dirty-check prompt.
bool syncCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp);
bool saveProject(SessionState& s, QWidget& host);
bool saveProjectAs(SessionState& s, QWidget& host);
// Push the current federation to the cloud target named in its manifest
// (push_ifcfed). No user prompt for destination. Caller is responsible for
// gating this on Federation::hasManifest.
bool saveCloudProject(SessionState& s, QWidget& host);
// Pick a connector and push the current federation to a fresh cloud target
// (push_ifcfed_interactive). The connector returns a new path + manifest;
// Federation repoints to that location.
bool saveAsCloudProject(SessionState& s, QWidget& host);
// Show the four-way Save dialog (Local / Save As Local / To Cloud / Save
// As To Cloud) and dispatch to one of the above. This is what the "Save
// Project" ribbon button is wired to.
bool saveProjectDialog(SessionState& s, QWidget& host);
} // namespace bonsaiviewer::modules::project::commands
#endif
@@ -0,0 +1,148 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SaveProjectDialog.h"
#include "../../components/Buttons.h"
#include "../../components/Section.h"
#include "../../components/Style.h"
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::modules::project {
namespace {
class HoverDescriptionFilter : public QObject {
public:
HoverDescriptionFilter(QLabel* label, QString hover_text, QString default_text)
: label_(label), hover_text_(std::move(hover_text)), default_text_(std::move(default_text)) {}
protected:
bool eventFilter(QObject*, QEvent* event) override {
if (event->type() == QEvent::Enter) label_->setText(hover_text_);
else if (event->type() == QEvent::Leave) label_->setText(default_text_);
return false;
}
private:
QLabel* label_ = nullptr;
QString hover_text_;
QString default_text_;
};
} // namespace
SaveProjectDialog::SaveProjectDialog(bool has_manifest, QWidget* parent)
: components::Dialog(parent)
{
setObjectName("appDialog");
setWindowTitle("Save Project");
setModal(true);
setupUi(has_manifest);
}
void SaveProjectDialog::setupUi(bool has_manifest) {
if (auto* root = qobject_cast<QVBoxLayout*>(layout())) {
root->setSizeConstraint(QLayout::SetFixedSize);
}
const QString default_description = "Choose where to save this project";
auto* description_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* description = new QLabel(default_description, description_section);
description->setProperty("textRole", "secondary");
description->setWordWrap(true);
description->setAlignment(Qt::AlignCenter);
description->setMinimumWidth((90 * 4) + (components::style::metrics::padding * 3));
description->setMinimumHeight(description->fontMetrics().lineSpacing() * 2 + 4);
description_section->addBodyWidget(description);
auto* choices_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* choices = new QWidget(choices_section);
auto* row = new QHBoxLayout(choices);
row->setContentsMargins(0, 0, 0, 0);
row->setSpacing(components::style::metrics::padding);
auto* save_local = components::buttons::makeButton(
"Save Local", ":/icons/floppy-disk.svg", choices);
connect(save_local, &QToolButton::clicked, this, [this]() {
selected_target_ = SaveTarget::Local;
accept();
});
save_local->installEventFilter(new HoverDescriptionFilter(
description,
"Save to the project's current file on disk (prompts for a path if none).",
default_description));
auto* save_as_local = components::buttons::makeButton(
"Save As\nLocal", ":/icons/floppy-disk-arrow-in.svg", choices);
connect(save_as_local, &QToolButton::clicked, this, [this]() {
selected_target_ = SaveTarget::LocalAs;
accept();
});
save_as_local->installEventFilter(new HoverDescriptionFilter(
description,
"Save the project to a new file on disk.",
default_description));
auto* save_cloud = components::buttons::makeButton(
"Save To\nCloud", ":/icons/cloud-square.svg", choices);
save_cloud->setEnabled(has_manifest);
if (!has_manifest) {
save_cloud->setToolTip(
"This project has no cloud target yet. Use \"Save As To Cloud\" first.");
}
connect(save_cloud, &QToolButton::clicked, this, [this]() {
selected_target_ = SaveTarget::Cloud;
accept();
});
save_cloud->installEventFilter(new HoverDescriptionFilter(
description,
has_manifest
? "Push back to the cloud location this project came from."
: "Disabled until this project has a cloud target (use Save As To Cloud).",
default_description));
auto* save_as_cloud = components::buttons::makeButton(
"Save As\nTo Cloud", ":/icons/cloud-square.svg", choices);
connect(save_as_cloud, &QToolButton::clicked, this, [this]() {
selected_target_ = SaveTarget::CloudAs;
accept();
});
save_as_cloud->installEventFilter(new HoverDescriptionFilter(
description,
"Pick a connector and push this project to a fresh cloud location.",
default_description));
row->addWidget(components::buttons::makeButtonGroup(
"LOCAL", {save_local, save_as_local}, choices, true, 8));
row->addWidget(components::buttons::makeButtonGroup(
"CLOUD", {save_cloud, save_as_cloud}, choices, false, 8));
choices_section->addBodyWidget(choices);
addBodyWidget(description_section);
addBodyWidget(choices_section);
}
} // namespace bonsaiviewer::modules::project
@@ -0,0 +1,54 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_PROJECT_SAVEPROJECTDIALOG_H
#define IFCINTERFACE_MODULES_PROJECT_SAVEPROJECTDIALOG_H
#include "../../components/Dialog.h"
namespace bonsaiviewer::modules::project {
enum class SaveTarget {
None,
Local, // saveProject: write to current file_path_ (or fall through to LocalAs)
LocalAs, // saveProjectAs: pick file with QFileDialog
Cloud, // saveCloudProject: push_ifcfed using existing manifest
CloudAs, // saveAsCloudProject: connector picker + push_ifcfed_interactive
};
// "Save Project" dispatch dialog, modelled on AddModelDialog. Always shows
// all four buttons; "Save to Cloud" is disabled when the project has no
// .ifcfed.manifest (there is no cloud target to push to).
class SaveProjectDialog : public components::Dialog {
Q_OBJECT
public:
explicit SaveProjectDialog(bool has_manifest, QWidget* parent = nullptr);
SaveTarget selectedTarget() const { return selected_target_; }
private:
void setupUi(bool has_manifest);
SaveTarget selected_target_ = SaveTarget::None;
};
} // namespace bonsaiviewer::modules::project
#endif
@@ -0,0 +1,237 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "../../components/KeyValueTable.h"
#include "../../components/Section.h"
#include "../../components/Style.h"
#include "../../components/SvgIcon.h"
#include <QFrame>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QToolButton>
#include <QVBoxLayout>
namespace {
QWidget* makePropertySetPanel(const bonsaiviewer::modules::properties::PropertySet& property_set, QWidget* parent = nullptr) {
auto* group = new QGroupBox(property_set.title, parent);
group->setObjectName("propertySetBox");
auto* layout = new QVBoxLayout(group);
layout->setContentsMargins(10, 10, 10, 10);
layout->setSpacing(0);
QList<bonsaiviewer::components::KeyValueTableRow> rows;
for (const auto& row : property_set.rows) {
rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0});
}
layout->addWidget(new bonsaiviewer::components::KeyValueTable(rows, group));
return group;
}
QWidget* makeAttributeList(const QList<bonsaiviewer::modules::properties::KeyValueRow>& rows, QWidget* parent = nullptr) {
QList<bonsaiviewer::components::KeyValueTableRow> table_rows;
for (const auto& row : rows) {
table_rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0});
}
return new bonsaiviewer::components::KeyValueTable(table_rows, parent);
}
QWidget* makeRelationshipList(const QList<bonsaiviewer::modules::properties::RelationshipRow>& rows, QWidget* parent = nullptr) {
QList<bonsaiviewer::components::KeyValueTableRow> table_rows;
for (const auto& row_data : rows) {
table_rows.append({row_data.key,
row_data.value,
"keyValueValueLabel",
":/icons/cursor-pointer.svg",
"keyValueTrailingIconLabel",
72});
}
return new bonsaiviewer::components::KeyValueTable(table_rows, parent);
}
QWidget* makeFilterWrapper(QLineEdit** field_out, QWidget* parent = nullptr) {
auto* wrapper = new QWidget(parent);
wrapper->setObjectName("panelSectionFilterWrapper");
auto* layout = new QVBoxLayout(wrapper);
layout->setContentsMargins(bonsaiviewer::components::style::metrics::section_body_padding,
0,
bonsaiviewer::components::style::metrics::section_body_padding,
0);
layout->setSpacing(0);
auto* field = new QLineEdit(wrapper);
field->setClearButtonEnabled(true);
field->addAction(bonsaiviewer::components::icons::makeSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition);
field->setVisible(false);
layout->addWidget(field);
if (field_out) *field_out = field;
return wrapper;
}
QFrame* makeEntityBox(const bonsaiviewer::modules::properties::EntitySummary& entity, QWidget* parent = nullptr) {
auto* entity_box = new QFrame(parent);
entity_box->setObjectName("entityClassBox");
auto* entity_layout = new QHBoxLayout(entity_box);
entity_layout->setContentsMargins(10, 8, 10, 8);
entity_layout->setSpacing(10);
auto* entity_icon = new QLabel(entity_box);
entity_icon->setPixmap(bonsaiviewer::components::icons::makeSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28)));
entity_icon->setAlignment(Qt::AlignCenter);
auto* entity_text = new QWidget(entity_box);
auto* entity_text_layout = new QVBoxLayout(entity_text);
entity_text_layout->setContentsMargins(0, 0, 0, 0);
entity_text_layout->setSpacing(2);
auto* entity_class_label = new QLabel(entity.entity_class, entity_text);
entity_class_label->setObjectName("entityClassLabel");
auto* entity_type_label = new QLabel(entity.predefined_type, entity_text);
entity_type_label->setProperty("textRole", "secondary");
entity_text_layout->addWidget(entity_class_label);
entity_text_layout->addWidget(entity_type_label);
entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter);
entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter);
return entity_box;
}
} // namespace
namespace bonsaiviewer::modules::properties {
PropertiesPanel::PropertiesPanel(QWidget* parent)
: components::Panel("Properties", nullptr, parent, false, true)
{
}
void PropertiesPanel::render(const PropertiesPanelState& state) {
clearBodyWidgets();
QList<QWidget*> property_set_widgets;
for (const auto& property_set : state.property_sets) {
property_set_widgets.append(makePropertySetPanel(property_set, this));
}
QList<QWidget*> quantity_set_widgets;
for (const auto& property_set : state.quantity_sets) {
quantity_set_widgets.append(makePropertySetPanel(property_set, this));
}
auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
entity_section->addBodyWidget(makeEntityBox(state.entity, this));
auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, this);
attributes_section->addBodyWidget(makeAttributeList(state.attributes, this));
attributes_section->setExpanded(attributes_expanded_);
auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, this);
relationships_section->addBodyWidget(makeRelationshipList(state.relationships, this));
relationships_section->setExpanded(relationships_expanded_);
auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, this);
auto* properties_filter_toggle = new QToolButton(properties_section);
properties_filter_toggle->setObjectName("panelSectionFilterToggle");
properties_filter_toggle->setCheckable(true);
properties_filter_toggle->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg"));
properties_filter_toggle->setAutoRaise(true);
properties_section->addHeaderWidget(properties_filter_toggle);
QLineEdit* properties_filter_field = nullptr;
auto* properties_filter_wrapper = makeFilterWrapper(&properties_filter_field, properties_section);
properties_filter_field->setPlaceholderText("Filter properties or sets");
properties_filter_field->setText(properties_filter_text_);
properties_filter_wrapper->setVisible(properties_filter_visible_);
properties_filter_field->setVisible(properties_filter_visible_);
connect(properties_filter_toggle, &QToolButton::toggled, properties_filter_field, [this, properties_filter_field, properties_filter_wrapper](bool visible) {
properties_filter_visible_ = visible;
properties_filter_field->setVisible(visible);
properties_filter_wrapper->setVisible(visible);
if (visible) properties_filter_field->setFocus();
});
connect(properties_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) {
properties_filter_text_ = text;
});
properties_section->addBodyWidget(properties_filter_wrapper);
for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget);
properties_section->setExpanded(properties_expanded_);
properties_filter_toggle->setChecked(properties_filter_visible_);
auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, this);
auto* quantities_filter_toggle = new QToolButton(quantities_section);
quantities_filter_toggle->setObjectName("panelSectionFilterToggle");
quantities_filter_toggle->setCheckable(true);
quantities_filter_toggle->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg"));
quantities_filter_toggle->setAutoRaise(true);
quantities_section->addHeaderWidget(quantities_filter_toggle);
QLineEdit* quantities_filter_field = nullptr;
auto* quantities_filter_wrapper = makeFilterWrapper(&quantities_filter_field, quantities_section);
quantities_filter_field->setPlaceholderText("Filter quantities or sets");
quantities_filter_field->setText(quantities_filter_text_);
quantities_filter_wrapper->setVisible(quantities_filter_visible_);
quantities_filter_field->setVisible(quantities_filter_visible_);
connect(quantities_filter_toggle, &QToolButton::toggled, quantities_filter_field, [this, quantities_filter_field, quantities_filter_wrapper](bool visible) {
quantities_filter_visible_ = visible;
quantities_filter_field->setVisible(visible);
quantities_filter_wrapper->setVisible(visible);
if (visible) quantities_filter_field->setFocus();
});
connect(quantities_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) {
quantities_filter_text_ = text;
});
quantities_section->addBodyWidget(quantities_filter_wrapper);
for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget);
quantities_section->setExpanded(quantities_expanded_);
quantities_filter_toggle->setChecked(quantities_filter_visible_);
if (auto* button = attributes_section->findChild<QToolButton*>("panelSectionHeaderButton")) {
connect(button, &QToolButton::toggled, this, [this](bool expanded) {
attributes_expanded_ = expanded;
});
}
if (auto* button = relationships_section->findChild<QToolButton*>("panelSectionHeaderButton")) {
connect(button, &QToolButton::toggled, this, [this](bool expanded) {
relationships_expanded_ = expanded;
});
}
if (auto* button = properties_section->findChild<QToolButton*>("panelSectionHeaderButton")) {
connect(button, &QToolButton::toggled, this, [this](bool expanded) {
properties_expanded_ = expanded;
});
}
if (auto* button = quantities_section->findChild<QToolButton*>("panelSectionHeaderButton")) {
connect(button, &QToolButton::toggled, this, [this](bool expanded) {
quantities_expanded_ = expanded;
});
}
addBodyWidget(entity_section);
addBodyWidget(attributes_section);
addBodyWidget(relationships_section);
addBodyWidget(properties_section);
addBodyWidget(quantities_section);
}
} // namespace bonsaiviewer::modules::properties
@@ -0,0 +1,56 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_PROPERTIES_PANEL_H
#define IFCINTERFACE_MODULES_PROPERTIES_PANEL_H
#include "Types.h"
#include "../../components/Panel.h"
#include <QString>
class QLabel;
class QLineEdit;
class QToolButton;
namespace bonsaiviewer::modules::properties {
class PropertiesPanel : public components::Panel {
Q_OBJECT
public:
explicit PropertiesPanel(QWidget* parent = nullptr);
void render(const PropertiesPanelState& state);
private:
bool attributes_expanded_ = true;
bool relationships_expanded_ = true;
bool properties_expanded_ = true;
bool quantities_expanded_ = true;
bool properties_filter_visible_ = false;
bool quantities_filter_visible_ = false;
QString properties_filter_text_;
QString quantities_filter_text_;
};
} // namespace bonsaiviewer::modules::properties
#endif
@@ -0,0 +1,60 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_PROPERTIESPANELTYPES_H
#define IFCINTERFACE_PANELS_PROPERTIESPANELTYPES_H
#include <QList>
#include <QPair>
#include <QString>
namespace bonsaiviewer::modules::properties {
struct KeyValueRow {
QString key;
QString value;
};
struct RelationshipRow {
QString key;
QString value;
};
struct PropertySet {
QString title;
QList<KeyValueRow> rows;
};
struct EntitySummary {
QString entity_class;
QString predefined_type;
};
struct PropertiesPanelState {
EntitySummary entity;
QList<KeyValueRow> attributes;
QList<RelationshipRow> relationships;
QList<PropertySet> property_sets;
QList<PropertySet> quantity_sets;
};
} // namespace bonsaiviewer::modules::properties
#endif
@@ -0,0 +1,124 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "View.h"
#include "Panel.h"
#include "../../ElementRegistry.h"
#include "../../SessionState.h"
namespace bonsaiviewer::modules::properties {
PropertiesPanelView::PropertiesPanelView(PropertiesPanel* widget,
bonsaiviewer::SessionState* session_state,
QObject* parent)
: QObject(parent), widget_(widget), session_state_(session_state)
{
connect(session_state_, &bonsaiviewer::SessionState::selectionChanged, this, [this](uint32_t object_id) {
refresh(object_id);
});
connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, [this]() {
refresh(0);
});
connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, [this](const QString&) {
refresh(0);
});
refresh(0);
}
void PropertiesPanelView::refresh(uint32_t object_id) {
auto* registry = session_state_->elementRegistry();
PropertiesPanelState state;
state.entity = {"IfcWall", "SOLIDWALL"};
state.attributes = {
{"GlobalId", "2Q$n5SLPP9Q8B7wQKjKfUQ"},
{"Name", "Core-EXT-204"},
{"Description", "External load-bearing wall"},
};
state.relationships = {
{"Type", "Basic Wall: Exterior - 200mm"},
{"Container", "Level 02"},
};
state.property_sets = {
{"Pset_WallCommon",
{{"Reference", "Core-EXT-204"},
{"Status", "Reviewed"},
{"Fire Rating", "120 min"},
{"LoadBearing", "True"}}},
{"Identity Data",
{{"Type", "IfcWall"},
{"Name", "Core-EXT-204"},
{"Owner", "Architecture"},
{"Phase", "Construction"}}},
{"BIM Collaboration",
{{"Issue Count", "2 open"},
{"Last Review", "2026-04-30"},
{"Assigned To", "Design Coordination"}}},
};
state.quantity_sets = {
{"BaseQuantities",
{{"Length", "6.20 m"},
{"Height", "3.45 m"},
{"Width", "0.30 m"},
{"Volume", "6.42 m3"}}},
{"Finish Quantities",
{{"NetSideArea", "21.39 m2"},
{"GrossArea", "22.10 m2"},
{"Paint Coverage", "42.78 m2"}}},
};
if (!registry) {
widget_->render(state);
return;
}
auto entity = registry->findEntity(object_id);
if (entity) {
state.entity.entity_class = QString::fromStdString(entity->declaration().name());
if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) {
state.property_sets[1].rows[0].value = state.entity.entity_class;
}
} else {
// No live IFC source for this object — typical when a pure-geometry
// .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall
// back to the basic info cached in the element registry so the
// panel still shows class / name / guid for visible elements.
auto info = registry->findBasicElementInfo(object_id);
if (info && !info->type.isEmpty()) {
state.entity.entity_class = info->type;
if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) {
state.property_sets[1].rows[0].value = info->type;
}
}
if (info && !info->name.isEmpty()) {
state.attributes[1].value = info->name;
if (state.property_sets.size() > 1 && state.property_sets[1].rows.size() > 1) {
state.property_sets[1].rows[1].value = info->name;
}
}
if (info && !info->guid.isEmpty()) {
state.attributes[0].value = info->guid;
}
}
widget_->render(state);
}
} // namespace bonsaiviewer::modules::properties
@@ -0,0 +1,49 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H
#define IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H
#include "Types.h"
#include <QObject>
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::properties {
class PropertiesPanel;
class PropertiesPanelView : public QObject {
Q_OBJECT
public:
explicit PropertiesPanelView(PropertiesPanel* widget,
bonsaiviewer::SessionState* session_state,
QObject* parent = nullptr);
private:
void refresh(uint32_t object_id);
PropertiesPanel* widget_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
};
} // namespace bonsaiviewer::modules::properties
#endif
@@ -0,0 +1,454 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Dialog.h"
#include "../../SessionState.h"
#include "../../ViewerSettings.h"
#include "../../../ifcviewer/AppSettings.h"
#include "../../components/Dialog.h"
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include "../../components/Style.h"
#include "../connectors/Process.h"
#include "../connectors/Registry.h"
#include <QCheckBox>
#include <QColorDialog>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFrame>
#include <QFormLayout>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QShowEvent>
#include <QSpinBox>
#include <QToolButton>
#include <QVBoxLayout>
namespace bonsaiviewer::modules::settings {
SettingsDialog::SettingsDialog(bonsaiviewer::SessionState* session_state, QWidget* parent)
: components::TabbedDialog(parent)
, session_state_(session_state)
{
setObjectName("appDialog");
setWindowTitle("Settings");
setModal(true);
resize(520, 420);
setupUi();
}
void SettingsDialog::showEvent(QShowEvent* event) {
syncFromSettings();
QDialog::showEvent(event);
}
void SettingsDialog::setupUi() {
auto* graphics_tab = new QWidget(this);
auto* graphics_layout = new QVBoxLayout(graphics_tab);
graphics_layout->setContentsMargins(0, 0, 0, 0);
graphics_layout->setSpacing(components::style::metrics::padding);
graphics_layout->setAlignment(Qt::AlignTop);
auto* general_section = new components::Section("General", components::SectionHeaderMode::Visible, graphics_tab);
auto* general_body = new QWidget(general_section);
auto* general_form = new QFormLayout(general_body);
general_form->setContentsMargins(0, 0, 0, 0);
general_form->setHorizontalSpacing(16);
general_form->setVerticalSpacing(10);
geometry_library_edit_ = new QLineEdit(general_body);
geometry_library_edit_->setMinimumWidth(300);
general_form->addRow("Geometry Library", geometry_library_edit_);
show_stats_check_ = new QCheckBox(general_body);
general_form->addRow("Show Performance Stats", show_stats_check_);
backface_culling_check_ = new QCheckBox(general_body);
backface_culling_check_->setToolTip(
"Skip triangles facing away from the camera. Big FPS win on closed solids; "
"disable if you see holes in open geometry.");
general_form->addRow("Backface Culling", backface_culling_check_);
general_section->addBodyWidget(general_body);
auto* loading_section = new components::Section("Loading", components::SectionHeaderMode::Visible, graphics_tab);
auto* loading_body = new QWidget(loading_section);
auto* loading_form = new QFormLayout(loading_body);
loading_form->setContentsMargins(0, 0, 0, 0);
loading_form->setHorizontalSpacing(16);
loading_form->setVerticalSpacing(10);
void_limit_spin_ = new QSpinBox(loading_body);
void_limit_spin_->setRange(0, 100000);
loading_form->addRow("Void Limit", void_limit_spin_);
deflection_tolerance_spin_ = new QDoubleSpinBox(loading_body);
deflection_tolerance_spin_->setRange(0.000001, 1000.0);
deflection_tolerance_spin_->setDecimals(6);
deflection_tolerance_spin_->setSingleStep(0.001);
loading_form->addRow("Deflection Tolerance", deflection_tolerance_spin_);
angular_tolerance_spin_ = new QDoubleSpinBox(loading_body);
angular_tolerance_spin_->setRange(0.000001, 3.141592);
angular_tolerance_spin_->setDecimals(6);
angular_tolerance_spin_->setSingleStep(0.05);
loading_form->addRow("Angular Tolerance", angular_tolerance_spin_);
min_pixel_radius_spin_ = new QDoubleSpinBox(loading_body);
min_pixel_radius_spin_->setRange(0.0, 100.0);
min_pixel_radius_spin_->setDecimals(2);
min_pixel_radius_spin_->setSingleStep(0.5);
min_pixel_radius_spin_->setToolTip(
"Minimum projected sphere radius (in pixels) for an instance to "
"be drawn. Bigger = faster but more pop-in on small detail.");
loading_form->addRow("Min Pixel Radius", min_pixel_radius_spin_);
motion_min_pixel_radius_spin_ = new QDoubleSpinBox(loading_body);
motion_min_pixel_radius_spin_->setRange(0.0, 100.0);
motion_min_pixel_radius_spin_->setDecimals(2);
motion_min_pixel_radius_spin_->setSingleStep(1.0);
motion_min_pixel_radius_spin_->setToolTip(
"Aggressive cull threshold while the camera is moving. 0 = no "
"motion boost (motion uses the same threshold as still frames).");
loading_form->addRow("Motion Min Pixel Radius", motion_min_pixel_radius_spin_);
lod1_pixel_threshold_spin_ = new QDoubleSpinBox(loading_body);
lod1_pixel_threshold_spin_->setRange(0.0, 1000.0);
lod1_pixel_threshold_spin_->setDecimals(1);
lod1_pixel_threshold_spin_->setSingleStep(1.0);
lod1_pixel_threshold_spin_->setToolTip(
"Pixel radius below which an instance switches to its LOD1 "
"representation. 0 disables LOD1 entirely.");
loading_form->addRow("LOD1 Pixel Threshold", lod1_pixel_threshold_spin_);
hiz_enabled_check_ = new QCheckBox(loading_body);
hiz_enabled_check_->setToolTip(
"Enable HiZ (hierarchical Z) occlusion culling. Hides geometry "
"behind opaque blockers based on a downsampled depth pyramid.");
loading_form->addRow("HiZ Occlusion", hiz_enabled_check_);
hiz_resolution_spin_ = new QSpinBox(loading_body);
hiz_resolution_spin_->setRange(64, 4096);
hiz_resolution_spin_->setSingleStep(64);
hiz_resolution_spin_->setToolTip(
"Base HiZ pyramid width in texels (height tracks aspect). "
"Changes take effect on next viewport reinitialization.");
loading_form->addRow("HiZ Resolution", hiz_resolution_spin_);
loading_section->addBodyWidget(loading_body);
graphics_layout->addWidget(general_section);
graphics_layout->addWidget(loading_section);
graphics_layout->addStretch(1);
// Navigation tab: orbit / pan presets. Selection always stays on
// LMB so click + box-select keep working regardless of preset.
auto* interface_tab = new QWidget(this);
{
auto* layout = new QVBoxLayout(interface_tab);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(components::style::metrics::padding);
layout->setAlignment(Qt::AlignTop);
auto* section = new components::Section(
"Navigation", components::SectionHeaderMode::Visible, interface_tab);
auto* body = new QWidget(section);
auto* form = new QFormLayout(body);
form->setContentsMargins(0, 0, 0, 0);
form->setHorizontalSpacing(16);
form->setVerticalSpacing(10);
nav_preset_combo_ = new QComboBox(body);
// Order must match AppSettings::NavPreset enum ordering — index
// is what we read back via currentIndex / setCurrentIndex.
nav_preset_combo_->addItem("Blender (Orbit MMB, Pan Shift+MMB)");
nav_preset_combo_->addItem("Rhino (Orbit RMB, Pan Shift+RMB)");
nav_preset_combo_->addItem("Revit (Orbit Shift+MMB, Pan MMB)");
nav_preset_combo_->setToolTip(
"Mouse-button mapping for orbit and pan. Selection stays on "
"left mouse button for every preset, so click + box-select "
"always work.");
form->addRow("Preset", nav_preset_combo_);
section->addBodyWidget(body);
layout->addWidget(section);
auto* theme_section = new components::Section(
"Theme", components::SectionHeaderMode::Visible, interface_tab);
auto* theme_body = new QWidget(theme_section);
auto* theme_layout = new QVBoxLayout(theme_body);
theme_layout->setContentsMargins(0, 0, 0, 0);
theme_layout->setSpacing(components::style::metrics::padding);
auto* theme_form = new QFormLayout();
theme_form->setContentsMargins(0, 0, 0, 0);
theme_form->setHorizontalSpacing(16);
theme_form->setVerticalSpacing(10);
theme_mode_combo_ = new QComboBox(theme_body);
theme_mode_combo_->addItem("Dark", static_cast<int>(bonsaiviewer::ViewerSettings::ThemeMode::Dark));
theme_mode_combo_->addItem("Light", static_cast<int>(bonsaiviewer::ViewerSettings::ThemeMode::Light));
theme_mode_combo_->addItem("Custom", static_cast<int>(bonsaiviewer::ViewerSettings::ThemeMode::Custom));
theme_form->addRow("Preset", theme_mode_combo_);
theme_custom_body_ = new QWidget(theme_body);
auto* custom_grid = new QGridLayout(theme_custom_body_);
custom_grid->setContentsMargins(0, 0, 0, 0);
custom_grid->setHorizontalSpacing(12);
custom_grid->setVerticalSpacing(8);
int row = 0;
for (const auto& spec : bonsaiviewer::ViewerSettings::themeColorSpecs()) {
auto* label = new QLabel(QString::fromUtf8(spec.label), theme_custom_body_);
auto* edit = new QLineEdit(theme_custom_body_);
edit->setPlaceholderText("#000000");
auto* pick = new QPushButton("Pick", theme_custom_body_);
connect(pick, &QPushButton::clicked, this, [this, edit]() { pickThemeColor(edit); });
custom_grid->addWidget(label, row, 0);
custom_grid->addWidget(edit, row, 1);
custom_grid->addWidget(pick, row, 2);
theme_color_editors_.push_back({QString::fromUtf8(spec.key), edit});
++row;
}
auto* theme_form_widget = new QWidget(theme_body);
theme_form_widget->setLayout(theme_form);
theme_layout->addWidget(theme_form_widget);
theme_layout->addWidget(theme_custom_body_);
theme_section->addBodyWidget(theme_body);
layout->addWidget(theme_section);
connect(theme_mode_combo_, &QComboBox::currentIndexChanged, this, [this](int) {
updateThemeEditorEnabled();
});
}
auto make_placeholder_tab = [this](const QString& title, const QString& detail) {
auto* tab = new QWidget(this);
auto* layout = new QVBoxLayout(tab);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(components::style::metrics::padding);
layout->setAlignment(Qt::AlignTop);
auto* section = new components::Section(title, components::SectionHeaderMode::Visible, tab);
auto* body = new QWidget(section);
auto* body_layout = new QVBoxLayout(body);
body_layout->setContentsMargins(0, 0, 0, 0);
body_layout->setSpacing(8);
auto* heading = new QLabel(title, body);
auto* content = new QLabel(detail, body);
content->setProperty("textRole", "secondary");
content->setWordWrap(true);
body_layout->addWidget(heading);
body_layout->addWidget(content);
section->addBodyWidget(body);
layout->addWidget(section);
layout->addStretch(1);
return tab;
};
addTab("Interface", interface_tab);
addTab("Keybindings", make_placeholder_tab("Keybindings", "Shortcut presets and command bindings will live here."));
addTab("Graphics", graphics_tab);
addTab("Connectors", buildConnectorsTab());
addTab("About", make_placeholder_tab("About", "Version, credits, and environment information will live here."));
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
if (auto* ok = buttons->button(QDialogButtonBox::Ok)) {
ok->setText("OK");
ok->setIcon(components::icons::makeSvgIcon(":/icons/check.svg"));
}
if (auto* cancel = buttons->button(QDialogButtonBox::Cancel)) {
cancel->setText("Cancel");
cancel->setIcon(components::icons::makeSvgIcon(":/icons/xmark-circle.svg"));
}
connect(buttons, &QDialogButtonBox::accepted, this, &SettingsDialog::onAccepted);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
addFooterWidget(buttons);
}
void SettingsDialog::syncFromSettings() {
geometry_library_edit_->setText(AppSettings::instance().geometryLibrary());
show_stats_check_->setChecked(AppSettings::instance().showStats());
backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling());
void_limit_spin_->setValue(AppSettings::instance().voidLimit());
deflection_tolerance_spin_->setValue(AppSettings::instance().deflectionTolerance());
angular_tolerance_spin_->setValue(AppSettings::instance().angularTolerance());
min_pixel_radius_spin_->setValue(AppSettings::instance().minPixelRadius());
motion_min_pixel_radius_spin_->setValue(AppSettings::instance().motionMinPixelRadius());
lod1_pixel_threshold_spin_->setValue(AppSettings::instance().lod1PixelThreshold());
hiz_enabled_check_->setChecked(AppSettings::instance().hizEnabled());
hiz_resolution_spin_->setValue(AppSettings::instance().hizResolution());
nav_preset_combo_->setCurrentIndex(static_cast<int>(AppSettings::instance().navPreset()));
syncThemeSettings();
}
void SettingsDialog::syncThemeSettings() {
const auto& settings = bonsaiviewer::ViewerSettings::instance();
const int idx = theme_mode_combo_->findData(static_cast<int>(settings.themeMode()));
theme_mode_combo_->setCurrentIndex(idx >= 0 ? idx : 0);
for (auto& editor : theme_color_editors_) {
editor.edit->setText(settings.customColor(editor.key));
}
updateThemeEditorEnabled();
}
void SettingsDialog::updateThemeEditorEnabled() {
if (!theme_mode_combo_ || !theme_custom_body_) return;
const auto mode =
static_cast<bonsaiviewer::ViewerSettings::ThemeMode>(theme_mode_combo_->currentData().toInt());
const bool is_custom = mode == bonsaiviewer::ViewerSettings::ThemeMode::Custom;
theme_custom_body_->setVisible(is_custom);
theme_custom_body_->setEnabled(is_custom);
}
void SettingsDialog::pickThemeColor(QLineEdit* edit) {
QColorDialog dialog(QColor(edit->text()), this);
dialog.setObjectName("appDialog");
dialog.setWindowTitle("Choose Color");
dialog.setOption(QColorDialog::DontUseNativeDialog, true);
if (dialog.exec() != QDialog::Accepted) return;
const QColor color = dialog.selectedColor();
if (!color.isValid()) return;
edit->setText(color.name(QColor::HexRgb));
}
void SettingsDialog::onAccepted() {
AppSettings::instance().setGeometryLibrary(geometry_library_edit_->text());
AppSettings::instance().setShowStats(show_stats_check_->isChecked());
AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked());
AppSettings::instance().setVoidLimit(void_limit_spin_->value());
AppSettings::instance().setDeflectionTolerance(deflection_tolerance_spin_->value());
AppSettings::instance().setAngularTolerance(angular_tolerance_spin_->value());
AppSettings::instance().setMinPixelRadius(min_pixel_radius_spin_->value());
AppSettings::instance().setMotionMinPixelRadius(motion_min_pixel_radius_spin_->value());
AppSettings::instance().setLod1PixelThreshold(lod1_pixel_threshold_spin_->value());
AppSettings::instance().setHizEnabled(hiz_enabled_check_->isChecked());
AppSettings::instance().setHizResolution(hiz_resolution_spin_->value());
AppSettings::instance().setNavPreset(
static_cast<AppSettings::NavPreset>(nav_preset_combo_->currentIndex()));
auto& interface_settings = bonsaiviewer::ViewerSettings::instance();
interface_settings.setThemeMode(
static_cast<bonsaiviewer::ViewerSettings::ThemeMode>(theme_mode_combo_->currentData().toInt()));
for (const auto& editor : theme_color_editors_) {
interface_settings.setCustomColor(editor.key, editor.edit->text());
}
accept();
}
QWidget* SettingsDialog::buildConnectorsTab() {
auto* tab = new QWidget(this);
auto* layout = new QVBoxLayout(tab);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(components::style::metrics::padding);
layout->setAlignment(Qt::AlignTop);
auto* section = new components::Section(
"Installed Connectors", components::SectionHeaderMode::Visible, tab);
auto* body = new QWidget(section);
auto* body_layout = new QVBoxLayout(body);
body_layout->setContentsMargins(0, 0, 0, 0);
body_layout->setSpacing(8);
const auto& manifests = session_state_
? session_state_->connectorRegistry()->available()
: std::vector<connectors::ConnectorManifest>{};
if (manifests.empty()) {
auto* empty = new QLabel(
"No connectors found. Install one under your user connectors directory.",
body);
empty->setProperty("textRole", "secondary");
empty->setWordWrap(true);
body_layout->addWidget(empty);
}
for (const auto& m : manifests) {
auto* row = new QWidget(body);
auto* row_layout = new QHBoxLayout(row);
row_layout->setContentsMargins(0, 0, 0, 0);
row_layout->setSpacing(8);
auto* name = new QLabel(m.name, row);
auto* version = new QLabel(
m.version.isEmpty() ? QString() : QString("v%1").arg(m.version), row);
version->setProperty("textRole", "secondary");
auto* settings_button = new QPushButton("Settings…", row);
settings_button->setIcon(components::icons::makeSvgIcon(":/icons/settings.svg"));
const QString connector_id = m.id;
connect(settings_button, &QPushButton::clicked, this,
[this, connector_id, settings_button]() {
if (!session_state_) return;
auto* proc = session_state_->connectorRegistry()->get(connector_id);
if (!proc) {
QMessageBox::warning(this, "Connector",
QString("Could not launch connector '%1':\n%2")
.arg(connector_id,
session_state_->connectorRegistry()->lastError()));
return;
}
settings_button->setEnabled(false);
QPointer<QPushButton> guard(settings_button);
QPointer<SettingsDialog> self(this);
proc->call("open_settings", QJsonValue(),
[guard](const QJsonValue&) {
if (guard) guard->setEnabled(true);
},
[self, guard, connector_id](int code, const QString& message) {
if (guard) guard->setEnabled(true);
if (!self) return;
// -32601 = JSON-RPC "Method not found": connector opted
// out of the optional open_settings handler per spec.
if (code == -32601) {
QMessageBox::information(self, "Connector",
"Settings not available.");
} else {
QMessageBox::warning(self, "Connector",
QString("Connector '%1' failed to open settings:\n%2")
.arg(connector_id, message));
}
});
});
row_layout->addWidget(name);
row_layout->addWidget(version);
row_layout->addStretch(1);
row_layout->addWidget(settings_button);
body_layout->addWidget(row);
}
section->addBodyWidget(body);
layout->addWidget(section);
layout->addStretch(1);
return tab;
}
} // namespace bonsaiviewer::modules::settings
@@ -0,0 +1,85 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_SETTINGSDIALOG_H
#define IFCINTERFACE_PANELS_SETTINGSDIALOG_H
#include "../../components/Dialog.h"
#include <QString>
#include <vector>
class QCheckBox;
class QComboBox;
class QDoubleSpinBox;
class QLineEdit;
class QShowEvent;
class QSpinBox;
class QWidget;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::settings {
class SettingsDialog : public components::TabbedDialog {
Q_OBJECT
public:
explicit SettingsDialog(bonsaiviewer::SessionState* session_state,
QWidget* parent = nullptr);
protected:
void showEvent(QShowEvent* event) override;
private:
void setupUi();
QWidget* buildConnectorsTab();
void syncFromSettings();
void syncThemeSettings();
void updateThemeEditorEnabled();
void pickThemeColor(QLineEdit* edit);
void onAccepted();
bonsaiviewer::SessionState* session_state_ = nullptr;
struct ThemeColorEditor {
QString key;
QLineEdit* edit = nullptr;
};
QLineEdit* geometry_library_edit_ = nullptr;
QCheckBox* show_stats_check_ = nullptr;
QCheckBox* backface_culling_check_ = nullptr;
QSpinBox* void_limit_spin_ = nullptr;
QDoubleSpinBox* deflection_tolerance_spin_ = nullptr;
QDoubleSpinBox* angular_tolerance_spin_ = nullptr;
QDoubleSpinBox* min_pixel_radius_spin_ = nullptr;
QDoubleSpinBox* motion_min_pixel_radius_spin_ = nullptr;
QDoubleSpinBox* lod1_pixel_threshold_spin_ = nullptr;
QCheckBox* hiz_enabled_check_ = nullptr;
QSpinBox* hiz_resolution_spin_ = nullptr;
QComboBox* nav_preset_combo_ = nullptr;
QComboBox* theme_mode_combo_ = nullptr;
QWidget* theme_custom_body_ = nullptr;
std::vector<ThemeColorEditor> theme_color_editors_;
};
} // namespace bonsaiviewer::modules::settings
#endif
@@ -0,0 +1,95 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "../../components/Section.h"
#include "../../components/SvgIcon.h"
#include <QHeaderView>
#include <QTreeWidget>
#include <QTreeWidgetItem>
namespace bonsaiviewer::modules::spatial_hierarchy {
SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent)
: components::Panel("Spatial Hierarchy", nullptr, parent)
{
auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this);
tree_ = new QTreeWidget(section);
tree_->setColumnCount(2);
tree_->setHeaderLabels({"Spatial Item", ""});
tree_->setIconSize(QSize(16, 16));
tree_->setSelectionMode(QAbstractItemView::ExtendedSelection);
tree_->setUniformRowHeights(true);
tree_->header()->setStretchLastSection(false);
tree_->header()->setSectionResizeMode(0, QHeaderView::Stretch);
tree_->header()->setSectionResizeMode(1, QHeaderView::Fixed);
tree_->header()->resizeSection(1, 28);
tree_->header()->hide();
section->addBodyWidget(tree_);
addBodyWidget(section);
connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) {
if (!item || column != 1) return;
emit visibilityToggleRequested(itemPath(item));
});
}
void SpatialHierarchyPanel::setNodes(const QList<TreeNode>& nodes) {
tree_->clear();
for (const auto& node : nodes) {
addNode(tree_->invisibleRootItem(), node);
}
tree_->expandAll();
}
void SpatialHierarchyPanel::addNode(QTreeWidgetItem* parent, const TreeNode& node) {
auto* item = new QTreeWidgetItem(parent, {node.name, ""});
item->setData(1, Qt::UserRole, node.visible);
item->setSizeHint(0, QSize(0, 24));
item->setIcon(0, components::icons::makeSvgIcon(iconPath(node.kind)));
item->setIcon(1, components::icons::makeSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg"));
for (const auto& child : node.children) {
addNode(item, child);
}
}
NodePath SpatialHierarchyPanel::itemPath(QTreeWidgetItem* item) const {
NodePath path;
while (item) {
path.prepend(item->text(0));
item = item->parent();
}
return path;
}
QString SpatialHierarchyPanel::iconPath(ItemKind kind) const {
switch (kind) {
case ItemKind::Site: return ":/icons/frame-alt.svg";
case ItemKind::Building: return ":/icons/city.svg";
case ItemKind::Storey: return ":/icons/planimetry.svg";
case ItemKind::Space: return ":/icons/square3d-from-center.svg";
}
return ":/icons/frame-alt.svg";
}
} // namespace bonsaiviewer::modules::spatial_hierarchy
@@ -0,0 +1,53 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_SPATIAL_HIERARCHY_PANEL_H
#define IFCINTERFACE_MODULES_SPATIAL_HIERARCHY_PANEL_H
#include "Types.h"
#include "../../components/Panel.h"
class QTreeWidget;
class QTreeWidgetItem;
namespace bonsaiviewer::modules::spatial_hierarchy {
class SpatialHierarchyPanel : public components::Panel {
Q_OBJECT
public:
explicit SpatialHierarchyPanel(QWidget* parent = nullptr);
void setNodes(const QList<TreeNode>& nodes);
signals:
void visibilityToggleRequested(const NodePath& path);
private:
void addNode(QTreeWidgetItem* parent, const TreeNode& node);
NodePath itemPath(QTreeWidgetItem* item) const;
QString iconPath(ItemKind kind) const;
QTreeWidget* tree_ = nullptr;
};
} // namespace bonsaiviewer::modules::spatial_hierarchy
#endif
@@ -0,0 +1,48 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELTYPES_H
#define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELTYPES_H
#include <QList>
#include <QString>
#include <QStringList>
namespace bonsaiviewer::modules::spatial_hierarchy {
enum class ItemKind {
Site,
Building,
Storey,
Space,
};
struct TreeNode {
QString name;
ItemKind kind = ItemKind::Space;
bool visible = true;
QList<TreeNode> children;
};
using NodePath = QStringList;
} // namespace bonsaiviewer::modules::spatial_hierarchy
#endif
@@ -0,0 +1,75 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "View.h"
#include "Panel.h"
#include "../../SessionState.h"
namespace bonsaiviewer::modules::spatial_hierarchy {
namespace {
TreeNode* findNodeRecursive(QList<TreeNode>& nodes, const NodePath& path, int depth) {
for (auto& node : nodes) {
if (node.name != path.at(depth)) continue;
if (depth == path.size() - 1) return &node;
return findNodeRecursive(node.children, path, depth + 1);
}
return nullptr;
}
} // namespace
SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widget,
bonsaiviewer::SessionState* session_state,
QObject* parent)
: QObject(parent), widget_(widget), session_state_(session_state)
{
nodes_ = {
{"Site A", ItemKind::Site, true,
{{"Building 01", ItemKind::Building, true,
{{"Level 02", ItemKind::Storey, true,
{{"Lobby", ItemKind::Space, true, {}},
{"Core", ItemKind::Space, true, {}}}}}}}},
};
connect(widget_, &SpatialHierarchyPanel::visibilityToggleRequested, this, [this](const NodePath& path) {
if (auto* node = findNode(path)) {
node->visible = !node->visible;
reload();
session_state_->setStatusMessage("Spatial", node->visible ? "Item shown" : "Item hidden");
}
});
reload();
}
void SpatialHierarchyPanelView::reload() {
widget_->setNodes(nodes_);
}
TreeNode* SpatialHierarchyPanelView::findNode(const NodePath& path) {
if (path.isEmpty()) return nullptr;
return findNodeRecursive(nodes_, path, 0);
}
} // namespace bonsaiviewer::modules::spatial_hierarchy
@@ -0,0 +1,51 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELVIEW_H
#define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELVIEW_H
#include "Types.h"
#include <QObject>
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::spatial_hierarchy {
class SpatialHierarchyPanel;
class SpatialHierarchyPanelView : public QObject {
Q_OBJECT
public:
explicit SpatialHierarchyPanelView(SpatialHierarchyPanel* widget,
bonsaiviewer::SessionState* session_state,
QObject* parent = nullptr);
private:
void reload();
TreeNode* findNode(const NodePath& path);
SpatialHierarchyPanel* widget_ = nullptr;
bonsaiviewer::SessionState* session_state_ = nullptr;
QList<TreeNode> nodes_;
};
} // namespace bonsaiviewer::modules::spatial_hierarchy
#endif
+59
View File
@@ -0,0 +1,59 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "../../components/Section.h"
#include <QLabel>
#include <QVBoxLayout>
namespace bonsaiviewer::modules::todo {
TodoPanel::TodoPanel(const QString& title, QWidget* parent)
: QWidget(parent)
{
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this);
auto* body = new QWidget(section);
auto* body_layout = new QVBoxLayout(body);
body_layout->setContentsMargins(0, 12, 0, 12);
body_layout->setSpacing(12);
auto* heading = new QLabel(title, body);
auto* content = new QLabel("Coming soon", body);
content->setProperty("textRole", "disabled");
content->setAlignment(Qt::AlignCenter);
body_layout->addWidget(heading);
body_layout->addStretch(1);
body_layout->addWidget(content);
body_layout->addStretch(1);
section->addBodyWidget(body);
layout->addWidget(section);
}
} // namespace bonsaiviewer::modules::todo
+36
View File
@@ -0,0 +1,36 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_TODO_PANEL_H
#define IFCINTERFACE_MODULES_TODO_PANEL_H
#include <QWidget>
namespace bonsaiviewer::modules::todo {
class TodoPanel : public QWidget {
Q_OBJECT
public:
explicit TodoPanel(const QString& title, QWidget* parent = nullptr);
};
} // namespace bonsaiviewer::modules::todo
#endif
@@ -0,0 +1,102 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Commands.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/ViewportWindow.h"
namespace bonsaiviewer::modules::viewport::commands {
void setHome(SessionState& session, ViewportWindow& vp) {
auto camera = vp.cameraState();
Federation::HomeView home_view;
home_view.target = camera.target;
home_view.distance = camera.distance;
home_view.yaw = camera.yaw;
home_view.pitch = camera.pitch;
session.federation()->setHomeView(home_view);
session.setStatusMessage("Camera", "Home view updated");
}
void goHome(SessionState& session, ViewportWindow& vp) {
Federation* federation = session.federation();
if (!federation->hasHomeView()) {
session.setStatusMessage("Camera", "No home view set for this project");
return;
}
const auto& home_view = federation->homeView();
vp.setCamera(
home_view.target.x(), home_view.target.y(), home_view.target.z(),
home_view.distance, home_view.yaw, home_view.pitch);
session.setStatusMessage("Camera", "Home view restored");
}
void viewSelected(ViewportWindow& vp) {
vp.focusOnSelectedObject();
}
void fly(SessionState& session, ViewportWindow& vp) {
vp.requestActivate();
vp.enterFpsMode();
session.setStatusMessage("Mode", "Fly mode active");
}
void toggleSection(SessionState& session, ViewportWindow& vp) {
vp.toggleSectionTool();
session.setStatusMessage("Section",
vp.sectionToolActive() ? "Section tool active" : "Section tool off");
}
void clearSection(SessionState& session, ViewportWindow& vp) {
vp.clearSectionPlanes();
session.setStatusMessage("Section", "Section planes cleared");
}
void toggleDistance(ViewportWindow& vp) {
vp.toggleLengthTool();
}
void toggleArea(ViewportWindow& vp) {
vp.toggleAreaTool();
}
void toggleVolume(ViewportWindow& vp) {
vp.toggleVolumeTool();
}
void hideSelected(ViewportWindow& vp) {
vp.hideSelectedElements();
}
void isolateSelected(ViewportWindow& vp) {
vp.isolateSelectedElements();
}
void showAll(ViewportWindow& vp) {
vp.showAllElements();
}
void invertVisibility(ViewportWindow& vp) {
vp.invertElementVisibility();
}
} // namespace bonsaiviewer::modules::viewport::commands
@@ -0,0 +1,48 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_VIEWPORT_COMMANDS_H
#define IFCINTERFACE_MODULES_VIEWPORT_COMMANDS_H
class ViewportWindow;
namespace bonsaiviewer { class SessionState; }
namespace bonsaiviewer::modules::viewport::commands {
void setHome(SessionState& session, ViewportWindow& vp);
void goHome(SessionState& session, ViewportWindow& vp);
void viewSelected(ViewportWindow& vp);
void fly(SessionState& session, ViewportWindow& vp);
void toggleSection(SessionState& session, ViewportWindow& vp);
void clearSection(SessionState& session, ViewportWindow& vp);
void toggleDistance(ViewportWindow& vp);
void toggleArea(ViewportWindow& vp);
void toggleVolume(ViewportWindow& vp);
void hideSelected(ViewportWindow& vp);
void isolateSelected(ViewportWindow& vp);
void showAll(ViewportWindow& vp);
void invertVisibility(ViewportWindow& vp);
} // namespace bonsaiviewer::modules::viewport::commands
#endif
@@ -0,0 +1,60 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include <QFrame>
#include <QVBoxLayout>
#include <QWidget>
namespace bonsaiviewer::modules::viewport {
ViewportPanel::ViewportPanel(QWidget* parent)
: QWidget(parent)
{
auto* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
auto* shell = new QFrame(this);
shell->setObjectName("viewportShell");
auto* shell_layout = new QVBoxLayout(shell);
shell_layout->setContentsMargins(10, 10, 10, 10);
shell_layout->setSpacing(0);
auto* frame = new QFrame(shell);
frame->setObjectName("viewportFrame");
auto* frame_layout = new QVBoxLayout(frame);
frame_layout->setContentsMargins(0, 0, 0, 0);
frame_layout->setSpacing(0);
viewport_ = new ViewportWindow();
viewport_container_ = QWidget::createWindowContainer(viewport_, frame);
viewport_container_->setMinimumSize(400, 300);
viewport_container_->setFocusPolicy(Qt::StrongFocus);
frame_layout->addWidget(viewport_container_);
shell_layout->addWidget(frame);
root->addWidget(shell);
}
} // namespace bonsaiviewer::modules::viewport
+45
View File
@@ -0,0 +1,45 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_VIEWPORT_PANEL_H
#define IFCINTERFACE_MODULES_VIEWPORT_PANEL_H
#include <QWidget>
class ViewportWindow;
namespace bonsaiviewer::modules::viewport {
class ViewportPanel : public QWidget {
Q_OBJECT
public:
explicit ViewportPanel(QWidget* parent = nullptr);
ViewportWindow* viewport() const { return viewport_; }
private:
ViewportWindow* viewport_ = nullptr;
QWidget* viewport_container_ = nullptr;
};
} // namespace bonsaiviewer::modules::viewport
#endif
+232
View File
@@ -0,0 +1,232 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "View.h"
#include "../../ViewerSettings.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/SceneLoader.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include "../../../ifcviewer/OverlayRenderer.h"
#include "../../Measurement.h"
#include <Eigen/Dense>
#include <QVector3D>
#include <vector>
namespace bonsaiviewer::modules::viewport {
ViewportView::ViewportView(bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QObject* parent)
: QObject(parent)
, session_state_(session_state)
, viewport_(viewport)
, area_measurement_(std::make_unique<AreaMeasurement>())
, length_measurement_(std::make_unique<LengthMeasurement>())
{
auto& settings = bonsaiviewer::ViewerSettings::instance();
connect(&settings, &bonsaiviewer::ViewerSettings::themeChanged, this, [this]() {
viewport_->setBackgroundColor(
QColor(bonsaiviewer::ViewerSettings::instance().color("viewport_background")));
});
viewport_->setBackgroundColor(QColor(settings.color("viewport_background")));
connect(session_state_, &SessionState::projectReset, this, &ViewportView::refresh);
connect(session_state_, &SessionState::projectOpened, this, [this](const QString&) { refresh(); });
connect(session_state_, &SessionState::modelsChanged, this, &ViewportView::refresh);
connect(session_state_, &SessionState::federationChanged, this, &ViewportView::refresh);
connect(session_state_, &SessionState::visibilityChanged, this, &ViewportView::refresh);
connect(session_state_, &SessionState::modelGeometryReady, this, [this](uint32_t) { refresh(); });
// Measurement tools — input-driven, share the View's lifetime.
connect(viewport_, &ViewportWindow::surfacePickedInTool, this,
[this](int x, int y, int modifiers) {
const bool alt = (modifiers & Qt::AltModifier) != 0;
switch (viewport_->toolMode()) {
case ViewportWindow::ToolMode::Area:
area_measurement_->onPick(*viewport_, x, y, alt);
viewport_->setHudText(QString("Area: %1 m² (%2 tris)")
.arg(area_measurement_->totalArea(), 0, 'f', 4)
.arg(area_measurement_->triangleCount()));
break;
case ViewportWindow::ToolMode::Length:
length_measurement_->onPick(*viewport_, x, y, alt);
break;
case ViewportWindow::ToolMode::None:
case ViewportWindow::ToolMode::Volume:
break;
}
});
connect(viewport_, &ViewportWindow::toolModeChanged, this,
[this](ViewportWindow::ToolMode mode) {
area_measurement_->clear(*viewport_);
length_measurement_->clear(*viewport_);
switch (mode) {
case ViewportWindow::ToolMode::None:
viewport_->setHudText(QString());
viewport_->setOverlayLabels({});
session_state_->setStatusMessage("Measure", "Measurement tool off");
break;
case ViewportWindow::ToolMode::Length:
viewport_->setHudText("Length tool: click first point");
session_state_->setStatusMessage("Measure", "Length tool: LMB add point, Backspace remove last, Esc exits");
break;
case ViewportWindow::ToolMode::Area:
viewport_->setHudText("Area: 0.0000 m² (0 tris)");
session_state_->setStatusMessage("Measure", "Area tool: LMB add, Alt+LMB single tri, click again to remove, Esc exits");
break;
case ViewportWindow::ToolMode::Volume:
session_state_->setStatusMessage("Measure", "Volume tool: click / box-select objects, Esc exits");
updateVolumeReadout();
break;
}
});
connect(viewport_, &ViewportWindow::toolBackspacePressed, this, [this]() {
if (viewport_->toolMode() == ViewportWindow::ToolMode::Length) {
length_measurement_->removeLastPoint(*viewport_);
}
});
connect(viewport_, &ViewportWindow::objectPicked, this, [this](uint32_t) {
updateVolumeReadout();
});
refresh();
}
ViewportView::~ViewportView() = default;
void ViewportView::refresh() {
Federation* federation = session_state_->federation();
viewport_->setFederatedFalseOrigin(
composeFederatedFalseOrigin(federation->federatedFalseOrigin(), federation->config()));
for (uint32_t mid : session_state_->modelIds()) {
applyCoordinateOperation(mid);
applyModelVisibility(mid);
maybeGuessFederatedFalseOrigin(mid);
}
}
void ViewportView::applyCoordinateOperation(uint32_t mid) {
SceneLoader* loader = session_state_->loader();
Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity();
if (const ModelGeoref* georef = loader->modelGeoref(mid)) {
if (georef->has_coordinate_operation) {
matrix = georef->coordinate_operation_meters;
}
}
viewport_->setModelCoordinateOperation(mid, matrix);
applyModelTransformation(mid);
}
void ViewportView::applyModelTransformation(uint32_t mid) {
Federation* federation = session_state_->federation();
SceneLoader* loader = session_state_->loader();
Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity();
const QString fed_id = session_state_->fedIdForModelId(mid);
if (!fed_id.isEmpty()) {
if (const Federation::Model* model = federation->findById(fed_id)) {
ModelUnits units;
Eigen::Matrix4d coordinate_operation = Eigen::Matrix4d::Identity();
if (const ModelGeoref* georef = loader->modelGeoref(mid)) {
units = georef->units;
if (georef->has_coordinate_operation) {
coordinate_operation = georef->coordinate_operation_meters;
}
}
matrix = composeModelTransformation(
model->model_transformation, federation->config(), units, coordinate_operation);
}
}
viewport_->setModelTransformation(mid, matrix);
}
void ViewportView::applyModelVisibility(uint32_t mid) {
Federation* federation = session_state_->federation();
const QString fed_id = session_state_->fedIdForModelId(mid);
if (fed_id.isEmpty()) return;
if (federation->isModelEffectivelyVisible(fed_id)) {
viewport_->showModel(mid);
} else {
viewport_->hideModel(mid);
}
}
void ViewportView::maybeGuessFederatedFalseOrigin(uint32_t mid) {
Federation* federation = session_state_->federation();
SceneLoader* loader = session_state_->loader();
if (!federation->filePath().isEmpty()) return;
const FederatedFalseOrigin& current = federation->federatedFalseOrigin();
const FederatedFalseOrigin defaults;
if (current.xyz != defaults.xyz || current.rz_deg != defaults.rz_deg) return;
const Eigen::Matrix4d* placement = loader->firstPlacement(mid);
const ModelGeoref* georef = loader->modelGeoref(mid);
if (placement == nullptr || georef == nullptr) return;
federation->setFederatedFalseOrigin(guessFederatedFalseOrigin(
*placement, *georef, federation->config()));
// The federation mutation above will emit its own signal, but to keep
// views off the Federation bus we re-emit through SessionState.
session_state_->notifyFederationChanged();
}
void ViewportView::updateVolumeReadout() {
if (viewport_->toolMode() != ViewportWindow::ToolMode::Volume) return;
const auto& sel = viewport_->selection().selectionIds();
if (sel.empty()) {
viewport_->setHudText(QString());
viewport_->setOverlayLabels({});
return;
}
std::vector<uint32_t> ids(sel.begin(), sel.end());
const auto per_obj = volumesPerObject(*viewport_, ids);
double total = 0.0;
std::vector<OverlayRenderer::Label> labels;
labels.reserve(per_obj.size());
for (const auto& [oid, v] : per_obj) {
total += v;
QVector3D mn, mx;
if (!viewport_->computeObjectAabb(oid, mn, mx)) continue;
OverlayRenderer::Label lbl;
const QVector3D c = (mn + mx) * 0.5f;
lbl.world_pos[0] = c.x();
lbl.world_pos[1] = c.y();
lbl.world_pos[2] = c.z();
lbl.text = QString::number(v, 'f', 4) + "";
labels.push_back(std::move(lbl));
}
viewport_->setHudText(QString("Volume: %1 m³ (%2 object%3)")
.arg(total, 0, 'f', 4)
.arg(per_obj.size())
.arg(per_obj.size() == 1 ? "" : "s"));
viewport_->setOverlayLabels(labels);
}
} // namespace bonsaiviewer::modules::viewport
+67
View File
@@ -0,0 +1,67 @@
// This file was generated with the assistance of an AI coding tool.
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser 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. *
* *
* IfcOpenShell 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_MODULES_VIEWPORT_VIEW_H
#define IFCINTERFACE_MODULES_VIEWPORT_VIEW_H
#include <QObject>
#include <memory>
namespace bonsaiviewer { class SessionState; }
class ViewportWindow;
class AreaMeasurement;
class LengthMeasurement;
namespace bonsaiviewer::modules::viewport {
// Renders SessionState into the OpenGL viewport. Subscribes to session-level
// signals only; on each one it calls refresh() to re-derive viewport state
// (false origin, per-model coord op + transformation + visibility) from
// SessionState idempotently.
//
// Also owns the stateful measurement tools and subscribes to viewport input
// events for them. That's a distinct concern from the state-render side but
// kept here to avoid a second tiny QObject.
class ViewportView : public QObject {
Q_OBJECT
public:
explicit ViewportView(bonsaiviewer::SessionState* session_state,
ViewportWindow* viewport,
QObject* parent = nullptr);
~ViewportView() override;
private:
void refresh();
void applyCoordinateOperation(uint32_t mid);
void applyModelTransformation(uint32_t mid);
void applyModelVisibility(uint32_t mid);
void maybeGuessFederatedFalseOrigin(uint32_t mid);
void updateVolumeReadout();
bonsaiviewer::SessionState* session_state_ = nullptr;
ViewportWindow* viewport_ = nullptr;
std::unique_ptr<AreaMeasurement> area_measurement_;
std::unique_ptr<LengthMeasurement> length_measurement_;
};
} // namespace bonsaiviewer::modules::viewport
#endif