mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 02:02:22 +00:00
ifcviewer-full: add .ifcfed federation save/load
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "Federation.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QUuid>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#ifndef FEDERATION_H
|
||||
#define FEDERATION_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QDateTime>
|
||||
#include <QVector3D>
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
// 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<float, 16> 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<Model>& 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<Model> models_;
|
||||
bool has_home_view_ = false;
|
||||
HomeView home_view_;
|
||||
bool dirty_ = false;
|
||||
};
|
||||
|
||||
#endif // FEDERATION_H
|
||||
@@ -19,11 +19,13 @@
|
||||
|
||||
#include "MainWindow.h"
|
||||
#include "AppSettings.h"
|
||||
#include "Federation.h"
|
||||
#include "SettingsWindow.h"
|
||||
#include "LodBuilder.h"
|
||||
#include "SidecarCache.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCloseEvent>
|
||||
#include <QMenuBar>
|
||||
#include <QFileDialog>
|
||||
#include <QFileInfo>
|
||||
@@ -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<int>(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<uint32_t>(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;
|
||||
|
||||
@@ -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<uint32_t, QTreeWidgetItem*> tree_roots_;
|
||||
|
||||
// Bidirectional federation_id <-> model_id map. Federation owns the
|
||||
// persistent ids; SceneLoader owns the runtime model_ids.
|
||||
std::unordered_map<QString, uint32_t> fed_id_to_model_id_;
|
||||
std::unordered_map<uint32_t, QString> model_id_to_fed_id_;
|
||||
|
||||
// Display-side element registry for tree + property lookup.
|
||||
std::unordered_map<uint32_t, ElementInfo> element_map_;
|
||||
std::unordered_map<uint32_t, QTreeWidgetItem*> tree_items_;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user