From 83a313127648c8a2e7e1257346b2c20c7aa79479 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 20:49:39 +1000 Subject: [PATCH] Multi-model project support with sequential loading Introduce ModelHandle and per-model GeometryStreamers so multiple IFC files can be loaded simultaneously. Object IDs are globally unique (monotonically increasing across models). File picker is now multiselect. Each model gets a top-level tree node. Property lookup uses the correct model's ifcopenshell::file. ViewportWindow supports hide/show/remove per model via model_id filtering in the frustum cull pass. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/GeometryStreamer.cpp | 7 +- src/ifcviewer/GeometryStreamer.h | 7 +- src/ifcviewer/MainWindow.cpp | 134 +++++++++++++++++++++-------- src/ifcviewer/MainWindow.h | 31 ++++++- src/ifcviewer/ViewportWindow.cpp | 22 +++++ src/ifcviewer/ViewportWindow.h | 9 ++ src/ifcviewer/main.cpp | 4 +- 7 files changed, 167 insertions(+), 47 deletions(-) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 437209c909..7235bced9f 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -40,7 +40,7 @@ GeometryStreamer::~GeometryStreamer() { } } -void GeometryStreamer::loadFile(const std::string& path, int num_threads) { +void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_id, uint32_t model_id, int num_threads) { if (running_.load()) { cancel(); if (worker_thread_ && worker_thread_->isRunning()) { @@ -52,7 +52,8 @@ void GeometryStreamer::loadFile(const std::string& path, int num_threads) { cancel_requested_ = false; running_ = true; progress_ = 0; - next_object_id_ = 1; + next_object_id_ = start_object_id; + model_id_ = model_id; { std::lock_guard lock(elements_mutex_); @@ -139,6 +140,7 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { // Record element metadata ElementInfo info; info.object_id = object_id; + info.model_id = model_id_; info.ifc_id = tri_elem->id(); info.guid = tri_elem->guid(); info.name = tri_elem->name(); @@ -201,6 +203,7 @@ static inline uint32_t packRGBA8(const MaterialInfo& m) { UploadChunk GeometryStreamer::convertElement(const IfcGeom::TriangulationElement* elem, uint32_t object_id) { UploadChunk chunk; chunk.object_id = object_id; + chunk.model_id = model_id_; const auto& geom = elem->geometry(); const auto& verts = geom.verts(); diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h index abd087463c..0d49a12ca7 100644 --- a/src/ifcviewer/GeometryStreamer.h +++ b/src/ifcviewer/GeometryStreamer.h @@ -38,6 +38,7 @@ struct ElementInfo { uint32_t object_id; + uint32_t model_id; int ifc_id; std::string guid; std::string name; @@ -51,11 +52,13 @@ public: explicit GeometryStreamer(QObject* parent = nullptr); ~GeometryStreamer(); - void loadFile(const std::string& path, int num_threads = 0); + void loadFile(const std::string& path, uint32_t start_object_id, uint32_t model_id, int num_threads = 0); void cancel(); bool isRunning() const { return running_.load(); } int progress() const { return progress_.load(); } + uint32_t lastObjectId() const { return next_object_id_; } + uint32_t modelId() const { return model_id_; } ifcopenshell::file* ifcFile() const { return ifc_file_.get(); } @@ -82,8 +85,8 @@ private: std::mutex elements_mutex_; std::vector pending_elements_; - // Map from IFC product id to our compact object_id uint32_t next_object_id_ = 1; // 0 = no object + uint32_t model_id_ = 0; }; #endif // GEOMETRYSTREAMER_H diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 4abd929b0b..3b4e58fbac 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -36,14 +37,6 @@ MainWindow::MainWindow(QWidget* parent) setupUi(); setupMenus(); - streamer_ = new GeometryStreamer(this); - connect(streamer_, &GeometryStreamer::progressChanged, this, &MainWindow::onProgressChanged, Qt::QueuedConnection); - connect(streamer_, &GeometryStreamer::elementReady, this, &MainWindow::onElementReady, Qt::QueuedConnection); - connect(streamer_, &GeometryStreamer::finished, this, &MainWindow::onStreamingFinished, Qt::QueuedConnection); - connect(streamer_, &GeometryStreamer::errorOccurred, this, [this](const QString& msg) { - QMessageBox::warning(this, "Error", msg); - }, Qt::QueuedConnection); - connect(viewport_, &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& s) { if (!stats_label_->isVisible()) return; stats_label_->setText( @@ -118,7 +111,7 @@ void MainWindow::setupUi() { void MainWindow::setupMenus() { auto* file_menu = menuBar()->addMenu("&File"); - auto* open_action = file_menu->addAction("&Open...", this, &MainWindow::onFileOpen); + auto* open_action = file_menu->addAction("&Add Files...", this, &MainWindow::onFileOpen); open_action->setShortcut(QKeySequence::Open); file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings); file_menu->addSeparator(); @@ -126,9 +119,11 @@ void MainWindow::setupMenus() { } void MainWindow::onFileOpen() { - QString path = QFileDialog::getOpenFileName(this, "Open IFC File", QString(), "IFC Files (*.ifc *.ifcxml *.ifczip);;All Files (*)"); - if (!path.isEmpty()) { - openFile(path); + QStringList paths = QFileDialog::getOpenFileNames( + this, "Add IFC Files", QString(), + "IFC Files (*.ifc *.ifcxml *.ifczip);;All Files (*)"); + if (!paths.isEmpty()) { + addFiles(paths); } } @@ -141,21 +136,64 @@ void MainWindow::onFileSettings() { settings_->raise(); } -void MainWindow::openFile(const QString& path) { - viewport_->resetScene(); - element_tree_->clear(); - property_table_->setRowCount(0); - element_map_.clear(); - tree_items_.clear(); - ifc_id_to_object_id_.clear(); +void MainWindow::addFiles(const QStringList& paths) { + for (const auto& path : paths) { + ModelId id = next_model_id_++; + + ModelHandle handle; + handle.id = id; + handle.file_path = path; + handle.display_name = QFileInfo(path).fileName(); + handle.streamer = new GeometryStreamer(this); + + // Create top-level tree item for this model + auto* root = new QTreeWidgetItem(element_tree_); + root->setText(0, handle.display_name); + root->setText(1, "IFC Model"); + root->setData(0, Qt::UserRole, static_cast(0)); // 0 = not a pickable object + handle.tree_root = root; + + models_[id] = handle; + load_queue_.push_back(id); + } + + if (loading_model_id_ == 0) { + startNextLoad(); + } +} + +void MainWindow::connectStreamer(GeometryStreamer* streamer) { + connect(streamer, &GeometryStreamer::progressChanged, + this, &MainWindow::onProgressChanged, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::elementReady, + this, &MainWindow::onElementReady, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::finished, + this, &MainWindow::onStreamingFinished, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::errorOccurred, this, [this](const QString& msg) { + QMessageBox::warning(this, "Error", msg); + }, Qt::QueuedConnection); +} + +void MainWindow::startNextLoad() { + if (load_queue_.empty()) { + loading_model_id_ = 0; + return; + } + + loading_model_id_ = load_queue_.front(); + load_queue_.pop_front(); + + auto& model = models_[loading_model_id_]; + connectStreamer(model.streamer); progress_bar_->setValue(0); progress_bar_->setVisible(true); - status_label_->setText("Loading: " + path); + status_label_->setText("Loading: " + model.display_name); load_timer_.restart(); element_poll_timer_.start(); - streamer_->loadFile(path.toStdString()); + model.streamer->loadFile( + model.file_path.toStdString(), next_object_id_, loading_model_id_); } void MainWindow::onProgressChanged(int percent) { @@ -170,15 +208,30 @@ void MainWindow::onStreamingFinished() { element_poll_timer_.stop(); pollNewElements(); // drain remaining + // Update next_object_id_ from the streamer that just finished. + if (loading_model_id_ != 0) { + auto it = models_.find(loading_model_id_); + if (it != models_.end()) { + next_object_id_ = it->second.streamer->lastObjectId(); + } + } + progress_bar_->setVisible(false); qint64 ms = load_timer_.elapsed(); QString elapsed = (ms >= 1000) ? QString::number(ms / 1000.0, 'f', 2) + " s" : QString::number(ms) + " ms"; - status_label_->setText(QString("Loaded %1 elements in %2") - .arg(element_map_.size()) + + size_t total_elements = element_map_.size(); + size_t num_models = models_.size(); + status_label_->setText(QString("%1 elements across %2 model(s) — last loaded in %3") + .arg(total_elements) + .arg(num_models) .arg(elapsed)); + + // Start next model if queued. + startNextLoad(); } void MainWindow::onObjectPicked(uint32_t object_id) { @@ -205,15 +258,23 @@ void MainWindow::onTreeSelectionChanged() { } void MainWindow::pollNewElements() { - auto elements = streamer_->drainElements(); + if (loading_model_id_ == 0) return; + + auto it = models_.find(loading_model_id_); + if (it == models_.end()) return; + + auto& model = it->second; + auto elements = model.streamer->drainElements(); + for (auto& info : elements) { element_map_[info.object_id] = info; - ifc_id_to_object_id_[info.ifc_id] = info.object_id; + scoped_ifc_id_to_object_id_[scopedKey(info.model_id, info.ifc_id)] = info.object_id; - // Find parent tree item - QTreeWidgetItem* parent_item = nullptr; - auto parent_obj_it = ifc_id_to_object_id_.find(info.parent_id); - if (parent_obj_it != ifc_id_to_object_id_.end()) { + // Find parent tree item (scoped to this model) + QTreeWidgetItem* parent_item = model.tree_root; + auto parent_obj_it = scoped_ifc_id_to_object_id_.find( + scopedKey(info.model_id, info.parent_id)); + if (parent_obj_it != scoped_ifc_id_to_object_id_.end()) { auto tree_it = tree_items_.find(parent_obj_it->second); if (tree_it != tree_items_.end()) { parent_item = tree_it->second; @@ -225,12 +286,7 @@ void MainWindow::pollNewElements() { display_name = QString::fromStdString(info.type) + " #" + QString::number(info.ifc_id); } - QTreeWidgetItem* item; - if (parent_item) { - item = new QTreeWidgetItem(parent_item); - } else { - item = new QTreeWidgetItem(element_tree_); - } + auto* item = new QTreeWidgetItem(parent_item); item->setText(0, display_name); item->setText(1, QString::fromStdString(info.type)); item->setText(2, QString::fromStdString(info.guid)); @@ -261,8 +317,11 @@ void MainWindow::populateProperties(uint32_t object_id) { addRow("Name", QString::fromStdString(info.name)); addRow("Type", QString::fromStdString(info.type)); - // If the file is loaded, try to get property sets - auto* file = streamer_->ifcFile(); + // Find the correct model's file for property lookup + auto model_it = models_.find(info.model_id); + if (model_it == models_.end()) return; + + auto* file = model_it->second.streamer->ifcFile(); if (!file) return; auto product = file->instance_by_id(info.ifc_id); @@ -280,7 +339,6 @@ void MainWindow::populateProperties(uint32_t object_id) { try { str_val = static_cast(val); } catch (...) { - // Not a string-convertible attribute (entity ref, aggregate, etc.) str_val = "<" + std::string(ifcopenshell::argument_type_to_string(val.type())) + ">"; } addRow(QString::fromStdString(attr->name()), QString::fromStdString(str_val)); diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h index bbec6ce83d..e9bcc37cb6 100644 --- a/src/ifcviewer/MainWindow.h +++ b/src/ifcviewer/MainWindow.h @@ -29,6 +29,8 @@ #include #include +#include +#include #include #include "ViewportWindow.h" @@ -36,13 +38,24 @@ class SettingsWindow; +using ModelId = uint32_t; + +struct ModelHandle { + ModelId id = 0; + QString file_path; + QString display_name; + GeometryStreamer* streamer = nullptr; + QTreeWidgetItem* tree_root = nullptr; + bool visible = true; +}; + class MainWindow : public QMainWindow { Q_OBJECT public: explicit MainWindow(QWidget* parent = nullptr); ~MainWindow(); - void openFile(const QString& path); + void addFiles(const QStringList& paths); private slots: void onFileOpen(); @@ -58,6 +71,8 @@ private: void setupUi(); void setupMenus(); void populateProperties(uint32_t object_id); + void startNextLoad(); + void connectStreamer(GeometryStreamer* streamer); ViewportWindow* viewport_ = nullptr; SettingsWindow* settings_ = nullptr; @@ -70,12 +85,22 @@ private: QTimer element_poll_timer_; QElapsedTimer load_timer_; - GeometryStreamer* streamer_ = nullptr; + // Multi-model state + std::map models_; + ModelId next_model_id_ = 1; + uint32_t next_object_id_ = 1; // monotonically increasing across all models + std::deque load_queue_; + ModelId loading_model_id_ = 0; // Map object_id -> tree item and element info std::unordered_map element_map_; std::unordered_map tree_items_; - std::unordered_map ifc_id_to_object_id_; + // Scoped (model_id, ifc_id) -> object_id + std::unordered_map scoped_ifc_id_to_object_id_; + + static uint64_t scopedKey(uint32_t model_id, int ifc_id) { + return (static_cast(model_id) << 32) | static_cast(ifc_id); + } }; #endif // MAINWINDOW_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 1ebe988554..4217c99742 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -427,6 +427,7 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) { ObjectDrawInfo info; info.index_offset = static_cast(ebo_used_); info.index_count = static_cast(chunk.indices.size()); + info.model_id = chunk.model_id; const size_t num_verts = chunk.vertices.size() / VERTEX_STRIDE; if (num_verts > 0) { @@ -467,6 +468,23 @@ void ViewportWindow::resetScene() { total_triangles_ = 0; selected_object_id_ = 0; object_draw_info_.clear(); + hidden_models_.clear(); + removed_models_.clear(); +} + +void ViewportWindow::hideModel(uint32_t model_id) { + std::lock_guard lock(upload_mutex_); + hidden_models_.insert(model_id); +} + +void ViewportWindow::showModel(uint32_t model_id) { + std::lock_guard lock(upload_mutex_); + hidden_models_.erase(model_id); +} + +void ViewportWindow::removeModel(uint32_t model_id) { + std::lock_guard lock(upload_mutex_); + removed_models_.insert(model_id); } void ViewportWindow::setSelectedObjectId(uint32_t id) { @@ -567,6 +585,10 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { visible_offsets_.reserve(object_draw_info_.size()); for (const auto& obj : object_draw_info_) { + // Skip hidden or removed models. + if (hidden_models_.count(obj.model_id) || removed_models_.count(obj.model_id)) + continue; + bool visible = true; for (int p = 0; p < 6; ++p) { // p-vertex: the AABB corner most in the direction of the plane normal. diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 58a6334321..fda82a1db5 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -29,6 +29,7 @@ #include #include +#include #include #include @@ -39,6 +40,7 @@ struct MaterialInfo { struct ObjectDrawInfo { uint32_t index_offset; // byte offset into EBO uint32_t index_count; // number of indices + uint32_t model_id; // which model this object belongs to float aabb_min[3]; // world-space AABB float aabb_max[3]; }; @@ -51,6 +53,7 @@ struct UploadChunk { std::vector vertices; std::vector indices; // local to this chunk's vertices uint32_t object_id = 0; + uint32_t model_id = 0; }; class ViewportWindow : public QWindow { @@ -62,6 +65,10 @@ public: void uploadChunk(const UploadChunk& chunk); void resetScene(); + void hideModel(uint32_t model_id); + void showModel(uint32_t model_id); + void removeModel(uint32_t model_id); + void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); @@ -136,6 +143,8 @@ private: // Per-object draw metadata for frustum culling. std::vector object_draw_info_; + std::unordered_set hidden_models_; + std::unordered_set removed_models_; uint32_t total_index_count_ = 0; std::mutex upload_mutex_; diff --git a/src/ifcviewer/main.cpp b/src/ifcviewer/main.cpp index 3bca693a37..a5bb487db8 100644 --- a/src/ifcviewer/main.cpp +++ b/src/ifcviewer/main.cpp @@ -40,7 +40,7 @@ int main(int argc, char* argv[]) { QCommandLineParser parser; parser.setApplicationDescription("IfcOpenShell IFC Viewer"); parser.addHelpOption(); - parser.addPositionalArgument("file", "IFC file to open"); + parser.addPositionalArgument("files", "IFC file(s) to open", "[files...]"); parser.process(app); MainWindow window; @@ -48,7 +48,7 @@ int main(int argc, char* argv[]) { auto args = parser.positionalArguments(); if (!args.isEmpty()) { - window.openFile(args.first()); + window.addFiles(args); } return app.exec();