diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt index ad5a5083ad..f578d96f70 100644 --- a/src/ifcviewer-full/CMakeLists.txt +++ b/src/ifcviewer-full/CMakeLists.txt @@ -34,7 +34,3 @@ set_target_properties(IfcViewerFull PROPERTIES target_link_libraries(IfcViewerFull PRIVATE IfcViewer) install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) - -if(BUILD_IFCVIEWER_TESTS) - add_subdirectory(tests) -endif() diff --git a/src/ifcviewer-full/Federation.cpp b/src/ifcviewer-full/Federation.cpp deleted file mode 100644 index a72f783103..0000000000 --- a/src/ifcviewer-full/Federation.cpp +++ /dev/null @@ -1,308 +0,0 @@ -/******************************************************************************** - * * - * 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 deleted file mode 100644 index 21bb6ba3bd..0000000000 --- a/src/ifcviewer-full/Federation.h +++ /dev/null @@ -1,105 +0,0 @@ -/******************************************************************************** - * * - * 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/tests/CMakeLists.txt b/src/ifcviewer-full/tests/CMakeLists.txt deleted file mode 100644 index 705fec1ad0..0000000000 --- a/src/ifcviewer-full/tests/CMakeLists.txt +++ /dev/null @@ -1,41 +0,0 @@ -################################################################################ -# # -# 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 . # -# # -################################################################################ - -# Tier-1 tests for ifcviewer-full. Federation is QObject-derived but only -# uses Qt6::Core (no event loop, no GL), so tests can construct it directly. - -set(IFCVIEWER_FULL_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..) - -# Federation::HomeView holds a QVector3D (defined in QtGui), and QSignalSpy -# / QTest live in Qt6::Test. -find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) - -add_executable(test_federation - test_federation.cpp - ${IFCVIEWER_FULL_SRC}/Federation.cpp -) -set_target_properties(test_federation PROPERTIES AUTOMOC ON) -target_include_directories(test_federation PRIVATE ${IFCVIEWER_FULL_SRC}) -target_link_libraries(test_federation PRIVATE - Catch2::Catch2WithMain - Qt${QT_VERSION}::Core - Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui - Qt${QT_VERSION}::Test # QSignalSpy -) -catch_discover_tests(test_federation) diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 12ba3acb0f..166a48910d 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -18,86 +18,275 @@ ********************************************************************************/ #include "Federation.h" -#include "Unit.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace { +constexpr const char* kSchema = "ifcfed/1"; -constexpr double kPi = 3.14159265358979323846; -constexpr double kDegToRad = kPi / 180.0; - -Eigen::Matrix4d translation4(const Eigen::Vector3d& t) { - Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); - M(0, 3) = t.x(); - M(1, 3) = t.y(); - M(2, 3) = t.z(); - return M; +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)); } -// Intrinsic XYZ Euler: R = R_z(z) · R_y(y) · R_x(x). -Eigen::Matrix4d eulerXYZ(const Eigen::Vector3d& rxyz_rad) { - const Eigen::Matrix3d R3 = - (Eigen::AngleAxisd(rxyz_rad.z(), Eigen::Vector3d::UnitZ()) * - Eigen::AngleAxisd(rxyz_rad.y(), Eigen::Vector3d::UnitY()) * - Eigen::AngleAxisd(rxyz_rad.x(), Eigen::Vector3d::UnitX())).matrix(); - Eigen::Matrix4d R = Eigen::Matrix4d::Identity(); - R.block<3, 3>(0, 0) = R3; - return R; +// 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 -double federationUnitToMeters(const FederationConfig& cfg) { - return convert(1.0, cfg.unit_prefix, cfg.unit_name, "", "METRE"); +Federation::Federation(QObject* parent) : QObject(parent) {} + +QString Federation::generateId() { + return QUuid::createUuid().toString(QUuid::WithoutBraces); } -Eigen::Matrix4d composeFederationOrigin(const FederationOrigin& origin, - const FederationConfig& cfg) { - const double u = federationUnitToMeters(cfg); - const Eigen::Vector3d xyz_m = origin.xyz * u; - const double rz_rad = origin.rz_deg * kDegToRad; - - const Eigen::Matrix3d Rz = - Eigen::AngleAxisd(rz_rad, Eigen::Vector3d::UnitZ()).matrix(); - Eigen::Matrix4d Rz4 = Eigen::Matrix4d::Identity(); - Rz4.block<3, 3>(0, 0) = Rz; - - return Rz4 * translation4(-xyz_m); +bool Federation::isFederationPath(const QString& path) { + return path.endsWith(".ifcfed", Qt::CaseInsensitive); } -Eigen::Matrix4d composeModelTransform(const ModelTransform& xf, - const FederationConfig& fed_cfg, - const ModelUnits& model_units, - const Eigen::Matrix4d& stage2_meters) { - const double u_fed = federationUnitToMeters(fed_cfg); +void Federation::clear() { + file_path_.clear(); + name_.clear(); + created_ = QDateTime(); + modified_ = QDateTime(); + models_.clear(); + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(false); +} - Eigen::Vector3d A_m; - if (xf.a_frame == AFrame::ModelLocal) { - // a is in the model's project length unit, expressed in the - // pre-stage2 frame. Convert to metres, then lift through stage 2. - const Eigen::Vector4d a_h( - xf.a.x() * model_units.project_length_to_meters, - xf.a.y() * model_units.project_length_to_meters, - xf.a.z() * model_units.project_length_to_meters, - 1.0); - A_m = (stage2_meters * a_h).head<3>(); - } else { - // a is in the model's map unit, expressed in the post-stage2 frame. - A_m = xf.a * model_units.map_unit_to_meters; +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); } - const Eigen::Vector3d B_m = xf.b * u_fed; - const Eigen::Vector3d pivot_m = xf.pivot * u_fed; + name_ = root.value("name").toString(); + created_ = QDateTime::fromString(root.value("created").toString(), Qt::ISODate); + modified_ = QDateTime::fromString(root.value("modified").toString(), Qt::ISODate); - const Eigen::Matrix4d R_local = eulerXYZ(xf.rxyz_deg * kDegToRad); - const Eigen::Matrix4d R_at_pivot = - translation4(pivot_m) * R_local * translation4(-pivot_m); + 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(); - // Translate so R_at_pivot · A lands at B. - const Eigen::Vector4d Ah(A_m.x(), A_m.y(), A_m.z(), 1.0); - const Eigen::Vector3d RA = (R_at_pivot * Ah).head<3>(); - const Eigen::Matrix4d T = translation4(B_m - RA); + Model m; + m.id = mo.value("id").toString(); + if (m.id.isEmpty()) m.id = generateId(); + m.display_name = mo.value("display_name").toString(); - return T * R_at_pivot; + 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 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.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/Federation.h b/src/ifcviewer/Federation.h index 885ee51ed9..f3174582cc 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -17,100 +17,85 @@ * * ********************************************************************************/ -// Federation-level transformation data model and compose helpers. -// -// A federation is the user's working scene, composed of one or more IFC -// models. Each model lives at: -// -// stage3 · stage4 · stage2 · placement_stage1 -// -// where: -// - stage1 is per-mesh vertex rebasing (applied to the geometry buffers) -// - stage2 is the per-model georef matrix (immutable, derived from the IFC) -// - stage3 is the federation-wide false origin (mutable, this header) -// - stage4 is the per-model placement within the federation (mutable, this header) -// -// All composed matrices are in metres. The user-authored intent is stored -// in source units (model project unit / model map unit / federation unit) to -// preserve precision; conversion to metres happens in the compose helpers. - #ifndef FEDERATION_H #define FEDERATION_H -#include +#include +#include +#include +#include +#include -#include +#include -// Federation-wide settings persisted in .ifcfed. -struct FederationConfig { - // IfcSIUnit name ("METRE") or IfcConversionBasedUnit name ("foot", "inch", ...). - std::string unit_name = "METRE"; - // SI prefix ("MILLI", "KILO", ...) — empty for unprefixed or for - // conversion-based units. - std::string unit_prefix = ""; -}; - -// Stage 3 — the federation false origin. Authoring intent is "nominate this -// XYZ as the new origin, with optional Z-axis heading rotation". Composed as +// In-memory representation of an .ifcfed file (IFC federation). // -// stage3 = R_z(rz_deg) · T(-xyz_in_metres) +// 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. // -// i.e. translate the federation so the nominated point lands at the origin, -// then rotate around the new origin. Translation is given in federation unit; -// rotation is in degrees. -struct FederationOrigin { - Eigen::Vector3d xyz = Eigen::Vector3d::Zero(); // federation unit - double rz_deg = 0.0; // degrees +// Round-trip-only fields today (no UI to edit, but preserved across load/ +// save): 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 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; }; -// Frame in which ModelTransform.a is expressed. -// ModelLocal — pre-stage2 model coordinates, in the model's project length unit -// ModelGlobal — post-stage2 model coordinates, in the model's map unit -enum class AFrame { ModelLocal, ModelGlobal }; - -// Stage 4 — the per-model placement within the federation. Authoring intent -// is "rotate the model around `pivot`, then translate so that point `a` lands -// at point `b`". Composed as -// -// R_local = R_z(rz) · R_y(ry) · R_x(rx) [intrinsic XYZ] -// R_at_pivot = T(pivot_m) · R_local · T(-pivot_m) -// stage4 = T(b_m - R_at_pivot · a_m) · R_at_pivot -// -// Numbers are stored in their original input unit (a in model project or map -// unit per a_frame, b/pivot in federation unit) so that the user's typed -// values round-trip without precision loss. -struct ModelTransform { - AFrame a_frame = AFrame::ModelGlobal; - Eigen::Vector3d a = Eigen::Vector3d::Zero(); // model project / map unit - Eigen::Vector3d b = Eigen::Vector3d::Zero(); // federation unit - Eigen::Vector3d rxyz_deg = Eigen::Vector3d::Zero(); // degrees, intrinsic XYZ - Eigen::Vector3d pivot = Eigen::Vector3d::Zero(); // federation unit -}; - -// Per-model unit scales captured at load time. project_length_to_meters -// comes from calculateUnitScale(file, "LENGTHUNIT"); map_unit_to_meters from -// siScaleFromNamedUnit(getMapUnit(file)) and falls back to the project length -// scale when the model has no MapUnit. -struct ModelUnits { - double project_length_to_meters = 1.0; - double map_unit_to_meters = 1.0; -}; - -// 1 federation_unit -> N metres. Cached at the call site if needed. -double federationUnitToMeters(const FederationConfig&); - -// Compose stage 3 (federation false origin) into a 4x4 matrix in metres. -Eigen::Matrix4d composeFederationOrigin(const FederationOrigin&, - const FederationConfig&); - -// Compose stage 4 (per-model placement within the federation) into a 4x4 -// matrix in metres. `stage2_meters` is the model's georef matrix (e.g. -// helmertMetersFromParameters · inv(wcs_meters)) — needed to lift `a` into -// metres when a_frame == ModelLocal. Pass identity when stage 2 is disabled -// or absent. -Eigen::Matrix4d composeModelTransform(const ModelTransform&, - const FederationConfig& fed_cfg, - const ModelUnits& model_units, - const Eigen::Matrix4d& stage2_meters); - #endif // FEDERATION_H diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index d6739bb22b..70436a9b73 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -47,3 +47,22 @@ add_ifcviewer_unit_test(test_sidecar_cache ) add_ifcviewer_unit_test(test_instanced_geometry) + +# Federation is Qt-derived (QObject + signals + QVector3D + QJson*). Unlike +# the other Tier-1 tests it has to pull Qt6::Core/Gui/Test in directly and +# enable AUTOMOC for the Q_OBJECT moc-generation. +find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) + +add_executable(test_federation + test_federation.cpp + ${IFCVIEWER_SRC}/Federation.cpp +) +set_target_properties(test_federation PROPERTIES AUTOMOC ON) +target_include_directories(test_federation PRIVATE ${IFCVIEWER_SRC}) +target_link_libraries(test_federation PRIVATE + Catch2::Catch2WithMain + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui + Qt${QT_VERSION}::Test # QSignalSpy +) +catch_discover_tests(test_federation) diff --git a/src/ifcviewer-full/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp similarity index 100% rename from src/ifcviewer-full/tests/test_federation.cpp rename to src/ifcviewer/tests/test_federation.cpp