From 7bc64bef1a068f503ca13578f3f5450eb99db04e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 27 Apr 2026 18:31:59 +1000 Subject: [PATCH] ifcviewer-full: add .ifcfed federation save/load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation (JSON) tracks an ordered list of model sources plus an optional home-view camera state. Sources are stored relative when under the federation file's directory, absolute otherwise. File menu now exposes New / Open / Save / Save As; Add Files moves to Ctrl+Shift+O. View menu gains Set/Go to Home View. Window title binds to dirty state via setWindowModified, and the close-window prompt offers Save/Discard/Cancel. Per-model transform (4x4 column-major) and visible round-trip through load/save but are not yet applied at the viewport — the georeferencing work uses them. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/Federation.cpp | 308 ++++++++++++++++++++++++++++++ src/ifcviewer-full/Federation.h | 105 ++++++++++ src/ifcviewer-full/MainWindow.cpp | 214 ++++++++++++++++++++- src/ifcviewer-full/MainWindow.h | 22 +++ src/ifcviewer-full/main.cpp | 16 +- src/ifcviewer/ViewportWindow.cpp | 4 + src/ifcviewer/ViewportWindow.h | 8 + 7 files changed, 669 insertions(+), 8 deletions(-) create mode 100644 src/ifcviewer-full/Federation.cpp create mode 100644 src/ifcviewer-full/Federation.h diff --git a/src/ifcviewer-full/Federation.cpp b/src/ifcviewer-full/Federation.cpp new file mode 100644 index 0000000000..a72f783103 --- /dev/null +++ b/src/ifcviewer-full/Federation.cpp @@ -0,0 +1,308 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "Federation.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr const char* kSchema = "ifcfed/1"; + +QString resolvePath(const QString& fed_dir, const QString& stored) { + if (stored.isEmpty()) return stored; + QFileInfo fi(stored); + if (fi.isAbsolute()) return QDir::cleanPath(stored); + return QDir::cleanPath(QDir(fed_dir).absoluteFilePath(stored)); +} + +// Returns abs_path relative to fed_dir if abs_path lives under fed_dir, +// otherwise returns abs_path unchanged. +QString relativizePath(const QString& fed_dir, const QString& abs_path) { + QString fed_canon = QDir::cleanPath(fed_dir); + QString abs_canon = QDir::cleanPath(abs_path); + if (!fed_canon.endsWith('/')) fed_canon += '/'; + if (abs_canon.startsWith(fed_canon)) { + return QDir(fed_canon).relativeFilePath(abs_canon); + } + return abs_canon; +} +} // namespace + +Federation::Federation(QObject* parent) : QObject(parent) {} + +QString Federation::generateId() { + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +bool Federation::isFederationPath(const QString& path) { + return path.endsWith(".ifcfed", Qt::CaseInsensitive); +} + +void Federation::clear() { + file_path_.clear(); + name_.clear(); + created_ = QDateTime(); + modified_ = QDateTime(); + models_.clear(); + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(false); +} + +void Federation::markClean() { + setDirty(false); +} + +void Federation::setDirty(bool d) { + if (dirty_ == d) return; + dirty_ = d; + emit dirtyChanged(d); +} + +const Federation::Model* Federation::findById(const QString& fed_id) const { + for (const auto& m : models_) { + if (m.id == fed_id) return &m; + } + return nullptr; +} + +QString Federation::addModel(const QString& source_path, + const QString& display_name) { + if (source_path.isEmpty()) return {}; + if (isFederationPath(source_path)) return {}; // no nested federations + + Model m; + m.id = generateId(); + m.display_name = display_name.isEmpty() + ? QFileInfo(source_path).fileName() + : display_name; + m.source_kind = "local"; + m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); + models_.push_back(std::move(m)); + setDirty(true); + return models_.back().id; +} + +void Federation::removeModel(const QString& fed_id) { + for (auto it = models_.begin(); it != models_.end(); ++it) { + if (it->id == fed_id) { + models_.erase(it); + setDirty(true); + return; + } + } +} + +void Federation::setHomeView(const HomeView& hv) { + home_view_ = hv; + has_home_view_ = true; + setDirty(true); +} + +void Federation::clearHomeView() { + if (!has_home_view_) return; + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(true); +} + +bool Federation::load(const QString& path, + QStringList* warnings, + QString* err) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + if (err) *err = QString("Cannot open %1: %2").arg(path, f.errorString()); + return false; + } + QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError pe; + QJsonDocument doc = QJsonDocument::fromJson(bytes, &pe); + if (doc.isNull() || !doc.isObject()) { + if (err) *err = QString("Parse error in %1: %2").arg(path, pe.errorString()); + return false; + } + QJsonObject root = doc.object(); + + clear(); + file_path_ = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + QString fed_dir = QFileInfo(file_path_).absolutePath(); + + QString schema = root.value("schema").toString(); + if (schema != kSchema && warnings) { + *warnings << QString("Unknown schema '%1' (expected '%2'); attempting to load anyway.") + .arg(schema, kSchema); + } + + name_ = root.value("name").toString(); + created_ = QDateTime::fromString(root.value("created").toString(), Qt::ISODate); + modified_ = QDateTime::fromString(root.value("modified").toString(), Qt::ISODate); + + QJsonArray arr = root.value("models").toArray(); + for (int i = 0; i < arr.size(); ++i) { + if (!arr[i].isObject()) { + if (warnings) *warnings << QString("models[%1] is not an object; skipping.").arg(i); + continue; + } + QJsonObject mo = arr[i].toObject(); + + Model m; + m.id = mo.value("id").toString(); + if (m.id.isEmpty()) m.id = generateId(); + 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; + } + + 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(); + + QJsonValue tv = mo.value("transform"); + if (tv.isArray()) { + QJsonArray ta = tv.toArray(); + if (ta.size() == 16) { + for (int k = 0; k < 16; ++k) m.transform[k] = float(ta[k].toDouble()); + m.has_transform = true; + } else if (warnings) { + *warnings << QString("models[%1]: transform must be 16 floats; ignored.").arg(i); + } + } + + QJsonValue vv = mo.value("visible"); + if (vv.isBool()) m.visible = vv.toBool(); + + models_.push_back(std::move(m)); + } + + QJsonValue hv = root.value("home_view"); + if (hv.isObject()) { + QJsonObject ho = hv.toObject(); + QJsonArray ta = ho.value("target").toArray(); + HomeView v; + if (ta.size() == 3) { + v.target = QVector3D(float(ta[0].toDouble()), + float(ta[1].toDouble()), + float(ta[2].toDouble())); + } + v.distance = float(ho.value("distance").toDouble(50.0)); + v.yaw = float(ho.value("yaw").toDouble(45.0)); + v.pitch = float(ho.value("pitch").toDouble(30.0)); + home_view_ = v; + has_home_view_ = true; + } + + setDirty(false); + return true; +} + +bool Federation::save(const QString& path, QString* err) { + QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + 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); + + QJsonArray arr; + for (const auto& m : models_) { + QJsonObject mo; + mo["id"] = m.id; + mo["display_name"] = m.display_name; + + QJsonObject so; + so["kind"] = m.source_kind; + if (m.source_kind == "local") { + so["path"] = relativizePath(fed_dir, m.source_path); + } else { + // Round-trip raw value for unsupported kinds. + so["path"] = m.source_path; + } + mo["source"] = so; + + if (m.has_transform) { + QJsonArray ta; + for (float v : m.transform) ta.append(double(v)); + mo["transform"] = ta; + } + if (!m.visible) mo["visible"] = false; + + arr.append(mo); + } + root["models"] = arr; + + if (has_home_view_) { + QJsonObject ho; + QJsonArray ta; + ta.append(double(home_view_.target.x())); + ta.append(double(home_view_.target.y())); + ta.append(double(home_view_.target.z())); + ho["target"] = ta; + ho["distance"] = double(home_view_.distance); + ho["yaw"] = double(home_view_.yaw); + ho["pitch"] = double(home_view_.pitch); + root["home_view"] = ho; + } else { + root["home_view"] = QJsonValue(); // null + } + + QSaveFile f(abs_path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (err) *err = QString("Cannot write %1: %2").arg(abs_path, f.errorString()); + return false; + } + f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!f.commit()) { + if (err) *err = QString("Failed to commit %1: %2").arg(abs_path, f.errorString()); + return false; + } + + file_path_ = abs_path; + setDirty(false); + return true; +} diff --git a/src/ifcviewer-full/Federation.h b/src/ifcviewer-full/Federation.h new file mode 100644 index 0000000000..21bb6ba3bd --- /dev/null +++ b/src/ifcviewer-full/Federation.h @@ -0,0 +1,105 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef FEDERATION_H +#define FEDERATION_H + +#include +#include +#include +#include +#include + +#include +#include + +// In-memory representation of an .ifcfed file (IFC federation). +// +// A federation is a named, ordered list of model sources plus an optional +// "home view" camera state. Source paths can be relative (resolved against +// the .ifcfed's directory) or absolute. Save() reserialises paths relative +// when they live under the federation file's directory tree, absolute +// otherwise — Save As recomputes against the new location. +// +// Round-trip-only fields today (no UI to edit, but preserved across load/ +// save): per-model `transform` (4x4, column-major), per-model `visible`, +// future cloud `source.kind`s. +class Federation : public QObject { + Q_OBJECT +public: + struct HomeView { + QVector3D target; + float distance = 50.0f; + float yaw = 45.0f; // degrees + float pitch = 30.0f; // degrees + }; + + struct Model { + QString id; // stable, persisted + QString display_name; + QString source_kind = "local"; // future: "http", "speckle", ... + QString source_path; // resolved absolute when kind == "local" + bool has_transform = false; + std::array transform{}; // column-major; identity when !has_transform + bool visible = true; + }; + + explicit Federation(QObject* parent = nullptr); + + // Round-trip + bool load(const QString& path, QStringList* warnings, QString* err); + bool save(const QString& path, QString* err); + + // Mutations + void clear(); + QString addModel(const QString& source_path, + const QString& display_name = QString()); + void removeModel(const QString& fed_id); + void setHomeView(const HomeView& hv); + void clearHomeView(); + + // Accessors + const std::vector& models() const { return models_; } + const Model* findById(const QString& fed_id) const; + bool isDirty() const { return dirty_; } + void markClean(); + QString filePath() const { return file_path_; } + QString name() const { return name_; } + bool hasHomeView() const { return has_home_view_; } + const HomeView& homeView() const { return home_view_; } + +signals: + void dirtyChanged(bool dirty); + +private: + void setDirty(bool d); + static QString generateId(); + static bool isFederationPath(const QString& path); + + QString file_path_; + QString name_; + QDateTime created_; + QDateTime modified_; + std::vector models_; + bool has_home_view_ = false; + HomeView home_view_; + bool dirty_ = false; +}; + +#endif // FEDERATION_H diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index e36af95670..3105af7cf5 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -19,11 +19,13 @@ #include "MainWindow.h" #include "AppSettings.h" +#include "Federation.h" #include "SettingsWindow.h" #include "LodBuilder.h" #include "SidecarCache.h" #include +#include #include #include #include @@ -40,6 +42,11 @@ MainWindow::MainWindow(QWidget* parent) setupUi(); setupMenus(); + federation_ = new Federation(this); + connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { + setWindowModified(dirty); + }); + loader_ = new SceneLoader(viewport_, this); connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); @@ -82,7 +89,7 @@ MainWindow::MainWindow(QWidget* parent) if (!show) stats_label_->clear(); }); - setWindowTitle("IfcViewer"); + updateWindowTitle(); resize(1400, 900); } @@ -132,12 +139,32 @@ void MainWindow::setupUi() { void MainWindow::setupMenus() { auto* file_menu = menuBar()->addMenu("&File"); - auto* open_action = file_menu->addAction("&Add Files...", this, &MainWindow::onFileOpen); - open_action->setShortcut(QKeySequence::Open); + file_menu->addAction("&New Federation", + this, &MainWindow::onFederationNew, + QKeySequence::New); + file_menu->addAction("&Open Federation...", + this, &MainWindow::onFederationOpen, + QKeySequence::Open); + file_menu->addSeparator(); + file_menu->addAction("&Add Files...", + this, &MainWindow::onFileOpen, + QKeySequence("Ctrl+Shift+O")); file_menu->addAction("Add &Database...", this, &MainWindow::onDatabaseOpen); + file_menu->addSeparator(); + file_menu->addAction("&Save Federation", + this, &MainWindow::onFederationSave, + QKeySequence::Save); + file_menu->addAction("Save Federation &As...", + this, &MainWindow::onFederationSaveAs, + QKeySequence::SaveAs); + file_menu->addSeparator(); file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings); file_menu->addSeparator(); file_menu->addAction("&Quit", QKeySequence::Quit, qApp, &QApplication::quit); + + auto* view_menu = menuBar()->addMenu("&View"); + view_menu->addAction("Set &Home View", this, &MainWindow::onSetHomeView); + view_menu->addAction("&Go to Home View", this, &MainWindow::onGoHomeView); } void MainWindow::onFileOpen() { @@ -170,18 +197,189 @@ void MainWindow::onFileSettings() { } void MainWindow::addFiles(const QStringList& paths) { + QStringList accepted_paths; + QStringList accepted_fed_ids; + for (const auto& p : paths) { + QString fed_id = federation_->addModel(p); + if (fed_id.isEmpty()) continue; // .ifcfed or empty path — silently skipped + accepted_paths << p; + accepted_fed_ids << fed_id; + } + loadModelsFromPaths(accepted_paths, accepted_fed_ids); + updateWindowTitle(); +} + +void MainWindow::loadModelsFromPaths(const QStringList& paths, + const QStringList& fed_ids) { + if (paths.isEmpty()) return; auto ids = loader_->addFiles(paths); for (int i = 0; i < paths.size() && i < static_cast(ids.size()); ++i) { - uint32_t id = ids[i]; + uint32_t mid = ids[i]; + const QString& fed_id = fed_ids[i]; + fed_id_to_model_id_[fed_id] = mid; + model_id_to_fed_id_[mid] = fed_id; + QString display = QFileInfo(paths[i]).fileName(); auto* root = new QTreeWidgetItem(element_tree_); root->setText(0, display); root->setText(1, "IFC Model"); root->setData(0, Qt::UserRole, static_cast(0)); - tree_roots_[id] = root; + tree_roots_[mid] = root; } } +bool MainWindow::openFederation(const QString& path) { + if (!confirmDiscardIfDirty()) return false; + + QStringList warnings; + QString err; + if (!federation_->load(path, &warnings, &err)) { + QMessageBox::warning(this, "Open Federation", + QString("Could not open federation:\n%1").arg(err)); + return false; + } + + clearScene(); + + QStringList paths; + QStringList fed_ids; + QStringList missing; + for (const auto& m : federation_->models()) { + if (m.source_kind != "local") continue; // already warned by load() + if (!QFileInfo::exists(m.source_path)) { + missing << m.source_path; + continue; + } + paths << m.source_path; + fed_ids << m.id; + } + loadModelsFromPaths(paths, fed_ids); + + for (const auto& msg : missing) { + warnings << QString("Source not found, kept in federation: %1").arg(msg); + } + if (!warnings.isEmpty()) { + QMessageBox::warning(this, "Open Federation", + "Federation opened with warnings:\n\n" + warnings.join("\n")); + } + + federation_->markClean(); + updateWindowTitle(); + + if (federation_->hasHomeView()) { + const auto& hv = federation_->homeView(); + viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), + hv.distance, hv.yaw, hv.pitch); + } + return true; +} + +void MainWindow::onFederationNew() { + if (!confirmDiscardIfDirty()) return; + clearScene(); + federation_->clear(); + updateWindowTitle(); +} + +void MainWindow::onFederationOpen() { + QString path = QFileDialog::getOpenFileName( + this, "Open Federation", QString(), + "IFC Federation (*.ifcfed);;All Files (*)"); + if (path.isEmpty()) return; + openFederation(path); +} + +bool MainWindow::onFederationSave() { + if (federation_->filePath().isEmpty()) return onFederationSaveAs(); + QString err; + if (!federation_->save(federation_->filePath(), &err)) { + QMessageBox::warning(this, "Save Federation", + QString("Could not save federation:\n%1").arg(err)); + return false; + } + updateWindowTitle(); + return true; +} + +bool MainWindow::onFederationSaveAs() { + QString suggested = federation_->filePath(); + if (suggested.isEmpty()) suggested = "federation.ifcfed"; + QString path = QFileDialog::getSaveFileName( + this, "Save Federation As", suggested, + "IFC Federation (*.ifcfed);;All Files (*)"); + if (path.isEmpty()) return false; + if (!path.endsWith(".ifcfed", Qt::CaseInsensitive)) path += ".ifcfed"; + + QString err; + if (!federation_->save(path, &err)) { + QMessageBox::warning(this, "Save Federation", + QString("Could not save federation:\n%1").arg(err)); + return false; + } + updateWindowTitle(); + return true; +} + +void MainWindow::onSetHomeView() { + auto cs = viewport_->cameraState(); + Federation::HomeView hv; + hv.target = cs.target; + hv.distance = cs.distance; + hv.yaw = cs.yaw; + hv.pitch = cs.pitch; + federation_->setHomeView(hv); + updateWindowTitle(); +} + +void MainWindow::onGoHomeView() { + if (!federation_->hasHomeView()) { + status_label_->setText("No home view set for this federation."); + return; + } + const auto& hv = federation_->homeView(); + viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), + hv.distance, hv.yaw, hv.pitch); +} + +void MainWindow::clearScene() { + while (!tree_roots_.empty()) { + uint32_t mid = tree_roots_.begin()->first; + viewport_->removeModel(mid); + removeModelUi(mid); + } + fed_id_to_model_id_.clear(); + model_id_to_fed_id_.clear(); +} + +bool MainWindow::confirmDiscardIfDirty() { + if (!federation_->isDirty()) return true; + auto ret = QMessageBox::question( + this, "Unsaved Federation", + "The current federation has unsaved changes. Save before continuing?", + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, + QMessageBox::Save); + if (ret == QMessageBox::Cancel) return false; + if (ret == QMessageBox::Save) return onFederationSave(); + return true; // Discard +} + +void MainWindow::updateWindowTitle() { + QString fed_path = federation_->filePath(); + if (fed_path.isEmpty() && federation_->models().empty()) { + setWindowTitle("IfcViewer"); + } else if (fed_path.isEmpty()) { + setWindowTitle("untitled[*] — IfcViewer"); + } else { + setWindowTitle(QFileInfo(fed_path).fileName() + "[*] — IfcViewer"); + } + setWindowModified(federation_->isDirty()); +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (confirmDiscardIfDirty()) event->accept(); + else event->ignore(); +} + void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { progress_bar_->setValue(0); progress_bar_->setVisible(true); @@ -326,6 +524,12 @@ void MainWindow::writeSidecarForModel(uint32_t mid) { } void MainWindow::removeModelUi(uint32_t mid) { + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it != model_id_to_fed_id_.end()) { + fed_id_to_model_id_.erase(fed_it->second); + model_id_to_fed_id_.erase(fed_it); + } + auto root_it = tree_roots_.find(mid); if (root_it != tree_roots_.end()) { delete root_it->second; diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index b616293d59..13dfecf061 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -34,6 +34,7 @@ #include "ViewportWindow.h" #include "SceneLoader.h" +class Federation; class SettingsWindow; class MainWindow : public QMainWindow { @@ -43,12 +44,22 @@ public: ~MainWindow(); void addFiles(const QStringList& paths); + bool openFederation(const QString& path); void setPendingCamera(const QString& params); void setPendingBenchmark(int frames); +protected: + void closeEvent(QCloseEvent* event) override; + private slots: void onFileOpen(); void onDatabaseOpen(); + void onFederationNew(); + void onFederationOpen(); + bool onFederationSave(); + bool onFederationSaveAs(); + void onSetHomeView(); + void onGoHomeView(); void onFileSettings(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); @@ -69,6 +80,11 @@ private slots: private: void setupUi(); void setupMenus(); + void loadModelsFromPaths(const QStringList& paths, + const QStringList& fed_ids); + void clearScene(); + bool confirmDiscardIfDirty(); + void updateWindowTitle(); void populateProperties(uint32_t object_id); void appendElementToTree(uint32_t model_id, uint32_t object_id, @@ -84,6 +100,7 @@ private: ViewportWindow* viewport_ = nullptr; SceneLoader* loader_ = nullptr; + Federation* federation_ = nullptr; SettingsWindow* settings_ = nullptr; QWidget* viewport_container_ = nullptr; QTreeWidget* element_tree_ = nullptr; @@ -95,6 +112,11 @@ private: // Per-model tree roots, keyed by model_id. std::map tree_roots_; + // Bidirectional federation_id <-> model_id map. Federation owns the + // persistent ids; SceneLoader owns the runtime model_ids. + std::unordered_map fed_id_to_model_id_; + std::unordered_map model_id_to_fed_id_; + // Display-side element registry for tree + property lookup. std::unordered_map element_map_; std::unordered_map tree_items_; diff --git a/src/ifcviewer-full/main.cpp b/src/ifcviewer-full/main.cpp index d15a525c53..8f861e3f31 100644 --- a/src/ifcviewer-full/main.cpp +++ b/src/ifcviewer-full/main.cpp @@ -40,7 +40,9 @@ int main(int argc, char* argv[]) { QCommandLineParser parser; parser.setApplicationDescription("IfcOpenShell IFC Viewer"); parser.addHelpOption(); - parser.addPositionalArgument("files", "IFC file(s) to open", "[files...]"); + parser.addPositionalArgument("files", + "IFC file(s) and/or one .ifcfed federation to open", + "[files...]"); parser.addOption({{"c", "camera"}, "Set camera: tx,ty,tz,dist,yaw,pitch", "params"}); parser.addOption({{"b", "benchmark"}, @@ -51,9 +53,17 @@ int main(int argc, char* argv[]) { window.show(); auto args = parser.positionalArguments(); - if (!args.isEmpty()) { - window.addFiles(args); + QStringList file_args; + QString fed_arg; + for (const auto& a : args) { + if (fed_arg.isEmpty() && a.endsWith(".ifcfed", Qt::CaseInsensitive)) { + fed_arg = a; + } else { + file_args << a; + } } + if (!fed_arg.isEmpty()) window.openFederation(fed_arg); + if (!file_args.isEmpty()) window.addFiles(file_args); if (parser.isSet("camera")) { window.setPendingCamera(parser.value("camera")); diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 81d75aebd7..e928525f03 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1186,6 +1186,10 @@ QString ViewportWindow::cameraString() const { .arg(camera_pitch_, 0, 'f', 2); } +ViewportWindow::CameraState ViewportWindow::cameraState() const { + return { camera_target_, camera_distance_, camera_yaw_, camera_pitch_ }; +} + void ViewportWindow::keyPressEvent(QKeyEvent* event) { const int key = event->key(); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 68e9893020..ec223c559f 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -172,6 +172,14 @@ public: void setBenchmarkFrames(int n); QString cameraString() const; + struct CameraState { + QVector3D target; + float distance; + float yaw; // degrees + float pitch; // degrees + }; + CameraState cameraState() const; + struct FrameStats { float fps; float frame_time_ms;