diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index 502d23b4cc..69667eff07 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -71,6 +71,7 @@ option(BUILD_EXAMPLES "Build example applications." ON)
option(BUILD_GEOMSERVER "Build IfcGeomServer executable (Open CASCADE is required)." ON)
option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF)
option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6
+option(BUILD_IFCVIEWER "Build IfcViewer, a high-performance IFC viewer" OFF) # Requires Qt6 + OpenGL 4.5
option(BUILD_PACKAGE "" OFF)
option(WITH_OPENCASCADE "Enable geometry interpretation using Open CASCADE" ON)
@@ -671,6 +672,9 @@ if(BUILD_IFCGEOM)
install(TARGETS ${IFCGEOM_SCHEMA_LIBRARIES} ${kernel_libraries} IfcGeom)
endif(BUILD_IFCGEOM)
+if(BUILD_IFCVIEWER)
+ add_subdirectory(../src/ifcviewer ifcviewer)
+endif()
# Cmake uninstall target
if(NOT TARGET uninstall)
diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp
new file mode 100644
index 0000000000..07c5f8c3bc
--- /dev/null
+++ b/src/ifcviewer/AppSettings.cpp
@@ -0,0 +1,57 @@
+/********************************************************************************
+ * *
+ * 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 "AppSettings.h"
+
+#include
+
+namespace {
+constexpr const char* kGeometryLibraryKey = "geometry/library";
+constexpr const char* kGeometryLibraryDefault = "hybrid-cgal-simple-opencascade";
+}
+
+AppSettings& AppSettings::instance() {
+ static AppSettings inst;
+ return inst;
+}
+
+AppSettings::AppSettings() {
+ load();
+}
+
+QString AppSettings::geometryLibrary() const {
+ return geometry_library_;
+}
+
+void AppSettings::setGeometryLibrary(const QString& value) {
+ if (geometry_library_ == value) return;
+ geometry_library_ = value;
+ persist();
+ emit geometryLibraryChanged(value);
+}
+
+void AppSettings::load() {
+ QSettings settings;
+ geometry_library_ = settings.value(kGeometryLibraryKey, kGeometryLibraryDefault).toString();
+}
+
+void AppSettings::persist() {
+ QSettings settings;
+ settings.setValue(kGeometryLibraryKey, geometry_library_);
+}
diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h
new file mode 100644
index 0000000000..9658c10b95
--- /dev/null
+++ b/src/ifcviewer/AppSettings.h
@@ -0,0 +1,48 @@
+/********************************************************************************
+ * *
+ * 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 APPSETTINGS_H
+#define APPSETTINGS_H
+
+#include
+#include
+
+// Application-wide preferences. Cached in memory, persisted via QSettings to
+// the OS-native config location (registry on Windows, plist on macOS, INI on
+// Linux). Access via AppSettings::instance().
+class AppSettings : public QObject {
+ Q_OBJECT
+public:
+ static AppSettings& instance();
+
+ QString geometryLibrary() const;
+ void setGeometryLibrary(const QString& value);
+
+signals:
+ void geometryLibraryChanged(const QString& value);
+
+private:
+ AppSettings();
+ void load();
+ void persist();
+
+ QString geometry_library_;
+};
+
+#endif // APPSETTINGS_H
diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
new file mode 100644
index 0000000000..9f1c4dac50
--- /dev/null
+++ b/src/ifcviewer/CMakeLists.txt
@@ -0,0 +1,61 @@
+################################################################################
+# #
+# 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 . #
+# #
+################################################################################
+
+message("Running CMakeLists.txt in /src/ifcviewer")
+
+set(QT_VERSION 6 CACHE STRING "Qt version")
+# IfcViewer always needs OpenGL in addition to Core/Gui/Widgets. We don't use
+# the CACHE'd QT_COMPONENTS here because it may have been set by another target
+# (e.g. qtviewer) without the OpenGL component.
+find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets OpenGL REQUIRED PATHS ${QT_DIR})
+
+find_package(OpenGL REQUIRED)
+
+file(GLOB IFCVIEWER_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
+file(GLOB IFCVIEWER_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
+set(IFCVIEWER_FILES ${IFCVIEWER_CPP_FILES} ${IFCVIEWER_H_FILES})
+
+add_executable(IfcViewer ${IFCVIEWER_FILES})
+
+set_target_properties(IfcViewer PROPERTIES
+ AUTOMOC ON
+ WIN32_EXECUTABLE ON
+ MACOSX_BUNDLE ON
+)
+
+target_link_libraries(IfcViewer PRIVATE
+ IfcGeom
+ IfcParse
+ ${kernel_libraries}
+ ${OpenCASCADE_LIBRARIES}
+ ${Boost_LIBRARIES}
+ ${CGAL_LIBRARIES}
+ Qt${QT_VERSION}::Core
+ Qt${QT_VERSION}::Gui
+ Qt${QT_VERSION}::Widgets
+ Qt${QT_VERSION}::OpenGL
+ OpenGL::GL
+)
+
+if(UNIX AND NOT APPLE)
+ find_package(Threads REQUIRED)
+ target_link_libraries(IfcViewer PRIVATE Threads::Threads)
+endif()
+
+install(TARGETS IfcViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp
new file mode 100644
index 0000000000..39698c84e6
--- /dev/null
+++ b/src/ifcviewer/GeometryStreamer.cpp
@@ -0,0 +1,285 @@
+/********************************************************************************
+ * *
+ * 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 "GeometryStreamer.h"
+#include "AppSettings.h"
+#include "../ifcgeom/hybrid_kernel.h"
+
+#include
+#include
+#include
+#include
+#include
+
+GeometryStreamer::GeometryStreamer(QObject* parent)
+ : QObject(parent)
+{
+}
+
+GeometryStreamer::~GeometryStreamer() {
+ cancel();
+ if (worker_thread_ && worker_thread_->isRunning()) {
+ worker_thread_->quit();
+ worker_thread_->wait();
+ }
+}
+
+void GeometryStreamer::loadFile(const std::string& path, int num_threads) {
+ if (running_.load()) {
+ cancel();
+ if (worker_thread_ && worker_thread_->isRunning()) {
+ worker_thread_->quit();
+ worker_thread_->wait();
+ }
+ }
+
+ cancel_requested_ = false;
+ running_ = true;
+ progress_ = 0;
+ next_object_id_ = 1;
+
+ {
+ std::lock_guard lock(elements_mutex_);
+ pending_elements_.clear();
+ }
+
+ if (num_threads <= 0) {
+ num_threads = std::max(1u, std::thread::hardware_concurrency());
+ }
+
+ worker_thread_ = std::make_unique();
+ QObject* context = new QObject();
+ context->moveToThread(worker_thread_.get());
+
+ connect(worker_thread_.get(), &QThread::started, context, [this, path, num_threads, context]() {
+ run(path, num_threads);
+ context->deleteLater();
+ worker_thread_->quit();
+ });
+
+ connect(worker_thread_.get(), &QThread::finished, this, [this]() {
+ running_ = false;
+ emit finished();
+ });
+
+ worker_thread_->start();
+}
+
+void GeometryStreamer::cancel() {
+ cancel_requested_ = true;
+}
+
+std::vector GeometryStreamer::drainElements() {
+ std::lock_guard lock(elements_mutex_);
+ std::vector result;
+ result.swap(pending_elements_);
+ return result;
+}
+
+void GeometryStreamer::run(const std::string& path, int num_threads) {
+ try {
+ ifc_file_ = std::make_unique(path);
+ } catch (const std::exception& e) {
+ emit errorOccurred(QString("Failed to parse IFC file: %1").arg(e.what()));
+ return;
+ }
+
+ ifcopenshell::geometry::Settings settings;
+ settings.set("use-world-coords", true);
+ settings.set("weld-vertices", false);
+ settings.set("apply-default-materials", true);
+
+ std::unique_ptr iterator;
+ try {
+ const std::string geometry_library =
+ AppSettings::instance().geometryLibrary().toStdString();
+ auto kernel = ifcopenshell::geometry::kernels::construct(
+ ifc_file_.get(), geometry_library, settings);
+ iterator = std::make_unique(
+ std::move(kernel), settings, ifc_file_.get(), std::vector(), num_threads);
+ } catch (const std::exception& e) {
+ emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what()));
+ return;
+ }
+
+ if (!iterator->initialize()) {
+ emit errorOccurred("No geometry found in IFC file");
+ return;
+ }
+
+ int last_progress = 0;
+
+ do {
+ if (cancel_requested_.load()) break;
+
+ const IfcGeom::Element* elem = iterator->get();
+ if (!elem) continue;
+
+ const auto* tri_elem = dynamic_cast(elem);
+ if (!tri_elem) continue;
+
+ uint32_t object_id = next_object_id_++;
+
+ // Record element metadata
+ ElementInfo info;
+ info.object_id = object_id;
+ info.ifc_id = tri_elem->id();
+ info.guid = tri_elem->guid();
+ info.name = tri_elem->name();
+ info.type = tri_elem->type();
+ info.parent_id = tri_elem->parent_id();
+
+ {
+ std::lock_guard lock(elements_mutex_);
+ pending_elements_.push_back(std::move(info));
+ }
+
+ // Convert geometry to upload chunk
+ UploadChunk chunk = convertElement(tri_elem, object_id);
+ if (!chunk.indices.empty()) {
+ emit elementReady(std::move(chunk));
+ }
+
+ int p = iterator->progress();
+ if (p != last_progress) {
+ last_progress = p;
+ progress_ = p;
+ emit progressChanged(p);
+ }
+ } while (iterator->next());
+
+ progress_ = 100;
+ emit progressChanged(100);
+}
+
+static MaterialInfo materialFromStyle(const ifcopenshell::geometry::taxonomy::style::ptr& style) {
+ MaterialInfo m;
+ if (!style) return m;
+
+ const auto& color = style->get_color();
+ if (color) {
+ m.r = static_cast(color.r());
+ m.g = static_cast(color.g());
+ m.b = static_cast(color.b());
+ }
+ if (!std::isnan(style->transparency)) {
+ m.a = 1.0f - static_cast(style->transparency);
+ }
+ return m;
+}
+
+static inline uint32_t packRGBA8(const MaterialInfo& m) {
+ auto to_byte = [](float v) -> uint32_t {
+ float c = std::clamp(v, 0.0f, 1.0f);
+ return static_cast(c * 255.0f + 0.5f);
+ };
+ uint32_t r = to_byte(m.r);
+ uint32_t g = to_byte(m.g);
+ uint32_t b = to_byte(m.b);
+ uint32_t a = to_byte(m.a);
+ // Layout in memory (little-endian) reads as bytes [r, g, b, a] which is
+ // what the GL_UNSIGNED_BYTE * 4 normalized vertex attribute expects.
+ return r | (g << 8) | (b << 16) | (a << 24);
+}
+
+UploadChunk GeometryStreamer::convertElement(const IfcGeom::TriangulationElement* elem, uint32_t object_id) {
+ UploadChunk chunk;
+ chunk.object_id = object_id;
+
+ const auto& geom = elem->geometry();
+ const auto& verts = geom.verts();
+ const auto& faces = geom.faces();
+ const auto& normals = geom.normals();
+ const auto& materials = geom.materials();
+ const auto& material_ids = geom.material_ids();
+
+ if (verts.empty() || faces.empty()) return chunk;
+
+ // Encode object_id as float bits for the vertex attribute
+ float id_as_float;
+ static_assert(sizeof(float) == sizeof(uint32_t));
+ std::memcpy(&id_as_float, &object_id, sizeof(float));
+
+ const size_t num_verts = verts.size() / 3;
+ const size_t num_tris = faces.size() / 3;
+ const bool have_per_tri_material = (material_ids.size() == num_tris);
+
+ // Per-vertex color requires that any vertex shared between triangles with
+ // *different* materials be split. We dedupe (orig_vert_idx, mat_id) pairs
+ // so vertices that are only ever used by one material stay shared.
+ auto make_key = [](uint32_t orig_idx, int mat_id) -> uint64_t {
+ return (static_cast(orig_idx) << 32) |
+ static_cast(mat_id);
+ };
+
+ std::unordered_map remap;
+ remap.reserve(num_verts);
+
+ chunk.vertices.reserve(num_verts * 8);
+ chunk.indices.reserve(faces.size());
+
+ auto emit_vertex = [&](uint32_t orig_idx, int mat_id) -> uint32_t {
+ const uint64_t key = make_key(orig_idx, mat_id);
+ auto it = remap.find(key);
+ if (it != remap.end()) return it->second;
+
+ const uint32_t new_idx = static_cast(chunk.vertices.size() / 8);
+
+ // pos
+ chunk.vertices.push_back(static_cast(verts[orig_idx * 3 + 0]));
+ chunk.vertices.push_back(static_cast(verts[orig_idx * 3 + 1]));
+ chunk.vertices.push_back(static_cast(verts[orig_idx * 3 + 2]));
+
+ // normal
+ if (orig_idx * 3 + 2 < normals.size()) {
+ chunk.vertices.push_back(static_cast(normals[orig_idx * 3 + 0]));
+ chunk.vertices.push_back(static_cast(normals[orig_idx * 3 + 1]));
+ chunk.vertices.push_back(static_cast(normals[orig_idx * 3 + 2]));
+ } else {
+ chunk.vertices.push_back(0.0f);
+ chunk.vertices.push_back(1.0f);
+ chunk.vertices.push_back(0.0f);
+ }
+
+ // object_id (float bits)
+ chunk.vertices.push_back(id_as_float);
+
+ // color (packed RGBA8 reinterpreted as float)
+ MaterialInfo m;
+ if (mat_id >= 0 && mat_id < static_cast(materials.size())) {
+ m = materialFromStyle(materials[mat_id]);
+ }
+ uint32_t packed = packRGBA8(m);
+ float packed_as_float;
+ std::memcpy(&packed_as_float, &packed, sizeof(float));
+ chunk.vertices.push_back(packed_as_float);
+
+ remap.emplace(key, new_idx);
+ return new_idx;
+ };
+
+ for (size_t t = 0; t < num_tris; ++t) {
+ const int mat_id = have_per_tri_material ? material_ids[t] : -1;
+ chunk.indices.push_back(emit_vertex(static_cast(faces[t * 3 + 0]), mat_id));
+ chunk.indices.push_back(emit_vertex(static_cast(faces[t * 3 + 1]), mat_id));
+ chunk.indices.push_back(emit_vertex(static_cast(faces[t * 3 + 2]), mat_id));
+ }
+
+ return chunk;
+}
diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h
new file mode 100644
index 0000000000..06b6364a24
--- /dev/null
+++ b/src/ifcviewer/GeometryStreamer.h
@@ -0,0 +1,89 @@
+/********************************************************************************
+ * *
+ * 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 GEOMETRYSTREAMER_H
+#define GEOMETRYSTREAMER_H
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../ifcparse/IfcFile.h"
+#include "../ifcgeom/Iterator.h"
+
+#include "ViewportWindow.h"
+
+struct ElementInfo {
+ uint32_t object_id;
+ int ifc_id;
+ std::string guid;
+ std::string name;
+ std::string type;
+ int parent_id;
+};
+
+class GeometryStreamer : public QObject {
+ Q_OBJECT
+public:
+ explicit GeometryStreamer(QObject* parent = nullptr);
+ ~GeometryStreamer();
+
+ void loadFile(const std::string& path, int num_threads = 0);
+ void cancel();
+
+ bool isRunning() const { return running_.load(); }
+ int progress() const { return progress_.load(); }
+
+ IfcParse::IfcFile* ifcFile() const { return ifc_file_.get(); }
+
+ // Thread-safe access to discovered elements
+ std::vector drainElements();
+
+signals:
+ void progressChanged(int percent);
+ void elementReady(UploadChunk chunk);
+ void finished();
+ void errorOccurred(const QString& message);
+
+private:
+ void run(const std::string& path, int num_threads);
+
+ UploadChunk convertElement(const IfcGeom::TriangulationElement* elem, uint32_t object_id);
+
+ std::unique_ptr ifc_file_;
+ std::unique_ptr worker_thread_;
+ std::atomic running_{false};
+ std::atomic cancel_requested_{false};
+ std::atomic progress_{0};
+
+ 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
+};
+
+#endif // GEOMETRYSTREAMER_H
diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp
new file mode 100644
index 0000000000..1f32ce0877
--- /dev/null
+++ b/src/ifcviewer/MainWindow.cpp
@@ -0,0 +1,270 @@
+/********************************************************************************
+ * *
+ * 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 "MainWindow.h"
+#include "SettingsWindow.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+MainWindow::MainWindow(QWidget* parent)
+ : QMainWindow(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(&element_poll_timer_, &QTimer::timeout, this, &MainWindow::pollNewElements);
+ element_poll_timer_.setInterval(100);
+
+ setWindowTitle("IfcViewer");
+ resize(1400, 900);
+}
+
+MainWindow::~MainWindow() {}
+
+void MainWindow::setupUi() {
+ // 3D Viewport as central widget
+ viewport_ = new ViewportWindow();
+ viewport_container_ = QWidget::createWindowContainer(viewport_, this);
+ viewport_container_->setMinimumSize(400, 300);
+ viewport_container_->setFocusPolicy(Qt::StrongFocus);
+ setCentralWidget(viewport_container_);
+
+ connect(viewport_, &ViewportWindow::objectPicked, this, &MainWindow::onObjectPicked);
+
+ // Element tree dock
+ auto* tree_dock = new QDockWidget("Elements", this);
+ tree_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
+ element_tree_ = new QTreeWidget();
+ element_tree_->setHeaderLabels({"Name", "Type", "GUID"});
+ element_tree_->setColumnWidth(0, 200);
+ element_tree_->setColumnWidth(1, 120);
+ element_tree_->setSelectionMode(QAbstractItemView::SingleSelection);
+ connect(element_tree_, &QTreeWidget::itemSelectionChanged, this, &MainWindow::onTreeSelectionChanged);
+ tree_dock->setWidget(element_tree_);
+ addDockWidget(Qt::LeftDockWidgetArea, tree_dock);
+
+ // Properties dock
+ auto* prop_dock = new QDockWidget("Properties", this);
+ prop_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
+ property_table_ = new QTableWidget();
+ property_table_->setColumnCount(2);
+ property_table_->setHorizontalHeaderLabels({"Property", "Value"});
+ property_table_->horizontalHeader()->setStretchLastSection(true);
+ property_table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
+ property_table_->setSelectionBehavior(QAbstractItemView::SelectRows);
+ prop_dock->setWidget(property_table_);
+ addDockWidget(Qt::RightDockWidgetArea, prop_dock);
+
+ // Status bar with progress
+ progress_bar_ = new QProgressBar();
+ progress_bar_->setMaximumWidth(200);
+ progress_bar_->setVisible(false);
+ status_label_ = new QLabel("Ready");
+ statusBar()->addWidget(status_label_, 1);
+ statusBar()->addPermanentWidget(progress_bar_);
+}
+
+void MainWindow::setupMenus() {
+ auto* file_menu = menuBar()->addMenu("&File");
+ auto* open_action = file_menu->addAction("&Open...", this, &MainWindow::onFileOpen);
+ open_action->setShortcut(QKeySequence::Open);
+ file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings);
+ file_menu->addSeparator();
+ file_menu->addAction("&Quit", QKeySequence::Quit, qApp, &QApplication::quit);
+}
+
+void MainWindow::onFileOpen() {
+ QString path = QFileDialog::getOpenFileName(this, "Open IFC File", QString(), "IFC Files (*.ifc *.ifcxml *.ifczip);;All Files (*)");
+ if (!path.isEmpty()) {
+ openFile(path);
+ }
+}
+
+void MainWindow::onFileSettings() {
+ if (settings_ == nullptr) {
+ settings_ = new SettingsWindow(this);
+ }
+ settings_->open();
+ settings_->activateWindow();
+ 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();
+
+ progress_bar_->setValue(0);
+ progress_bar_->setVisible(true);
+ status_label_->setText("Loading: " + path);
+
+ load_timer_.restart();
+ element_poll_timer_.start();
+ streamer_->loadFile(path.toStdString());
+}
+
+void MainWindow::onProgressChanged(int percent) {
+ progress_bar_->setValue(percent);
+}
+
+void MainWindow::onElementReady(UploadChunk chunk) {
+ viewport_->uploadChunk(chunk);
+}
+
+void MainWindow::onStreamingFinished() {
+ element_poll_timer_.stop();
+ pollNewElements(); // drain remaining
+
+ 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())
+ .arg(elapsed));
+}
+
+void MainWindow::onObjectPicked(uint32_t object_id) {
+ viewport_->setSelectedObjectId(object_id);
+
+ // Select in tree
+ auto it = tree_items_.find(object_id);
+ if (it != tree_items_.end()) {
+ element_tree_->blockSignals(true);
+ element_tree_->setCurrentItem(it->second);
+ element_tree_->blockSignals(false);
+ }
+
+ populateProperties(object_id);
+}
+
+void MainWindow::onTreeSelectionChanged() {
+ auto items = element_tree_->selectedItems();
+ if (items.isEmpty()) return;
+
+ uint32_t object_id = items.first()->data(0, Qt::UserRole).toUInt();
+ viewport_->setSelectedObjectId(object_id);
+ populateProperties(object_id);
+}
+
+void MainWindow::pollNewElements() {
+ auto elements = streamer_->drainElements();
+ for (auto& info : elements) {
+ element_map_[info.object_id] = info;
+ ifc_id_to_object_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()) {
+ auto tree_it = tree_items_.find(parent_obj_it->second);
+ if (tree_it != tree_items_.end()) {
+ parent_item = tree_it->second;
+ }
+ }
+
+ QString display_name = QString::fromStdString(info.name);
+ if (display_name.isEmpty()) {
+ 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_);
+ }
+ item->setText(0, display_name);
+ item->setText(1, QString::fromStdString(info.type));
+ item->setText(2, QString::fromStdString(info.guid));
+ item->setData(0, Qt::UserRole, info.object_id);
+
+ tree_items_[info.object_id] = item;
+ }
+}
+
+void MainWindow::populateProperties(uint32_t object_id) {
+ property_table_->setRowCount(0);
+ if (object_id == 0) return;
+
+ auto it = element_map_.find(object_id);
+ if (it == element_map_.end()) return;
+
+ const auto& info = it->second;
+
+ auto addRow = [this](const QString& key, const QString& value) {
+ int row = property_table_->rowCount();
+ property_table_->insertRow(row);
+ property_table_->setItem(row, 0, new QTableWidgetItem(key));
+ property_table_->setItem(row, 1, new QTableWidgetItem(value));
+ };
+
+ addRow("IFC ID", QString::number(info.ifc_id));
+ addRow("GUID", QString::fromStdString(info.guid));
+ 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();
+ if (!file) return;
+
+ auto* product = file->instance_by_id(info.ifc_id);
+ if (!product) return;
+
+ // Show all direct attributes
+ auto& decl = product->declaration();
+ if (auto* entity = decl.as_entity()) {
+ for (size_t i = 0; i < entity->attribute_count(); ++i) {
+ auto* attr = entity->attribute_by_index(i);
+ try {
+ auto val = product->get_attribute_value(i);
+ if (!val.isNull()) {
+ std::string str_val;
+ try {
+ str_val = static_cast(val);
+ } catch (...) {
+ // Not a string-convertible attribute (entity ref, aggregate, etc.)
+ str_val = "<" + std::string(IfcUtil::ArgumentTypeToString(val.type())) + ">";
+ }
+ addRow(QString::fromStdString(attr->name()), QString::fromStdString(str_val));
+ }
+ } catch (...) {}
+ }
+ }
+}
diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h
new file mode 100644
index 0000000000..d5f4c18a39
--- /dev/null
+++ b/src/ifcviewer/MainWindow.h
@@ -0,0 +1,80 @@
+/********************************************************************************
+ * *
+ * 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 MAINWINDOW_H
+#define MAINWINDOW_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "ViewportWindow.h"
+#include "GeometryStreamer.h"
+
+class SettingsWindow;
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+public:
+ explicit MainWindow(QWidget* parent = nullptr);
+ ~MainWindow();
+
+ void openFile(const QString& path);
+
+private slots:
+ void onFileOpen();
+ void onFileSettings();
+ void onProgressChanged(int percent);
+ void onElementReady(UploadChunk chunk);
+ void onStreamingFinished();
+ void onObjectPicked(uint32_t object_id);
+ void onTreeSelectionChanged();
+ void pollNewElements();
+
+private:
+ void setupUi();
+ void setupMenus();
+ void populateProperties(uint32_t object_id);
+
+ ViewportWindow* viewport_ = nullptr;
+ SettingsWindow* settings_ = nullptr;
+ QWidget* viewport_container_ = nullptr;
+ QTreeWidget* element_tree_ = nullptr;
+ QTableWidget* property_table_ = nullptr;
+ QProgressBar* progress_bar_ = nullptr;
+ QLabel* status_label_ = nullptr;
+ QTimer element_poll_timer_;
+ QElapsedTimer load_timer_;
+
+ GeometryStreamer* streamer_ = nullptr;
+
+ // 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_;
+};
+
+#endif // MAINWINDOW_H
diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md
new file mode 100644
index 0000000000..b9194cefd1
--- /dev/null
+++ b/src/ifcviewer/README.md
@@ -0,0 +1,129 @@
+# IfcViewer
+
+A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine with a Qt6 interface and OpenGL 4.5 rendering.
+
+## Architecture
+
+```
++-------------------------------------------+
+| Qt6 Application (MainWindow) |
+| +----------+ +--------------------------+|
+| | Element | | 3D Viewport ||
+| | Tree | | (QWindow + OpenGL 4.5) ||
+| | | | ||
+| +----------+ | Single VBO/EBO ||
+| | Property | | DrawElementsBaseVertex ||
+| | Table | | GPU pick pass ||
+| +----------+ +--------------------------+|
+| | Status / Progress |
++-------------------------------------------+
+ ^ ^
+ | |
+ element metadata UploadChunks
+ | |
++-------------------------------------------+
+| GeometryStreamer (background QThread) |
+| IfcGeom::Iterator with N threads |
+| (one per CPU core by default) |
++-------------------------------------------+
+```
+
+### Key design decisions
+
+- **QWindow viewport** embedded via `QWidget::createWindowContainer()`. This gives us a raw native surface for OpenGL, bypassing `QOpenGLWidget`'s compositor overhead.
+- **One big vertex buffer + index buffer** (64 MB + 32 MB initial). Geometry is appended as it streams in. No per-object VBOs, no rebinding.
+- **Interleaved vertex format**: position (3 floats) + normal (3 floats) + object ID (1 float, bitcast uint32) = 28 bytes per vertex.
+- **GPU object picking**: a second render pass writes object IDs to an R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting.
+- **Multi-threaded tessellation**: `IfcGeom::Iterator` runs on a background thread and internally parallelizes geometry conversion across all CPU cores.
+- **Non-blocking streaming**: the iterator emits `UploadChunk` signals via Qt's queued connection. The main thread uploads to the GPU without blocking iteration.
+- **World coordinates**: geometry is emitted in world space (`use-world-coords=true`) so no per-object transform matrices are needed on the GPU.
+
+### Files
+
+| File | Purpose |
+|------|---------|
+| `main.cpp` | Application entry point, GL 4.5 surface format, CLI argument parsing |
+| `MainWindow.h/cpp` | Qt main window: dockable element tree, property table, status bar, menus |
+| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, picking |
+| `GeometryStreamer.h/cpp` | Background geometry processing: loads IFC, runs iterator, emits chunks |
+| `CMakeLists.txt` | Build configuration |
+
+## Dependencies
+
+- **Qt6** (Core, Gui, Widgets)
+- **OpenGL 4.5** (GL_ARB_direct_state_access) - available on Windows and Linux; macOS will need a Vulkan/MoltenVK backend (not yet implemented)
+- **IfcOpenShell C++ libraries** (IfcParse, IfcGeom, and their dependencies: Open CASCADE, Boost, Eigen3, optionally CGAL)
+
+## Building
+
+IfcViewer is built as part of the IfcOpenShell CMake project. You do not need to build everything - disable the targets you don't need.
+
+### Minimal build (IfcViewer only)
+
+From the repository root:
+
+```sh
+mkdir build && cd build
+
+cmake ../cmake \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_IFCVIEWER=ON \
+ -DBUILD_CONVERT=OFF \
+ -DBUILD_IFCPYTHON=OFF \
+ -DBUILD_GEOMSERVER=OFF \
+ -DBUILD_DOCUMENTATION=OFF \
+ -DBUILD_EXAMPLES=OFF \
+ -DCOLLADA_SUPPORT=OFF \
+ -DGLTF_SUPPORT=OFF \
+ -DHDF5_SUPPORT=OFF
+
+make -j$(nproc) IfcViewer
+```
+
+This builds only IfcParse, IfcGeom (with geometry kernels), and IfcViewer itself. All other targets (IfcConvert, Python bindings, serializers, etc.) are skipped.
+
+If Qt6 is not in a standard location, pass `-DQT_DIR=/path/to/qt6`.
+
+### Full build with IfcViewer enabled
+
+```sh
+cmake ../cmake -DBUILD_IFCVIEWER=ON
+make -j$(nproc)
+```
+
+## Usage
+
+```sh
+# Open a file directly
+./IfcViewer model.ifc
+
+# Or use File -> Open from the menu
+./IfcViewer
+```
+
+### Controls
+
+| Input | Action |
+|-------|--------|
+| Middle mouse drag | Orbit camera |
+| Shift + middle mouse drag | Pan camera |
+| Scroll wheel | Zoom |
+| Left click | Select object (highlights in viewport and tree) |
+
+### Keyboard shortcuts
+
+| Key | Action |
+|-----|--------|
+| Ctrl+O | Open file |
+| Ctrl+Q | Quit |
+
+## Roadmap
+
+- [ ] Material color support (currently renders default grey per batch)
+- [ ] Buffer growth (reallocate when 64 MB VBO fills up)
+- [ ] `glMultiDrawElementsIndirect` for fewer draw calls
+- [ ] Vulkan/MoltenVK backend for macOS
+- [ ] Spatial tree (BVH) for frustum culling
+- [ ] LOD: coarse tessellation during streaming, refine in background
+- [ ] Embedded Python scripting console
+- [ ] CJK text input support (Qt6 handles this natively)
diff --git a/src/ifcviewer/SettingsWindow.cpp b/src/ifcviewer/SettingsWindow.cpp
new file mode 100644
index 0000000000..a24f9bc976
--- /dev/null
+++ b/src/ifcviewer/SettingsWindow.cpp
@@ -0,0 +1,68 @@
+/********************************************************************************
+ * *
+ * 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 "SettingsWindow.h"
+#include "AppSettings.h"
+
+#include
+#include
+#include
+#include
+#include
+
+SettingsWindow::SettingsWindow(QWidget *parent)
+ : QDialog(parent)
+{
+ setWindowTitle("Settings");
+ setupUi();
+}
+
+void SettingsWindow::setupUi() {
+ auto* form = new QFormLayout();
+
+ geometry_library_edit_ = new QLineEdit(this);
+ geometry_library_edit_->setMinimumWidth(280);
+ form->addRow("Geometry Library", geometry_library_edit_);
+
+ auto* button_box = new QDialogButtonBox(
+ QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
+
+ auto* root = new QVBoxLayout(this);
+ root->addLayout(form);
+ root->addWidget(button_box);
+
+ connect(button_box, &QDialogButtonBox::accepted, this, &SettingsWindow::onAccepted);
+ connect(button_box, &QDialogButtonBox::rejected, this, &SettingsWindow::reject);
+}
+
+void SettingsWindow::showEvent(QShowEvent* event) {
+ // Re-sync widgets from the persisted settings every time the dialog is
+ // shown, so a previous Cancel doesn't leave stale text in the field.
+ syncFromSettings();
+ QDialog::showEvent(event);
+}
+
+void SettingsWindow::syncFromSettings() {
+ geometry_library_edit_->setText(AppSettings::instance().geometryLibrary());
+}
+
+void SettingsWindow::onAccepted() {
+ AppSettings::instance().setGeometryLibrary(geometry_library_edit_->text());
+ accept();
+}
diff --git a/src/ifcviewer/SettingsWindow.h b/src/ifcviewer/SettingsWindow.h
new file mode 100644
index 0000000000..77affe7757
--- /dev/null
+++ b/src/ifcviewer/SettingsWindow.h
@@ -0,0 +1,46 @@
+/********************************************************************************
+ * *
+ * 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 SETTINGSWINDOW_H
+#define SETTINGSWINDOW_H
+
+#include
+
+class QLineEdit;
+class QShowEvent;
+
+class SettingsWindow : public QDialog {
+ Q_OBJECT
+public:
+ explicit SettingsWindow(QWidget *parent = nullptr);
+
+protected:
+ void showEvent(QShowEvent* event) override;
+
+private slots:
+ void onAccepted();
+
+private:
+ void setupUi();
+ void syncFromSettings();
+
+ QLineEdit* geometry_library_edit_ = nullptr;
+};
+
+#endif
diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp
new file mode 100644
index 0000000000..99624cb9f5
--- /dev/null
+++ b/src/ifcviewer/ViewportWindow.cpp
@@ -0,0 +1,674 @@
+/********************************************************************************
+ * *
+ * 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 "ViewportWindow.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+static const size_t INITIAL_VBO_SIZE = 64 * 1024 * 1024; // 64 MB
+static const size_t INITIAL_EBO_SIZE = 32 * 1024 * 1024; // 32 MB
+// Cap buffer growth so a runaway upload can't try to allocate the world.
+static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB
+static const int VERTEX_STRIDE = 8; // pos(3) + normal(3) + object_id(1) + color(1 packed)
+
+static const char* MAIN_VERTEX_SHADER = R"(
+#version 450 core
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in float a_object_id;
+layout(location = 3) in vec4 a_color;
+
+uniform mat4 u_view_projection;
+uniform uint u_selected_id;
+
+out vec3 v_normal;
+out vec3 v_position;
+out vec4 v_color;
+flat out uint v_object_id;
+flat out uint v_selected;
+
+void main() {
+ gl_Position = u_view_projection * vec4(a_position, 1.0);
+ v_normal = a_normal;
+ v_position = a_position;
+ v_color = a_color;
+ v_object_id = floatBitsToUint(a_object_id);
+ v_selected = (v_object_id == u_selected_id) ? 1u : 0u;
+}
+)";
+
+static const char* MAIN_FRAGMENT_SHADER = R"(
+#version 450 core
+in vec3 v_normal;
+in vec3 v_position;
+in vec4 v_color;
+flat in uint v_object_id;
+flat in uint v_selected;
+
+uniform vec3 u_light_dir;
+
+out vec4 frag_color;
+
+void main() {
+ vec3 n = normalize(v_normal);
+ float ndotl = max(dot(n, u_light_dir), 0.0);
+ float ambient = 0.25;
+ float diffuse = 0.75 * ndotl;
+ vec3 color = v_color.rgb * (ambient + diffuse);
+
+ if (v_selected == 1u) {
+ color = mix(color, vec3(0.2, 0.6, 1.0), 0.5);
+ }
+
+ frag_color = vec4(color, v_color.a);
+}
+)";
+
+static const char* PICK_VERTEX_SHADER = R"(
+#version 450 core
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in float a_object_id;
+
+uniform mat4 u_view_projection;
+
+flat out uint v_object_id;
+
+void main() {
+ gl_Position = u_view_projection * vec4(a_position, 1.0);
+ v_object_id = floatBitsToUint(a_object_id);
+}
+)";
+
+static const char* PICK_FRAGMENT_SHADER = R"(
+#version 450 core
+flat in uint v_object_id;
+
+out uint frag_id;
+
+void main() {
+ frag_id = v_object_id;
+}
+)";
+
+static const char* AXIS_VERTEX_SHADER = R"(
+#version 450 core
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_color;
+
+uniform mat4 u_mvp;
+
+out vec3 v_color;
+
+void main() {
+ gl_Position = u_mvp * vec4(a_position, 1.0);
+ v_color = a_color;
+}
+)";
+
+static const char* AXIS_FRAGMENT_SHADER = R"(
+#version 450 core
+in vec3 v_color;
+out vec4 frag_color;
+
+void main() {
+ frag_color = vec4(v_color, 1.0);
+}
+)";
+
+static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) {
+ GLuint shader = gl->glCreateShader(type);
+ gl->glShaderSource(shader, 1, &source, nullptr);
+ gl->glCompileShader(shader);
+ GLint ok = 0;
+ gl->glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
+ if (!ok) {
+ char log[1024];
+ gl->glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
+ qWarning("Shader compile error: %s", log);
+ }
+ return shader;
+}
+
+static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint frag) {
+ GLuint prog = gl->glCreateProgram();
+ gl->glAttachShader(prog, vert);
+ gl->glAttachShader(prog, frag);
+ gl->glLinkProgram(prog);
+ GLint ok = 0;
+ gl->glGetProgramiv(prog, GL_LINK_STATUS, &ok);
+ if (!ok) {
+ char log[1024];
+ gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log);
+ qWarning("Program link error: %s", log);
+ }
+ gl->glDeleteShader(vert);
+ gl->glDeleteShader(frag);
+ return prog;
+}
+
+ViewportWindow::ViewportWindow(QWindow* parent)
+ : QWindow(parent)
+{
+ setSurfaceType(QWindow::OpenGLSurface);
+
+ QSurfaceFormat fmt;
+ fmt.setVersion(4, 5);
+ fmt.setProfile(QSurfaceFormat::CoreProfile);
+ fmt.setDepthBufferSize(24);
+ fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
+ fmt.setSamples(4);
+ setFormat(fmt);
+
+ connect(&render_timer_, &QTimer::timeout, this, [this]() {
+ if (isExposed()) render();
+ });
+ render_timer_.setInterval(16); // ~60 fps
+}
+
+ViewportWindow::~ViewportWindow() {
+ if (context_) {
+ context_->makeCurrent(this);
+ if (gl_) {
+ if (vao_) gl_->glDeleteVertexArrays(1, &vao_);
+ if (vbo_) gl_->glDeleteBuffers(1, &vbo_);
+ if (ebo_) gl_->glDeleteBuffers(1, &ebo_);
+ if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_);
+ if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_);
+ if (main_program_) gl_->glDeleteProgram(main_program_);
+ if (pick_program_) gl_->glDeleteProgram(pick_program_);
+ if (axis_program_) gl_->glDeleteProgram(axis_program_);
+ if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_);
+ if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_);
+ if (pick_depth_rbo_) gl_->glDeleteRenderbuffers(1, &pick_depth_rbo_);
+ }
+ context_->doneCurrent();
+ }
+}
+
+void ViewportWindow::initGL() {
+ if (gl_initialized_) return;
+
+ context_ = new QOpenGLContext(this);
+ context_->setFormat(requestedFormat());
+ if (!context_->create()) {
+ qFatal("Failed to create OpenGL context");
+ return;
+ }
+ context_->makeCurrent(this);
+
+ gl_ = QOpenGLVersionFunctionsFactory::get(context_);
+ if (!gl_) {
+ qWarning("OpenGL 4.5 not available, falling back");
+ return;
+ }
+
+ buildShaders();
+ buildAxisGizmo();
+
+ // Create VAO
+ gl_->glCreateVertexArrays(1, &vao_);
+
+ // Create VBO with initial capacity
+ vbo_capacity_ = INITIAL_VBO_SIZE;
+ gl_->glCreateBuffers(1, &vbo_);
+ gl_->glNamedBufferStorage(vbo_, vbo_capacity_, nullptr,
+ GL_DYNAMIC_STORAGE_BIT);
+
+ // Create EBO with initial capacity
+ ebo_capacity_ = INITIAL_EBO_SIZE;
+ gl_->glCreateBuffers(1, &ebo_);
+ gl_->glNamedBufferStorage(ebo_, ebo_capacity_, nullptr,
+ GL_DYNAMIC_STORAGE_BIT);
+
+ // Vertex layout: pos(3f) + normal(3f) + object_id(1f) + color(4 unorm bytes)
+ // = 8 floats = 32 bytes per vertex.
+ gl_->glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, VERTEX_STRIDE * sizeof(float));
+ gl_->glVertexArrayElementBuffer(vao_, ebo_);
+
+ // position
+ gl_->glEnableVertexArrayAttrib(vao_, 0);
+ gl_->glVertexArrayAttribFormat(vao_, 0, 3, GL_FLOAT, GL_FALSE, 0);
+ gl_->glVertexArrayAttribBinding(vao_, 0, 0);
+
+ // normal
+ gl_->glEnableVertexArrayAttrib(vao_, 1);
+ gl_->glVertexArrayAttribFormat(vao_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float));
+ gl_->glVertexArrayAttribBinding(vao_, 1, 0);
+
+ // object_id (passed as float, decoded in shader via floatBitsToUint)
+ gl_->glEnableVertexArrayAttrib(vao_, 2);
+ gl_->glVertexArrayAttribFormat(vao_, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float));
+ gl_->glVertexArrayAttribBinding(vao_, 2, 0);
+
+ // color (RGBA8 packed into the 4 bytes at offset 28; normalized to vec4)
+ gl_->glEnableVertexArrayAttrib(vao_, 3);
+ gl_->glVertexArrayAttribFormat(vao_, 3, 4, GL_UNSIGNED_BYTE, GL_TRUE, 7 * sizeof(float));
+ gl_->glVertexArrayAttribBinding(vao_, 3, 0);
+
+ gl_->glEnable(GL_DEPTH_TEST);
+ gl_->glEnable(GL_MULTISAMPLE);
+ gl_->glClearColor(0.18f, 0.20f, 0.22f, 1.0f);
+
+ gl_initialized_ = true;
+ frame_clock_.start();
+ render_timer_.start();
+
+ emit initialized();
+}
+
+void ViewportWindow::buildShaders() {
+ {
+ GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, MAIN_VERTEX_SHADER);
+ GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, MAIN_FRAGMENT_SHADER);
+ main_program_ = linkProgram(gl_, vs, fs);
+ }
+ {
+ GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, PICK_VERTEX_SHADER);
+ GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PICK_FRAGMENT_SHADER);
+ pick_program_ = linkProgram(gl_, vs, fs);
+ }
+ {
+ GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, AXIS_VERTEX_SHADER);
+ GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER);
+ axis_program_ = linkProgram(gl_, vs, fs);
+ }
+}
+
+void ViewportWindow::buildAxisGizmo() {
+ // 3 line segments (X red, Y green, Z blue), 6 vertices, pos(3) + color(3).
+ static const float axis_data[] = {
+ // X axis - red
+ 0.0f, 0.0f, 0.0f, 1.0f, 0.25f, 0.25f,
+ 1.0f, 0.0f, 0.0f, 1.0f, 0.25f, 0.25f,
+ // Y axis - green
+ 0.0f, 0.0f, 0.0f, 0.30f, 0.95f, 0.30f,
+ 0.0f, 1.0f, 0.0f, 0.30f, 0.95f, 0.30f,
+ // Z axis - blue
+ 0.0f, 0.0f, 0.0f, 0.30f, 0.55f, 1.0f,
+ 0.0f, 0.0f, 1.0f, 0.30f, 0.55f, 1.0f,
+ };
+
+ gl_->glCreateVertexArrays(1, &axis_vao_);
+ gl_->glCreateBuffers(1, &axis_vbo_);
+ gl_->glNamedBufferStorage(axis_vbo_, sizeof(axis_data), axis_data, 0);
+
+ gl_->glVertexArrayVertexBuffer(axis_vao_, 0, axis_vbo_, 0, 6 * sizeof(float));
+
+ gl_->glEnableVertexArrayAttrib(axis_vao_, 0);
+ gl_->glVertexArrayAttribFormat(axis_vao_, 0, 3, GL_FLOAT, GL_FALSE, 0);
+ gl_->glVertexArrayAttribBinding(axis_vao_, 0, 0);
+
+ gl_->glEnableVertexArrayAttrib(axis_vao_, 1);
+ gl_->glVertexArrayAttribFormat(axis_vao_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float));
+ gl_->glVertexArrayAttribBinding(axis_vao_, 1, 0);
+}
+
+bool ViewportWindow::growVbo(size_t needed_total) {
+ // Double until it fits, but don't blow past the cap.
+ size_t new_capacity = vbo_capacity_;
+ while (new_capacity < needed_total) {
+ new_capacity *= 2;
+ }
+ if (new_capacity > MAX_BUFFER_SIZE) {
+ qWarning("VBO grow request (%zu MB) exceeds cap (%zu MB)",
+ new_capacity / (1024 * 1024), MAX_BUFFER_SIZE / (1024 * 1024));
+ return false;
+ }
+
+ GLuint new_vbo = 0;
+ gl_->glCreateBuffers(1, &new_vbo);
+ gl_->glNamedBufferStorage(new_vbo, new_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT);
+
+ if (vbo_used_ > 0) {
+ gl_->glCopyNamedBufferSubData(vbo_, new_vbo, 0, 0, vbo_used_);
+ }
+
+ gl_->glDeleteBuffers(1, &vbo_);
+ vbo_ = new_vbo;
+ vbo_capacity_ = new_capacity;
+
+ // Rebind on the VAO so subsequent draws see the new buffer.
+ gl_->glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, VERTEX_STRIDE * sizeof(float));
+
+ qInfo("VBO grew to %zu MB", vbo_capacity_ / (1024 * 1024));
+ return true;
+}
+
+bool ViewportWindow::growEbo(size_t needed_total) {
+ size_t new_capacity = ebo_capacity_;
+ while (new_capacity < needed_total) {
+ new_capacity *= 2;
+ }
+ if (new_capacity > MAX_BUFFER_SIZE) {
+ qWarning("EBO grow request (%zu MB) exceeds cap (%zu MB)",
+ new_capacity / (1024 * 1024), MAX_BUFFER_SIZE / (1024 * 1024));
+ return false;
+ }
+
+ GLuint new_ebo = 0;
+ gl_->glCreateBuffers(1, &new_ebo);
+ gl_->glNamedBufferStorage(new_ebo, new_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT);
+
+ if (ebo_used_ > 0) {
+ gl_->glCopyNamedBufferSubData(ebo_, new_ebo, 0, 0, ebo_used_);
+ }
+
+ gl_->glDeleteBuffers(1, &ebo_);
+ ebo_ = new_ebo;
+ ebo_capacity_ = new_capacity;
+
+ gl_->glVertexArrayElementBuffer(vao_, ebo_);
+
+ qInfo("EBO grew to %zu MB", ebo_capacity_ / (1024 * 1024));
+ return true;
+}
+
+void ViewportWindow::uploadChunk(const UploadChunk& chunk) {
+ if (!gl_initialized_) return;
+ if (chunk.vertices.empty() || chunk.indices.empty()) return;
+
+ context_->makeCurrent(this);
+
+ size_t vb_size = chunk.vertices.size() * sizeof(float);
+ size_t ib_size = chunk.indices.size() * sizeof(uint32_t);
+
+ if (vbo_used_ + vb_size > vbo_capacity_) {
+ if (!growVbo(vbo_used_ + vb_size)) {
+ qWarning("VBO at cap, skipping chunk");
+ return;
+ }
+ }
+ if (ebo_used_ + ib_size > ebo_capacity_) {
+ if (!growEbo(ebo_used_ + ib_size)) {
+ qWarning("EBO at cap, skipping chunk");
+ return;
+ }
+ }
+
+ uint32_t base_vertex = vertex_count_;
+
+ gl_->glNamedBufferSubData(vbo_, vbo_used_, vb_size, chunk.vertices.data());
+
+ // Remap chunk-local indices into global indices so the whole EBO can be
+ // drawn with a single glDrawElements call.
+ std::vector global_indices(chunk.indices.size());
+ for (size_t i = 0; i < chunk.indices.size(); ++i) {
+ global_indices[i] = chunk.indices[i] + base_vertex;
+ }
+ gl_->glNamedBufferSubData(ebo_, ebo_used_, ib_size, global_indices.data());
+
+ {
+ std::lock_guard lock(upload_mutex_);
+ total_index_count_ += static_cast(chunk.indices.size());
+ }
+
+ vbo_used_ += vb_size;
+ ebo_used_ += ib_size;
+ vertex_count_ += static_cast(chunk.vertices.size() / VERTEX_STRIDE);
+ total_triangles_ += static_cast(chunk.indices.size() / 3);
+}
+
+void ViewportWindow::resetScene() {
+ if (!gl_initialized_) return;
+
+ std::lock_guard lock(upload_mutex_);
+ total_index_count_ = 0;
+ vbo_used_ = 0;
+ ebo_used_ = 0;
+ vertex_count_ = 0;
+ total_triangles_ = 0;
+ selected_object_id_ = 0;
+}
+
+void ViewportWindow::setSelectedObjectId(uint32_t id) {
+ selected_object_id_ = id;
+}
+
+uint32_t ViewportWindow::pickObjectAt(int x, int y) {
+ if (!gl_initialized_) return 0;
+
+ context_->makeCurrent(this);
+
+ int w = width() * devicePixelRatio();
+ int h = height() * devicePixelRatio();
+
+ // Create/resize pick FBO if needed
+ if (pick_width_ != w || pick_height_ != h) {
+ if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_);
+ if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_);
+ if (pick_depth_rbo_) gl_->glDeleteRenderbuffers(1, &pick_depth_rbo_);
+
+ gl_->glCreateFramebuffers(1, &pick_fbo_);
+
+ gl_->glCreateTextures(GL_TEXTURE_2D, 1, &pick_color_tex_);
+ gl_->glTextureStorage2D(pick_color_tex_, 1, GL_R32UI, w, h);
+ gl_->glNamedFramebufferTexture(pick_fbo_, GL_COLOR_ATTACHMENT0, pick_color_tex_, 0);
+
+ gl_->glCreateRenderbuffers(1, &pick_depth_rbo_);
+ gl_->glNamedRenderbufferStorage(pick_depth_rbo_, GL_DEPTH_COMPONENT24, w, h);
+ gl_->glNamedFramebufferRenderbuffer(pick_fbo_, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, pick_depth_rbo_);
+
+ pick_width_ = w;
+ pick_height_ = h;
+ }
+
+ renderPickPass();
+
+ int px = x * devicePixelRatio();
+ int py = (height() - y) * devicePixelRatio();
+ uint32_t pixel = 0;
+ gl_->glGetTextureSubImage(pick_color_tex_, 0, px, py, 0, 1, 1, 1, GL_RED_INTEGER, GL_UNSIGNED_INT, sizeof(pixel), &pixel);
+
+ return pixel;
+}
+
+void ViewportWindow::updateCamera() {
+ float yaw_rad = qDegreesToRadians(camera_yaw_);
+ float pitch_rad = qDegreesToRadians(camera_pitch_);
+
+ // IFC / Blender convention: X right, Y forward, Z up.
+ QVector3D eye;
+ eye.setX(camera_target_.x() + camera_distance_ * cosf(pitch_rad) * cosf(yaw_rad));
+ eye.setY(camera_target_.y() + camera_distance_ * cosf(pitch_rad) * sinf(yaw_rad));
+ eye.setZ(camera_target_.z() + camera_distance_ * sinf(pitch_rad));
+
+ view_matrix_.setToIdentity();
+ view_matrix_.lookAt(eye, camera_target_, QVector3D(0, 0, 1));
+
+ proj_matrix_.setToIdentity();
+ float aspect = width() > 0 ? float(width()) / float(height()) : 1.0f;
+ proj_matrix_.perspective(45.0f, aspect, 0.1f, camera_distance_ * 10.0f);
+}
+
+void ViewportWindow::render() {
+ if (!gl_initialized_ || !isExposed()) return;
+
+ context_->makeCurrent(this);
+ updateCamera();
+
+ int w = width() * devicePixelRatio();
+ int h = height() * devicePixelRatio();
+ gl_->glViewport(0, 0, w, h);
+ gl_->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+
+ QMatrix4x4 vp = proj_matrix_ * view_matrix_;
+
+ gl_->glUseProgram(main_program_);
+ gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(main_program_, "u_view_projection"), 1, GL_FALSE, vp.constData());
+ gl_->glUniform3f(gl_->glGetUniformLocation(main_program_, "u_light_dir"), 0.3f, 0.5f, 0.8f);
+ gl_->glUniform1ui(gl_->glGetUniformLocation(main_program_, "u_selected_id"), selected_object_id_);
+
+ gl_->glBindVertexArray(vao_);
+
+ {
+ std::lock_guard lock(upload_mutex_);
+ if (total_index_count_ > 0) {
+ gl_->glDrawElements(GL_TRIANGLES, total_index_count_, GL_UNSIGNED_INT, nullptr);
+ }
+ }
+
+ renderAxisGizmo();
+
+ context_->swapBuffers(this);
+}
+
+void ViewportWindow::renderAxisGizmo() {
+ if (!axis_program_ || !axis_vao_) return;
+
+ const int dpr = devicePixelRatio();
+ const int gizmo_size = 110 * dpr;
+ const int margin = 10 * dpr;
+
+ gl_->glViewport(margin, margin, gizmo_size, gizmo_size);
+ gl_->glDisable(GL_DEPTH_TEST);
+
+ // Build a view matrix from the same camera orientation but with a fixed
+ // close-up distance, so the gizmo rotates with the scene camera. Z-up.
+ float yaw_rad = qDegreesToRadians(camera_yaw_);
+ float pitch_rad = qDegreesToRadians(camera_pitch_);
+
+ QVector3D eye_dir;
+ eye_dir.setX(cosf(pitch_rad) * cosf(yaw_rad));
+ eye_dir.setY(cosf(pitch_rad) * sinf(yaw_rad));
+ eye_dir.setZ(sinf(pitch_rad));
+
+ QMatrix4x4 gizmo_view;
+ gizmo_view.lookAt(eye_dir * 3.0f, QVector3D(0, 0, 0), QVector3D(0, 0, 1));
+
+ QMatrix4x4 gizmo_proj;
+ gizmo_proj.ortho(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f);
+
+ QMatrix4x4 mvp = gizmo_proj * gizmo_view;
+
+ gl_->glUseProgram(axis_program_);
+ gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(axis_program_, "u_mvp"), 1, GL_FALSE, mvp.constData());
+
+ gl_->glLineWidth(2.5f); // ignored on some core-profile drivers, that's OK
+ gl_->glBindVertexArray(axis_vao_);
+ gl_->glDrawArrays(GL_LINES, 0, 6);
+
+ gl_->glEnable(GL_DEPTH_TEST);
+}
+
+void ViewportWindow::renderPickPass() {
+ gl_->glBindFramebuffer(GL_FRAMEBUFFER, pick_fbo_);
+ gl_->glViewport(0, 0, pick_width_, pick_height_);
+
+ GLuint clear_val = 0;
+ gl_->glClearBufferuiv(GL_COLOR, 0, &clear_val);
+ gl_->glClear(GL_DEPTH_BUFFER_BIT);
+
+ QMatrix4x4 vp = proj_matrix_ * view_matrix_;
+ gl_->glUseProgram(pick_program_);
+ gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(pick_program_, "u_view_projection"), 1, GL_FALSE, vp.constData());
+
+ gl_->glBindVertexArray(vao_);
+
+ {
+ std::lock_guard lock(upload_mutex_);
+ if (total_index_count_ > 0) {
+ gl_->glDrawElements(GL_TRIANGLES, total_index_count_, GL_UNSIGNED_INT, nullptr);
+ }
+ }
+
+ gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0);
+}
+
+void ViewportWindow::exposeEvent(QExposeEvent*) {
+ if (isExposed() && !gl_initialized_) {
+ initGL();
+ }
+}
+
+void ViewportWindow::resizeEvent(QResizeEvent*) {
+ if (gl_initialized_) render();
+}
+
+bool ViewportWindow::event(QEvent* e) {
+ switch (e->type()) {
+ case QEvent::MouseButtonPress:
+ handleMousePress(static_cast(e));
+ return true;
+ case QEvent::MouseButtonRelease:
+ handleMouseRelease(static_cast(e));
+ return true;
+ case QEvent::MouseMove:
+ handleMouseMove(static_cast(e));
+ return true;
+ case QEvent::Wheel:
+ handleWheel(static_cast(e));
+ return true;
+ default:
+ return QWindow::event(e);
+ }
+}
+
+void ViewportWindow::handleMousePress(QMouseEvent* e) {
+ active_button_ = e->button();
+ last_mouse_pos_ = e->pos();
+}
+
+void ViewportWindow::handleMouseRelease(QMouseEvent* e) {
+ if (active_button_ == Qt::LeftButton && (e->pos() - last_mouse_pos_).manhattanLength() < 5) {
+ uint32_t id = pickObjectAt(e->pos().x(), e->pos().y());
+ selected_object_id_ = id;
+ emit objectPicked(id);
+ }
+ active_button_ = Qt::NoButton;
+}
+
+void ViewportWindow::handleMouseMove(QMouseEvent* e) {
+ QPoint delta = e->pos() - last_mouse_pos_;
+ last_mouse_pos_ = e->pos();
+
+ if (active_button_ == Qt::MiddleButton) {
+ if (e->modifiers() & Qt::ShiftModifier) {
+ // Pan in screen space, derived from the Z-up camera basis.
+ float pan_speed = camera_distance_ * 0.002f;
+ float yaw_rad = qDegreesToRadians(camera_yaw_);
+ float pitch_rad = qDegreesToRadians(camera_pitch_);
+ QVector3D right(-sinf(yaw_rad), cosf(yaw_rad), 0.0f);
+ QVector3D up(
+ -sinf(pitch_rad) * cosf(yaw_rad),
+ -sinf(pitch_rad) * sinf(yaw_rad),
+ cosf(pitch_rad));
+ camera_target_ -= right * delta.x() * pan_speed;
+ camera_target_ += up * delta.y() * pan_speed;
+ } else {
+ // Orbit
+ camera_yaw_ -= delta.x() * 0.3f;
+ camera_pitch_ += delta.y() * 0.3f;
+ camera_pitch_ = qBound(-89.0f, camera_pitch_, 89.0f);
+ }
+ }
+}
+
+void ViewportWindow::handleWheel(QWheelEvent* e) {
+ float factor = e->angleDelta().y() > 0 ? 0.9f : 1.1f;
+ camera_distance_ *= factor;
+ camera_distance_ = qMax(0.1f, camera_distance_);
+}
diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h
new file mode 100644
index 0000000000..cb718050c8
--- /dev/null
+++ b/src/ifcviewer/ViewportWindow.h
@@ -0,0 +1,146 @@
+/********************************************************************************
+ * *
+ * 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 VIEWPORTWINDOW_H
+#define VIEWPORTWINDOW_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+struct MaterialInfo {
+ float r = 0.75f, g = 0.75f, b = 0.78f, a = 1.0f;
+};
+
+struct UploadChunk {
+ // Interleaved per-vertex layout (8 floats / 32 bytes per vertex):
+ // pos(3 float) + normal(3 float) + object_id(1 float bitcast from uint)
+ // + color(1 float holding RGBA8 packed bytes, read on the GPU as
+ // GL_UNSIGNED_BYTE * 4 normalized).
+ std::vector vertices;
+ std::vector indices; // local to this chunk's vertices
+ uint32_t object_id = 0;
+};
+
+class ViewportWindow : public QWindow {
+ Q_OBJECT
+public:
+ explicit ViewportWindow(QWindow* parent = nullptr);
+ ~ViewportWindow();
+
+ void uploadChunk(const UploadChunk& chunk);
+ void resetScene();
+
+ void setSelectedObjectId(uint32_t id);
+ uint32_t pickObjectAt(int x, int y);
+
+signals:
+ void objectPicked(uint32_t object_id);
+ void initialized();
+
+protected:
+ void exposeEvent(QExposeEvent* event) override;
+ void resizeEvent(QResizeEvent* event) override;
+ bool event(QEvent* event) override;
+
+private:
+ void initGL();
+ void render();
+ void renderPickPass();
+ void renderAxisGizmo();
+ void updateCamera();
+ void buildShaders();
+ void buildAxisGizmo();
+ bool growVbo(size_t needed_total);
+ bool growEbo(size_t needed_total);
+
+ // Mouse interaction
+ void handleMousePress(QMouseEvent* event);
+ void handleMouseRelease(QMouseEvent* event);
+ void handleMouseMove(QMouseEvent* event);
+ void handleWheel(QWheelEvent* event);
+
+ QOpenGLContext* context_ = nullptr;
+ QOpenGLFunctions_4_5_Core* gl_ = nullptr;
+ QTimer render_timer_;
+ QElapsedTimer frame_clock_;
+ bool gl_initialized_ = false;
+
+ // Shaders
+ GLuint main_program_ = 0;
+ GLuint pick_program_ = 0;
+ GLuint axis_program_ = 0;
+
+ // Axis gizmo (separate VAO/VBO since vertex layout differs from scene)
+ GLuint axis_vao_ = 0;
+ GLuint axis_vbo_ = 0;
+
+ // Geometry buffers - one big buffer pair
+ GLuint vao_ = 0;
+ GLuint vbo_ = 0;
+ GLuint ebo_ = 0;
+ size_t vbo_capacity_ = 0;
+ size_t ebo_capacity_ = 0;
+ size_t vbo_used_ = 0; // in bytes
+ size_t ebo_used_ = 0; // in bytes
+ uint32_t vertex_count_ = 0;
+
+ // Pick framebuffer
+ GLuint pick_fbo_ = 0;
+ GLuint pick_color_tex_ = 0;
+ GLuint pick_depth_rbo_ = 0;
+ int pick_width_ = 0;
+ int pick_height_ = 0;
+
+ // The entire scene is a single mega-batch: per-vertex color removes the
+ // need to switch materials between draw calls. Indices are written into
+ // the EBO already offset by base_vertex so one glDrawElements covers all.
+ uint32_t total_index_count_ = 0;
+ std::mutex upload_mutex_;
+
+ // Camera
+ QVector3D camera_target_{0, 0, 0};
+ float camera_distance_ = 50.0f;
+ float camera_yaw_ = 45.0f;
+ float camera_pitch_ = 30.0f;
+ QMatrix4x4 view_matrix_;
+ QMatrix4x4 proj_matrix_;
+
+ // Mouse state
+ Qt::MouseButton active_button_ = Qt::NoButton;
+ QPoint last_mouse_pos_;
+
+ // Selection
+ uint32_t selected_object_id_ = 0;
+ bool pick_requested_ = false;
+ int pick_x_ = 0, pick_y_ = 0;
+
+ // Stats
+ uint32_t total_triangles_ = 0;
+};
+
+#endif // VIEWPORTWINDOW_H
diff --git a/src/ifcviewer/main.cpp b/src/ifcviewer/main.cpp
new file mode 100644
index 0000000000..3bca693a37
--- /dev/null
+++ b/src/ifcviewer/main.cpp
@@ -0,0 +1,55 @@
+/********************************************************************************
+ * *
+ * 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
+#include
+#include
+
+#include "MainWindow.h"
+
+int main(int argc, char* argv[]) {
+ QApplication app(argc, argv);
+ app.setApplicationName("IfcViewer");
+ app.setOrganizationName("IfcOpenShell");
+
+ // Request OpenGL 4.5 Core globally
+ QSurfaceFormat fmt;
+ fmt.setVersion(4, 5);
+ fmt.setProfile(QSurfaceFormat::CoreProfile);
+ fmt.setDepthBufferSize(24);
+ fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
+ fmt.setSamples(4);
+ QSurfaceFormat::setDefaultFormat(fmt);
+
+ QCommandLineParser parser;
+ parser.setApplicationDescription("IfcOpenShell IFC Viewer");
+ parser.addHelpOption();
+ parser.addPositionalArgument("file", "IFC file to open");
+ parser.process(app);
+
+ MainWindow window;
+ window.show();
+
+ auto args = parser.positionalArguments();
+ if (!args.isEmpty()) {
+ window.openFile(args.first());
+ }
+
+ return app.exec();
+}