mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 03:19:53 +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:
+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