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