mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-26 18:21:59 +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 "MainWindow.h"
|
||||||
#include "AppSettings.h"
|
#include "AppSettings.h"
|
||||||
|
#include "Federation.h"
|
||||||
#include "SettingsWindow.h"
|
#include "SettingsWindow.h"
|
||||||
#include "LodBuilder.h"
|
#include "LodBuilder.h"
|
||||||
#include "SidecarCache.h"
|
#include "SidecarCache.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QCloseEvent>
|
||||||
#include <QMenuBar>
|
#include <QMenuBar>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
@@ -40,6 +42,11 @@ MainWindow::MainWindow(QWidget* parent)
|
|||||||
setupUi();
|
setupUi();
|
||||||
setupMenus();
|
setupMenus();
|
||||||
|
|
||||||
|
federation_ = new Federation(this);
|
||||||
|
connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) {
|
||||||
|
setWindowModified(dirty);
|
||||||
|
});
|
||||||
|
|
||||||
loader_ = new SceneLoader(viewport_, this);
|
loader_ = new SceneLoader(viewport_, this);
|
||||||
connect(loader_, &SceneLoader::loadStarted,
|
connect(loader_, &SceneLoader::loadStarted,
|
||||||
this, &MainWindow::onLoadStarted);
|
this, &MainWindow::onLoadStarted);
|
||||||
@@ -82,7 +89,7 @@ MainWindow::MainWindow(QWidget* parent)
|
|||||||
if (!show) stats_label_->clear();
|
if (!show) stats_label_->clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
setWindowTitle("IfcViewer");
|
updateWindowTitle();
|
||||||
resize(1400, 900);
|
resize(1400, 900);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,12 +139,32 @@ void MainWindow::setupUi() {
|
|||||||
|
|
||||||
void MainWindow::setupMenus() {
|
void MainWindow::setupMenus() {
|
||||||
auto* file_menu = menuBar()->addMenu("&File");
|
auto* file_menu = menuBar()->addMenu("&File");
|
||||||
auto* open_action = file_menu->addAction("&Add Files...", this, &MainWindow::onFileOpen);
|
file_menu->addAction("&New Federation",
|
||||||
open_action->setShortcut(QKeySequence::Open);
|
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->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->addAction("&Settings...", this, &MainWindow::onFileSettings);
|
||||||
file_menu->addSeparator();
|
file_menu->addSeparator();
|
||||||
file_menu->addAction("&Quit", QKeySequence::Quit, qApp, &QApplication::quit);
|
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() {
|
void MainWindow::onFileOpen() {
|
||||||
@@ -170,18 +197,189 @@ void MainWindow::onFileSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::addFiles(const QStringList& paths) {
|
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);
|
auto ids = loader_->addFiles(paths);
|
||||||
for (int i = 0; i < paths.size() && i < static_cast<int>(ids.size()); ++i) {
|
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();
|
QString display = QFileInfo(paths[i]).fileName();
|
||||||
auto* root = new QTreeWidgetItem(element_tree_);
|
auto* root = new QTreeWidgetItem(element_tree_);
|
||||||
root->setText(0, display);
|
root->setText(0, display);
|
||||||
root->setText(1, "IFC Model");
|
root->setText(1, "IFC Model");
|
||||||
root->setData(0, Qt::UserRole, static_cast<uint32_t>(0));
|
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) {
|
void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) {
|
||||||
progress_bar_->setValue(0);
|
progress_bar_->setValue(0);
|
||||||
progress_bar_->setVisible(true);
|
progress_bar_->setVisible(true);
|
||||||
@@ -326,6 +524,12 @@ void MainWindow::writeSidecarForModel(uint32_t mid) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::removeModelUi(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);
|
auto root_it = tree_roots_.find(mid);
|
||||||
if (root_it != tree_roots_.end()) {
|
if (root_it != tree_roots_.end()) {
|
||||||
delete root_it->second;
|
delete root_it->second;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
#include "ViewportWindow.h"
|
#include "ViewportWindow.h"
|
||||||
#include "SceneLoader.h"
|
#include "SceneLoader.h"
|
||||||
|
|
||||||
|
class Federation;
|
||||||
class SettingsWindow;
|
class SettingsWindow;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow {
|
class MainWindow : public QMainWindow {
|
||||||
@@ -43,12 +44,22 @@ public:
|
|||||||
~MainWindow();
|
~MainWindow();
|
||||||
|
|
||||||
void addFiles(const QStringList& paths);
|
void addFiles(const QStringList& paths);
|
||||||
|
bool openFederation(const QString& path);
|
||||||
void setPendingCamera(const QString& params);
|
void setPendingCamera(const QString& params);
|
||||||
void setPendingBenchmark(int frames);
|
void setPendingBenchmark(int frames);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void closeEvent(QCloseEvent* event) override;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onFileOpen();
|
void onFileOpen();
|
||||||
void onDatabaseOpen();
|
void onDatabaseOpen();
|
||||||
|
void onFederationNew();
|
||||||
|
void onFederationOpen();
|
||||||
|
bool onFederationSave();
|
||||||
|
bool onFederationSaveAs();
|
||||||
|
void onSetHomeView();
|
||||||
|
void onGoHomeView();
|
||||||
void onFileSettings();
|
void onFileSettings();
|
||||||
void onObjectPicked(uint32_t object_id);
|
void onObjectPicked(uint32_t object_id);
|
||||||
void onTreeSelectionChanged();
|
void onTreeSelectionChanged();
|
||||||
@@ -69,6 +80,11 @@ private slots:
|
|||||||
private:
|
private:
|
||||||
void setupUi();
|
void setupUi();
|
||||||
void setupMenus();
|
void setupMenus();
|
||||||
|
void loadModelsFromPaths(const QStringList& paths,
|
||||||
|
const QStringList& fed_ids);
|
||||||
|
void clearScene();
|
||||||
|
bool confirmDiscardIfDirty();
|
||||||
|
void updateWindowTitle();
|
||||||
void populateProperties(uint32_t object_id);
|
void populateProperties(uint32_t object_id);
|
||||||
void appendElementToTree(uint32_t model_id,
|
void appendElementToTree(uint32_t model_id,
|
||||||
uint32_t object_id,
|
uint32_t object_id,
|
||||||
@@ -84,6 +100,7 @@ private:
|
|||||||
|
|
||||||
ViewportWindow* viewport_ = nullptr;
|
ViewportWindow* viewport_ = nullptr;
|
||||||
SceneLoader* loader_ = nullptr;
|
SceneLoader* loader_ = nullptr;
|
||||||
|
Federation* federation_ = nullptr;
|
||||||
SettingsWindow* settings_ = nullptr;
|
SettingsWindow* settings_ = nullptr;
|
||||||
QWidget* viewport_container_ = nullptr;
|
QWidget* viewport_container_ = nullptr;
|
||||||
QTreeWidget* element_tree_ = nullptr;
|
QTreeWidget* element_tree_ = nullptr;
|
||||||
@@ -95,6 +112,11 @@ private:
|
|||||||
// Per-model tree roots, keyed by model_id.
|
// Per-model tree roots, keyed by model_id.
|
||||||
std::map<uint32_t, QTreeWidgetItem*> tree_roots_;
|
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.
|
// Display-side element registry for tree + property lookup.
|
||||||
std::unordered_map<uint32_t, ElementInfo> element_map_;
|
std::unordered_map<uint32_t, ElementInfo> element_map_;
|
||||||
std::unordered_map<uint32_t, QTreeWidgetItem*> tree_items_;
|
std::unordered_map<uint32_t, QTreeWidgetItem*> tree_items_;
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ int main(int argc, char* argv[]) {
|
|||||||
QCommandLineParser parser;
|
QCommandLineParser parser;
|
||||||
parser.setApplicationDescription("IfcOpenShell IFC Viewer");
|
parser.setApplicationDescription("IfcOpenShell IFC Viewer");
|
||||||
parser.addHelpOption();
|
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"},
|
parser.addOption({{"c", "camera"},
|
||||||
"Set camera: tx,ty,tz,dist,yaw,pitch", "params"});
|
"Set camera: tx,ty,tz,dist,yaw,pitch", "params"});
|
||||||
parser.addOption({{"b", "benchmark"},
|
parser.addOption({{"b", "benchmark"},
|
||||||
@@ -51,9 +53,17 @@ int main(int argc, char* argv[]) {
|
|||||||
window.show();
|
window.show();
|
||||||
|
|
||||||
auto args = parser.positionalArguments();
|
auto args = parser.positionalArguments();
|
||||||
if (!args.isEmpty()) {
|
QStringList file_args;
|
||||||
window.addFiles(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")) {
|
if (parser.isSet("camera")) {
|
||||||
window.setPendingCamera(parser.value("camera"));
|
window.setPendingCamera(parser.value("camera"));
|
||||||
|
|||||||
@@ -1186,6 +1186,10 @@ QString ViewportWindow::cameraString() const {
|
|||||||
.arg(camera_pitch_, 0, 'f', 2);
|
.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) {
|
void ViewportWindow::keyPressEvent(QKeyEvent* event) {
|
||||||
const int key = event->key();
|
const int key = event->key();
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ public:
|
|||||||
void setBenchmarkFrames(int n);
|
void setBenchmarkFrames(int n);
|
||||||
QString cameraString() const;
|
QString cameraString() const;
|
||||||
|
|
||||||
|
struct CameraState {
|
||||||
|
QVector3D target;
|
||||||
|
float distance;
|
||||||
|
float yaw; // degrees
|
||||||
|
float pitch; // degrees
|
||||||
|
};
|
||||||
|
CameraState cameraState() const;
|
||||||
|
|
||||||
struct FrameStats {
|
struct FrameStats {
|
||||||
float fps;
|
float fps;
|
||||||
float frame_time_ms;
|
float frame_time_ms;
|
||||||
|
|||||||
Reference in New Issue
Block a user