Wire IfcViewer to cloud sync connectors

Implements the viewer side of CLOUD_SYNC_PROTOCOL.md: connector
discovery, JSON-RPC stdio host, and Open/Save/Sync/Add cloud workflows
wired through the ribbon, Models panel right-click, and Settings tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-20 09:15:03 +10:00
parent 95c62cc70e
commit 486b858d56
28 changed files with 2153 additions and 67 deletions
+12 -18
View File
@@ -466,35 +466,29 @@ small, fixed set of locations for these folders.
`.ifcfed.manifest`. Must be unique across all discovered connectors.
- `name` — human-readable label shown in the IfcViewer UI.
- `version` — connector version string; informational only.
- `exec` — how to launch the connector:
- Relative path (starts with `./` or `../`): resolved against the
connector folder. This is the recommended form for bundled connectors.
- Absolute path: used as-is.
- Bare name (no path separators): looked up via the system `PATH`.
- `exec` — path to the connector executable. Relative paths are resolved
against the connector folder; absolute paths are used as-is. Bundled
connectors should use a relative path so the bundle is self-contained.
On Windows, the IfcViewer will also try `<exec>.exe` if `<exec>` does not
exist as written.
### Search locations
The IfcViewer scans, in order of precedence (first match wins for a given `id`):
The IfcViewer scans the **user connectors directory**. The platform's per-user
application data location:
1. **`IFCVIEWER_CONNECTOR_PATH` environment variable.** A list of directories
separated by the platform path separator (`:` on Linux/macOS, `;` on
Windows). Intended for development and unusual installs.
2. **User connectors directory.** The platform's per-user application data
location:
- Linux: `~/.local/share/IfcOpenShell/IfcViewer/connectors/`
- macOS: `~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/`
- Windows: `%APPDATA%\IfcOpenShell\IfcViewer\connectors\`
- Linux: `~/.local/share/IfcOpenShell/IfcViewer/connectors/`
- macOS: `~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/`
- Windows: `%APPDATA%\IfcOpenShell\IfcViewer\connectors\`
In each search location, the IfcViewer looks at every immediate subdirectory
and treats it as a connector iff it contains a `connector.json`. Connectors
are launched on demand when the user invokes a cloud workflow, not at startup.
The IfcViewer looks at every immediate subdirectory and treats it as a
connector iff it contains a `connector.json`. Connectors are launched on
demand when the user invokes a cloud workflow, not at startup.
### Conflicts and errors
- If two folders declare the same `id`, the one found earlier in the search
- If two folders declare the same `id`, the one found earlier in directory
order wins; the loser is skipped and a warning is written to the IfcViewer's
log.
- A `connector.json` that is missing, unreadable, malformed, or missing
+10
View File
@@ -51,6 +51,14 @@ set(IFCVIEWER_FULL_FILES
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Discovery.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Discovery.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/PickerDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/PickerDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Process.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Process.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Registry.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/connectors/Registry.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsDialog.cpp
@@ -75,6 +83,8 @@ set(IFCVIEWER_FULL_FILES
${CMAKE_CURRENT_SOURCE_DIR}/modules/properties/View.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/SaveProjectDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.h
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Types.h
+10 -12
View File
@@ -159,7 +159,8 @@ QWidget* MainWindow::buildHomeRibbonPage() {
});
auto* open_cloud = components::buttons::makeButton("Open Cloud", ":/icons/cloud-square.svg", this);
connect(open_cloud, &QToolButton::clicked, this, [this]() {
session_state_->setStatusMessage("Project", "Open Cloud Project coming soon");
modules::project::commands::openCloudProject(
*session_state_, *this, *viewport_widget_->viewport());
});
auto* open_recent = components::buttons::makeButton("Open Recent", ":/icons/clock-rotate-right.svg", this);
connect(open_recent, &QToolButton::clicked, this, [this]() {
@@ -167,30 +168,27 @@ QWidget* MainWindow::buildHomeRibbonPage() {
});
auto* save_project = components::buttons::makeButton("Save Project", ":/icons/floppy-disk.svg", this);
connect(save_project, &QToolButton::clicked, this, [this]() {
modules::project::commands::saveProject(*session_state_, *this);
});
auto* save_project_as = components::buttons::makeButton("Save As", ":/icons/floppy-disk-arrow-in.svg", this);
connect(save_project_as, &QToolButton::clicked, this, [this]() {
modules::project::commands::saveProjectAs(*session_state_, *this);
modules::project::commands::saveProjectDialog(*session_state_, *this);
});
auto* add_model = components::buttons::makeButton("Add Model", ":/icons/cube.svg", this);
connect(add_model, &QToolButton::clicked, this, [this]() {
modules::models::commands::addModel(*session_state_, *this);
});
auto* sync_models = components::buttons::makeButton("Sync Models", ":/icons/refresh-double.svg", this);
connect(sync_models, &QToolButton::clicked, this, [this]() {
session_state_->setStatusMessage("Models", "Sync models coming soon");
auto* sync_from_cloud = components::buttons::makeButton("Sync From Cloud", ":/icons/refresh-double.svg", this);
connect(sync_from_cloud, &QToolButton::clicked, this, [this]() {
modules::project::commands::syncCloudProject(
*session_state_, *this, *viewport_widget_->viewport());
});
auto* settings_button = components::buttons::makeButton("Settings", ":/icons/settings.svg", this);
connect(settings_button, &QToolButton::clicked, this, [this]() {
modules::settings::SettingsDialog dialog(this);
modules::settings::SettingsDialog dialog(session_state_, this);
dialog.exec();
});
row->addWidget(components::buttons::makeButtonGroup("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project, save_project_as}, this));
row->addWidget(components::buttons::makeButtonGroup("MODELS", {add_model, sync_models}, this));
row->addWidget(components::buttons::makeButtonGroup("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project}, this));
row->addWidget(components::buttons::makeButtonGroup("MODELS", {add_model, sync_from_cloud}, this));
row->addWidget(components::buttons::makeButtonGroup("SETTINGS", {settings_button}, this));
row->addStretch(1);
return page;
+13
View File
@@ -21,6 +21,7 @@
#include "SessionState.h"
#include "ElementRegistry.h"
#include "modules/connectors/Registry.h"
#include "../ifcviewer/Federation.h"
#include "../ifcviewer/SceneLoader.h"
@@ -30,6 +31,7 @@ SessionState::SessionState(QObject* parent)
: QObject(parent)
, federation_(new Federation(this))
, element_registry_(new ElementRegistry(this))
, connector_registry_(new modules::connectors::ConnectorRegistry(this))
{
}
@@ -115,6 +117,7 @@ void SessionState::setModelMapping(const QString& fed_id, uint32_t model_id) {
}
void SessionState::removeModelMappingByFedId(const QString& fed_id) {
cloud_metadata_.remove(fed_id);
auto it = fed_id_to_model_id_.find(fed_id);
if (it == fed_id_to_model_id_.end()) return;
model_id_to_fed_id_.remove(it.value());
@@ -124,6 +127,16 @@ void SessionState::removeModelMappingByFedId(const QString& fed_id) {
void SessionState::clearModelMappings() {
fed_id_to_model_id_.clear();
model_id_to_fed_id_.clear();
cloud_metadata_.clear();
}
void SessionState::setCloudMetadata(const QString& fed_id, const QVariantMap& metadata) {
if (metadata.isEmpty()) cloud_metadata_.remove(fed_id);
else cloud_metadata_.insert(fed_id, metadata);
}
QVariantMap SessionState::cloudMetadata(const QString& fed_id) const {
return cloud_metadata_.value(fed_id);
}
uint32_t SessionState::modelIdForFedId(const QString& fed_id) const {
+13
View File
@@ -24,6 +24,7 @@
#include <QHash>
#include <QObject>
#include <QString>
#include <QVariantMap>
class Federation;
class SceneLoader;
@@ -33,6 +34,8 @@ namespace ifcviewerfull {
class ElementRegistry;
namespace modules::connectors { class ConnectorRegistry; }
class SessionState : public QObject {
Q_OBJECT
@@ -46,6 +49,7 @@ public:
Federation* federation() const { return federation_; }
SceneLoader* loader() const { return loader_; }
ElementRegistry* elementRegistry() const { return element_registry_; }
modules::connectors::ConnectorRegistry* connectorRegistry() const { return connector_registry_; }
QString statusMode() const { return status_mode_; }
QString statusDetail() const { return status_detail_; }
@@ -63,6 +67,13 @@ public:
void setModelMapping(const QString& fed_id, uint32_t model_id);
void removeModelMappingByFedId(const QString& fed_id);
void clearModelMappings();
// Per-session cloud metadata returned by connectors (revision/date/
// author/...). Not persisted to the .ifcfed; display only. Lifetime
// is tied to the fed_id — removeModelMappingByFedId and
// clearModelMappings drop the matching entries.
void setCloudMetadata(const QString& fed_id, const QVariantMap& metadata);
QVariantMap cloudMetadata(const QString& fed_id) const;
uint32_t modelIdForFedId(const QString& fed_id) const;
QString fedIdForModelId(uint32_t model_id) const;
QList<uint32_t> modelIds() const;
@@ -104,11 +115,13 @@ private:
Federation* federation_ = nullptr;
SceneLoader* loader_ = nullptr;
ElementRegistry* element_registry_ = nullptr;
modules::connectors::ConnectorRegistry* connector_registry_ = nullptr;
uint32_t selected_object_id_ = 0;
QString status_mode_;
QString status_detail_;
QHash<QString, uint32_t> fed_id_to_model_id_;
QHash<uint32_t, QString> model_id_to_fed_id_;
QHash<QString, QVariantMap> cloud_metadata_;
};
} // namespace ifcviewerfull
@@ -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 ifcviewerfull::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/IfcViewer/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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::modules::connectors
#endif
@@ -119,6 +119,16 @@ void AddModelDialog::setupUi() {
"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;
@@ -139,7 +149,7 @@ void AddModelDialog::setupUi() {
"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}, choices, true, 8));
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);
@@ -30,6 +30,7 @@ enum class SourceMode {
IfcFile,
IfcDatabase,
GeometryOnly,
CloudModel,
ConvertToDatabase,
ExportGeometryDatabase,
};
@@ -25,6 +25,9 @@
#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"
@@ -40,9 +43,13 @@
#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>
@@ -227,6 +234,9 @@ void addModel(SessionState& s, QWidget& host) {
}
break;
}
case SourceMode::CloudModel:
addModelFromCloud(s, host);
return;
case SourceMode::ConvertToDatabase:
convertIfcToDatabase(s, host);
return;
@@ -249,6 +259,221 @@ void addModel(SessionState& s, QWidget& host) {
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);
@@ -43,6 +43,16 @@ void moveModels(SessionState& s, const QStringList& ids, const QString& parent_g
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);
@@ -62,6 +62,7 @@ FederationItemModel::FederationItemModel(Federation* federation, QObject* parent
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() {
@@ -249,6 +250,14 @@ void FederationItemModel::onModelVisibilityChanged(const QString& fed_id, bool /
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;
@@ -62,6 +62,7 @@ private slots:
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;
@@ -344,6 +344,27 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state,
});
}
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]() {
+492 -1
View File
@@ -20,16 +20,31 @@
#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 ifcviewerfull::modules::project::commands {
@@ -63,6 +78,131 @@ bool confirmDiscardIfDirty(SessionState& s, QWidget& 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()) {
@@ -86,7 +226,7 @@ bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QSt
QStringList paths;
QStringList fed_ids;
for (const auto& model : s.federation()->models()) {
if (model.source_kind != "local") continue;
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;
@@ -109,6 +249,8 @@ bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QSt
}
s.setStatusMessage("Project", QFileInfo(path).fileName());
s.notifyProjectOpened(path);
resolveCloudModels(s, vp);
return true;
}
@@ -155,6 +297,179 @@ bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
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());
@@ -177,4 +492,180 @@ bool saveProjectAs(SessionState& s, QWidget& host) {
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 ifcviewerfull::modules::project::commands
@@ -34,8 +34,29 @@ namespace ifcviewerfull::modules::project::commands {
// 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 ifcviewerfull::modules::project::commands
@@ -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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::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 ifcviewerfull::modules::project
#endif
+98 -1
View File
@@ -20,12 +20,15 @@
#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>
@@ -35,17 +38,22 @@
#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 ifcviewerfull::modules::settings {
SettingsDialog::SettingsDialog(QWidget* parent)
SettingsDialog::SettingsDialog(ifcviewerfull::SessionState* session_state, QWidget* parent)
: components::TabbedDialog(parent)
, session_state_(session_state)
{
setObjectName("appDialog");
setWindowTitle("Settings");
@@ -268,6 +276,7 @@ void SettingsDialog::setupUi() {
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);
@@ -354,4 +363,92 @@ void SettingsDialog::onAccepted() {
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 ifcviewerfull::modules::settings
+7 -1
View File
@@ -34,24 +34,30 @@ class QShowEvent;
class QSpinBox;
class QWidget;
namespace ifcviewerfull { class SessionState; }
namespace ifcviewerfull::modules::settings {
class SettingsDialog : public components::TabbedDialog {
Q_OBJECT
public:
explicit SettingsDialog(QWidget* parent = nullptr);
explicit SettingsDialog(ifcviewerfull::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();
ifcviewerfull::SessionState* session_state_ = nullptr;
struct ThemeColorEditor {
QString key;
QLineEdit* edit = nullptr;
+162 -31
View File
@@ -216,6 +216,8 @@ void Federation::clear() {
federated_false_origin_ = FederatedFalseOrigin{};
has_home_view_ = false;
home_view_ = HomeView{};
has_manifest_ = false;
manifest_ = QJsonObject{};
setDirty(false);
}
@@ -269,6 +271,46 @@ void Federation::setModelGroup(const QString& fed_id, const QString& group_id) {
}
}
void Federation::setModelDisplayName(const QString& fed_id, const QString& display_name) {
if (display_name.isEmpty()) return;
for (auto& m : models_) {
if (m.id != fed_id) continue;
if (m.display_name == display_name) return;
m.display_name = display_name;
setDirty(true);
emit modelChanged(fed_id);
return;
}
}
void Federation::setModelSource(const QString& fed_id,
const QString& connector_id,
const QJsonObject& source_data) {
if (connector_id.isEmpty()) return;
for (auto& m : models_) {
if (m.id != fed_id) continue;
m.source_connector = connector_id;
m.source_data = source_data;
m.source_data.remove("connector");
if (connector_id == "local") {
// Round-trip the path through source_data when caller chooses
// to encode it there; otherwise leave m.source_path untouched.
const QString path_field = source_data.value("path").toString();
if (!path_field.isEmpty()) {
m.source_path = QDir::cleanPath(QFileInfo(path_field).absoluteFilePath());
m.source_data.remove("path");
}
} else {
// Cloud sources don't track a source_path — local file lives in
// the connector's cache, looked up via SceneLoader.
m.source_path.clear();
}
setDirty(true);
emit modelChanged(fed_id);
return;
}
}
QString Federation::addGroup(const QString& display_name,
const QString& parent_id) {
Group* parent = nullptr;
@@ -471,7 +513,7 @@ QString Federation::addModel(const QString& source_path,
m.display_name = display_name.isEmpty()
? QFileInfo(source_path).fileName()
: display_name;
m.source_kind = "local";
m.source_connector = "local";
m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath());
models_.push_back(std::move(m));
const QString new_id = models_.back().id;
@@ -480,6 +522,24 @@ QString Federation::addModel(const QString& source_path,
return new_id;
}
QString Federation::addCloudModel(const QString& display_name,
const QString& connector_id,
const QJsonObject& source_data) {
if (connector_id.isEmpty() || connector_id == "local") return {};
Model m;
m.id = generateId();
m.display_name = display_name.isEmpty() ? m.id : display_name;
m.source_connector = connector_id;
m.source_data = source_data;
m.source_data.remove("connector"); // canonicalize: never duplicated
models_.push_back(std::move(m));
const QString new_id = models_.back().id;
setDirty(true);
emit modelAdded(new_id);
return new_id;
}
void Federation::removeModel(const QString& fed_id) {
for (auto it = models_.begin(); it != models_.end(); ++it) {
if (it->id == fed_id) {
@@ -603,27 +663,26 @@ bool Federation::load(const QString& path,
m.display_name = mo.value("display_name").toString();
QJsonObject so = mo.value("source").toObject();
m.source_kind = so.value("kind").toString("local");
if (m.source_kind != "local") {
if (warnings)
*warnings << QString("models[%1]: unsupported source kind '%2'; entry kept but not loaded.")
.arg(i).arg(m.source_kind);
// Keep raw stored path so save() round-trips correctly.
m.source_path = so.value("path").toString();
models_.push_back(std::move(m));
continue;
m.source_connector = so.value("connector").toString("local");
if (m.source_connector == "local") {
QString stored = so.value("path").toString();
if (stored.isEmpty()) {
if (warnings) *warnings << QString("models[%1]: missing source.path; skipping.").arg(i);
continue;
}
m.source_path = resolvePath(fed_dir, stored);
if (m.display_name.isEmpty())
m.display_name = QFileInfo(m.source_path).fileName();
} else {
// Cloud source: keep every key except "connector" itself; the
// connector resolves these to a local path on demand.
QJsonObject data = so;
data.remove("connector");
m.source_data = data;
if (m.display_name.isEmpty())
m.display_name = m.id;
}
QString stored = so.value("path").toString();
if (stored.isEmpty()) {
if (warnings) *warnings << QString("models[%1]: missing source.path; skipping.").arg(i);
continue;
}
m.source_path = resolvePath(fed_dir, stored);
if (m.display_name.isEmpty())
m.display_name = QFileInfo(m.source_path).fileName();
if (QJsonValue tv = mo.value("model_transformation"); tv.isObject()) {
QJsonObject to = tv.toObject();
const QString af = to.value("a_frame").toString("ModelGlobal");
@@ -671,22 +730,95 @@ bool Federation::load(const QString& path,
has_home_view_ = true;
}
// Best-effort manifest read. Missing file is not a warning — most
// .ifcfed files are local-only and never have a manifest. A malformed
// manifest is logged as a warning but does not fail the load.
{
const QString manifest_path = file_path_ + ".manifest";
QFile mf(manifest_path);
if (mf.open(QIODevice::ReadOnly)) {
QJsonParseError mpe{};
const QJsonDocument mdoc = QJsonDocument::fromJson(mf.readAll(), &mpe);
if (mdoc.isObject()) {
manifest_ = mdoc.object();
has_manifest_ = true;
} else if (warnings) {
*warnings << QString("Ignoring malformed manifest %1: %2")
.arg(manifest_path, mpe.errorString());
}
}
}
setDirty(false);
return true;
}
void Federation::setManifest(const QJsonObject& manifest) {
manifest_ = manifest;
has_manifest_ = !manifest_.isEmpty();
}
void Federation::clearManifest() {
manifest_ = QJsonObject{};
has_manifest_ = false;
}
QString Federation::manifestConnectorId() const {
return manifest_.value("connector").toString();
}
bool Federation::save(const QString& path, QString* err) {
QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
const QDateTime created_now =
created_.isValid() ? created_ : QDateTime::currentDateTimeUtc();
const QDateTime modified_now = QDateTime::currentDateTimeUtc();
if (!writeJsonAt(abs_path, created_now, modified_now, err)) return false;
created_ = created_now;
modified_ = modified_now;
file_path_ = abs_path;
setDirty(false);
return true;
}
bool Federation::writeCopyTo(const QString& path, QString* err) const {
const QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
const QDateTime created_to_emit =
created_.isValid() ? created_ : QDateTime::currentDateTimeUtc();
const QDateTime modified_to_emit = QDateTime::currentDateTimeUtc();
return writeJsonAt(abs_path, created_to_emit, modified_to_emit, err);
}
void Federation::repointTo(const QString& new_path, QStringList* warnings) {
file_path_ = QDir::cleanPath(QFileInfo(new_path).absoluteFilePath());
has_manifest_ = false;
manifest_ = QJsonObject{};
QFile mf(file_path_ + ".manifest");
if (mf.open(QIODevice::ReadOnly)) {
QJsonParseError mpe{};
const QJsonDocument mdoc = QJsonDocument::fromJson(mf.readAll(), &mpe);
if (mdoc.isObject()) {
manifest_ = mdoc.object();
has_manifest_ = true;
} else if (warnings) {
*warnings << QString("Ignoring malformed manifest %1: %2")
.arg(file_path_ + ".manifest", mpe.errorString());
}
}
setDirty(false);
}
bool Federation::writeJsonAt(const QString& abs_path,
const QDateTime& created_to_emit,
const QDateTime& modified_to_emit,
QString* err) const {
QString fed_dir = QFileInfo(abs_path).absolutePath();
QJsonObject root;
root["schema"] = kSchema;
if (!name_.isEmpty()) root["name"] = name_;
if (!created_.isValid()) created_ = QDateTime::currentDateTimeUtc();
modified_ = QDateTime::currentDateTimeUtc();
root["created"] = created_.toUTC().toString(Qt::ISODate);
root["modified"] = modified_.toUTC().toString(Qt::ISODate);
root["created"] = created_to_emit.toUTC().toString(Qt::ISODate);
root["modified"] = modified_to_emit.toUTC().toString(Qt::ISODate);
{
QJsonObject co, uo;
@@ -730,12 +862,14 @@ bool Federation::save(const QString& path, QString* err) {
mo["display_name"] = m.display_name;
QJsonObject so;
so["kind"] = m.source_kind;
if (m.source_kind == "local") {
so["connector"] = m.source_connector;
if (m.source_connector == "local") {
so["path"] = relativizePath(fed_dir, m.source_path);
} else {
// Round-trip raw value for unsupported kinds.
so["path"] = m.source_path;
// Round-trip connector-specific keys verbatim.
for (auto it = m.source_data.begin(); it != m.source_data.end(); ++it) {
so[it.key()] = it.value();
}
}
mo["source"] = so;
@@ -793,8 +927,5 @@ bool Federation::save(const QString& path, QString* err) {
if (err) *err = QString("Failed to commit %1: %2").arg(abs_path, f.errorString());
return false;
}
file_path_ = abs_path;
setDirty(false);
return true;
}
+60 -2
View File
@@ -22,6 +22,7 @@
#include <Eigen/Dense>
#include <QJsonObject>
#include <QObject>
#include <QString>
#include <QStringList>
@@ -188,8 +189,13 @@ public:
struct Model {
QString id; // stable, persisted
QString display_name;
QString source_kind = "local"; // future: "http", "speckle", ...
QString source_path; // resolved absolute when kind == "local"
// "local" or a connector id (e.g. "autodesk") per CLOUD_SYNC_PROTOCOL.md.
QString source_connector = "local";
// Local connector: resolved absolute path. Empty for cloud sources.
QString source_path;
// Cloud connectors: arbitrary connector-specific keys, round-tripped
// verbatim. Empty / unused for "local".
QJsonObject source_data;
ModelTransformation model_transformation;
bool visible = true;
QString group_id; // empty = root level
@@ -222,11 +228,28 @@ public:
// Round-trip
bool load(const QString& path, QStringList* warnings, QString* err);
bool save(const QString& path, QString* err);
// Serialise to `path` without touching internal state (file_path_,
// modified_, dirty_). Used by cloud-push paths to produce a temporary
// .ifcfed without claiming it as the project's canonical location.
bool writeCopyTo(const QString& path, QString* err) const;
// Repoint this project to a new on-disk location without re-loading its
// content. Updates file_path_, re-reads adjacent .manifest, marks clean.
// Used after push_ifcfed[_interactive] — the connector wrote a fresh
// copy and (maybe) manifest to its cache; we already have the content
// in memory.
void repointTo(const QString& new_path, QStringList* warnings = nullptr);
// Mutations
void clear();
QString addModel(const QString& source_path,
const QString& display_name = QString());
// Add a model whose source is a cloud connector (anything other than
// "local"). `source_data` holds the connector-specific keys; the
// top-level "connector" field, if present, is overwritten with
// `connector_id`. Returns the new fed id, or {} on empty inputs.
QString addCloudModel(const QString& display_name,
const QString& connector_id,
const QJsonObject& source_data);
void removeModel(const QString& fed_id);
void setHomeView(const HomeView& hv);
void clearHomeView();
@@ -235,6 +258,17 @@ public:
void setFederatedFalseOrigin(const FederatedFalseOrigin&);
void setModelTransformation(const QString& fed_id, const ModelTransformation&);
void setModelVisible(const QString& fed_id, bool visible);
// Rename a model. No-op when fed_id is unknown, name is empty, or
// name is unchanged.
void setModelDisplayName(const QString& fed_id, const QString& display_name);
// Replace a model's source. Used after push_model[_interactive] when
// the connector reports a fresh source (e.g. a new version_id) or when
// a previously-local model gets uploaded for the first time. The
// top-level "connector" key in `source_data`, if any, is dropped —
// it's expressed via `connector_id`.
void setModelSource(const QString& fed_id,
const QString& connector_id,
const QJsonObject& source_data);
// Reassign a model to a group (or to root, when group_id is empty).
// No-op when fed_id is unknown or group_id is unknown-and-non-empty.
void setModelGroup(const QString& fed_id, const QString& group_id);
@@ -277,6 +311,16 @@ public:
const FederationConfig& config() const { return config_; }
const FederatedFalseOrigin& federatedFalseOrigin() const { return federated_false_origin_; }
// .ifcfed.manifest sidecar — present iff this project came from a
// cloud connector. Read best-effort during load() (no warning if
// absent); the connector owns writing. setManifest is called after
// push_ifcfed[_interactive] returns a fresh manifest.
bool hasManifest() const { return has_manifest_; }
const QJsonObject& manifest() const { return manifest_; }
QString manifestConnectorId() const;
void setManifest(const QJsonObject& manifest);
void clearManifest();
signals:
void dirtyChanged(bool dirty);
@@ -290,6 +334,9 @@ signals:
void modelTransformationChanged(const QString& fed_id);
void modelVisibilityChanged(const QString& fed_id, bool visible);
void modelGroupChanged(const QString& fed_id, const QString& group_id);
// Emitted on rename / source change — anything that affects how the
// model is displayed but is not covered by the other granular signals.
void modelChanged(const QString& fed_id);
void groupAdded(const QString& group_id);
void groupRemoved(const QString& group_id);
@@ -305,6 +352,15 @@ private:
static QString generateId();
static bool isFederationPath(const QString& path);
// Pure-write helper shared by save() and writeCopyTo(). Builds the
// JSON using the timestamps it's given (so save() can commit them to
// member state, while writeCopyTo() can pass throwaway values), and
// writes via QSaveFile. Never mutates `this`.
bool writeJsonAt(const QString& abs_path,
const QDateTime& created_to_emit,
const QDateTime& modified_to_emit,
QString* err) const;
Group* findGroupByIdMutable(const QString& group_id);
// Detach a group from its current parent's children vector, returning
// ownership. group->parent is left set to its former parent — the
@@ -327,6 +383,8 @@ private:
FederatedFalseOrigin federated_false_origin_;
bool has_home_view_ = false;
HomeView home_view_;
bool has_manifest_ = false;
QJsonObject manifest_;
bool dirty_ = false;
};