diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp
index 0fd00530b1..8dda5cfd42 100644
--- a/src/ifcviewer-full/MainWindow.cpp
+++ b/src/ifcviewer-full/MainWindow.cpp
@@ -21,6 +21,7 @@
#include "AppSettings.h"
#include "Federation.h"
#include "FederationSettingsDialog.h"
+#include "ModelTransformationDialog.h"
#include "SettingsWindow.h"
#include "LodBuilder.h"
#include "SidecarCache.h"
@@ -192,6 +193,8 @@ void MainWindow::setupMenus() {
QKeySequence::SaveAs);
file_menu->addAction("Federation Se&ttings...",
this, &MainWindow::onFederationSettings);
+ file_menu->addAction("&Model Transformations...",
+ this, &MainWindow::onModelTransformations);
file_menu->addSeparator();
file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings);
file_menu->addSeparator();
@@ -240,6 +243,15 @@ void MainWindow::onFederationSettings() {
federation_settings_->raise();
}
+void MainWindow::onModelTransformations() {
+ if (model_transformations_ == nullptr) {
+ model_transformations_ = new ModelTransformationDialog(federation_, this);
+ }
+ model_transformations_->open();
+ model_transformations_->activateWindow();
+ model_transformations_->raise();
+}
+
void MainWindow::addFiles(const QStringList& paths) {
QStringList accepted_paths;
QStringList accepted_fed_ids;
diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h
index 20a514c073..85bd6b2473 100644
--- a/src/ifcviewer-full/MainWindow.h
+++ b/src/ifcviewer-full/MainWindow.h
@@ -37,6 +37,7 @@
class Federation;
class SettingsWindow;
class FederationSettingsDialog;
+class ModelTransformationDialog;
class MainWindow : public QMainWindow {
Q_OBJECT
@@ -63,6 +64,7 @@ private slots:
void onGoHomeView();
void onFileSettings();
void onFederationSettings();
+ void onModelTransformations();
void onObjectPicked(uint32_t object_id);
void onTreeSelectionChanged();
@@ -122,6 +124,7 @@ private:
Federation* federation_ = nullptr;
SettingsWindow* settings_ = nullptr;
FederationSettingsDialog* federation_settings_ = nullptr;
+ ModelTransformationDialog* model_transformations_ = nullptr;
QWidget* viewport_container_ = nullptr;
QTreeWidget* element_tree_ = nullptr;
QTableWidget* property_table_ = nullptr;
diff --git a/src/ifcviewer-full/ModelTransformationDialog.cpp b/src/ifcviewer-full/ModelTransformationDialog.cpp
new file mode 100644
index 0000000000..f42c45ca42
--- /dev/null
+++ b/src/ifcviewer-full/ModelTransformationDialog.cpp
@@ -0,0 +1,248 @@
+/********************************************************************************
+ * *
+ * 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 "ModelTransformationDialog.h"
+#include "Federation.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+QString unitDisplay(const FederationConfig& cfg) {
+ QString s;
+ if (!cfg.unit_prefix.empty()) s += QString::fromStdString(cfg.unit_prefix) + " ";
+ s += QString::fromStdString(cfg.unit_name);
+ return s;
+}
+
+} // namespace
+
+ModelTransformationDialog::ModelTransformationDialog(Federation* federation,
+ QWidget* parent)
+ : QDialog(parent), federation_(federation)
+{
+ setWindowTitle("Model Transformation");
+ setupUi();
+}
+
+void ModelTransformationDialog::setupUi() {
+ auto* root = new QVBoxLayout(this);
+
+ // Model picker.
+ {
+ auto* row = new QHBoxLayout();
+ row->addWidget(new QLabel("Model:", this));
+ model_combo_ = new QComboBox(this);
+ row->addWidget(model_combo_, 1);
+ root->addLayout(row);
+ connect(model_combo_, QOverload::of(&QComboBox::currentIndexChanged),
+ this, &ModelTransformationDialog::onModelChanged);
+ }
+
+ auto build_xyz = [](QDoubleSpinBox*& sb) {
+ sb = new QDoubleSpinBox();
+ sb->setRange(-1e12, 1e12);
+ sb->setDecimals(6);
+ sb->setSingleStep(1.0);
+ };
+
+ // A-frame + A.
+ {
+ auto* group = new QGroupBox("Point A", this);
+ group->setToolTip(
+ "The model-side anchor point. ModelLocal expresses A in the "
+ "model's project length unit, before its CoordinateOperation. "
+ "ModelGlobal expresses A in the model's map unit, after its "
+ "CoordinateOperation.");
+ auto* form = new QFormLayout(group);
+
+ radio_local_ = new QRadioButton("ModelLocal", group);
+ radio_global_ = new QRadioButton("ModelGlobal", group);
+ radio_global_->setChecked(true);
+ auto* af_group = new QButtonGroup(this);
+ af_group->addButton(radio_local_);
+ af_group->addButton(radio_global_);
+ connect(radio_local_, &QRadioButton::toggled,
+ this, &ModelTransformationDialog::onAFrameToggled);
+ connect(radio_global_, &QRadioButton::toggled,
+ this, &ModelTransformationDialog::onAFrameToggled);
+
+ auto* af_row = new QHBoxLayout();
+ af_row->addWidget(radio_local_);
+ af_row->addWidget(radio_global_);
+ af_row->addStretch();
+ form->addRow("Frame", af_row);
+
+ build_xyz(a_x_); build_xyz(a_y_); build_xyz(a_z_);
+ form->addRow("X", a_x_);
+ form->addRow("Y", a_y_);
+ form->addRow("Z", a_z_);
+ a_unit_label_ = new QLabel("(model unit)", group);
+ form->addRow("Unit", a_unit_label_);
+
+ root->addWidget(group);
+ }
+
+ // B.
+ {
+ auto* group = new QGroupBox("Point B (federation target)", this);
+ group->setToolTip(
+ "Where in the federation point A should land. In federation units.");
+ auto* form = new QFormLayout(group);
+ build_xyz(b_x_); build_xyz(b_y_); build_xyz(b_z_);
+ form->addRow("X", b_x_);
+ form->addRow("Y", b_y_);
+ form->addRow("Z", b_z_);
+ b_unit_label_ = new QLabel("(federation unit)", group);
+ form->addRow("Unit", b_unit_label_);
+ root->addWidget(group);
+ }
+
+ // Rotation (intrinsic XYZ).
+ {
+ auto* group = new QGroupBox("Rotation (intrinsic XYZ, degrees)", this);
+ group->setToolTip(
+ "Composed as R = R_z(rz) · R_y(ry) · R_x(rx). Y-up models "
+ "rotated to Z-up: rx = 90, ry = 0, rz = 0.");
+ auto* form = new QFormLayout(group);
+ rx_ = new QDoubleSpinBox(); rx_->setRange(-360, 360); rx_->setDecimals(6);
+ ry_ = new QDoubleSpinBox(); ry_->setRange(-360, 360); ry_->setDecimals(6);
+ rz_ = new QDoubleSpinBox(); rz_->setRange(-360, 360); rz_->setDecimals(6);
+ form->addRow("rx (°)", rx_);
+ form->addRow("ry (°)", ry_);
+ form->addRow("rz (°)", rz_);
+ root->addWidget(group);
+ }
+
+ // Pivot.
+ {
+ auto* group = new QGroupBox("Rotation Pivot", this);
+ group->setToolTip(
+ "The point the rotation rotates around, in federation units. "
+ "Set pivot = B to keep A landing on B regardless of rotation.");
+ auto* form = new QFormLayout(group);
+ build_xyz(pivot_x_); build_xyz(pivot_y_); build_xyz(pivot_z_);
+ form->addRow("X", pivot_x_);
+ form->addRow("Y", pivot_y_);
+ form->addRow("Z", pivot_z_);
+ pivot_unit_label_ = new QLabel("(federation unit)", group);
+ form->addRow("Unit", pivot_unit_label_);
+ root->addWidget(group);
+ }
+
+ auto* button_box = new QDialogButtonBox(
+ QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+ root->addWidget(button_box);
+ connect(button_box, &QDialogButtonBox::accepted, this,
+ &ModelTransformationDialog::onAccepted);
+ connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
+}
+
+void ModelTransformationDialog::showEvent(QShowEvent* event) {
+ populateModelCombo();
+ refreshUnitLabels();
+ if (model_combo_->count() > 0) {
+ syncFromModel(model_combo_->currentData().toString());
+ }
+ QDialog::showEvent(event);
+}
+
+void ModelTransformationDialog::populateModelCombo() {
+ model_combo_->blockSignals(true);
+ QString prev_id = model_combo_->currentData().toString();
+ model_combo_->clear();
+ if (federation_) {
+ for (const auto& m : federation_->models()) {
+ QString label = m.display_name.isEmpty() ? m.id : m.display_name;
+ model_combo_->addItem(label, m.id);
+ }
+ }
+ int restore = -1;
+ for (int i = 0; i < model_combo_->count(); ++i) {
+ if (model_combo_->itemData(i).toString() == prev_id) { restore = i; break; }
+ }
+ if (restore >= 0) model_combo_->setCurrentIndex(restore);
+ model_combo_->blockSignals(false);
+}
+
+void ModelTransformationDialog::syncFromModel(const QString& fed_id) {
+ if (!federation_) return;
+ const Federation::Model* m = federation_->findById(fed_id);
+ if (!m) return;
+ const ModelTransformation& xf = m->model_transformation;
+
+ radio_local_->setChecked(xf.a_frame == AFrame::ModelLocal);
+ radio_global_->setChecked(xf.a_frame == AFrame::ModelGlobal);
+
+ a_x_->setValue(xf.a.x()); a_y_->setValue(xf.a.y()); a_z_->setValue(xf.a.z());
+ b_x_->setValue(xf.b.x()); b_y_->setValue(xf.b.y()); b_z_->setValue(xf.b.z());
+ rx_->setValue(xf.rxyz_deg.x());
+ ry_->setValue(xf.rxyz_deg.y());
+ rz_->setValue(xf.rxyz_deg.z());
+ pivot_x_->setValue(xf.pivot.x());
+ pivot_y_->setValue(xf.pivot.y());
+ pivot_z_->setValue(xf.pivot.z());
+}
+
+void ModelTransformationDialog::refreshUnitLabels() {
+ if (!federation_) return;
+ const QString fed_unit = unitDisplay(federation_->config());
+ b_unit_label_->setText("(federation: " + fed_unit + ")");
+ pivot_unit_label_->setText("(federation: " + fed_unit + ")");
+ onAFrameToggled();
+}
+
+void ModelTransformationDialog::onModelChanged(int /*idx*/) {
+ if (model_combo_->count() == 0) return;
+ syncFromModel(model_combo_->currentData().toString());
+}
+
+void ModelTransformationDialog::onAFrameToggled() {
+ if (!a_unit_label_) return;
+ if (radio_local_->isChecked()) {
+ a_unit_label_->setText("(model project length unit, e.g. millimetre)");
+ } else {
+ a_unit_label_->setText("(model map unit, e.g. metre)");
+ }
+}
+
+void ModelTransformationDialog::onAccepted() {
+ if (!federation_ || model_combo_->count() == 0) { accept(); return; }
+ const QString fed_id = model_combo_->currentData().toString();
+
+ ModelTransformation xf;
+ xf.a_frame = radio_local_->isChecked() ? AFrame::ModelLocal : AFrame::ModelGlobal;
+ xf.a = Eigen::Vector3d(a_x_->value(), a_y_->value(), a_z_->value());
+ xf.b = Eigen::Vector3d(b_x_->value(), b_y_->value(), b_z_->value());
+ xf.rxyz_deg = Eigen::Vector3d(rx_->value(), ry_->value(), rz_->value());
+ xf.pivot = Eigen::Vector3d(pivot_x_->value(), pivot_y_->value(), pivot_z_->value());
+
+ federation_->setModelTransformation(fed_id, xf);
+ accept();
+}
diff --git a/src/ifcviewer-full/ModelTransformationDialog.h b/src/ifcviewer-full/ModelTransformationDialog.h
new file mode 100644
index 0000000000..1d4d910095
--- /dev/null
+++ b/src/ifcviewer-full/ModelTransformationDialog.h
@@ -0,0 +1,88 @@
+/********************************************************************************
+ * *
+ * 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 MODELTRANSFORMATIONDIALOG_H
+#define MODELTRANSFORMATIONDIALOG_H
+
+#include
+#include
+
+QT_BEGIN_NAMESPACE
+class QComboBox;
+class QDoubleSpinBox;
+class QLabel;
+class QRadioButton;
+QT_END_NAMESPACE
+
+class Federation;
+
+// Edits one model's ModelTransformation (the per-model placement within
+// the federation: a_frame, a, b, rxyz Euler degrees, pivot). A combobox
+// at the top picks which model to edit. Switching models discards any
+// unsaved form edits — Ok saves the currently-visible model. On Ok,
+// calls Federation::setModelTransformation, which fires
+// modelTransformationChanged → MainWindow recomposes the viewport for
+// that model.
+class ModelTransformationDialog : public QDialog {
+ Q_OBJECT
+public:
+ explicit ModelTransformationDialog(Federation* federation, QWidget* parent = nullptr);
+
+protected:
+ void showEvent(QShowEvent* event) override;
+
+private slots:
+ void onModelChanged(int idx);
+ void onAFrameToggled();
+ void onAccepted();
+
+private:
+ void setupUi();
+ void populateModelCombo();
+ void syncFromModel(const QString& fed_id);
+ void refreshUnitLabels();
+
+ Federation* federation_ = nullptr;
+
+ QComboBox* model_combo_ = nullptr;
+
+ QRadioButton* radio_local_ = nullptr;
+ QRadioButton* radio_global_ = nullptr;
+
+ QDoubleSpinBox* a_x_ = nullptr;
+ QDoubleSpinBox* a_y_ = nullptr;
+ QDoubleSpinBox* a_z_ = nullptr;
+ QLabel* a_unit_label_ = nullptr;
+
+ QDoubleSpinBox* b_x_ = nullptr;
+ QDoubleSpinBox* b_y_ = nullptr;
+ QDoubleSpinBox* b_z_ = nullptr;
+ QLabel* b_unit_label_ = nullptr;
+
+ QDoubleSpinBox* rx_ = nullptr;
+ QDoubleSpinBox* ry_ = nullptr;
+ QDoubleSpinBox* rz_ = nullptr;
+
+ QDoubleSpinBox* pivot_x_ = nullptr;
+ QDoubleSpinBox* pivot_y_ = nullptr;
+ QDoubleSpinBox* pivot_z_ = nullptr;
+ QLabel* pivot_unit_label_ = nullptr;
+};
+
+#endif