mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-17 22:11:36 +00:00
Wire IfcViewer to cloud sync connectors
Implements the viewer side of CLOUD_SYNC_PROTOCOL.md: connector discovery, JSON-RPC stdio host, and Open/Save/Sync/Add cloud workflows wired through the ribbon, Models panel right-click, and Settings tab. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -466,35 +466,29 @@ small, fixed set of locations for these folders.
|
|||||||
`.ifcfed.manifest`. Must be unique across all discovered connectors.
|
`.ifcfed.manifest`. Must be unique across all discovered connectors.
|
||||||
- `name` — human-readable label shown in the IfcViewer UI.
|
- `name` — human-readable label shown in the IfcViewer UI.
|
||||||
- `version` — connector version string; informational only.
|
- `version` — connector version string; informational only.
|
||||||
- `exec` — how to launch the connector:
|
- `exec` — path to the connector executable. Relative paths are resolved
|
||||||
- Relative path (starts with `./` or `../`): resolved against the
|
against the connector folder; absolute paths are used as-is. Bundled
|
||||||
connector folder. This is the recommended form for bundled connectors.
|
connectors should use a relative path so the bundle is self-contained.
|
||||||
- Absolute path: used as-is.
|
|
||||||
- Bare name (no path separators): looked up via the system `PATH`.
|
|
||||||
|
|
||||||
On Windows, the IfcViewer will also try `<exec>.exe` if `<exec>` does not
|
On Windows, the IfcViewer will also try `<exec>.exe` if `<exec>` does not
|
||||||
exist as written.
|
exist as written.
|
||||||
|
|
||||||
### Search locations
|
### 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
|
- Linux: `~/.local/share/IfcOpenShell/IfcViewer/connectors/`
|
||||||
separated by the platform path separator (`:` on Linux/macOS, `;` on
|
- macOS: `~/Library/Application Support/IfcOpenShell/IfcViewer/connectors/`
|
||||||
Windows). Intended for development and unusual installs.
|
- Windows: `%APPDATA%\IfcOpenShell\IfcViewer\connectors\`
|
||||||
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\`
|
|
||||||
|
|
||||||
In each search location, the IfcViewer looks at every immediate subdirectory
|
The IfcViewer looks at every immediate subdirectory and treats it as a
|
||||||
and treats it as a connector iff it contains a `connector.json`. Connectors
|
connector iff it contains a `connector.json`. Connectors are launched on
|
||||||
are launched on demand when the user invokes a cloud workflow, not at startup.
|
demand when the user invokes a cloud workflow, not at startup.
|
||||||
|
|
||||||
### Conflicts and errors
|
### 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
|
order wins; the loser is skipped and a warning is written to the IfcViewer's
|
||||||
log.
|
log.
|
||||||
- A `connector.json` that is missing, unreadable, malformed, or missing
|
- A `connector.json` that is missing, unreadable, malformed, or missing
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ set(IFCVIEWER_FULL_FILES
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h
|
${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h
|
${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.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/AddModelDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/models/SettingsDialog.cpp
|
${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/properties/View.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/project/Commands.h
|
${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.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/modules/settings/Dialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Types.h
|
${CMAKE_CURRENT_SOURCE_DIR}/modules/spatial_hierarchy/Types.h
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ QWidget* MainWindow::buildHomeRibbonPage() {
|
|||||||
});
|
});
|
||||||
auto* open_cloud = components::buttons::makeButton("Open Cloud", ":/icons/cloud-square.svg", this);
|
auto* open_cloud = components::buttons::makeButton("Open Cloud", ":/icons/cloud-square.svg", this);
|
||||||
connect(open_cloud, &QToolButton::clicked, this, [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);
|
auto* open_recent = components::buttons::makeButton("Open Recent", ":/icons/clock-rotate-right.svg", this);
|
||||||
connect(open_recent, &QToolButton::clicked, this, [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);
|
auto* save_project = components::buttons::makeButton("Save Project", ":/icons/floppy-disk.svg", this);
|
||||||
connect(save_project, &QToolButton::clicked, this, [this]() {
|
connect(save_project, &QToolButton::clicked, this, [this]() {
|
||||||
modules::project::commands::saveProject(*session_state_, *this);
|
modules::project::commands::saveProjectDialog(*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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
auto* add_model = components::buttons::makeButton("Add Model", ":/icons/cube.svg", this);
|
auto* add_model = components::buttons::makeButton("Add Model", ":/icons/cube.svg", this);
|
||||||
connect(add_model, &QToolButton::clicked, this, [this]() {
|
connect(add_model, &QToolButton::clicked, this, [this]() {
|
||||||
modules::models::commands::addModel(*session_state_, *this);
|
modules::models::commands::addModel(*session_state_, *this);
|
||||||
});
|
});
|
||||||
auto* sync_models = components::buttons::makeButton("Sync Models", ":/icons/refresh-double.svg", this);
|
auto* sync_from_cloud = components::buttons::makeButton("Sync From Cloud", ":/icons/refresh-double.svg", this);
|
||||||
connect(sync_models, &QToolButton::clicked, this, [this]() {
|
connect(sync_from_cloud, &QToolButton::clicked, this, [this]() {
|
||||||
session_state_->setStatusMessage("Models", "Sync models coming soon");
|
modules::project::commands::syncCloudProject(
|
||||||
|
*session_state_, *this, *viewport_widget_->viewport());
|
||||||
});
|
});
|
||||||
|
|
||||||
auto* settings_button = components::buttons::makeButton("Settings", ":/icons/settings.svg", this);
|
auto* settings_button = components::buttons::makeButton("Settings", ":/icons/settings.svg", this);
|
||||||
connect(settings_button, &QToolButton::clicked, this, [this]() {
|
connect(settings_button, &QToolButton::clicked, this, [this]() {
|
||||||
modules::settings::SettingsDialog dialog(this);
|
modules::settings::SettingsDialog dialog(session_state_, this);
|
||||||
dialog.exec();
|
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("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project}, this));
|
||||||
row->addWidget(components::buttons::makeButtonGroup("MODELS", {add_model, sync_models}, this));
|
row->addWidget(components::buttons::makeButtonGroup("MODELS", {add_model, sync_from_cloud}, this));
|
||||||
row->addWidget(components::buttons::makeButtonGroup("SETTINGS", {settings_button}, this));
|
row->addWidget(components::buttons::makeButtonGroup("SETTINGS", {settings_button}, this));
|
||||||
row->addStretch(1);
|
row->addStretch(1);
|
||||||
return page;
|
return page;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#include "SessionState.h"
|
#include "SessionState.h"
|
||||||
|
|
||||||
#include "ElementRegistry.h"
|
#include "ElementRegistry.h"
|
||||||
|
#include "modules/connectors/Registry.h"
|
||||||
#include "../ifcviewer/Federation.h"
|
#include "../ifcviewer/Federation.h"
|
||||||
#include "../ifcviewer/SceneLoader.h"
|
#include "../ifcviewer/SceneLoader.h"
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ SessionState::SessionState(QObject* parent)
|
|||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
, federation_(new Federation(this))
|
, federation_(new Federation(this))
|
||||||
, element_registry_(new ElementRegistry(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) {
|
void SessionState::removeModelMappingByFedId(const QString& fed_id) {
|
||||||
|
cloud_metadata_.remove(fed_id);
|
||||||
auto it = fed_id_to_model_id_.find(fed_id);
|
auto it = fed_id_to_model_id_.find(fed_id);
|
||||||
if (it == fed_id_to_model_id_.end()) return;
|
if (it == fed_id_to_model_id_.end()) return;
|
||||||
model_id_to_fed_id_.remove(it.value());
|
model_id_to_fed_id_.remove(it.value());
|
||||||
@@ -124,6 +127,16 @@ void SessionState::removeModelMappingByFedId(const QString& fed_id) {
|
|||||||
void SessionState::clearModelMappings() {
|
void SessionState::clearModelMappings() {
|
||||||
fed_id_to_model_id_.clear();
|
fed_id_to_model_id_.clear();
|
||||||
model_id_to_fed_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 {
|
uint32_t SessionState::modelIdForFedId(const QString& fed_id) const {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
#include <QHash>
|
#include <QHash>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
class Federation;
|
class Federation;
|
||||||
class SceneLoader;
|
class SceneLoader;
|
||||||
@@ -33,6 +34,8 @@ namespace ifcviewerfull {
|
|||||||
|
|
||||||
class ElementRegistry;
|
class ElementRegistry;
|
||||||
|
|
||||||
|
namespace modules::connectors { class ConnectorRegistry; }
|
||||||
|
|
||||||
class SessionState : public QObject {
|
class SessionState : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ public:
|
|||||||
Federation* federation() const { return federation_; }
|
Federation* federation() const { return federation_; }
|
||||||
SceneLoader* loader() const { return loader_; }
|
SceneLoader* loader() const { return loader_; }
|
||||||
ElementRegistry* elementRegistry() const { return element_registry_; }
|
ElementRegistry* elementRegistry() const { return element_registry_; }
|
||||||
|
modules::connectors::ConnectorRegistry* connectorRegistry() const { return connector_registry_; }
|
||||||
QString statusMode() const { return status_mode_; }
|
QString statusMode() const { return status_mode_; }
|
||||||
QString statusDetail() const { return status_detail_; }
|
QString statusDetail() const { return status_detail_; }
|
||||||
|
|
||||||
@@ -63,6 +67,13 @@ public:
|
|||||||
void setModelMapping(const QString& fed_id, uint32_t model_id);
|
void setModelMapping(const QString& fed_id, uint32_t model_id);
|
||||||
void removeModelMappingByFedId(const QString& fed_id);
|
void removeModelMappingByFedId(const QString& fed_id);
|
||||||
void clearModelMappings();
|
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;
|
uint32_t modelIdForFedId(const QString& fed_id) const;
|
||||||
QString fedIdForModelId(uint32_t model_id) const;
|
QString fedIdForModelId(uint32_t model_id) const;
|
||||||
QList<uint32_t> modelIds() const;
|
QList<uint32_t> modelIds() const;
|
||||||
@@ -104,11 +115,13 @@ private:
|
|||||||
Federation* federation_ = nullptr;
|
Federation* federation_ = nullptr;
|
||||||
SceneLoader* loader_ = nullptr;
|
SceneLoader* loader_ = nullptr;
|
||||||
ElementRegistry* element_registry_ = nullptr;
|
ElementRegistry* element_registry_ = nullptr;
|
||||||
|
modules::connectors::ConnectorRegistry* connector_registry_ = nullptr;
|
||||||
uint32_t selected_object_id_ = 0;
|
uint32_t selected_object_id_ = 0;
|
||||||
QString status_mode_;
|
QString status_mode_;
|
||||||
QString status_detail_;
|
QString status_detail_;
|
||||||
QHash<QString, uint32_t> fed_id_to_model_id_;
|
QHash<QString, uint32_t> fed_id_to_model_id_;
|
||||||
QHash<uint32_t, QString> model_id_to_fed_id_;
|
QHash<uint32_t, QString> model_id_to_fed_id_;
|
||||||
|
QHash<QString, QVariantMap> cloud_metadata_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace ifcviewerfull
|
} // namespace ifcviewerfull
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "Discovery.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString resolveExec(const QString& exec_field, const QString& folder) {
|
||||||
|
QString resolved = QDir(folder).absoluteFilePath(exec_field);
|
||||||
|
#if defined(Q_OS_WIN)
|
||||||
|
if (!QFileInfo::exists(resolved) && QFileInfo::exists(resolved + ".exe")) {
|
||||||
|
resolved += ".exe";
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return QDir::cleanPath(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseManifest(const QString& folder, ConnectorManifest& out) {
|
||||||
|
const QString manifest_path = QDir(folder).filePath("connector.json");
|
||||||
|
QFile file(manifest_path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
|
qWarning() << "ifcviewer connectors: cannot read" << manifest_path
|
||||||
|
<< ":" << file.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QJsonParseError err{};
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err);
|
||||||
|
if (doc.isNull() || !doc.isObject()) {
|
||||||
|
qWarning() << "ifcviewer connectors: malformed manifest"
|
||||||
|
<< manifest_path << ":" << err.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = doc.object();
|
||||||
|
const QString id = obj.value("id").toString();
|
||||||
|
const QString name = obj.value("name").toString();
|
||||||
|
const QString version = obj.value("version").toString();
|
||||||
|
const QString exec_field = obj.value("exec").toString();
|
||||||
|
if (id.isEmpty() || name.isEmpty() || exec_field.isEmpty()) {
|
||||||
|
qWarning() << "ifcviewer connectors: missing required field (id/name/exec) in"
|
||||||
|
<< manifest_path;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.id = id;
|
||||||
|
out.name = name;
|
||||||
|
out.version = version;
|
||||||
|
out.folder = QDir::cleanPath(folder);
|
||||||
|
out.exec_path = resolveExec(exec_field, out.folder);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString userConnectorsDir() {
|
||||||
|
// GenericDataLocation maps to the platform's per-user data root:
|
||||||
|
// Linux -> ~/.local/share
|
||||||
|
// macOS -> ~/Library/Application Support
|
||||||
|
// Win -> %APPDATA% (Roaming)
|
||||||
|
// matching CLOUD_SYNC_PROTOCOL.md's listed locations.
|
||||||
|
const QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
|
||||||
|
return QDir(base).filePath("IfcOpenShell/IfcViewer/connectors");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ConnectorManifest> discoverConnectors() {
|
||||||
|
std::vector<ConnectorManifest> result;
|
||||||
|
QSet<QString> seen_ids;
|
||||||
|
QDir dir(userConnectorsDir());
|
||||||
|
if (!dir.exists()) return result;
|
||||||
|
const QFileInfoList entries = dir.entryInfoList(
|
||||||
|
QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
|
||||||
|
for (const QFileInfo& entry : entries) {
|
||||||
|
if (!QFileInfo(QDir(entry.absoluteFilePath()).filePath("connector.json")).exists()) continue;
|
||||||
|
ConnectorManifest m;
|
||||||
|
if (!parseManifest(entry.absoluteFilePath(), m)) continue;
|
||||||
|
if (seen_ids.contains(m.id)) {
|
||||||
|
qWarning() << "ifcviewer connectors: duplicate id" << m.id
|
||||||
|
<< "at" << entry.absoluteFilePath()
|
||||||
|
<< "ignored (earlier match wins)";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen_ids.insert(m.id);
|
||||||
|
result.push_back(std::move(m));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#ifndef IFCINTERFACE_MODULES_CONNECTORS_DISCOVERY_H
|
||||||
|
#define IFCINTERFACE_MODULES_CONNECTORS_DISCOVERY_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
struct ConnectorManifest {
|
||||||
|
QString id; // from connector.json; stable identifier
|
||||||
|
QString name; // human-readable label
|
||||||
|
QString version; // informational
|
||||||
|
QString folder; // directory containing connector.json
|
||||||
|
QString exec_path; // resolved executable; absolute when possible,
|
||||||
|
// otherwise a bare name for PATH lookup at launch
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scans the per-user connectors directory per CLOUD_SYNC_PROTOCOL.md.
|
||||||
|
// First match wins for any given id; duplicates and malformed manifests
|
||||||
|
// are skipped with a qWarning. Returned in discovery order so first-wins
|
||||||
|
// is observable to callers.
|
||||||
|
std::vector<ConnectorManifest> discoverConnectors();
|
||||||
|
|
||||||
|
// Per-user connectors directory (platform-specific). Exposed for tests and
|
||||||
|
// for "open user connectors dir" affordances.
|
||||||
|
QString userConnectorsDir();
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "PickerDialog.h"
|
||||||
|
|
||||||
|
#include "../../components/Buttons.h"
|
||||||
|
#include "../../components/Section.h"
|
||||||
|
#include "../../components/Style.h"
|
||||||
|
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
ConnectorPickerDialog::ConnectorPickerDialog(const std::vector<ConnectorManifest>& manifests,
|
||||||
|
const QString& title,
|
||||||
|
const QString& description,
|
||||||
|
QWidget* parent)
|
||||||
|
: components::Dialog(parent)
|
||||||
|
{
|
||||||
|
setObjectName("appDialog");
|
||||||
|
setWindowTitle(title);
|
||||||
|
setModal(true);
|
||||||
|
|
||||||
|
if (auto* root = qobject_cast<QVBoxLayout*>(layout())) {
|
||||||
|
root->setSizeConstraint(QLayout::SetFixedSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* description_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
|
||||||
|
auto* description_label = new QLabel(description, description_section);
|
||||||
|
description_label->setProperty("textRole", "secondary");
|
||||||
|
description_label->setWordWrap(true);
|
||||||
|
description_label->setAlignment(Qt::AlignCenter);
|
||||||
|
description_label->setMinimumWidth((90 * 3) + (components::style::metrics::padding * 2));
|
||||||
|
description_section->addBodyWidget(description_label);
|
||||||
|
|
||||||
|
auto* choices_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
|
||||||
|
auto* choices = new QWidget(choices_section);
|
||||||
|
auto* row = new QHBoxLayout(choices);
|
||||||
|
row->setContentsMargins(0, 0, 0, 0);
|
||||||
|
row->setSpacing(components::style::metrics::padding);
|
||||||
|
|
||||||
|
QList<QToolButton*> buttons;
|
||||||
|
for (const auto& m : manifests) {
|
||||||
|
auto* button = components::buttons::makeButton(m.name, ":/icons/cloud-square.svg", choices);
|
||||||
|
const QString id = m.id;
|
||||||
|
connect(button, &QToolButton::clicked, this, [this, id]() {
|
||||||
|
selected_id_ = id;
|
||||||
|
accept();
|
||||||
|
});
|
||||||
|
buttons.push_back(button);
|
||||||
|
}
|
||||||
|
row->addWidget(components::buttons::makeButtonGroup("CONNECTORS", buttons, choices, true, 8));
|
||||||
|
choices_section->addBodyWidget(choices);
|
||||||
|
|
||||||
|
addBodyWidget(description_section);
|
||||||
|
addBodyWidget(choices_section);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#ifndef IFCINTERFACE_MODULES_CONNECTORS_PICKERDIALOG_H
|
||||||
|
#define IFCINTERFACE_MODULES_CONNECTORS_PICKERDIALOG_H
|
||||||
|
|
||||||
|
#include "Discovery.h"
|
||||||
|
|
||||||
|
#include "../../components/Dialog.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
// Connector chooser, modelled on AddModelDialog: one button per available
|
||||||
|
// connector. Always shown (even when only one connector is installed), per
|
||||||
|
// project UX direction.
|
||||||
|
class ConnectorPickerDialog : public components::Dialog {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
// `title` and `description` adapt the dialog to the calling workflow
|
||||||
|
// (e.g. "Open from Cloud", "Save Model to Cloud").
|
||||||
|
ConnectorPickerDialog(const std::vector<ConnectorManifest>& manifests,
|
||||||
|
const QString& title,
|
||||||
|
const QString& description,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
QString selectedId() const { return selected_id_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString selected_id_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "Process.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kStartTimeoutMs = 5000;
|
||||||
|
constexpr int kShutdownTimeoutMs = 3000;
|
||||||
|
|
||||||
|
// Reserved out-of-band error codes used by the host (not by the connector).
|
||||||
|
// JSON-RPC reserves -32000..-32099 for "implementation-defined server
|
||||||
|
// errors"; we co-opt three for transport failures.
|
||||||
|
constexpr int kErrLaunchFailed = -32000;
|
||||||
|
constexpr int kErrShutdown = -32001;
|
||||||
|
constexpr int kErrProcessExited = -32002;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ConnectorProcess::ConnectorProcess(ConnectorManifest manifest, QObject* parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, manifest_(std::move(manifest))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectorProcess::~ConnectorProcess() {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConnectorProcess::isRunning() const {
|
||||||
|
return process_ && process_->state() != QProcess::NotRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConnectorProcess::ensureStarted() {
|
||||||
|
if (isRunning()) return true;
|
||||||
|
if (manifest_.exec_path.isEmpty()) {
|
||||||
|
last_error_ = QString("Connector '%1' has no executable configured.")
|
||||||
|
.arg(manifest_.id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
process_ = new QProcess(this);
|
||||||
|
process_->setWorkingDirectory(manifest_.folder);
|
||||||
|
process_->setProcessChannelMode(QProcess::SeparateChannels);
|
||||||
|
connect(process_, &QProcess::readyReadStandardOutput,
|
||||||
|
this, &ConnectorProcess::onReadyReadStdout);
|
||||||
|
connect(process_, &QProcess::finished,
|
||||||
|
this, &ConnectorProcess::onProcessFinished);
|
||||||
|
|
||||||
|
process_->start(manifest_.exec_path, QStringList{});
|
||||||
|
if (!process_->waitForStarted(kStartTimeoutMs)) {
|
||||||
|
last_error_ = QString("Failed to launch connector '%1': %2")
|
||||||
|
.arg(manifest_.id, process_->errorString());
|
||||||
|
process_->deleteLater();
|
||||||
|
process_ = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
last_error_.clear();
|
||||||
|
shutting_down_ = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorProcess::call(const QString& method,
|
||||||
|
const QJsonValue& params,
|
||||||
|
ResultHandler on_result,
|
||||||
|
ErrorHandler on_error) {
|
||||||
|
if (!ensureStarted()) {
|
||||||
|
const QString err = last_error_;
|
||||||
|
// Queue rather than invoke inline so callers see uniform async order
|
||||||
|
// regardless of whether the launch succeeded.
|
||||||
|
QTimer::singleShot(0, this, [on_error = std::move(on_error), err]() {
|
||||||
|
if (on_error) on_error(kErrLaunchFailed, err);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString id = QString::number(next_request_id_++);
|
||||||
|
pending_.insert(id, Pending{std::move(on_result), std::move(on_error)});
|
||||||
|
|
||||||
|
QJsonObject msg;
|
||||||
|
msg["jsonrpc"] = "2.0";
|
||||||
|
msg["id"] = id;
|
||||||
|
msg["method"] = method;
|
||||||
|
if (!params.isUndefined() && !params.isNull()) {
|
||||||
|
msg["params"] = params;
|
||||||
|
}
|
||||||
|
const QByteArray line = QJsonDocument(msg).toJson(QJsonDocument::Compact) + '\n';
|
||||||
|
process_->write(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorProcess::shutdown() {
|
||||||
|
if (!process_) return;
|
||||||
|
shutting_down_ = true;
|
||||||
|
if (process_->state() == QProcess::Running) {
|
||||||
|
process_->closeWriteChannel();
|
||||||
|
if (!process_->waitForFinished(kShutdownTimeoutMs)) {
|
||||||
|
qWarning() << "ifcviewer connectors:" << manifest_.id
|
||||||
|
<< "did not exit within" << kShutdownTimeoutMs
|
||||||
|
<< "ms after stdin close; killing.";
|
||||||
|
process_->kill();
|
||||||
|
process_->waitForFinished(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
failPendingAndClear(kErrShutdown,
|
||||||
|
QString("Connector '%1' was shut down.").arg(manifest_.id));
|
||||||
|
process_->deleteLater();
|
||||||
|
process_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorProcess::onReadyReadStdout() {
|
||||||
|
if (!process_) return;
|
||||||
|
buffer_.append(process_->readAllStandardOutput());
|
||||||
|
int newline = buffer_.indexOf('\n');
|
||||||
|
while (newline >= 0) {
|
||||||
|
const QByteArray line = buffer_.left(newline);
|
||||||
|
buffer_.remove(0, newline + 1);
|
||||||
|
if (!line.isEmpty()) dispatchLine(line);
|
||||||
|
newline = buffer_.indexOf('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorProcess::onProcessFinished(int exit_code, QProcess::ExitStatus status) {
|
||||||
|
const bool unexpected = !shutting_down_;
|
||||||
|
const QString reason = (status == QProcess::CrashExit)
|
||||||
|
? QString("Connector '%1' crashed (exit %2).").arg(manifest_.id).arg(exit_code)
|
||||||
|
: QString("Connector '%1' exited (code %2).").arg(manifest_.id).arg(exit_code);
|
||||||
|
failPendingAndClear(kErrProcessExited, reason);
|
||||||
|
if (unexpected) emit crashed(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorProcess::dispatchLine(const QByteArray& line) {
|
||||||
|
QJsonParseError err{};
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(line, &err);
|
||||||
|
if (doc.isNull() || !doc.isObject()) {
|
||||||
|
qWarning() << "ifcviewer connectors:" << manifest_.id
|
||||||
|
<< "emitted non-JSON line:" << line << err.errorString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QJsonObject obj = doc.object();
|
||||||
|
if (obj.value("jsonrpc").toString() != "2.0") {
|
||||||
|
qWarning() << "ifcviewer connectors:" << manifest_.id
|
||||||
|
<< "ignored message missing jsonrpc=2.0:" << line;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Connector echoes our id verbatim (we always send strings), but accept
|
||||||
|
// numeric ids too in case a connector parses our "0" as an int.
|
||||||
|
const QString id = obj.value("id").toVariant().toString();
|
||||||
|
if (!pending_.contains(id)) {
|
||||||
|
qWarning() << "ifcviewer connectors:" << manifest_.id
|
||||||
|
<< "ignored response with unknown id:" << id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Pending pending = pending_.take(id);
|
||||||
|
if (obj.contains("error")) {
|
||||||
|
const QJsonObject err_obj = obj.value("error").toObject();
|
||||||
|
const int code = err_obj.value("code").toInt();
|
||||||
|
const QString message = err_obj.value("message").toString();
|
||||||
|
if (pending.on_error) pending.on_error(code, message);
|
||||||
|
} else {
|
||||||
|
if (pending.on_result) pending.on_result(obj.value("result"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorProcess::failPendingAndClear(int code, const QString& message) {
|
||||||
|
QHash<QString, Pending> snapshot;
|
||||||
|
snapshot.swap(pending_);
|
||||||
|
for (const auto& p : snapshot) {
|
||||||
|
if (p.on_error) p.on_error(code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#ifndef IFCINTERFACE_MODULES_CONNECTORS_PROCESS_H
|
||||||
|
#define IFCINTERFACE_MODULES_CONNECTORS_PROCESS_H
|
||||||
|
|
||||||
|
#include "Discovery.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QString>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
// One running connector subprocess. Owns the QProcess, frames stdio as
|
||||||
|
// newline-delimited JSON-RPC 2.0, and routes responses back to per-call
|
||||||
|
// callbacks. Per the protocol, one process per session: launch lazily on
|
||||||
|
// first call(), keep alive until shutdown().
|
||||||
|
class ConnectorProcess : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
using ResultHandler = std::function<void(const QJsonValue& result)>;
|
||||||
|
using ErrorHandler = std::function<void(int code, const QString& message)>;
|
||||||
|
|
||||||
|
explicit ConnectorProcess(ConnectorManifest manifest, QObject* parent = nullptr);
|
||||||
|
~ConnectorProcess() override;
|
||||||
|
|
||||||
|
const ConnectorManifest& manifest() const { return manifest_; }
|
||||||
|
bool isRunning() const;
|
||||||
|
QString lastError() const { return last_error_; }
|
||||||
|
|
||||||
|
// Lazy-launches the underlying process. Returns false on launch failure;
|
||||||
|
// the user-facing reason is in lastError().
|
||||||
|
bool ensureStarted();
|
||||||
|
|
||||||
|
// Async JSON-RPC 2.0 call. Exactly one of on_result / on_error fires
|
||||||
|
// exactly once, on the same thread (queued). Launch failure synthesizes
|
||||||
|
// an immediate on_error via singleShot so callers see consistent
|
||||||
|
// async ordering.
|
||||||
|
void call(const QString& method,
|
||||||
|
const QJsonValue& params,
|
||||||
|
ResultHandler on_result,
|
||||||
|
ErrorHandler on_error);
|
||||||
|
|
||||||
|
// Closes stdin and waits a few seconds for clean exit; SIGKILLs on
|
||||||
|
// timeout. Any in-flight calls fail via on_error. Safe to call when
|
||||||
|
// not running.
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
// Process exited unexpectedly (not via shutdown()). In-flight calls have
|
||||||
|
// already been failed via on_error before this fires.
|
||||||
|
void crashed(const QString& message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void onReadyReadStdout();
|
||||||
|
void onProcessFinished(int exit_code, QProcess::ExitStatus status);
|
||||||
|
void dispatchLine(const QByteArray& line);
|
||||||
|
void failPendingAndClear(int code, const QString& message);
|
||||||
|
|
||||||
|
ConnectorManifest manifest_;
|
||||||
|
QProcess* process_ = nullptr;
|
||||||
|
QByteArray buffer_;
|
||||||
|
QString last_error_;
|
||||||
|
bool shutting_down_ = false;
|
||||||
|
quint64 next_request_id_ = 0;
|
||||||
|
|
||||||
|
struct Pending {
|
||||||
|
ResultHandler on_result;
|
||||||
|
ErrorHandler on_error;
|
||||||
|
};
|
||||||
|
QHash<QString, Pending> pending_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "Registry.h"
|
||||||
|
|
||||||
|
#include "Process.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
ConnectorRegistry::ConnectorRegistry(QObject* parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectorRegistry::~ConnectorRegistry() {
|
||||||
|
shutdownAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<ConnectorManifest>& ConnectorRegistry::available() {
|
||||||
|
if (!discovered_) refresh();
|
||||||
|
return manifests_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorRegistry::refresh() {
|
||||||
|
manifests_ = discoverConnectors();
|
||||||
|
discovered_ = true;
|
||||||
|
|
||||||
|
// Cull processes whose connector is no longer discoverable, or whose
|
||||||
|
// exec changed under us. Surviving entries keep their running process.
|
||||||
|
for (auto it = processes_.begin(); it != processes_.end();) {
|
||||||
|
const QString id = it.key();
|
||||||
|
ConnectorProcess* p = it.value();
|
||||||
|
const ConnectorManifest* now = manifestFor(id);
|
||||||
|
const bool stale = !now || !p ||
|
||||||
|
p->manifest().exec_path != now->exec_path;
|
||||||
|
if (stale) {
|
||||||
|
if (p) {
|
||||||
|
p->shutdown();
|
||||||
|
p->deleteLater();
|
||||||
|
}
|
||||||
|
it = processes_.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectorManifest* ConnectorRegistry::manifestFor(const QString& id) const {
|
||||||
|
for (const auto& m : manifests_) {
|
||||||
|
if (m.id == id) return &m;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectorProcess* ConnectorRegistry::get(const QString& id) {
|
||||||
|
if (!discovered_) refresh();
|
||||||
|
if (auto* existing = processes_.value(id, nullptr)) return existing;
|
||||||
|
|
||||||
|
const ConnectorManifest* manifest = manifestFor(id);
|
||||||
|
if (!manifest) {
|
||||||
|
last_error_ = QString("Unknown connector '%1'.").arg(id);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto* proc = new ConnectorProcess(*manifest, this);
|
||||||
|
if (!proc->ensureStarted()) {
|
||||||
|
last_error_ = proc->lastError();
|
||||||
|
delete proc;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
last_error_.clear();
|
||||||
|
processes_.insert(id, proc);
|
||||||
|
connect(proc, &ConnectorProcess::crashed, this, [this, id](const QString& message) {
|
||||||
|
qWarning() << "ifcviewer connectors:" << message;
|
||||||
|
if (auto* p = processes_.take(id)) p->deleteLater();
|
||||||
|
});
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConnectorRegistry::shutdownAll() {
|
||||||
|
const auto procs = processes_;
|
||||||
|
processes_.clear();
|
||||||
|
for (auto it = procs.begin(); it != procs.end(); ++it) {
|
||||||
|
if (auto* p = it.value()) {
|
||||||
|
p->shutdown();
|
||||||
|
p->deleteLater();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#ifndef IFCINTERFACE_MODULES_CONNECTORS_REGISTRY_H
|
||||||
|
#define IFCINTERFACE_MODULES_CONNECTORS_REGISTRY_H
|
||||||
|
|
||||||
|
#include "Discovery.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::connectors {
|
||||||
|
|
||||||
|
class ConnectorProcess;
|
||||||
|
|
||||||
|
// Per-session catalog of available connectors plus their lazily-launched
|
||||||
|
// subprocesses. One instance lives on SessionState; processes are launched
|
||||||
|
// on first get() and shut down on ~Registry / shutdownAll().
|
||||||
|
class ConnectorRegistry : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ConnectorRegistry(QObject* parent = nullptr);
|
||||||
|
~ConnectorRegistry() override;
|
||||||
|
|
||||||
|
// Discovered connectors in discovery order. First call triggers a scan;
|
||||||
|
// call refresh() to force a rescan.
|
||||||
|
const std::vector<ConnectorManifest>& available();
|
||||||
|
|
||||||
|
// Re-scans the filesystem. Live processes for ids that have disappeared
|
||||||
|
// (or whose exec changed) are shut down; surviving connectors keep their
|
||||||
|
// running process.
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
// Returns the manifest for `id`, or nullptr.
|
||||||
|
const ConnectorManifest* manifestFor(const QString& id) const;
|
||||||
|
|
||||||
|
// Lazily-launched process. Returns nullptr if `id` is unknown or launch
|
||||||
|
// failed; the reason is in lastError().
|
||||||
|
ConnectorProcess* get(const QString& id);
|
||||||
|
|
||||||
|
QString lastError() const { return last_error_; }
|
||||||
|
|
||||||
|
// Close stdin on every live connector. Idempotent.
|
||||||
|
void shutdownAll();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool discovered_ = false;
|
||||||
|
std::vector<ConnectorManifest> manifests_;
|
||||||
|
QHash<QString, ConnectorProcess*> processes_;
|
||||||
|
QString last_error_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::connectors
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -119,6 +119,16 @@ void AddModelDialog::setupUi() {
|
|||||||
"Add pure geometry for fast visualisation",
|
"Add pure geometry for fast visualisation",
|
||||||
default_description));
|
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);
|
auto* convert_database = components::buttons::makeButton("Convert IFC File\nto Database", ":/icons/database-restore.svg", choices);
|
||||||
connect(convert_database, &QToolButton::clicked, this, [this]() {
|
connect(convert_database, &QToolButton::clicked, this, [this]() {
|
||||||
selected_mode_ = SourceMode::ConvertToDatabase;
|
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.",
|
"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));
|
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));
|
row->addWidget(components::buttons::makeButtonGroup("TOOLS", {convert_database, export_geometry_database}, choices, false, 8));
|
||||||
choices_section->addBodyWidget(choices);
|
choices_section->addBodyWidget(choices);
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ enum class SourceMode {
|
|||||||
IfcFile,
|
IfcFile,
|
||||||
IfcDatabase,
|
IfcDatabase,
|
||||||
GeometryOnly,
|
GeometryOnly,
|
||||||
|
CloudModel,
|
||||||
ConvertToDatabase,
|
ConvertToDatabase,
|
||||||
ExportGeometryDatabase,
|
ExportGeometryDatabase,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
|
|
||||||
#include "../../ElementRegistry.h"
|
#include "../../ElementRegistry.h"
|
||||||
#include "../../SessionState.h"
|
#include "../../SessionState.h"
|
||||||
|
#include "../connectors/PickerDialog.h"
|
||||||
|
#include "../connectors/Process.h"
|
||||||
|
#include "../connectors/Registry.h"
|
||||||
#include "../../../ifcviewer/Federation.h"
|
#include "../../../ifcviewer/Federation.h"
|
||||||
#include "../../../ifcviewer/SceneLoader.h"
|
#include "../../../ifcviewer/SceneLoader.h"
|
||||||
#include "../../../ifcviewer/SidecarBuilder.h"
|
#include "../../../ifcviewer/SidecarBuilder.h"
|
||||||
@@ -40,9 +43,13 @@
|
|||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QInputDialog>
|
#include <QInputDialog>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
#include <QListView>
|
#include <QListView>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QPointer>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
#include <QTreeView>
|
#include <QTreeView>
|
||||||
@@ -227,6 +234,9 @@ void addModel(SessionState& s, QWidget& host) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case SourceMode::CloudModel:
|
||||||
|
addModelFromCloud(s, host);
|
||||||
|
return;
|
||||||
case SourceMode::ConvertToDatabase:
|
case SourceMode::ConvertToDatabase:
|
||||||
convertIfcToDatabase(s, host);
|
convertIfcToDatabase(s, host);
|
||||||
return;
|
return;
|
||||||
@@ -249,6 +259,221 @@ void addModel(SessionState& s, QWidget& host) {
|
|||||||
s.notifyModelsChanged();
|
s.notifyModelsChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void addModelFromCloud(SessionState& s, QWidget& host) {
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
const auto& manifests = registry->available();
|
||||||
|
if (manifests.empty()) {
|
||||||
|
QMessageBox::information(&host, "Add From Cloud",
|
||||||
|
"No connectors are installed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modules::connectors::ConnectorPickerDialog picker(
|
||||||
|
manifests, "Add From Cloud",
|
||||||
|
"Pick a connector to browse models on.", &host);
|
||||||
|
if (picker.exec() != QDialog::Accepted) return;
|
||||||
|
const QString connector_id = picker.selectedId();
|
||||||
|
if (connector_id.isEmpty()) return;
|
||||||
|
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Add From Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setStatusMessage("Cloud", QString("Browsing %1...").arg(connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
|
||||||
|
proc->call("pull_models_interactive", QJsonValue(),
|
||||||
|
[sguard, connector_id](const QJsonValue& result) {
|
||||||
|
if (!sguard) return;
|
||||||
|
const QJsonArray arr = result.toArray();
|
||||||
|
QStringList paths;
|
||||||
|
QStringList fed_ids;
|
||||||
|
int added = 0;
|
||||||
|
for (const QJsonValue& v : arr) {
|
||||||
|
if (v.isNull() || !v.isObject()) continue;
|
||||||
|
const QJsonObject entry = v.toObject();
|
||||||
|
const QString display_name = entry.value("display_name").toString();
|
||||||
|
const QString path = entry.value("path").toString();
|
||||||
|
if (path.isEmpty()) continue;
|
||||||
|
const QJsonObject source = entry.value("source").toObject();
|
||||||
|
QString src_connector = source.value("connector").toString();
|
||||||
|
if (src_connector.isEmpty()) src_connector = connector_id;
|
||||||
|
|
||||||
|
const QString fed_id = sguard->federation()->addCloudModel(
|
||||||
|
display_name, src_connector, source);
|
||||||
|
if (fed_id.isEmpty()) continue;
|
||||||
|
|
||||||
|
const QJsonObject meta = entry.value("metadata").toObject();
|
||||||
|
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
|
||||||
|
|
||||||
|
paths << path;
|
||||||
|
fed_ids << fed_id;
|
||||||
|
++added;
|
||||||
|
}
|
||||||
|
if (!paths.isEmpty()) {
|
||||||
|
detail::loadModels(*sguard, paths, fed_ids);
|
||||||
|
sguard->notifyModelsChanged();
|
||||||
|
}
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("Added %1 model(s) from %2").arg(added).arg(connector_id));
|
||||||
|
},
|
||||||
|
[sguard, connector_id](int code, const QString& message) {
|
||||||
|
qWarning() << "pull_models_interactive from" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Shared "local path on disk" lookup for the right-click cloud commands:
|
||||||
|
// the loader keeps the path keyed by mid (set when a file or pull_models
|
||||||
|
// path was queued). Both local-sourced and resolved cloud-sourced models
|
||||||
|
// have one; only un-resolved cloud models (where pull_models hasn't
|
||||||
|
// returned yet) won't.
|
||||||
|
QString localPathForModel(SessionState& s, const QString& fed_id) {
|
||||||
|
const uint32_t mid = s.modelIdForFedId(fed_id);
|
||||||
|
if (mid == 0 || !s.loader()) return {};
|
||||||
|
return s.loader()->filePath(mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void saveModelToCloud(SessionState& s, QWidget& host, const QString& fed_id) {
|
||||||
|
auto* fed = s.federation();
|
||||||
|
const Federation::Model* model = fed->findById(fed_id);
|
||||||
|
if (!model) return;
|
||||||
|
if (model->source_connector == "local") {
|
||||||
|
QMessageBox::information(&host, "Save Model To Cloud",
|
||||||
|
"This model has no cloud target. Use \"Save As To Cloud\" first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString local_path = localPathForModel(s, fed_id);
|
||||||
|
if (local_path.isEmpty()) {
|
||||||
|
QMessageBox::warning(&host, "Save Model To Cloud",
|
||||||
|
"Cannot find a local copy of this model to push.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString connector_id = model->source_connector;
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Save Model To Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject source = model->source_data;
|
||||||
|
source["connector"] = connector_id;
|
||||||
|
QJsonObject params;
|
||||||
|
params["path"] = local_path;
|
||||||
|
params["source"] = source;
|
||||||
|
|
||||||
|
s.setStatusMessage("Cloud",
|
||||||
|
QString("Saving %1 to %2...").arg(model->display_name, connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
proc->call("push_model", params,
|
||||||
|
[sguard, fed_id, connector_id](const QJsonValue& result) {
|
||||||
|
if (!sguard) return;
|
||||||
|
const QJsonObject obj = result.toObject();
|
||||||
|
const QJsonObject new_source = obj.value("source").toObject();
|
||||||
|
QString new_connector = new_source.value("connector").toString();
|
||||||
|
if (new_connector.isEmpty()) new_connector = connector_id;
|
||||||
|
sguard->federation()->setModelSource(fed_id, new_connector, new_source);
|
||||||
|
const QJsonObject meta = obj.value("metadata").toObject();
|
||||||
|
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("Saved to %1").arg(new_connector));
|
||||||
|
},
|
||||||
|
[sguard, connector_id](int code, const QString& message) {
|
||||||
|
qWarning() << "push_model to" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveModelAsToCloud(SessionState& s, QWidget& host, const QString& fed_id) {
|
||||||
|
const Federation::Model* model = s.federation()->findById(fed_id);
|
||||||
|
if (!model) return;
|
||||||
|
const QString local_path = localPathForModel(s, fed_id);
|
||||||
|
if (local_path.isEmpty()) {
|
||||||
|
QMessageBox::warning(&host, "Save Model As To Cloud",
|
||||||
|
"Cannot find a local copy of this model to push.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
const auto& manifests = registry->available();
|
||||||
|
if (manifests.empty()) {
|
||||||
|
QMessageBox::information(&host, "Save Model As To Cloud",
|
||||||
|
"No connectors are installed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modules::connectors::ConnectorPickerDialog picker(
|
||||||
|
manifests, "Save Model As To Cloud",
|
||||||
|
QString("Pick a connector to push '%1' to.").arg(model->display_name),
|
||||||
|
&host);
|
||||||
|
if (picker.exec() != QDialog::Accepted) return;
|
||||||
|
const QString connector_id = picker.selectedId();
|
||||||
|
if (connector_id.isEmpty()) return;
|
||||||
|
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Save Model As To Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject params;
|
||||||
|
params["path"] = local_path;
|
||||||
|
|
||||||
|
s.setStatusMessage("Cloud",
|
||||||
|
QString("Pushing %1 to %2...").arg(model->display_name, connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
proc->call("push_model_interactive", params,
|
||||||
|
[sguard, fed_id, connector_id](const QJsonValue& result) {
|
||||||
|
if (!sguard) return;
|
||||||
|
const QJsonObject obj = result.toObject();
|
||||||
|
const QJsonObject new_source = obj.value("source").toObject();
|
||||||
|
QString new_connector = new_source.value("connector").toString();
|
||||||
|
if (new_connector.isEmpty()) new_connector = connector_id;
|
||||||
|
sguard->federation()->setModelSource(fed_id, new_connector, new_source);
|
||||||
|
|
||||||
|
const QString new_name = obj.value("display_name").toString();
|
||||||
|
if (!new_name.isEmpty()) {
|
||||||
|
sguard->federation()->setModelDisplayName(fed_id, new_name);
|
||||||
|
}
|
||||||
|
const QJsonObject meta = obj.value("metadata").toObject();
|
||||||
|
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("Pushed to %1").arg(new_connector));
|
||||||
|
},
|
||||||
|
[sguard, connector_id](int code, const QString& message) {
|
||||||
|
qWarning() << "push_model_interactive to" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void convertIfcToDatabase(SessionState& s, QWidget& host) {
|
void convertIfcToDatabase(SessionState& s, QWidget& host) {
|
||||||
QFileDialog input_dialog(&host, "Select IFC File to Convert");
|
QFileDialog input_dialog(&host, "Select IFC File to Convert");
|
||||||
input_dialog.setFileMode(QFileDialog::ExistingFile);
|
input_dialog.setFileMode(QFileDialog::ExistingFile);
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ void moveModels(SessionState& s, const QStringList& ids, const QString& parent_g
|
|||||||
void removeGroup(SessionState& s, QWidget& host, const QString& group_id);
|
void removeGroup(SessionState& s, QWidget& host, const QString& group_id);
|
||||||
void removeModel(SessionState& s, ViewportWindow& vp, QWidget& host, const QString& fed_id);
|
void removeModel(SessionState& s, ViewportWindow& vp, QWidget& host, const QString& fed_id);
|
||||||
void addModel(SessionState& s, QWidget& host);
|
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 convertIfcToDatabase(SessionState& s, QWidget& host);
|
||||||
void exportGeometryDatabase(SessionState& s, QWidget& host);
|
void exportGeometryDatabase(SessionState& s, QWidget& host);
|
||||||
void openSettings(SessionState& s, QWidget& host);
|
void openSettings(SessionState& s, QWidget& host);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ FederationItemModel::FederationItemModel(Federation* federation, QObject* parent
|
|||||||
connect(federation_, &Federation::modelRemoved, this, &FederationItemModel::onModelRemoved);
|
connect(federation_, &Federation::modelRemoved, this, &FederationItemModel::onModelRemoved);
|
||||||
connect(federation_, &Federation::modelVisibilityChanged, this, &FederationItemModel::onModelVisibilityChanged);
|
connect(federation_, &Federation::modelVisibilityChanged, this, &FederationItemModel::onModelVisibilityChanged);
|
||||||
connect(federation_, &Federation::modelGroupChanged, this, &FederationItemModel::onModelGroupChanged);
|
connect(federation_, &Federation::modelGroupChanged, this, &FederationItemModel::onModelGroupChanged);
|
||||||
|
connect(federation_, &Federation::modelChanged, this, &FederationItemModel::onModelChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FederationItemModel::rebuildAll() {
|
void FederationItemModel::rebuildAll() {
|
||||||
@@ -249,6 +250,14 @@ void FederationItemModel::onModelVisibilityChanged(const QString& fed_id, bool /
|
|||||||
refreshSubtreeVisibility(item);
|
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) {
|
void FederationItemModel::onModelGroupChanged(const QString& fed_id, const QString& new_group_id) {
|
||||||
QStandardItem* item = findItem(fed_id);
|
QStandardItem* item = findItem(fed_id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ private slots:
|
|||||||
void onModelRemoved(const QString& fed_id);
|
void onModelRemoved(const QString& fed_id);
|
||||||
void onModelVisibilityChanged(const QString& fed_id, bool visible);
|
void onModelVisibilityChanged(const QString& fed_id, bool visible);
|
||||||
void onModelGroupChanged(const QString& fed_id, const QString& new_group_id);
|
void onModelGroupChanged(const QString& fed_id, const QString& new_group_id);
|
||||||
|
void onModelChanged(const QString& fed_id);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const;
|
QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const;
|
||||||
|
|||||||
@@ -344,6 +344,27 @@ ModelsPanel::ModelsPanel(ifcviewerfull::SessionState* session_state,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Federation::Model* selected = session_state_->federation()->findById(id);
|
||||||
|
const bool has_cloud_source = selected && selected->source_connector != "local";
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
QAction* save_to_cloud = menu.addAction(
|
||||||
|
components::icons::makeSvgIcon(":/icons/cloud-square.svg"), "Save To Cloud");
|
||||||
|
save_to_cloud->setEnabled(has_cloud_source);
|
||||||
|
if (!has_cloud_source) {
|
||||||
|
save_to_cloud->setToolTip(
|
||||||
|
"This model has no cloud target yet. Use \"Save As To Cloud\" first.");
|
||||||
|
}
|
||||||
|
connect(save_to_cloud, &QAction::triggered, this, [this, id]() {
|
||||||
|
commands::saveModelToCloud(*session_state_, *this, id);
|
||||||
|
});
|
||||||
|
QAction* save_as_to_cloud = menu.addAction(
|
||||||
|
components::icons::makeSvgIcon(":/icons/cloud-square.svg"), "Save As To Cloud");
|
||||||
|
connect(save_as_to_cloud, &QAction::triggered, this, [this, id]() {
|
||||||
|
commands::saveModelAsToCloud(*session_state_, *this, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
QAction* remove = menu.addAction(
|
QAction* remove = menu.addAction(
|
||||||
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
|
components::icons::makeSvgIcon(":/icons/minus-square.svg"), "Remove Model");
|
||||||
connect(remove, &QAction::triggered, this, [this, id]() {
|
connect(remove, &QAction::triggered, this, [this, id]() {
|
||||||
|
|||||||
@@ -20,16 +20,31 @@
|
|||||||
|
|
||||||
#include "Commands.h"
|
#include "Commands.h"
|
||||||
|
|
||||||
|
#include "SaveProjectDialog.h"
|
||||||
|
|
||||||
#include "../../ElementRegistry.h"
|
#include "../../ElementRegistry.h"
|
||||||
#include "../../SessionState.h"
|
#include "../../SessionState.h"
|
||||||
|
#include "../connectors/PickerDialog.h"
|
||||||
|
#include "../connectors/Process.h"
|
||||||
|
#include "../connectors/Registry.h"
|
||||||
#include "../models/Commands.h"
|
#include "../models/Commands.h"
|
||||||
#include "../../../ifcviewer/Federation.h"
|
#include "../../../ifcviewer/Federation.h"
|
||||||
#include "../../../ifcviewer/SceneLoader.h"
|
#include "../../../ifcviewer/SceneLoader.h"
|
||||||
#include "../../../ifcviewer/ViewportWindow.h"
|
#include "../../../ifcviewer/ViewportWindow.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
namespace ifcviewerfull::modules::project::commands {
|
namespace ifcviewerfull::modules::project::commands {
|
||||||
|
|
||||||
@@ -63,6 +78,131 @@ bool confirmDiscardIfDirty(SessionState& s, QWidget& host) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget async resolution of any non-local models in the
|
||||||
|
// federation. Groups by source_connector and issues one pull_models per
|
||||||
|
// group. For each returned entry:
|
||||||
|
// - if the fed_id already has a scene entry pointed at the same path,
|
||||||
|
// just refresh cloud metadata (no reload, preserves view state);
|
||||||
|
// - if the path differs, tear down the stale scene entry and queue a
|
||||||
|
// fresh load (federation entry is preserved either way);
|
||||||
|
// - if no scene entry exists yet (initial open), queue a load.
|
||||||
|
// Per spec, connector errors are not surfaced to the user; the connector
|
||||||
|
// has already shown its own UI.
|
||||||
|
void resolveCloudModels(SessionState& s, ViewportWindow& vp) {
|
||||||
|
auto* fed = s.federation();
|
||||||
|
QHash<QString, QStringList> connector_to_fed_ids;
|
||||||
|
for (const auto& m : fed->models()) {
|
||||||
|
if (m.source_connector == "local") continue;
|
||||||
|
connector_to_fed_ids[m.source_connector].push_back(m.id);
|
||||||
|
}
|
||||||
|
if (connector_to_fed_ids.isEmpty()) return;
|
||||||
|
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
QPointer<ViewportWindow> vguard(&vp);
|
||||||
|
|
||||||
|
for (auto it = connector_to_fed_ids.constBegin();
|
||||||
|
it != connector_to_fed_ids.constEnd(); ++it) {
|
||||||
|
const QString connector_id = it.key();
|
||||||
|
const QStringList fed_ids = it.value();
|
||||||
|
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
qWarning() << "resolveCloudModels: cannot launch connector"
|
||||||
|
<< connector_id << ":" << registry->lastError();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray params;
|
||||||
|
for (const QString& fed_id : fed_ids) {
|
||||||
|
const Federation::Model* m = fed->findById(fed_id);
|
||||||
|
if (!m) continue;
|
||||||
|
QJsonObject source = m->source_data;
|
||||||
|
source["connector"] = m->source_connector;
|
||||||
|
QJsonObject entry;
|
||||||
|
entry["display_name"] = m->display_name;
|
||||||
|
entry["id"] = m->id;
|
||||||
|
entry["source"] = source;
|
||||||
|
params.append(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
proc->call("pull_models", params,
|
||||||
|
[sguard, vguard, fed_ids](const QJsonValue& result) {
|
||||||
|
if (!sguard) return;
|
||||||
|
const QJsonArray arr = result.toArray();
|
||||||
|
QStringList paths_to_load;
|
||||||
|
QStringList fed_ids_to_load;
|
||||||
|
bool any_detached = false;
|
||||||
|
for (int i = 0; i < arr.size() && i < fed_ids.size(); ++i) {
|
||||||
|
if (arr[i].isNull()) continue;
|
||||||
|
const QJsonObject obj = arr[i].toObject();
|
||||||
|
const QString new_path = obj.value("path").toString();
|
||||||
|
if (new_path.isEmpty()) continue;
|
||||||
|
const QString fed_id = fed_ids[i];
|
||||||
|
const QJsonObject meta = obj.value("metadata").toObject();
|
||||||
|
|
||||||
|
const uint32_t existing_mid = sguard->modelIdForFedId(fed_id);
|
||||||
|
if (existing_mid != 0 && sguard->loader()) {
|
||||||
|
const QString existing_path = sguard->loader()->filePath(existing_mid);
|
||||||
|
if (QDir::cleanPath(existing_path) == QDir::cleanPath(new_path)) {
|
||||||
|
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Path changed (new revision lives in a fresh cache dir).
|
||||||
|
// Detach the stale scene entry; federation entry stays.
|
||||||
|
if (vguard) vguard->removeModel(existing_mid);
|
||||||
|
sguard->loader()->removeModel(existing_mid);
|
||||||
|
sguard->elementRegistry()->removeModel(existing_mid);
|
||||||
|
sguard->removeModelMappingByFedId(fed_id);
|
||||||
|
any_detached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sguard->setCloudMetadata(fed_id, meta.toVariantMap());
|
||||||
|
paths_to_load << new_path;
|
||||||
|
fed_ids_to_load << fed_id;
|
||||||
|
}
|
||||||
|
if (any_detached) {
|
||||||
|
if (vguard) vguard->setSelectedObjectId(0);
|
||||||
|
sguard->setSelectedObjectId(0);
|
||||||
|
sguard->notifySelectionChanged();
|
||||||
|
}
|
||||||
|
if (!paths_to_load.isEmpty()) {
|
||||||
|
modules::models::commands::detail::loadModels(
|
||||||
|
*sguard, paths_to_load, fed_ids_to_load);
|
||||||
|
sguard->notifyModelsChanged();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sguard, connector_id](int code, const QString& message) {
|
||||||
|
qWarning() << "pull_models from" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
int total = 0;
|
||||||
|
for (auto it = connector_to_fed_ids.constBegin();
|
||||||
|
it != connector_to_fed_ids.constEnd(); ++it) {
|
||||||
|
total += it.value().size();
|
||||||
|
}
|
||||||
|
s.setStatusMessage("Cloud", QString("Resolving %1 model(s)...").arg(total));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte-equality check for "is this .ifcfed the same as what we have loaded?"
|
||||||
|
// — used by Sync From Cloud to skip the full reload when the connector
|
||||||
|
// served an unchanged revision.
|
||||||
|
bool isIfcfedUnchanged(const QString& current_path, const QString& candidate_path) {
|
||||||
|
if (current_path.isEmpty() || candidate_path.isEmpty()) return false;
|
||||||
|
if (QDir::cleanPath(current_path) == QDir::cleanPath(candidate_path)) return true;
|
||||||
|
QFile a(current_path);
|
||||||
|
QFile b(candidate_path);
|
||||||
|
if (!a.open(QIODevice::ReadOnly) || !b.open(QIODevice::ReadOnly)) return false;
|
||||||
|
if (a.size() != b.size()) return false;
|
||||||
|
return a.readAll() == b.readAll();
|
||||||
|
}
|
||||||
|
|
||||||
bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QString& path) {
|
bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QString& path) {
|
||||||
SceneLoader* loader = s.loader();
|
SceneLoader* loader = s.loader();
|
||||||
if (loader && loader->isLoading()) {
|
if (loader && loader->isLoading()) {
|
||||||
@@ -86,7 +226,7 @@ bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QSt
|
|||||||
QStringList paths;
|
QStringList paths;
|
||||||
QStringList fed_ids;
|
QStringList fed_ids;
|
||||||
for (const auto& model : s.federation()->models()) {
|
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)) {
|
if (!QFileInfo::exists(model.source_path)) {
|
||||||
warnings << QString("Source not found, kept in project: %1").arg(model.source_path);
|
warnings << QString("Source not found, kept in project: %1").arg(model.source_path);
|
||||||
continue;
|
continue;
|
||||||
@@ -109,6 +249,8 @@ bool openProjectAt(SessionState& s, QWidget& host, ViewportWindow& vp, const QSt
|
|||||||
}
|
}
|
||||||
s.setStatusMessage("Project", QFileInfo(path).fileName());
|
s.setStatusMessage("Project", QFileInfo(path).fileName());
|
||||||
s.notifyProjectOpened(path);
|
s.notifyProjectOpened(path);
|
||||||
|
|
||||||
|
resolveCloudModels(s, vp);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +297,179 @@ bool openProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
|
|||||||
return openProjectAt(s, host, vp, path);
|
return openProjectAt(s, host, vp, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool openCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
|
||||||
|
SceneLoader* loader = s.loader();
|
||||||
|
if (loader && loader->isLoading()) {
|
||||||
|
QMessageBox::information(
|
||||||
|
&host, "Open from Cloud",
|
||||||
|
"Wait until the current model load finishes before opening another project.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmDiscardIfDirty(s, host)) return false;
|
||||||
|
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
const auto& manifests = registry->available();
|
||||||
|
if (manifests.empty()) {
|
||||||
|
QMessageBox::information(&host, "Open from Cloud",
|
||||||
|
"No connectors are installed. Install one under your user connectors "
|
||||||
|
"directory.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
modules::connectors::ConnectorPickerDialog picker(
|
||||||
|
manifests, "Open from Cloud",
|
||||||
|
"Pick a connector to browse a project on.", &host);
|
||||||
|
if (picker.exec() != QDialog::Accepted) return false;
|
||||||
|
const QString connector_id = picker.selectedId();
|
||||||
|
if (connector_id.isEmpty()) return false;
|
||||||
|
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Open from Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.beginProgress(QString("Opening project from %1...").arg(connector_id));
|
||||||
|
s.setStatusMessage("Cloud", QString("Browsing %1...").arg(connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
QPointer<QWidget> hguard(&host);
|
||||||
|
QPointer<ViewportWindow> vguard(&vp);
|
||||||
|
|
||||||
|
proc->call("pull_ifcfed_interactive", QJsonValue(),
|
||||||
|
[sguard, hguard, vguard, connector_id](const QJsonValue& result) {
|
||||||
|
if (!sguard) return;
|
||||||
|
sguard->endProgress();
|
||||||
|
const QString path = result.toObject().value("path").toString();
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
if (hguard) {
|
||||||
|
QMessageBox::warning(hguard, "Open from Cloud",
|
||||||
|
QString("Connector '%1' returned no path.").arg(connector_id));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hguard && vguard) {
|
||||||
|
openProjectAt(*sguard, *hguard, *vguard, path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sguard, connector_id](int code, const QString& message) {
|
||||||
|
qWarning() << "pull_ifcfed_interactive from" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->endProgress();
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool syncCloudProject(SessionState& s, QWidget& host, ViewportWindow& vp) {
|
||||||
|
auto* fed = s.federation();
|
||||||
|
|
||||||
|
// Per spec, sync has two independent phases — refreshing the .ifcfed
|
||||||
|
// (requires manifest) and refreshing cloud models (requires any
|
||||||
|
// non-local source). Either is sufficient.
|
||||||
|
const bool has_manifest = fed->hasManifest();
|
||||||
|
bool has_cloud_models = false;
|
||||||
|
for (const auto& m : fed->models()) {
|
||||||
|
if (m.source_connector != "local") { has_cloud_models = true; break; }
|
||||||
|
}
|
||||||
|
if (!has_manifest && !has_cloud_models) {
|
||||||
|
QMessageBox::information(&host, "Sync From Cloud",
|
||||||
|
"Nothing to sync — this project has no cloud resources.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
SceneLoader* loader = s.loader();
|
||||||
|
if (loader && loader->isLoading()) {
|
||||||
|
QMessageBox::information(&host, "Sync From Cloud",
|
||||||
|
"Wait until the current model load finishes before syncing.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No manifest: skip the pull_ifcfed phase entirely (spec step 2-4
|
||||||
|
// skipped). Just refresh cloud-sourced models against the .ifcfed
|
||||||
|
// already on disk. Federation state is preserved, so no dirty prompt.
|
||||||
|
if (!has_manifest) {
|
||||||
|
s.setStatusMessage("Cloud", "Refreshing cloud models...");
|
||||||
|
resolveCloudModels(s, vp);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest path: the .ifcfed itself may be replaced. Confirm dirty —
|
||||||
|
// even though we'll attempt to preserve the session if the returned
|
||||||
|
// .ifcfed is byte-equal, that's not known until after the round-trip.
|
||||||
|
if (!confirmDiscardIfDirty(s, host)) return false;
|
||||||
|
|
||||||
|
const QString connector_id = fed->manifestConnectorId();
|
||||||
|
if (connector_id.isEmpty()) {
|
||||||
|
QMessageBox::warning(&host, "Sync From Cloud",
|
||||||
|
"The project's manifest does not name a connector.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Sync From Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.beginProgress(QString("Syncing from %1...").arg(connector_id));
|
||||||
|
s.setStatusMessage("Cloud", QString("Syncing from %1...").arg(connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
QPointer<QWidget> hguard(&host);
|
||||||
|
QPointer<ViewportWindow> vguard(&vp);
|
||||||
|
const QString current_path = fed->filePath();
|
||||||
|
|
||||||
|
proc->call("pull_ifcfed", fed->manifest(),
|
||||||
|
[sguard, hguard, vguard, connector_id, current_path](const QJsonValue& result) {
|
||||||
|
if (!sguard) return;
|
||||||
|
sguard->endProgress();
|
||||||
|
const QString new_path = result.toObject().value("path").toString();
|
||||||
|
if (new_path.isEmpty()) {
|
||||||
|
if (hguard) {
|
||||||
|
QMessageBox::warning(hguard, "Sync From Cloud",
|
||||||
|
QString("Connector '%1' returned no path.").arg(connector_id));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Per spec: if the returned .ifcfed is unchanged from the one
|
||||||
|
// already loaded, preserve the current session — just repoint
|
||||||
|
// if the cache path moved and resync models.
|
||||||
|
if (isIfcfedUnchanged(current_path, new_path)) {
|
||||||
|
if (QDir::cleanPath(current_path) != QDir::cleanPath(new_path)) {
|
||||||
|
sguard->federation()->repointTo(new_path);
|
||||||
|
}
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
"Project up to date; refreshing cloud models...");
|
||||||
|
if (vguard) resolveCloudModels(*sguard, *vguard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Content differs — fall through to the standard open path,
|
||||||
|
// which clears the scene and triggers resolveCloudModels for
|
||||||
|
// any cloud-sourced models in the freshly loaded federation.
|
||||||
|
if (hguard && vguard) {
|
||||||
|
openProjectAt(*sguard, *hguard, *vguard, new_path);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sguard, connector_id](int code, const QString& message) {
|
||||||
|
qWarning() << "pull_ifcfed from" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->endProgress();
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool saveProject(SessionState& s, QWidget& host) {
|
bool saveProject(SessionState& s, QWidget& host) {
|
||||||
if (s.federation()->filePath().isEmpty()) return saveProjectAs(s, host);
|
if (s.federation()->filePath().isEmpty()) return saveProjectAs(s, host);
|
||||||
return saveProjectTo(s, host, s.federation()->filePath());
|
return saveProjectTo(s, host, s.federation()->filePath());
|
||||||
@@ -177,4 +492,180 @@ bool saveProjectAs(SessionState& s, QWidget& host) {
|
|||||||
return saveProjectTo(s, host, path);
|
return saveProjectTo(s, host, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Writes the current federation to a fresh QTemporaryDir as a `.ifcfed`
|
||||||
|
// without touching Federation's state, returning the temp dir (so callers
|
||||||
|
// can keep it alive through the async RPC) and the resolved file path.
|
||||||
|
struct TempProjectFile {
|
||||||
|
std::shared_ptr<QTemporaryDir> dir;
|
||||||
|
QString path;
|
||||||
|
};
|
||||||
|
|
||||||
|
TempProjectFile writeProjectToTemp(SessionState& s, QWidget& host, const QString& op_title) {
|
||||||
|
TempProjectFile out;
|
||||||
|
out.dir = std::make_shared<QTemporaryDir>();
|
||||||
|
if (!out.dir->isValid()) {
|
||||||
|
QMessageBox::warning(&host, op_title,
|
||||||
|
QString("Could not create a temporary directory:\n%1")
|
||||||
|
.arg(out.dir->errorString()));
|
||||||
|
out.dir.reset();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const QString name = s.federation()->filePath().isEmpty()
|
||||||
|
? "project.ifcfed"
|
||||||
|
: QFileInfo(s.federation()->filePath()).fileName();
|
||||||
|
const QString tmp_path = QDir(out.dir->path()).filePath(name);
|
||||||
|
QString err;
|
||||||
|
if (!s.federation()->writeCopyTo(tmp_path, &err)) {
|
||||||
|
QMessageBox::warning(&host, op_title,
|
||||||
|
QString("Failed to write temporary project:\n%1").arg(err));
|
||||||
|
out.dir.reset();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.path = tmp_path;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared continuation for push_ifcfed[_interactive]: on success, repoint
|
||||||
|
// Federation to the returned path and notify; on error, log + status.
|
||||||
|
void onPushIfcfedResult(SessionState& s,
|
||||||
|
QWidget& host,
|
||||||
|
const QString& op_title,
|
||||||
|
const QString& connector_id,
|
||||||
|
const QJsonValue& result) {
|
||||||
|
s.endProgress();
|
||||||
|
const QString new_path = result.toObject().value("path").toString();
|
||||||
|
if (new_path.isEmpty()) {
|
||||||
|
QMessageBox::warning(&host, op_title,
|
||||||
|
QString("Connector '%1' returned no path.").arg(connector_id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QStringList warnings;
|
||||||
|
s.federation()->repointTo(new_path, &warnings);
|
||||||
|
s.setStatusMessage("Cloud",
|
||||||
|
QString("Saved to %1 via %2")
|
||||||
|
.arg(QFileInfo(new_path).fileName(), connector_id));
|
||||||
|
s.notifyProjectSaved(new_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool saveCloudProject(SessionState& s, QWidget& host) {
|
||||||
|
auto* fed = s.federation();
|
||||||
|
if (!fed->hasManifest()) {
|
||||||
|
QMessageBox::information(&host, "Save To Cloud",
|
||||||
|
"This project has no cloud target. Use \"Save As To Cloud\" first.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QString connector_id = fed->manifestConnectorId();
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Save To Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto tmp = writeProjectToTemp(s, host, "Save To Cloud");
|
||||||
|
if (!tmp.dir) return false;
|
||||||
|
|
||||||
|
QJsonObject params;
|
||||||
|
params["path"] = tmp.path;
|
||||||
|
params["manifest"] = fed->manifest();
|
||||||
|
|
||||||
|
s.beginProgress(QString("Saving to %1...").arg(connector_id));
|
||||||
|
s.setStatusMessage("Cloud", QString("Saving to %1...").arg(connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
QPointer<QWidget> hguard(&host);
|
||||||
|
|
||||||
|
proc->call("push_ifcfed", params,
|
||||||
|
[sguard, hguard, connector_id, tmp_keepalive = tmp.dir](const QJsonValue& result) {
|
||||||
|
(void)tmp_keepalive;
|
||||||
|
if (!sguard || !hguard) return;
|
||||||
|
onPushIfcfedResult(*sguard, *hguard, "Save To Cloud", connector_id, result);
|
||||||
|
},
|
||||||
|
[sguard, connector_id, tmp_keepalive = tmp.dir](int code, const QString& message) {
|
||||||
|
(void)tmp_keepalive;
|
||||||
|
qWarning() << "push_ifcfed to" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->endProgress();
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveAsCloudProject(SessionState& s, QWidget& host) {
|
||||||
|
auto* registry = s.connectorRegistry();
|
||||||
|
const auto& manifests = registry->available();
|
||||||
|
if (manifests.empty()) {
|
||||||
|
QMessageBox::information(&host, "Save As To Cloud",
|
||||||
|
"No connectors are installed.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
modules::connectors::ConnectorPickerDialog picker(
|
||||||
|
manifests, "Save As To Cloud",
|
||||||
|
"Pick a connector to push this project to.", &host);
|
||||||
|
if (picker.exec() != QDialog::Accepted) return false;
|
||||||
|
const QString connector_id = picker.selectedId();
|
||||||
|
if (connector_id.isEmpty()) return false;
|
||||||
|
|
||||||
|
auto* proc = registry->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(&host, "Save As To Cloud",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id, registry->lastError()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto tmp = writeProjectToTemp(s, host, "Save As To Cloud");
|
||||||
|
if (!tmp.dir) return false;
|
||||||
|
|
||||||
|
QJsonObject params;
|
||||||
|
params["path"] = tmp.path;
|
||||||
|
|
||||||
|
s.beginProgress(QString("Pushing to %1...").arg(connector_id));
|
||||||
|
s.setStatusMessage("Cloud", QString("Pushing to %1...").arg(connector_id));
|
||||||
|
|
||||||
|
QPointer<SessionState> sguard(&s);
|
||||||
|
QPointer<QWidget> hguard(&host);
|
||||||
|
|
||||||
|
proc->call("push_ifcfed_interactive", params,
|
||||||
|
[sguard, hguard, connector_id, tmp_keepalive = tmp.dir](const QJsonValue& result) {
|
||||||
|
(void)tmp_keepalive;
|
||||||
|
if (!sguard || !hguard) return;
|
||||||
|
onPushIfcfedResult(*sguard, *hguard, "Save As To Cloud", connector_id, result);
|
||||||
|
},
|
||||||
|
[sguard, connector_id, tmp_keepalive = tmp.dir](int code, const QString& message) {
|
||||||
|
(void)tmp_keepalive;
|
||||||
|
qWarning() << "push_ifcfed_interactive to" << connector_id
|
||||||
|
<< "failed:" << code << message;
|
||||||
|
if (sguard) {
|
||||||
|
sguard->endProgress();
|
||||||
|
sguard->setStatusMessage("Cloud",
|
||||||
|
QString("%1 reported an error (see connector UI)").arg(connector_id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveProjectDialog(SessionState& s, QWidget& host) {
|
||||||
|
SaveProjectDialog dialog(s.federation()->hasManifest(), &host);
|
||||||
|
if (dialog.exec() != QDialog::Accepted) return false;
|
||||||
|
switch (dialog.selectedTarget()) {
|
||||||
|
case SaveTarget::Local: return saveProject(s, host);
|
||||||
|
case SaveTarget::LocalAs: return saveProjectAs(s, host);
|
||||||
|
case SaveTarget::Cloud: return saveCloudProject(s, host);
|
||||||
|
case SaveTarget::CloudAs: return saveAsCloudProject(s, host);
|
||||||
|
case SaveTarget::None: return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ifcviewerfull::modules::project::commands
|
} // namespace ifcviewerfull::modules::project::commands
|
||||||
|
|||||||
@@ -34,8 +34,29 @@ namespace ifcviewerfull::modules::project::commands {
|
|||||||
// projectSaved) so views refresh once per command.
|
// projectSaved) so views refresh once per command.
|
||||||
bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp);
|
bool newProject(SessionState& s, QWidget& host, ViewportWindow& vp);
|
||||||
bool openProject(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 saveProject(SessionState& s, QWidget& host);
|
||||||
bool saveProjectAs(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
|
} // namespace ifcviewerfull::modules::project::commands
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "SaveProjectDialog.h"
|
||||||
|
|
||||||
|
#include "../../components/Buttons.h"
|
||||||
|
#include "../../components/Section.h"
|
||||||
|
#include "../../components/Style.h"
|
||||||
|
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::project {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
class HoverDescriptionFilter : public QObject {
|
||||||
|
public:
|
||||||
|
HoverDescriptionFilter(QLabel* label, QString hover_text, QString default_text)
|
||||||
|
: label_(label), hover_text_(std::move(hover_text)), default_text_(std::move(default_text)) {}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool eventFilter(QObject*, QEvent* event) override {
|
||||||
|
if (event->type() == QEvent::Enter) label_->setText(hover_text_);
|
||||||
|
else if (event->type() == QEvent::Leave) label_->setText(default_text_);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QLabel* label_ = nullptr;
|
||||||
|
QString hover_text_;
|
||||||
|
QString default_text_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SaveProjectDialog::SaveProjectDialog(bool has_manifest, QWidget* parent)
|
||||||
|
: components::Dialog(parent)
|
||||||
|
{
|
||||||
|
setObjectName("appDialog");
|
||||||
|
setWindowTitle("Save Project");
|
||||||
|
setModal(true);
|
||||||
|
setupUi(has_manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SaveProjectDialog::setupUi(bool has_manifest) {
|
||||||
|
if (auto* root = qobject_cast<QVBoxLayout*>(layout())) {
|
||||||
|
root->setSizeConstraint(QLayout::SetFixedSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString default_description = "Choose where to save this project";
|
||||||
|
auto* description_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
|
||||||
|
auto* description = new QLabel(default_description, description_section);
|
||||||
|
description->setProperty("textRole", "secondary");
|
||||||
|
description->setWordWrap(true);
|
||||||
|
description->setAlignment(Qt::AlignCenter);
|
||||||
|
description->setMinimumWidth((90 * 4) + (components::style::metrics::padding * 3));
|
||||||
|
description->setMinimumHeight(description->fontMetrics().lineSpacing() * 2 + 4);
|
||||||
|
description_section->addBodyWidget(description);
|
||||||
|
|
||||||
|
auto* choices_section = new components::Section("", components::SectionHeaderMode::Hidden, this);
|
||||||
|
auto* choices = new QWidget(choices_section);
|
||||||
|
auto* row = new QHBoxLayout(choices);
|
||||||
|
row->setContentsMargins(0, 0, 0, 0);
|
||||||
|
row->setSpacing(components::style::metrics::padding);
|
||||||
|
|
||||||
|
auto* save_local = components::buttons::makeButton(
|
||||||
|
"Save Local", ":/icons/floppy-disk.svg", choices);
|
||||||
|
connect(save_local, &QToolButton::clicked, this, [this]() {
|
||||||
|
selected_target_ = SaveTarget::Local;
|
||||||
|
accept();
|
||||||
|
});
|
||||||
|
save_local->installEventFilter(new HoverDescriptionFilter(
|
||||||
|
description,
|
||||||
|
"Save to the project's current file on disk (prompts for a path if none).",
|
||||||
|
default_description));
|
||||||
|
|
||||||
|
auto* save_as_local = components::buttons::makeButton(
|
||||||
|
"Save As\nLocal", ":/icons/floppy-disk-arrow-in.svg", choices);
|
||||||
|
connect(save_as_local, &QToolButton::clicked, this, [this]() {
|
||||||
|
selected_target_ = SaveTarget::LocalAs;
|
||||||
|
accept();
|
||||||
|
});
|
||||||
|
save_as_local->installEventFilter(new HoverDescriptionFilter(
|
||||||
|
description,
|
||||||
|
"Save the project to a new file on disk.",
|
||||||
|
default_description));
|
||||||
|
|
||||||
|
auto* save_cloud = components::buttons::makeButton(
|
||||||
|
"Save To\nCloud", ":/icons/cloud-square.svg", choices);
|
||||||
|
save_cloud->setEnabled(has_manifest);
|
||||||
|
if (!has_manifest) {
|
||||||
|
save_cloud->setToolTip(
|
||||||
|
"This project has no cloud target yet. Use \"Save As To Cloud\" first.");
|
||||||
|
}
|
||||||
|
connect(save_cloud, &QToolButton::clicked, this, [this]() {
|
||||||
|
selected_target_ = SaveTarget::Cloud;
|
||||||
|
accept();
|
||||||
|
});
|
||||||
|
save_cloud->installEventFilter(new HoverDescriptionFilter(
|
||||||
|
description,
|
||||||
|
has_manifest
|
||||||
|
? "Push back to the cloud location this project came from."
|
||||||
|
: "Disabled until this project has a cloud target (use Save As To Cloud).",
|
||||||
|
default_description));
|
||||||
|
|
||||||
|
auto* save_as_cloud = components::buttons::makeButton(
|
||||||
|
"Save As\nTo Cloud", ":/icons/cloud-square.svg", choices);
|
||||||
|
connect(save_as_cloud, &QToolButton::clicked, this, [this]() {
|
||||||
|
selected_target_ = SaveTarget::CloudAs;
|
||||||
|
accept();
|
||||||
|
});
|
||||||
|
save_as_cloud->installEventFilter(new HoverDescriptionFilter(
|
||||||
|
description,
|
||||||
|
"Pick a connector and push this project to a fresh cloud location.",
|
||||||
|
default_description));
|
||||||
|
|
||||||
|
row->addWidget(components::buttons::makeButtonGroup(
|
||||||
|
"LOCAL", {save_local, save_as_local}, choices, true, 8));
|
||||||
|
row->addWidget(components::buttons::makeButtonGroup(
|
||||||
|
"CLOUD", {save_cloud, save_as_cloud}, choices, false, 8));
|
||||||
|
choices_section->addBodyWidget(choices);
|
||||||
|
|
||||||
|
addBodyWidget(description_section);
|
||||||
|
addBodyWidget(choices_section);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::project
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// This file was generated with the assistance of an AI coding tool.
|
||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#ifndef IFCINTERFACE_MODULES_PROJECT_SAVEPROJECTDIALOG_H
|
||||||
|
#define IFCINTERFACE_MODULES_PROJECT_SAVEPROJECTDIALOG_H
|
||||||
|
|
||||||
|
#include "../../components/Dialog.h"
|
||||||
|
|
||||||
|
namespace ifcviewerfull::modules::project {
|
||||||
|
|
||||||
|
enum class SaveTarget {
|
||||||
|
None,
|
||||||
|
Local, // saveProject: write to current file_path_ (or fall through to LocalAs)
|
||||||
|
LocalAs, // saveProjectAs: pick file with QFileDialog
|
||||||
|
Cloud, // saveCloudProject: push_ifcfed using existing manifest
|
||||||
|
CloudAs, // saveAsCloudProject: connector picker + push_ifcfed_interactive
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Save Project" dispatch dialog, modelled on AddModelDialog. Always shows
|
||||||
|
// all four buttons; "Save to Cloud" is disabled when the project has no
|
||||||
|
// .ifcfed.manifest (there is no cloud target to push to).
|
||||||
|
class SaveProjectDialog : public components::Dialog {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit SaveProjectDialog(bool has_manifest, QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
SaveTarget selectedTarget() const { return selected_target_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setupUi(bool has_manifest);
|
||||||
|
|
||||||
|
SaveTarget selected_target_ = SaveTarget::None;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ifcviewerfull::modules::project
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -20,12 +20,15 @@
|
|||||||
|
|
||||||
#include "Dialog.h"
|
#include "Dialog.h"
|
||||||
|
|
||||||
|
#include "../../SessionState.h"
|
||||||
#include "../../ViewerSettings.h"
|
#include "../../ViewerSettings.h"
|
||||||
#include "../../../ifcviewer/AppSettings.h"
|
#include "../../../ifcviewer/AppSettings.h"
|
||||||
#include "../../components/Dialog.h"
|
#include "../../components/Dialog.h"
|
||||||
#include "../../components/Section.h"
|
#include "../../components/Section.h"
|
||||||
#include "../../components/SvgIcon.h"
|
#include "../../components/SvgIcon.h"
|
||||||
#include "../../components/Style.h"
|
#include "../../components/Style.h"
|
||||||
|
#include "../connectors/Process.h"
|
||||||
|
#include "../connectors/Registry.h"
|
||||||
|
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
#include <QColorDialog>
|
#include <QColorDialog>
|
||||||
@@ -35,17 +38,22 @@
|
|||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QGridLayout>
|
#include <QGridLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPointer>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QShowEvent>
|
#include <QShowEvent>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
|
#include <QToolButton>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
namespace ifcviewerfull::modules::settings {
|
namespace ifcviewerfull::modules::settings {
|
||||||
|
|
||||||
SettingsDialog::SettingsDialog(QWidget* parent)
|
SettingsDialog::SettingsDialog(ifcviewerfull::SessionState* session_state, QWidget* parent)
|
||||||
: components::TabbedDialog(parent)
|
: components::TabbedDialog(parent)
|
||||||
|
, session_state_(session_state)
|
||||||
{
|
{
|
||||||
setObjectName("appDialog");
|
setObjectName("appDialog");
|
||||||
setWindowTitle("Settings");
|
setWindowTitle("Settings");
|
||||||
@@ -268,6 +276,7 @@ void SettingsDialog::setupUi() {
|
|||||||
addTab("Interface", interface_tab);
|
addTab("Interface", interface_tab);
|
||||||
addTab("Keybindings", make_placeholder_tab("Keybindings", "Shortcut presets and command bindings will live here."));
|
addTab("Keybindings", make_placeholder_tab("Keybindings", "Shortcut presets and command bindings will live here."));
|
||||||
addTab("Graphics", graphics_tab);
|
addTab("Graphics", graphics_tab);
|
||||||
|
addTab("Connectors", buildConnectorsTab());
|
||||||
addTab("About", make_placeholder_tab("About", "Version, credits, and environment information will live here."));
|
addTab("About", make_placeholder_tab("About", "Version, credits, and environment information will live here."));
|
||||||
|
|
||||||
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||||
@@ -354,4 +363,92 @@ void SettingsDialog::onAccepted() {
|
|||||||
accept();
|
accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QWidget* SettingsDialog::buildConnectorsTab() {
|
||||||
|
auto* tab = new QWidget(this);
|
||||||
|
auto* layout = new QVBoxLayout(tab);
|
||||||
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
layout->setSpacing(components::style::metrics::padding);
|
||||||
|
layout->setAlignment(Qt::AlignTop);
|
||||||
|
|
||||||
|
auto* section = new components::Section(
|
||||||
|
"Installed Connectors", components::SectionHeaderMode::Visible, tab);
|
||||||
|
auto* body = new QWidget(section);
|
||||||
|
auto* body_layout = new QVBoxLayout(body);
|
||||||
|
body_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
body_layout->setSpacing(8);
|
||||||
|
|
||||||
|
const auto& manifests = session_state_
|
||||||
|
? session_state_->connectorRegistry()->available()
|
||||||
|
: std::vector<connectors::ConnectorManifest>{};
|
||||||
|
|
||||||
|
if (manifests.empty()) {
|
||||||
|
auto* empty = new QLabel(
|
||||||
|
"No connectors found. Install one under your user connectors directory.",
|
||||||
|
body);
|
||||||
|
empty->setProperty("textRole", "secondary");
|
||||||
|
empty->setWordWrap(true);
|
||||||
|
body_layout->addWidget(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& m : manifests) {
|
||||||
|
auto* row = new QWidget(body);
|
||||||
|
auto* row_layout = new QHBoxLayout(row);
|
||||||
|
row_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
row_layout->setSpacing(8);
|
||||||
|
|
||||||
|
auto* name = new QLabel(m.name, row);
|
||||||
|
auto* version = new QLabel(
|
||||||
|
m.version.isEmpty() ? QString() : QString("v%1").arg(m.version), row);
|
||||||
|
version->setProperty("textRole", "secondary");
|
||||||
|
|
||||||
|
auto* settings_button = new QPushButton("Settings…", row);
|
||||||
|
settings_button->setIcon(components::icons::makeSvgIcon(":/icons/settings.svg"));
|
||||||
|
const QString connector_id = m.id;
|
||||||
|
connect(settings_button, &QPushButton::clicked, this,
|
||||||
|
[this, connector_id, settings_button]() {
|
||||||
|
if (!session_state_) return;
|
||||||
|
auto* proc = session_state_->connectorRegistry()->get(connector_id);
|
||||||
|
if (!proc) {
|
||||||
|
QMessageBox::warning(this, "Connector",
|
||||||
|
QString("Could not launch connector '%1':\n%2")
|
||||||
|
.arg(connector_id,
|
||||||
|
session_state_->connectorRegistry()->lastError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings_button->setEnabled(false);
|
||||||
|
QPointer<QPushButton> guard(settings_button);
|
||||||
|
QPointer<SettingsDialog> self(this);
|
||||||
|
proc->call("open_settings", QJsonValue(),
|
||||||
|
[guard](const QJsonValue&) {
|
||||||
|
if (guard) guard->setEnabled(true);
|
||||||
|
},
|
||||||
|
[self, guard, connector_id](int code, const QString& message) {
|
||||||
|
if (guard) guard->setEnabled(true);
|
||||||
|
if (!self) return;
|
||||||
|
// -32601 = JSON-RPC "Method not found": connector opted
|
||||||
|
// out of the optional open_settings handler per spec.
|
||||||
|
if (code == -32601) {
|
||||||
|
QMessageBox::information(self, "Connector",
|
||||||
|
"Settings not available.");
|
||||||
|
} else {
|
||||||
|
QMessageBox::warning(self, "Connector",
|
||||||
|
QString("Connector '%1' failed to open settings:\n%2")
|
||||||
|
.arg(connector_id, message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
row_layout->addWidget(name);
|
||||||
|
row_layout->addWidget(version);
|
||||||
|
row_layout->addStretch(1);
|
||||||
|
row_layout->addWidget(settings_button);
|
||||||
|
body_layout->addWidget(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
section->addBodyWidget(body);
|
||||||
|
layout->addWidget(section);
|
||||||
|
layout->addStretch(1);
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ifcviewerfull::modules::settings
|
} // namespace ifcviewerfull::modules::settings
|
||||||
|
|||||||
@@ -34,24 +34,30 @@ class QShowEvent;
|
|||||||
class QSpinBox;
|
class QSpinBox;
|
||||||
class QWidget;
|
class QWidget;
|
||||||
|
|
||||||
|
namespace ifcviewerfull { class SessionState; }
|
||||||
|
|
||||||
namespace ifcviewerfull::modules::settings {
|
namespace ifcviewerfull::modules::settings {
|
||||||
|
|
||||||
class SettingsDialog : public components::TabbedDialog {
|
class SettingsDialog : public components::TabbedDialog {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
explicit SettingsDialog(QWidget* parent = nullptr);
|
explicit SettingsDialog(ifcviewerfull::SessionState* session_state,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void showEvent(QShowEvent* event) override;
|
void showEvent(QShowEvent* event) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setupUi();
|
void setupUi();
|
||||||
|
QWidget* buildConnectorsTab();
|
||||||
void syncFromSettings();
|
void syncFromSettings();
|
||||||
void syncThemeSettings();
|
void syncThemeSettings();
|
||||||
void updateThemeEditorEnabled();
|
void updateThemeEditorEnabled();
|
||||||
void pickThemeColor(QLineEdit* edit);
|
void pickThemeColor(QLineEdit* edit);
|
||||||
void onAccepted();
|
void onAccepted();
|
||||||
|
|
||||||
|
ifcviewerfull::SessionState* session_state_ = nullptr;
|
||||||
|
|
||||||
struct ThemeColorEditor {
|
struct ThemeColorEditor {
|
||||||
QString key;
|
QString key;
|
||||||
QLineEdit* edit = nullptr;
|
QLineEdit* edit = nullptr;
|
||||||
|
|||||||
+162
-31
@@ -216,6 +216,8 @@ void Federation::clear() {
|
|||||||
federated_false_origin_ = FederatedFalseOrigin{};
|
federated_false_origin_ = FederatedFalseOrigin{};
|
||||||
has_home_view_ = false;
|
has_home_view_ = false;
|
||||||
home_view_ = HomeView{};
|
home_view_ = HomeView{};
|
||||||
|
has_manifest_ = false;
|
||||||
|
manifest_ = QJsonObject{};
|
||||||
setDirty(false);
|
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,
|
QString Federation::addGroup(const QString& display_name,
|
||||||
const QString& parent_id) {
|
const QString& parent_id) {
|
||||||
Group* parent = nullptr;
|
Group* parent = nullptr;
|
||||||
@@ -471,7 +513,7 @@ QString Federation::addModel(const QString& source_path,
|
|||||||
m.display_name = display_name.isEmpty()
|
m.display_name = display_name.isEmpty()
|
||||||
? QFileInfo(source_path).fileName()
|
? QFileInfo(source_path).fileName()
|
||||||
: display_name;
|
: display_name;
|
||||||
m.source_kind = "local";
|
m.source_connector = "local";
|
||||||
m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath());
|
m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath());
|
||||||
models_.push_back(std::move(m));
|
models_.push_back(std::move(m));
|
||||||
const QString new_id = models_.back().id;
|
const QString new_id = models_.back().id;
|
||||||
@@ -480,6 +522,24 @@ QString Federation::addModel(const QString& source_path,
|
|||||||
return new_id;
|
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) {
|
void Federation::removeModel(const QString& fed_id) {
|
||||||
for (auto it = models_.begin(); it != models_.end(); ++it) {
|
for (auto it = models_.begin(); it != models_.end(); ++it) {
|
||||||
if (it->id == fed_id) {
|
if (it->id == fed_id) {
|
||||||
@@ -603,27 +663,26 @@ bool Federation::load(const QString& path,
|
|||||||
m.display_name = mo.value("display_name").toString();
|
m.display_name = mo.value("display_name").toString();
|
||||||
|
|
||||||
QJsonObject so = mo.value("source").toObject();
|
QJsonObject so = mo.value("source").toObject();
|
||||||
m.source_kind = so.value("kind").toString("local");
|
m.source_connector = so.value("connector").toString("local");
|
||||||
if (m.source_kind != "local") {
|
if (m.source_connector == "local") {
|
||||||
if (warnings)
|
QString stored = so.value("path").toString();
|
||||||
*warnings << QString("models[%1]: unsupported source kind '%2'; entry kept but not loaded.")
|
if (stored.isEmpty()) {
|
||||||
.arg(i).arg(m.source_kind);
|
if (warnings) *warnings << QString("models[%1]: missing source.path; skipping.").arg(i);
|
||||||
// Keep raw stored path so save() round-trips correctly.
|
continue;
|
||||||
m.source_path = so.value("path").toString();
|
}
|
||||||
models_.push_back(std::move(m));
|
m.source_path = resolvePath(fed_dir, stored);
|
||||||
continue;
|
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()) {
|
if (QJsonValue tv = mo.value("model_transformation"); tv.isObject()) {
|
||||||
QJsonObject to = tv.toObject();
|
QJsonObject to = tv.toObject();
|
||||||
const QString af = to.value("a_frame").toString("ModelGlobal");
|
const QString af = to.value("a_frame").toString("ModelGlobal");
|
||||||
@@ -671,22 +730,95 @@ bool Federation::load(const QString& path,
|
|||||||
has_home_view_ = true;
|
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);
|
setDirty(false);
|
||||||
return true;
|
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) {
|
bool Federation::save(const QString& path, QString* err) {
|
||||||
QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
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();
|
QString fed_dir = QFileInfo(abs_path).absolutePath();
|
||||||
|
|
||||||
QJsonObject root;
|
QJsonObject root;
|
||||||
root["schema"] = kSchema;
|
root["schema"] = kSchema;
|
||||||
if (!name_.isEmpty()) root["name"] = name_;
|
if (!name_.isEmpty()) root["name"] = name_;
|
||||||
|
|
||||||
if (!created_.isValid()) created_ = QDateTime::currentDateTimeUtc();
|
root["created"] = created_to_emit.toUTC().toString(Qt::ISODate);
|
||||||
modified_ = QDateTime::currentDateTimeUtc();
|
root["modified"] = modified_to_emit.toUTC().toString(Qt::ISODate);
|
||||||
root["created"] = created_.toUTC().toString(Qt::ISODate);
|
|
||||||
root["modified"] = modified_.toUTC().toString(Qt::ISODate);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
QJsonObject co, uo;
|
QJsonObject co, uo;
|
||||||
@@ -730,12 +862,14 @@ bool Federation::save(const QString& path, QString* err) {
|
|||||||
mo["display_name"] = m.display_name;
|
mo["display_name"] = m.display_name;
|
||||||
|
|
||||||
QJsonObject so;
|
QJsonObject so;
|
||||||
so["kind"] = m.source_kind;
|
so["connector"] = m.source_connector;
|
||||||
if (m.source_kind == "local") {
|
if (m.source_connector == "local") {
|
||||||
so["path"] = relativizePath(fed_dir, m.source_path);
|
so["path"] = relativizePath(fed_dir, m.source_path);
|
||||||
} else {
|
} else {
|
||||||
// Round-trip raw value for unsupported kinds.
|
// Round-trip connector-specific keys verbatim.
|
||||||
so["path"] = m.source_path;
|
for (auto it = m.source_data.begin(); it != m.source_data.end(); ++it) {
|
||||||
|
so[it.key()] = it.value();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mo["source"] = so;
|
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());
|
if (err) *err = QString("Failed to commit %1: %2").arg(abs_path, f.errorString());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
file_path_ = abs_path;
|
|
||||||
setDirty(false);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
#include <Eigen/Dense>
|
#include <Eigen/Dense>
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
@@ -188,8 +189,13 @@ public:
|
|||||||
struct Model {
|
struct Model {
|
||||||
QString id; // stable, persisted
|
QString id; // stable, persisted
|
||||||
QString display_name;
|
QString display_name;
|
||||||
QString source_kind = "local"; // future: "http", "speckle", ...
|
// "local" or a connector id (e.g. "autodesk") per CLOUD_SYNC_PROTOCOL.md.
|
||||||
QString source_path; // resolved absolute when kind == "local"
|
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;
|
ModelTransformation model_transformation;
|
||||||
bool visible = true;
|
bool visible = true;
|
||||||
QString group_id; // empty = root level
|
QString group_id; // empty = root level
|
||||||
@@ -222,11 +228,28 @@ public:
|
|||||||
// Round-trip
|
// Round-trip
|
||||||
bool load(const QString& path, QStringList* warnings, QString* err);
|
bool load(const QString& path, QStringList* warnings, QString* err);
|
||||||
bool save(const QString& path, 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
|
// Mutations
|
||||||
void clear();
|
void clear();
|
||||||
QString addModel(const QString& source_path,
|
QString addModel(const QString& source_path,
|
||||||
const QString& display_name = QString());
|
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 removeModel(const QString& fed_id);
|
||||||
void setHomeView(const HomeView& hv);
|
void setHomeView(const HomeView& hv);
|
||||||
void clearHomeView();
|
void clearHomeView();
|
||||||
@@ -235,6 +258,17 @@ public:
|
|||||||
void setFederatedFalseOrigin(const FederatedFalseOrigin&);
|
void setFederatedFalseOrigin(const FederatedFalseOrigin&);
|
||||||
void setModelTransformation(const QString& fed_id, const ModelTransformation&);
|
void setModelTransformation(const QString& fed_id, const ModelTransformation&);
|
||||||
void setModelVisible(const QString& fed_id, bool visible);
|
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).
|
// 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.
|
// 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);
|
void setModelGroup(const QString& fed_id, const QString& group_id);
|
||||||
@@ -277,6 +311,16 @@ public:
|
|||||||
const FederationConfig& config() const { return config_; }
|
const FederationConfig& config() const { return config_; }
|
||||||
const FederatedFalseOrigin& federatedFalseOrigin() const { return federated_false_origin_; }
|
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:
|
signals:
|
||||||
void dirtyChanged(bool dirty);
|
void dirtyChanged(bool dirty);
|
||||||
|
|
||||||
@@ -290,6 +334,9 @@ signals:
|
|||||||
void modelTransformationChanged(const QString& fed_id);
|
void modelTransformationChanged(const QString& fed_id);
|
||||||
void modelVisibilityChanged(const QString& fed_id, bool visible);
|
void modelVisibilityChanged(const QString& fed_id, bool visible);
|
||||||
void modelGroupChanged(const QString& fed_id, const QString& group_id);
|
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 groupAdded(const QString& group_id);
|
||||||
void groupRemoved(const QString& group_id);
|
void groupRemoved(const QString& group_id);
|
||||||
@@ -305,6 +352,15 @@ private:
|
|||||||
static QString generateId();
|
static QString generateId();
|
||||||
static bool isFederationPath(const QString& path);
|
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);
|
Group* findGroupByIdMutable(const QString& group_id);
|
||||||
// Detach a group from its current parent's children vector, returning
|
// Detach a group from its current parent's children vector, returning
|
||||||
// ownership. group->parent is left set to its former parent — the
|
// ownership. group->parent is left set to its former parent — the
|
||||||
@@ -327,6 +383,8 @@ private:
|
|||||||
FederatedFalseOrigin federated_false_origin_;
|
FederatedFalseOrigin federated_false_origin_;
|
||||||
bool has_home_view_ = false;
|
bool has_home_view_ = false;
|
||||||
HomeView home_view_;
|
HomeView home_view_;
|
||||||
|
bool has_manifest_ = false;
|
||||||
|
QJsonObject manifest_;
|
||||||
bool dirty_ = false;
|
bool dirty_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user