From 486b858d568b8d576c1fcf0fa6767cfd38849f89 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 20 May 2026 09:15:03 +1000 Subject: [PATCH] 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 --- src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md | 30 +- src/ifcviewer-full/CMakeLists.txt | 10 + src/ifcviewer-full/MainWindow.cpp | 22 +- src/ifcviewer-full/SessionState.cpp | 13 + src/ifcviewer-full/SessionState.h | 13 + .../modules/connectors/Discovery.cpp | 114 ++++ .../modules/connectors/Discovery.h | 50 ++ .../modules/connectors/PickerDialog.cpp | 79 +++ .../modules/connectors/PickerDialog.h | 54 ++ .../modules/connectors/Process.cpp | 197 +++++++ .../modules/connectors/Process.h | 98 ++++ .../modules/connectors/Registry.cpp | 109 ++++ .../modules/connectors/Registry.h | 74 +++ .../modules/models/AddModelDialog.cpp | 12 +- .../modules/models/AddModelDialog.h | 1 + .../modules/models/Commands.cpp | 225 ++++++++ src/ifcviewer-full/modules/models/Commands.h | 10 + .../modules/models/FederationItemModel.cpp | 9 + .../modules/models/FederationItemModel.h | 1 + src/ifcviewer-full/modules/models/Panel.cpp | 21 + .../modules/project/Commands.cpp | 493 +++++++++++++++++- src/ifcviewer-full/modules/project/Commands.h | 21 + .../modules/project/SaveProjectDialog.cpp | 148 ++++++ .../modules/project/SaveProjectDialog.h | 54 ++ .../modules/settings/Dialog.cpp | 99 +++- src/ifcviewer-full/modules/settings/Dialog.h | 8 +- src/ifcviewer/Federation.cpp | 193 +++++-- src/ifcviewer/Federation.h | 62 ++- 28 files changed, 2153 insertions(+), 67 deletions(-) create mode 100644 src/ifcviewer-full/modules/connectors/Discovery.cpp create mode 100644 src/ifcviewer-full/modules/connectors/Discovery.h create mode 100644 src/ifcviewer-full/modules/connectors/PickerDialog.cpp create mode 100644 src/ifcviewer-full/modules/connectors/PickerDialog.h create mode 100644 src/ifcviewer-full/modules/connectors/Process.cpp create mode 100644 src/ifcviewer-full/modules/connectors/Process.h create mode 100644 src/ifcviewer-full/modules/connectors/Registry.cpp create mode 100644 src/ifcviewer-full/modules/connectors/Registry.h create mode 100644 src/ifcviewer-full/modules/project/SaveProjectDialog.cpp create mode 100644 src/ifcviewer-full/modules/project/SaveProjectDialog.h diff --git a/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md b/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md index 99840b1648..2380b816cb 100644 --- a/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md +++ b/src/ifcviewer-autodesk/CLOUD_SYNC_PROTOCOL.md @@ -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 `.exe` if `` 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 diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt index 3a1ac36ab0..a4ad6e25d1 100644 --- a/src/ifcviewer-full/CMakeLists.txt +++ b/src/ifcviewer-full/CMakeLists.txt @@ -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 diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 867c8898e9..a22aa68dff 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -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; diff --git a/src/ifcviewer-full/SessionState.cpp b/src/ifcviewer-full/SessionState.cpp index cd2d4b9194..03da127eeb 100644 --- a/src/ifcviewer-full/SessionState.cpp +++ b/src/ifcviewer-full/SessionState.cpp @@ -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 { diff --git a/src/ifcviewer-full/SessionState.h b/src/ifcviewer-full/SessionState.h index 7e9f20dfbf..a55752a50a 100644 --- a/src/ifcviewer-full/SessionState.h +++ b/src/ifcviewer-full/SessionState.h @@ -24,6 +24,7 @@ #include #include #include +#include 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 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 fed_id_to_model_id_; QHash model_id_to_fed_id_; + QHash cloud_metadata_; }; } // namespace ifcviewerfull diff --git a/src/ifcviewer-full/modules/connectors/Discovery.cpp b/src/ifcviewer-full/modules/connectors/Discovery.cpp new file mode 100644 index 0000000000..af395f62a1 --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/Discovery.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "Discovery.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 discoverConnectors() { + std::vector result; + QSet 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 diff --git a/src/ifcviewer-full/modules/connectors/Discovery.h b/src/ifcviewer-full/modules/connectors/Discovery.h new file mode 100644 index 0000000000..816bcd5738 --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/Discovery.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_MODULES_CONNECTORS_DISCOVERY_H +#define IFCINTERFACE_MODULES_CONNECTORS_DISCOVERY_H + +#include +#include + +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 discoverConnectors(); + +// Per-user connectors directory (platform-specific). Exposed for tests and +// for "open user connectors dir" affordances. +QString userConnectorsDir(); + +} // namespace ifcviewerfull::modules::connectors + +#endif diff --git a/src/ifcviewer-full/modules/connectors/PickerDialog.cpp b/src/ifcviewer-full/modules/connectors/PickerDialog.cpp new file mode 100644 index 0000000000..748a80170f --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/PickerDialog.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "PickerDialog.h" + +#include "../../components/Buttons.h" +#include "../../components/Section.h" +#include "../../components/Style.h" + +#include +#include +#include +#include + +namespace ifcviewerfull::modules::connectors { + +ConnectorPickerDialog::ConnectorPickerDialog(const std::vector& manifests, + const QString& title, + const QString& description, + QWidget* parent) + : components::Dialog(parent) +{ + setObjectName("appDialog"); + setWindowTitle(title); + setModal(true); + + if (auto* root = qobject_cast(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 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 diff --git a/src/ifcviewer-full/modules/connectors/PickerDialog.h b/src/ifcviewer-full/modules/connectors/PickerDialog.h new file mode 100644 index 0000000000..c7ecbc2f92 --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/PickerDialog.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_MODULES_CONNECTORS_PICKERDIALOG_H +#define IFCINTERFACE_MODULES_CONNECTORS_PICKERDIALOG_H + +#include "Discovery.h" + +#include "../../components/Dialog.h" + +#include +#include + +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& 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 diff --git a/src/ifcviewer-full/modules/connectors/Process.cpp b/src/ifcviewer-full/modules/connectors/Process.cpp new file mode 100644 index 0000000000..2f6445c94d --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/Process.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "Process.h" + +#include +#include +#include +#include +#include + +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 snapshot; + snapshot.swap(pending_); + for (const auto& p : snapshot) { + if (p.on_error) p.on_error(code, message); + } +} + +} // namespace ifcviewerfull::modules::connectors diff --git a/src/ifcviewer-full/modules/connectors/Process.h b/src/ifcviewer-full/modules/connectors/Process.h new file mode 100644 index 0000000000..ee68289222 --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/Process.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_MODULES_CONNECTORS_PROCESS_H +#define IFCINTERFACE_MODULES_CONNECTORS_PROCESS_H + +#include "Discovery.h" + +#include +#include +#include +#include +#include +#include +#include + +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; + using ErrorHandler = std::function; + + 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 pending_; +}; + +} // namespace ifcviewerfull::modules::connectors + +#endif diff --git a/src/ifcviewer-full/modules/connectors/Registry.cpp b/src/ifcviewer-full/modules/connectors/Registry.cpp new file mode 100644 index 0000000000..8da02c9195 --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/Registry.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "Registry.h" + +#include "Process.h" + +#include + +namespace ifcviewerfull::modules::connectors { + +ConnectorRegistry::ConnectorRegistry(QObject* parent) + : QObject(parent) +{ +} + +ConnectorRegistry::~ConnectorRegistry() { + shutdownAll(); +} + +const std::vector& 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 diff --git a/src/ifcviewer-full/modules/connectors/Registry.h b/src/ifcviewer-full/modules/connectors/Registry.h new file mode 100644 index 0000000000..536ec74ab5 --- /dev/null +++ b/src/ifcviewer-full/modules/connectors/Registry.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_MODULES_CONNECTORS_REGISTRY_H +#define IFCINTERFACE_MODULES_CONNECTORS_REGISTRY_H + +#include "Discovery.h" + +#include +#include +#include +#include + +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& 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 manifests_; + QHash processes_; + QString last_error_; +}; + +} // namespace ifcviewerfull::modules::connectors + +#endif diff --git a/src/ifcviewer-full/modules/models/AddModelDialog.cpp b/src/ifcviewer-full/modules/models/AddModelDialog.cpp index 364712f467..183403290c 100644 --- a/src/ifcviewer-full/modules/models/AddModelDialog.cpp +++ b/src/ifcviewer-full/modules/models/AddModelDialog.cpp @@ -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); diff --git a/src/ifcviewer-full/modules/models/AddModelDialog.h b/src/ifcviewer-full/modules/models/AddModelDialog.h index b343735376..c546abb2b0 100644 --- a/src/ifcviewer-full/modules/models/AddModelDialog.h +++ b/src/ifcviewer-full/modules/models/AddModelDialog.h @@ -30,6 +30,7 @@ enum class SourceMode { IfcFile, IfcDatabase, GeometryOnly, + CloudModel, ConvertToDatabase, ExportGeometryDatabase, }; diff --git a/src/ifcviewer-full/modules/models/Commands.cpp b/src/ifcviewer-full/modules/models/Commands.cpp index f9fe818f5e..723d7d353a 100644 --- a/src/ifcviewer-full/modules/models/Commands.cpp +++ b/src/ifcviewer-full/modules/models/Commands.cpp @@ -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 #include #include +#include +#include +#include #include #include #include +#include #include #include #include @@ -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 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 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 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); diff --git a/src/ifcviewer-full/modules/models/Commands.h b/src/ifcviewer-full/modules/models/Commands.h index 7d38dac03f..a55d74bd92 100644 --- a/src/ifcviewer-full/modules/models/Commands.h +++ b/src/ifcviewer-full/modules/models/Commands.h @@ -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); diff --git a/src/ifcviewer-full/modules/models/FederationItemModel.cpp b/src/ifcviewer-full/modules/models/FederationItemModel.cpp index 25879309e5..fe3bcf4ba1 100644 --- a/src/ifcviewer-full/modules/models/FederationItemModel.cpp +++ b/src/ifcviewer-full/modules/models/FederationItemModel.cpp @@ -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; diff --git a/src/ifcviewer-full/modules/models/FederationItemModel.h b/src/ifcviewer-full/modules/models/FederationItemModel.h index 779839500b..22cc61908d 100644 --- a/src/ifcviewer-full/modules/models/FederationItemModel.h +++ b/src/ifcviewer-full/modules/models/FederationItemModel.h @@ -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; diff --git a/src/ifcviewer-full/modules/models/Panel.cpp b/src/ifcviewer-full/modules/models/Panel.cpp index 62d5fce679..786d8b7223 100644 --- a/src/ifcviewer-full/modules/models/Panel.cpp +++ b/src/ifcviewer-full/modules/models/Panel.cpp @@ -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]() { diff --git a/src/ifcviewer-full/modules/project/Commands.cpp b/src/ifcviewer-full/modules/project/Commands.cpp index d5ecb74cd4..b893f24df1 100644 --- a/src/ifcviewer-full/modules/project/Commands.cpp +++ b/src/ifcviewer-full/modules/project/Commands.cpp @@ -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 +#include #include #include +#include +#include +#include +#include #include +#include +#include + +#include 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 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 sguard(&s); + QPointer 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 sguard(&s); + QPointer hguard(&host); + QPointer 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 sguard(&s); + QPointer hguard(&host); + QPointer 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 dir; + QString path; +}; + +TempProjectFile writeProjectToTemp(SessionState& s, QWidget& host, const QString& op_title) { + TempProjectFile out; + out.dir = std::make_shared(); + 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 sguard(&s); + QPointer 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 sguard(&s); + QPointer 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 diff --git a/src/ifcviewer-full/modules/project/Commands.h b/src/ifcviewer-full/modules/project/Commands.h index 58df26f551..44707f897f 100644 --- a/src/ifcviewer-full/modules/project/Commands.h +++ b/src/ifcviewer-full/modules/project/Commands.h @@ -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 diff --git a/src/ifcviewer-full/modules/project/SaveProjectDialog.cpp b/src/ifcviewer-full/modules/project/SaveProjectDialog.cpp new file mode 100644 index 0000000000..9de19f6383 --- /dev/null +++ b/src/ifcviewer-full/modules/project/SaveProjectDialog.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "SaveProjectDialog.h" + +#include "../../components/Buttons.h" +#include "../../components/Section.h" +#include "../../components/Style.h" + +#include +#include +#include +#include +#include + +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(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 diff --git a/src/ifcviewer-full/modules/project/SaveProjectDialog.h b/src/ifcviewer-full/modules/project/SaveProjectDialog.h new file mode 100644 index 0000000000..7162634557 --- /dev/null +++ b/src/ifcviewer-full/modules/project/SaveProjectDialog.h @@ -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 . * + * * + ********************************************************************************/ + +#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 diff --git a/src/ifcviewer-full/modules/settings/Dialog.cpp b/src/ifcviewer-full/modules/settings/Dialog.cpp index 2cfaa3a7a7..ce664f463e 100644 --- a/src/ifcviewer-full/modules/settings/Dialog.cpp +++ b/src/ifcviewer-full/modules/settings/Dialog.cpp @@ -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 #include @@ -35,17 +38,22 @@ #include #include #include +#include #include #include +#include +#include #include #include #include +#include #include 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{}; + + 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 guard(settings_button); + QPointer 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 diff --git a/src/ifcviewer-full/modules/settings/Dialog.h b/src/ifcviewer-full/modules/settings/Dialog.h index da581b395d..4f83387981 100644 --- a/src/ifcviewer-full/modules/settings/Dialog.h +++ b/src/ifcviewer-full/modules/settings/Dialog.h @@ -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; diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 8022c92b99..ec42696999 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -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; } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 6e671b58e1..726325efa4 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -22,6 +22,7 @@ #include +#include #include #include #include @@ -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; };