From 543d9f85884607c8220c94082ce15d2dbf44d899 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 16:27:06 +1000 Subject: [PATCH 001/120] Local hacks to compile and monkey patch issues in the Python world All AI generated slop. Do NOT trust these "fixes". It's just to get it working on my machine. --- build.sh | 30 ++ cmake/CMakeLists.txt | 3 +- findings.md | 317 ++++++++++++++++++ .../ifcopenshell/__init__.py | 5 + .../ifcopenshell/entity_instance.py | 52 ++- src/ifcparse/spf_header.h | 2 +- src/ifcparse/storage.h | 1 + 7 files changed, 406 insertions(+), 4 deletions(-) create mode 100755 build.sh create mode 100644 findings.md diff --git a/build.sh b/build.sh new file mode 100755 index 0000000000..939c4ae443 --- /dev/null +++ b/build.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +mkdir -p build && cd build + +cmake ../cmake \ + -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DPython_EXECUTABLE=/home/dion/Projects/env/bin/python3.11 \ + -DPython_INCLUDE_DIR=/usr/include/python3.11 \ + -DBUILD_IFCPYTHON=ON \ + -DBUILD_IFCGEOM=ON \ + -DBUILD_CONVERT=ON \ + -DBUILD_GEOMSERVER=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DWITH_OPENCASCADE=ON \ + -DWITH_CGAL=ON \ + -DWITH_MANIFOLD=ON \ + -DHDF5_SUPPORT=OFF \ + -DGLTF_SUPPORT=ON \ + -DIFCXML_SUPPORT=OFF \ + -DCOLLADA_SUPPORT=OFF \ + -DSCHEMA_VERSIONS="2x3;4;4x3_add2" \ + -DOCC_INCLUDE_DIR=/usr/include/opencascade \ + -DOCC_LIBRARY_DIR=/usr/lib64/opencascade + +ninja + +cp ifcwrap/_ifcopenshell_wrapper*.so ifcwrap/ifcopenshell_wrapper.py \ + ../src/ifcopenshell-python/ifcopenshell/ diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 5cd0b05f0d..502d23b4cc 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -295,7 +295,8 @@ if(WASM_BUILD) else() # @todo review this, shouldn't this be all possible header-only now? # ... or rewritten using C++17 features? - set(BOOST_COMPONENTS system program_options regex thread date_time iostreams) + # set(BOOST_COMPONENTS system program_options regex thread date_time iostreams) + set(BOOST_COMPONENTS program_options regex thread date_time iostreams) endif() if(USE_MMAP) diff --git a/findings.md b/findings.md new file mode 100644 index 0000000000..ea211e5952 --- /dev/null +++ b/findings.md @@ -0,0 +1,317 @@ +# Build fix: remove `boost_system` from CMake components + +`Boost.System` became header-only in Boost 1.69. Boost 1.90.0 no longer ships a compiled library or CMake config for it, so `find_package(Boost REQUIRED COMPONENTS system ...)` fails. + +## Fix + +`cmake/CMakeLists.txt`: + +```diff +- set(BOOST_COMPONENTS system program_options regex thread date_time iostreams) ++ set(BOOST_COMPONENTS program_options regex thread date_time iostreams) +``` + +The headers are still available; no linking is needed. + +# Build fix: add `template` keyword for dependent template member calls + +Calling a template member function through a dependent expression (e.g. `storage->has_attribute_value(...)` where `storage`'s type depends on a template parameter) requires the `template` keyword to disambiguate from a less-than comparison. + +## Error + +``` +src/ifcparse/IfcParse.cpp:1856:67: error: expected primary-expression before '>' token + 1856 | if (storage->has_attribute_value(attr_index)) { + | ^ +``` + +Six identical errors at lines 1856, 1865, 1896, 1905, 1934, 1943. + +## Fix + +`src/ifcparse/IfcParse.cpp`: + +```diff +-storage->has_attribute_value(attr_index) ++storage->template has_attribute_value(attr_index) + +-storage->has_attribute_value(attr_index) ++storage->template has_attribute_value(attr_index) +``` + +Applied at all six call sites in `in_memory_file_storage::read_from_stream`. + +# Linker fix: missing explicit template instantiations for `InstanceStreamer` + +`InstanceStreamer` is a class template with methods defined in `IfcParse.cpp`, not the header. Without explicit instantiations, the linker can't find the symbols when the SWIG wrapper loads. + +## Error + +``` +ImportError: undefined symbol: _ZN8IfcParse16InstanceStreamerINS_10FileReaderINS_14FullBufferImplEEEEC1EPS3_PNS_7IfcFileE + (IfcParse::InstanceStreamer>::InstanceStreamer(FileReader*, IfcFile*)) +``` + +## Fix + +Cannot use `template class InstanceStreamer<...>` because some constructors have `static_assert` guards that reject certain reader types. Instead, instantiate each member function individually per reader type, only including the constructors valid for that type. + +`src/ifcparse/IfcParse.cpp` (after the last `InstanceStreamer` method definition): + +```cpp +// FullBufferImpl +template IfcParse::InstanceStreamer>::InstanceStreamer(IfcParse::IfcFile*); +template IfcParse::InstanceStreamer>::InstanceStreamer(const std::string&, bool, IfcParse::IfcFile*); +template IfcParse::InstanceStreamer>::InstanceStreamer(void*, int, IfcParse::IfcFile*); +template IfcParse::InstanceStreamer>::InstanceStreamer(FileReader*, IfcParse::IfcFile*); +// ... plus ensure_header, initialize_header, hasSemicolon, semicolonCount, +// pushPage, bypassTypes, readInstance + +// PushedSequentialImpl — same pattern, different valid constructors + +// MMapFileReader (ifdef USE_MMAP) — same pattern +``` + +# Linker fix: `FullBufferImpl` missing buffer constructor + +SWIG's `stream_from_string` calls `InstanceStreamer>(void*, int, IfcFile*)`, but the `(void*, int)` constructor previously hit a `static_assert` for `FullBufferImpl` — it only allowed `PushedSequentialImpl`. + +## Error + +``` +ImportError: undefined symbol: _ZN8IfcParse16InstanceStreamerINS_10FileReaderINS_14FullBufferImplEEEEC1EPviPNS_7IfcFileE + (InstanceStreamer>::InstanceStreamer(void*, int, IfcFile*)) +``` + +## Fix + +Three changes to make `FullBufferImpl` support buffer-based and default construction: + +`src/ifcparse/FileReader.h` — add buffer constructor to `FullBufferImpl`: + +```diff + class IFC_PARSE_API FullBufferImpl { + public: + explicit FullBufferImpl(const std::string& fn); ++ FullBufferImpl(void* data, size_t length); +``` + +`src/ifcparse/FileReader.h` — add `FileReader(void*, size_t)` forwarding constructor: + +```diff ++ FileReader(void* data, size_t length) ++ : cursor_(0) { ++ if constexpr (std::is_same_v) { ++ impl_ = std::make_shared(data, length); ++ } else { ++ static_assert(...); ++ } ++ } +``` + +`src/ifcparse/FileReader.cpp` — implement the constructor: + +```cpp +FullBufferImpl::FullBufferImpl(void* data, size_t length) + : buf_(static_cast(data), static_cast(data) + length) + , size_(length) { +} +``` + +`src/ifcparse/IfcParse.cpp` — extend the two `InstanceStreamer` constructors to accept `FullBufferImpl`: + +```diff + // InstanceStreamer(IfcFile*): ++ } else if constexpr (std::is_same_v>) { ++ owned_stream_ = std::make_unique(nullptr, (size_t)0); + + // InstanceStreamer(void*, int, IfcFile*): ++ } else if constexpr (std::is_same_v>) { ++ owned_stream_ = std::make_unique(data, (size_t)length); +``` + +# Runtime fix: segfault in `parse_context::push()` due to vector reallocation + +`parse_context_pool` stores nodes in a `std::vector`. During parsing, `load()` takes a `parse_context&` parameter and calls `context.push()`, which calls `pool_->make()`. If the pool's vector reallocates (via `emplace_back`), all existing references into the vector — including the `context` reference held by the caller — become dangling. Subsequent access through the dangling reference causes a segfault. + +Triggered by larger IFC files (e.g. `ISSUE_159_kleine_Wohnung_R22.ifc`, 9.5 MB) that cause enough pool growth to trigger reallocation. + +## Error + +``` +Thread 1 received signal SIGSEGV, Segmentation fault. +0x... in IfcParse::parse_context::push() + #1 in_memory_file_storage::load(...) // context& is dangling after reallocation + #2 in_memory_file_storage::load(...) // parent call + #3 InstanceStreamer::readInstance() +``` + +## Fix + +`src/ifcparse/storage.h` — change the pool container from `std::vector` to `std::deque`, which does not invalidate references on `push_back`/`emplace_back`: + +```diff ++#include + + struct parse_context_pool { +- std::vector nodes_; ++ std::deque nodes_; +``` + +# Runtime fix: `express::Base` comparison operators throw on null/expired instances + +`express::Base::operator<` and `operator==` called `data()`, which throws `std::runtime_error("Trying to access deleted instance reference")` when the internal `weak_ptr` is expired. A default-constructed `express::Base` (the value-type equivalent of a null pointer) always has an expired `weak_ptr`. + +## Why this model triggers it + +The bug requires two conditions to coincide: + +1. A representation is shared by **more than one product** (via `IfcRepresentationMap` / `IfcMappedItem`). +2. At least one of those products has **no material association**, so `get_single_material_association()` returns `express::Base{}` (the null equivalent). + +In `advanced_model.ifc`, Body representations like `#449` (Body/Brep) have a single `IfcRepresentationMap` (`#453`) with 13 `IfcMappedItem` usages, meaning 13 products share the geometry. Some of those products (e.g. `IfcFlowTerminal` instances) have no `IfcRelAssociatesMaterial`, so `get_single_material_association` returns `express::Base{}`. + +Smaller or simpler models don't hit this because either: +- Every representation maps to only 1 product → `reuse_ok_` short-circuits at `products.size() == 1` before reaching the material check. +- Every product has a material association → no null `express::Base` is ever inserted into the set. + +## Exact call sequence + +``` +Iterator::initialize() + try { + mapping::get_representations(reps, filters_) + addRepresentationsFromDefaultContexts(representations) + → collects reps from subcontexts in order: + Axis (#115): 143 reps + Body (#117): 7550 reps + FootPrint (#119): 12 reps + + for (auto representation : representations): + + ── Axis reps (indices 0–142) ────────────────────────── + products_represented_by(rep, rmap) + → OfProductRepresentation: 1 product each + filter_products(products, filters) → 1 product + reuse_ok_(ifcproducts) + → products.size() == 1 → return true ← SHORT-CIRCUIT, no material check + representation_mapped_to(rep) → null (no MappedItem) + → task created. 143 tasks accumulated. + + ── First Body rep #449 (Body/Brep) ──────────────────── + products_represented_by(#449, rmap) + → OfProductRepresentation: empty + → RepresentationMap: 1 map (#453) + → MapUsage: 13 MappedItems → traces through to 13 IfcProducts + filter_products(products, filters) → 13 products + reuse_ok_(ifcproducts) ← CRASH HERE + → products.size() == 1? NO (13 products) + → for each product: + find_openings(product) → OK + get_single_material_association(product) + → some products have no IfcRelAssociatesMaterial + → returns express::Base{} (expired weak_ptr) + associated_single_materials.insert(result) + → std::set::insert calls operator< + → operator< calls data() + → data() calls data_.lock() → expired → THROWS + "Trying to access deleted instance reference" + + } catch (const std::exception& e) { + Logger::Error(e) ← exception caught here, get_representations aborted + } + + → reps contains only the 143 Axis tasks created before the throw + → all 143 Axis reps have Curve2D geometry → map(representation) returns null + → no valid elements produced → initialize() returns false +``` + +In the old pointer-based code, `reuse_ok_` used `std::set` and `get_single_material_association` returned `nullptr`. Inserting `nullptr` into a `std::set` is a plain pointer comparison — no dereference, no throw. The refactoring to `std::set` changed the comparison from pointer comparison to `express::Base::operator<`, which unconditionally dereferences through `data()`. + +## Error + +``` +[Error] Trying to access deleted instance reference +[Notice] Created 143 tasks for 143 products ← only Axis reps; all Body reps lost +initialize() returned: False +``` + +## Fix + +`src/ifcparse/express.h` — use `weak_ptr::lock().get()` instead of `data()` so that expired pointers compare as `nullptr` (matching old raw-pointer semantics): + +```diff + bool operator<(const Base& other) const { +- return data() < other.data(); ++ auto a = data_.lock(); ++ auto b = other.data_.lock(); ++ return a.get() < b.get(); + } + + bool operator==(const Base& other) const { +- return data() == other.data(); ++ auto a = data_.lock(); ++ auto b = other.data_.lock(); ++ return a.get() == b.get(); + } +``` + +# Runtime fix: `entity_instance` missing `get_inverse` due to SWIG `%rename` collision + +Accessing inverse attributes (e.g. `element.IsDecomposedBy`) on any entity raises `AttributeError: entity instance of type 'IFC2X3.IfcProject' has no attribute 'get_inverse'`. + +## Why + +`entity_instance_mixin.__getattr__` (line 106 of `entity_instance.py`) calls `self.get_inverse(name)` when it detects an inverse attribute. Since the mixin inherits into the SWIG-generated `entity_instance` class (via the `object = custom_base` hack in `IfcParseWrapper.i:936`), `self.get_inverse` must resolve to a method on the SWIG class. + +However, `IfcParseWrapper.i:70` has a global rename: + +``` +%rename("get_inverses_by_declaration") get_inverse; +``` + +This was intended for `ifcopenshell::file::get_inverse` (which takes an entity + declaration and returns instances by reference), but SWIG `%rename` is global — it also renames the `%extend express::Base` method `get_inverse(const std::string& a)` at line 551. So the Python-side `entity_instance` class exposes the method as `get_inverses_by_declaration`, not `get_inverse`. + +The old code (`v0.8.0`) didn't hit this because `__getattr__` called `self.wrapped_data.get_inverse(name)` on an inner `ifcopenshell_wrapper.entity_instance` object — but in that old layout, the inner object was constructed differently and the rename didn't apply the same way (or the method had a different path). In the new mixin approach, `self` **is** the SWIG object, so the rename is directly visible. + +## Fix + +`src/ifcwrap/IfcParseWrapper.i` — override the global rename specifically for `express::Base::get_inverse`, restoring the original name on entity instances: + +```diff ++%rename("get_inverse") express::Base::get_inverse; + %rename("get_inverses_by_declaration") get_inverse; +``` + +Add this line **before** the global rename (or anywhere before the `%extend express::Base` block). This scoped rename takes precedence for `express::Base`, so: +- `entity_instance.get_inverse(name)` works as the mixin expects +- `file.get_inverses_by_declaration(...)` keeps its intended name + +## Python-side workaround + +`entity_instance.py:106` — call the method by its SWIG-renamed name: + +```diff +- vs = self.get_inverse(name) ++ vs = self.get_inverses_by_declaration(name) +``` + +# Runtime fix: `entity_instance` class no longer importable from `entity_instance` module + +The class rename from `entity_instance` to `entity_instance_mixin` broke external code that does `from ifcopenshell.entity_instance import entity_instance`. + +## Error + +``` +ImportError: cannot import name 'entity_instance' from 'ifcopenshell.entity_instance' +``` + +Triggered at import time via `ifcopenshell.util.pset` (and likely other modules). + +## Fix + +`src/ifcopenshell-python/ifcopenshell/entity_instance.py` — add a backwards-compatible alias at the bottom of the module: + +```python +entity_instance = entity_instance_mixin +``` diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index f3e03b633f..cdc82c9c74 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -93,6 +93,11 @@ except Exception: from . import guid from .ifcopenshell_wrapper import entity_instance, file from .file import rocksdb_lazy_instance +# Hacks! +from .entity_instance import _patch_swig_comparisons +_patch_swig_comparisons() +del _patch_swig_comparisons +# End hacks! from .sql import sqlite, sqlite_entity # explicitly specify available imported symbols diff --git a/src/ifcopenshell-python/ifcopenshell/entity_instance.py b/src/ifcopenshell-python/ifcopenshell/entity_instance.py index b98529e270..324ea0e569 100644 --- a/src/ifcopenshell-python/ifcopenshell/entity_instance.py +++ b/src/ifcopenshell-python/ifcopenshell/entity_instance.py @@ -103,7 +103,7 @@ class entity_instance_mixin: idx = self.get_argument_index(name) return self.get_argument(idx) elif attr_cat == INVERSE: - vs = self.get_inverse(name) + vs = self.get_inverses_by_declaration(name) if settings.unpack_non_aggregate_inverses: schema_name = self.is_a(True).split(".")[0] ent: ifcopenshell_wrapper.entity @@ -213,11 +213,17 @@ class entity_instance_mixin: return value def __eq__(self, other: entity_instance_mixin) -> bool: - if not isinstance(self, type(other)): + if other is None or not isinstance(other, entity_instance_mixin): return False else: raise NotImplementedError + def __ne__(self, other: entity_instance_mixin) -> bool: + if other is None or not isinstance(other, entity_instance_mixin): + return True + else: + raise NotImplementedError + def is_entity(self) -> bool: """Tests whether the instance is an entity type as opposed to a simple data type. @@ -395,3 +401,45 @@ class entity_instance_mixin: assert return_type is dict assert len(ignore) == 0 return ifcopenshell_wrapper.get_info_cpp(self, recursive, include_identifier) + + +# Alias for backwards compatibility — external code imports this name. +entity_instance = entity_instance_mixin + + +# Monkey-patch SWIG's __eq__, __ne__, __lt__ on the generated entity_instance +# class to guard against None / non-entity arguments. SWIG generates these +# directly on the class (overriding the mixin), and they pass arguments straight +# to C++ which rejects null references. +# Deferred until after ifcopenshell_wrapper finishes loading to avoid circular import. +_swig_comparisons_patched = False + + +def _patch_swig_comparisons(): + global _swig_comparisons_patched + if _swig_comparisons_patched: + return + _swig_cls = ifcopenshell_wrapper.entity_instance + _orig_eq = _swig_cls.__eq__ + _orig_ne = _swig_cls.__ne__ + _orig_lt = _swig_cls.__lt__ + + def _safe_eq(self, other): + if other is None or not isinstance(other, _swig_cls): + return NotImplemented + return _orig_eq(self, other) + + def _safe_ne(self, other): + if other is None or not isinstance(other, _swig_cls): + return NotImplemented + return _orig_ne(self, other) + + def _safe_lt(self, other): + if other is None or not isinstance(other, _swig_cls): + return NotImplemented + return _orig_lt(self, other) + + _swig_cls.__eq__ = _safe_eq + _swig_cls.__ne__ = _safe_ne + _swig_cls.__lt__ = _safe_lt + _swig_comparisons_patched = True diff --git a/src/ifcparse/spf_header.h b/src/ifcparse/spf_header.h index 9daf4a25e8..efd6304553 100644 --- a/src/ifcparse/spf_header.h +++ b/src/ifcparse/spf_header.h @@ -30,7 +30,7 @@ class file; class IFC_PARSE_API spf_header { private: - file* file_; + ifcopenshell::file* file_; std::array, 3> header_entities_; diff --git a/src/ifcparse/storage.h b/src/ifcparse/storage.h index 2178e307f1..9944da494e 100644 --- a/src/ifcparse/storage.h +++ b/src/ifcparse/storage.h @@ -31,6 +31,7 @@ namespace rocksdb { #include #include #include +#include #include #include #include From 06eca938d74e50c1bb91c50be51155085e8d1e2f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 16:30:10 +1000 Subject: [PATCH 002/120] Dump of hello world ifc viewer code --- cmake/CMakeLists.txt | 4 + src/ifcviewer/AppSettings.cpp | 57 +++ src/ifcviewer/AppSettings.h | 48 ++ src/ifcviewer/CMakeLists.txt | 61 +++ src/ifcviewer/GeometryStreamer.cpp | 285 ++++++++++++ src/ifcviewer/GeometryStreamer.h | 89 ++++ src/ifcviewer/MainWindow.cpp | 270 ++++++++++++ src/ifcviewer/MainWindow.h | 80 ++++ src/ifcviewer/README.md | 129 ++++++ src/ifcviewer/SettingsWindow.cpp | 68 +++ src/ifcviewer/SettingsWindow.h | 46 ++ src/ifcviewer/ViewportWindow.cpp | 674 +++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 146 +++++++ src/ifcviewer/main.cpp | 55 +++ 14 files changed, 2012 insertions(+) create mode 100644 src/ifcviewer/AppSettings.cpp create mode 100644 src/ifcviewer/AppSettings.h create mode 100644 src/ifcviewer/CMakeLists.txt create mode 100644 src/ifcviewer/GeometryStreamer.cpp create mode 100644 src/ifcviewer/GeometryStreamer.h create mode 100644 src/ifcviewer/MainWindow.cpp create mode 100644 src/ifcviewer/MainWindow.h create mode 100644 src/ifcviewer/README.md create mode 100644 src/ifcviewer/SettingsWindow.cpp create mode 100644 src/ifcviewer/SettingsWindow.h create mode 100644 src/ifcviewer/ViewportWindow.cpp create mode 100644 src/ifcviewer/ViewportWindow.h create mode 100644 src/ifcviewer/main.cpp 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(); +} From d08a4e0706ad8bee3a108787ce01b473e0d6c6c9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 19:23:10 +1000 Subject: [PATCH 003/120] Update ifcviewer to compile with datamodel refactor --- src/ifcviewer/GeometryStreamer.cpp | 4 ++-- src/ifcviewer/GeometryStreamer.h | 6 +++--- src/ifcviewer/MainWindow.cpp | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 39698c84e6..437209c909 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -94,7 +94,7 @@ std::vector GeometryStreamer::drainElements() { void GeometryStreamer::run(const std::string& path, int num_threads) { try { - ifc_file_ = std::make_unique(path); + ifc_file_ = std::make_unique(path); } catch (const std::exception& e) { emit errorOccurred(QString("Failed to parse IFC file: %1").arg(e.what())); return; @@ -112,7 +112,7 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { 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); + 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; diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h index 06b6364a24..abd087463c 100644 --- a/src/ifcviewer/GeometryStreamer.h +++ b/src/ifcviewer/GeometryStreamer.h @@ -31,7 +31,7 @@ #include #include -#include "../ifcparse/IfcFile.h" +#include "../ifcparse/file.h" #include "../ifcgeom/Iterator.h" #include "ViewportWindow.h" @@ -57,7 +57,7 @@ public: bool isRunning() const { return running_.load(); } int progress() const { return progress_.load(); } - IfcParse::IfcFile* ifcFile() const { return ifc_file_.get(); } + ifcopenshell::file* ifcFile() const { return ifc_file_.get(); } // Thread-safe access to discovered elements std::vector drainElements(); @@ -73,7 +73,7 @@ private: UploadChunk convertElement(const IfcGeom::TriangulationElement* elem, uint32_t object_id); - std::unique_ptr ifc_file_; + std::unique_ptr ifc_file_; std::unique_ptr worker_thread_; std::atomic running_{false}; std::atomic cancel_requested_{false}; diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 1f32ce0877..6eede35353 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -244,23 +244,23 @@ void MainWindow::populateProperties(uint32_t object_id) { auto* file = streamer_->ifcFile(); if (!file) return; - auto* product = file->instance_by_id(info.ifc_id); + auto product = file->instance_by_id(info.ifc_id); if (!product) return; // Show all direct attributes - auto& decl = product->declaration(); + 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); + 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())) + ">"; + str_val = "<" + std::string(ifcopenshell::argument_type_to_string(val.type())) + ">"; } addRow(QString::fromStdString(attr->name()), QString::fromStdString(str_val)); } From d33055bb72148ab78e40fcee3d45b778faabb3e0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 19:50:44 +1000 Subject: [PATCH 004/120] Plan out performance strategy --- src/ifcviewer/README.md | 335 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 329 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index b9194cefd1..9c6c52560c 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -117,13 +117,336 @@ make -j$(nproc) | Ctrl+O | Open file | | Ctrl+Q | Quit | +## Performance Strategy + +The viewer targets smooth orbiting at 60 fps on models up to 1 million IFC objects. +Rendering performance is addressed in three phases. Each phase builds on the +previous one, and the system is designed so that smaller models never pay for +optimizations they don't need. + +### Phase 1: Per-Object Frustum Culling (CPU) + +**Status:** Implemented. + +The simplest win: don't draw what's off screen. + +#### Data model + +During `uploadChunk()`, the viewport records a small metadata struct for every +object that enters the GPU buffers: + +```cpp +struct ObjectDrawInfo { + uint32_t index_offset; // byte offset into the shared EBO + uint32_t index_count; // number of indices (triangles * 3) + float aabb_min[3]; // world-space axis-aligned bounding box + float aabb_max[3]; // (computed from vertex positions at upload time) +}; +``` + +This costs 32 bytes per object. For 1M objects that's ~32 MB of CPU-side +metadata — negligible next to the vertex data. + +#### Frustum extraction + +Each frame, before drawing, six clip planes are extracted from the +view-projection matrix (`VP = proj * view`). The standard Griess-Hartmann +method pulls them directly from the matrix rows: + +``` +left = VP[3] + VP[0] +right = VP[3] - VP[0] +bottom = VP[3] + VP[1] +top = VP[3] - VP[1] +near = VP[3] + VP[2] +far = VP[3] - VP[2] +``` + +Each plane is stored as (a, b, c, d) and normalized so that +`a*x + b*y + c*z + d` gives the signed distance from the plane. + +#### AABB-frustum test + +For each object, the AABB is tested against all six planes using the +"p-vertex / n-vertex" method: + +- For each plane, find the AABB corner most in the direction of the plane + normal (the p-vertex). +- If the p-vertex is on the negative side of the plane, the entire AABB is + outside the frustum → cull. +- If any plane culls the object, skip it. + +This test is conservative: it never culls a visible object, but may +occasionally keep an invisible one (when the AABB straddles a frustum corner). +That's fine — false positives just cost a few extra triangles. + +#### Drawing visible objects + +The surviving objects' `(index_count, index_offset)` pairs are passed to +`glMultiDrawElements()` in a single call. This replaces the previous single +`glDrawElements()` that drew everything. The GPU processes only the index +ranges that survived the frustum test. + +Alternatively, for the pick pass (which runs less frequently), the same +visibility list is reused — objects culled from the main pass are also culled +from picking. + +#### Performance characteristics + +| Metric | Value | +|--------|-------| +| Per-object cost | ~6 dot products + 6 comparisons per frame | +| 50k objects | ~0.3 ms on a modern CPU core | +| 500k objects | ~3 ms (starts to matter at 60 fps) | +| 1M objects | ~6 ms (too expensive — need phase 3) | +| Memory overhead | 32 bytes/object | +| Load-time overhead | Near zero (AABB computed during existing upload) | + +Phase 1 is sufficient for models up to ~100k objects. Beyond that, the CPU-side +frustum test becomes a measurable fraction of the frame budget, motivating +phase 3. + +### Phase 2: Spatial Tiling (optional, for large models) + +For models exceeding ~10k objects, spatial tiling groups nearby objects into +tiles and culls at the tile level rather than per-object. This reduces the +number of frustum tests from N_objects to N_tiles (typically hundreds to low +thousands). + +#### When tiling activates + +Tiling is **optional and non-disruptive**. The system treats a non-tiled model +as the degenerate case of "one tile containing everything" — the rendering loop +always iterates tiles, so no separate code path is needed. + +Tiling activates in one of three ways: + +1. **Preprocessed cache exists**: If a `.ifcview` sidecar file is found next to + the `.ifc` file, the tile structure is loaded from it instantly. The model + uploads geometry in tile order. +2. **Automatic by size**: If the model has more than a configurable threshold of + objects (default 10k), a background task builds the spatial tree after + initial loading completes. Until it finishes, phase 1 culling handles + visibility. +3. **Explicit user action**: A "preprocess for performance" option builds the + spatial tree and saves the sidecar for future loads. + +#### Spatial subdivision + +The world-space bounding box of the entire model is subdivided using a +**loose octree**: + +- The root node covers the scene AABB. +- Each node is split when it contains more than a threshold number of objects + (e.g. 256). +- Objects are assigned to the smallest node that fully contains their AABB. +- "Loose" bounds (inflated by 1.5x) reduce the number of objects that span + multiple nodes. +- Leaf nodes become tiles. + +An octree adapts to non-uniform object density (common in buildings — lots of +detail in MEP risers, sparse in open atriums) better than a uniform grid. + +#### EBO re-sorting + +For tile-level culling to translate into contiguous index ranges, the EBO must +be sorted so that all indices for objects in the same tile are adjacent. + +This happens via **deferred compaction**: + +1. During initial load, geometry uploads in iterator order (fast first frame, + phase 1 culling active). +2. After loading completes, a background thread: + a. Builds the octree from the per-object AABBs (already computed in phase 1). + b. Determines the tile for each object. + c. Computes the new index order (sorted by tile, then by object within tile). + d. Builds a new EBO on the CPU. +3. The main thread uploads the new EBO in one `glNamedBufferSubData` call and + swaps in the tile metadata. One frame of stutter, bounded by EBO upload + time. + +The per-tile metadata: + +```cpp +struct TileInfo { + float aabb_min[3]; // tile bounding box (union of contained AABBs) + float aabb_max[3]; + uint32_t index_offset; // into the re-sorted EBO + uint32_t index_count; // sum of all contained objects' indices + uint32_t object_count; // for stats / debugging +}; +``` + +#### Preprocessed sidecar format + +The `.ifcview` file stores: + +- Octree structure (node hierarchy, split planes). +- Per-object tile assignment (object_id → tile_id mapping). +- Per-tile index order (so the EBO can be built in tile order directly during + upload, skipping the compaction pass entirely). +- File hash of the source `.ifc` (invalidation check). + +This makes second-and-subsequent loads of the same model significantly faster: +the spatial tree doesn't need to be rebuilt, and geometry uploads in tile order +from the start. + +#### Performance characteristics + +| Metric | Value | +|--------|-------| +| Tile count (typical) | 500–5,000 for a large building | +| Per-frame frustum tests | N_tiles instead of N_objects | +| 500k objects, ~2k tiles | ~0.01 ms frustum testing | +| Memory overhead | ~64 bytes/tile + 32 bytes/object (phase 1 metadata retained) | +| Background compaction | 1–5 seconds for 1M objects (single-threaded) | +| Sidecar file size | ~10–50 KB (indices + tree, no geometry) | + +#### Spatial coherence bonus + +Beyond culling, tile-sorted EBOs improve GPU cache performance. When the GPU +rasterizes a tile's triangles, the vertices are contiguous in the VBO, so the +post-transform vertex cache hits more often. This can yield 10–20% rasterization +speedup even when nothing is culled (e.g. zoomed out to see the whole model). + +### Phase 3: GPU-Driven Indirect Draw + +For models with 500k+ objects, even tile-level CPU culling is fast, but the +real bottleneck shifts to draw call submission. Phase 3 moves all per-frame +visibility decisions to the GPU via compute shaders and indirect draw commands. + +#### How it works + +Phase 3 is **approach 2 layered on top of approach 3**. It does not replace +tiling — it accelerates it. + +1. **Upload phase** (once, at load time): + - Per-tile AABBs are uploaded to a GPU SSBO (`tile_aabbs`). + - One `DrawElementsIndirectCommand` per tile is written to an indirect draw + buffer: + ```c + struct DrawElementsIndirectCommand { + uint count; // tile's total index count + uint instanceCount; // 1 + uint firstIndex; // offset into EBO + uint baseVertex; // 0 (indices are global) + uint baseInstance; // tile_id (available in shader via gl_DrawID) + }; + ``` + - A "template" copy of the indirect buffer is kept so the compute shader + can reset culled commands each frame without re-uploading from CPU. + +2. **Cull phase** (every frame, on the GPU): + - The CPU uploads 6 frustum plane vec4s as a uniform or small UBO. + - A compute shader dispatches `ceil(N_tiles / 64)` workgroups: + ```glsl + layout(local_size_x = 64) in; + + void main() { + uint tile_id = gl_GlobalInvocationID.x; + if (tile_id >= tile_count) return; + + // Copy from template (resets any previously zeroed commands) + commands[tile_id] = template_commands[tile_id]; + + // Frustum test + if (!aabb_vs_frustum(tile_aabbs[tile_id], frustum_planes)) { + commands[tile_id].count = 0; // culled: GPU skips zero-count draws + } + } + ``` + - A memory barrier ensures the indirect buffer is visible to the draw stage. + +3. **Draw phase** (every frame): + - One call: `glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, + nullptr, N_tiles, 0)`. + - The GPU reads the indirect buffer, skips tiles with `count == 0`, and + draws the rest. Zero CPU-side per-object or per-tile work. + +#### What the CPU does per frame + +1. Upload 6 vec4 frustum planes (96 bytes). +2. Dispatch one compute shader. +3. Issue one `glMultiDrawElementsIndirect`. +4. Swap buffers. + +That's it. The CPU frame time is essentially constant regardless of model size. + +#### Future extensions (enabled by this architecture) + +Once the compute-based cull pass exists, it's straightforward to add: + +- **Hierarchical-Z occlusion culling**: render a coarse depth buffer from the + previous frame, then test tile AABBs against it in the compute shader. Tiles + fully behind closer geometry get culled. This handles interior-heavy BIM + models well (most rooms are occluded from any given viewpoint). +- **Distance-based LOD**: the compute shader can select different index ranges + (coarse vs. fine tessellation) per tile based on distance to camera. +- **Contribution culling**: tiles whose screen-space projection is below a + pixel threshold get `count = 0`. Removes distant small objects. + +#### Performance characteristics + +| Metric | Value | +|--------|-------| +| CPU per-frame work | ~0.01 ms (constant, independent of model size) | +| GPU compute dispatch | ~0.02 ms for 2k tiles | +| Draw call overhead | 1 indirect multi-draw call | +| GPU memory overhead | ~48 bytes/tile (AABB SSBO) + 20 bytes/tile (indirect commands) × 2 (template + live) | +| Total for 2k tiles | ~176 KB GPU memory | +| Implementation complexity | High (compute shaders, SSBOs, memory barriers, indirect draw) | + +#### When to use + +Phase 3 is worthwhile when: + +- The model has 500k+ objects (CPU frustum testing > 3 ms). +- Smooth 60 fps orbiting is required during interaction. +- The GPU has compute shader support (OpenGL 4.3+, which is guaranteed since + the viewer requires 4.5). + +For models under 100k objects, phase 1 alone is sufficient. For 100k–500k, +phase 2 (tiling) keeps CPU culling under 1 ms. Phase 3 is the final step that +makes the CPU frame time constant. + +### Summary + +``` +Model size Active phases CPU cull cost Draw calls +───────────── ────────────── ────────────── ────────── +< 10k objects Phase 1 ~0.06 ms 1 multi-draw +10k–100k Phase 1 ~0.6 ms 1 multi-draw +100k–500k Phase 1 + 2 ~0.01 ms 1 multi-draw +500k–1M+ Phase 1 + 2 + 3 ~0 (GPU) 1 indirect multi-draw +``` + +The load path: + +``` +open(model.ifc): + ├─ sidecar exists? + │ ├─ yes: load tile tree from .ifcview + │ │ upload geometry in tile order + │ │ (skip background compaction) + │ └─ no: upload geometry in iterator order (fast first frame) + │ phase 1 culling active immediately + │ if object_count > threshold: + │ background: build octree, re-sort EBO, save .ifcview + │ on completion: swap in tile structure + └─ rendering: + ├─ phase 3 available? → compute cull + indirect multi-draw + └─ else → CPU frustum test + glMultiDrawElements +``` + ## Roadmap -- [ ] Material color support (currently renders default grey per batch) -- [ ] Buffer growth (reallocate when 64 MB VBO fills up) -- [ ] `glMultiDrawElementsIndirect` for fewer draw calls +- [x] Material color support (per-vertex RGBA8) +- [x] Buffer growth (dynamic VBO/EBO resizing up to 4 GB) +- [x] Per-object frustum culling (phase 1) +- [ ] Spatial tiling with octree (phase 2) +- [ ] GPU-driven indirect draw (phase 3) +- [ ] Preprocessed `.ifcview` sidecar for fast re-loads +- [ ] Hierarchical-Z occlusion culling +- [ ] Distance-based LOD selection - [ ] 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) From bfac12dbe70f55b7beafb3b2d0554f9e338ff8dc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 19:50:46 +1000 Subject: [PATCH 005/120] Per-object frustum culling with glMultiDrawElements Track per-object AABB and index range during upload. Each frame, extract frustum planes from the view-projection matrix and cull objects whose AABB is entirely outside any plane. Draw only visible objects via glMultiDrawElements. Document the three-phase rendering performance strategy in README.md. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 104 ++++++++++++++++++++++++++++--- src/ifcviewer/ViewportWindow.h | 17 ++++- 2 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 99624cb9f5..414b9889fa 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -26,7 +26,9 @@ #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 @@ -421,9 +423,31 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) { } gl_->glNamedBufferSubData(ebo_, ebo_used_, ib_size, global_indices.data()); + // Compute AABB from vertex positions in this chunk. + ObjectDrawInfo info; + info.index_offset = static_cast(ebo_used_); + info.index_count = static_cast(chunk.indices.size()); + + const size_t num_verts = chunk.vertices.size() / VERTEX_STRIDE; + if (num_verts > 0) { + info.aabb_min[0] = info.aabb_min[1] = info.aabb_min[2] = std::numeric_limits::max(); + info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = -std::numeric_limits::max(); + for (size_t v = 0; v < num_verts; ++v) { + const float* pos = &chunk.vertices[v * VERTEX_STRIDE]; + for (int a = 0; a < 3; ++a) { + if (pos[a] < info.aabb_min[a]) info.aabb_min[a] = pos[a]; + if (pos[a] > info.aabb_max[a]) info.aabb_max[a] = pos[a]; + } + } + } else { + info.aabb_min[0] = info.aabb_min[1] = info.aabb_min[2] = 0.0f; + info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = 0.0f; + } + { std::lock_guard lock(upload_mutex_); total_index_count_ += static_cast(chunk.indices.size()); + object_draw_info_.push_back(info); } vbo_used_ += vb_size; @@ -442,6 +466,7 @@ void ViewportWindow::resetScene() { vertex_count_ = 0; total_triangles_ = 0; selected_object_id_ = 0; + object_draw_info_.clear(); } void ViewportWindow::setSelectedObjectId(uint32_t id) { @@ -504,6 +529,63 @@ void ViewportWindow::updateCamera() { proj_matrix_.perspective(45.0f, aspect, 0.1f, camera_distance_ * 10.0f); } +void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { + visible_counts_.clear(); + visible_offsets_.clear(); + + std::lock_guard lock(upload_mutex_); + if (object_draw_info_.empty()) return; + + // Extract 6 frustum planes from the view-projection matrix. + // Each plane is (a, b, c, d) where ax + by + cz + d >= 0 is inside. + // QMatrix4x4 is stored column-major; operator(row, col) gives element. + float planes[6][4]; + for (int i = 0; i < 4; ++i) { + planes[0][i] = vp(3, i) + vp(0, i); // left + planes[1][i] = vp(3, i) - vp(0, i); // right + planes[2][i] = vp(3, i) + vp(1, i); // bottom + planes[3][i] = vp(3, i) - vp(1, i); // top + planes[4][i] = vp(3, i) + vp(2, i); // near + planes[5][i] = vp(3, i) - vp(2, i); // far + } + // Normalize planes. + for (int p = 0; p < 6; ++p) { + float len = std::sqrt(planes[p][0] * planes[p][0] + + planes[p][1] * planes[p][1] + + planes[p][2] * planes[p][2]); + if (len > 0.0f) { + float inv = 1.0f / len; + planes[p][0] *= inv; + planes[p][1] *= inv; + planes[p][2] *= inv; + planes[p][3] *= inv; + } + } + + visible_counts_.reserve(object_draw_info_.size()); + visible_offsets_.reserve(object_draw_info_.size()); + + for (const auto& obj : object_draw_info_) { + bool visible = true; + for (int p = 0; p < 6; ++p) { + // p-vertex: the AABB corner most in the direction of the plane normal. + float px = planes[p][0] >= 0.0f ? obj.aabb_max[0] : obj.aabb_min[0]; + float py = planes[p][1] >= 0.0f ? obj.aabb_max[1] : obj.aabb_min[1]; + float pz = planes[p][2] >= 0.0f ? obj.aabb_max[2] : obj.aabb_min[2]; + float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3]; + if (dist < 0.0f) { + visible = false; + break; + } + } + if (visible) { + visible_counts_.push_back(static_cast(obj.index_count)); + visible_offsets_.push_back(reinterpret_cast( + static_cast(obj.index_offset))); + } + } +} + void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; @@ -524,11 +606,12 @@ void ViewportWindow::render() { 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); - } + buildVisibleList(vp); + if (!visible_counts_.empty()) { + gl_->glMultiDrawElements(GL_TRIANGLES, + visible_counts_.data(), GL_UNSIGNED_INT, + visible_offsets_.data(), + static_cast(visible_counts_.size())); } renderAxisGizmo(); @@ -588,11 +671,12 @@ void ViewportWindow::renderPickPass() { 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); - } + // Reuse the visible list from the most recent render() call. + if (!visible_counts_.empty()) { + gl_->glMultiDrawElements(GL_TRIANGLES, + visible_counts_.data(), GL_UNSIGNED_INT, + visible_offsets_.data(), + static_cast(visible_counts_.size())); } gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index cb718050c8..363158b16f 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -36,6 +36,13 @@ struct MaterialInfo { float r = 0.75f, g = 0.75f, b = 0.78f, a = 1.0f; }; +struct ObjectDrawInfo { + uint32_t index_offset; // byte offset into EBO + uint32_t index_count; // number of indices + float aabb_min[3]; // world-space AABB + float aabb_max[3]; +}; + 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) @@ -77,6 +84,7 @@ private: void buildAxisGizmo(); bool growVbo(size_t needed_total); bool growEbo(size_t needed_total); + void buildVisibleList(const QMatrix4x4& vp); // Mouse interaction void handleMousePress(QMouseEvent* event); @@ -116,12 +124,15 @@ private: 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. + // Per-object draw metadata for frustum culling. + std::vector object_draw_info_; uint32_t total_index_count_ = 0; std::mutex upload_mutex_; + // Scratch buffers reused each frame to avoid allocation. + std::vector visible_counts_; + std::vector visible_offsets_; + // Camera QVector3D camera_target_{0, 0, 0}; float camera_distance_ = 50.0f; From 6f6bebf387aa34b33af4aaf14cc5040159fa90ab Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 20:05:50 +1000 Subject: [PATCH 006/120] Add performance stats overlay in status bar Show FPS, frame time, visible/total objects, and visible/total triangles in the status bar. Toggled via Settings > Show Performance Stats, persisted in app settings. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/AppSettings.cpp | 14 ++++++++++++++ src/ifcviewer/AppSettings.h | 5 +++++ src/ifcviewer/MainWindow.cpp | 21 +++++++++++++++++++++ src/ifcviewer/MainWindow.h | 1 + src/ifcviewer/SettingsWindow.cpp | 6 ++++++ src/ifcviewer/SettingsWindow.h | 2 ++ src/ifcviewer/ViewportWindow.cpp | 21 +++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 14 ++++++++++++++ 8 files changed, 84 insertions(+) diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index 07c5f8c3bc..af1edfa36f 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -24,6 +24,7 @@ namespace { constexpr const char* kGeometryLibraryKey = "geometry/library"; constexpr const char* kGeometryLibraryDefault = "hybrid-cgal-simple-opencascade"; +constexpr const char* kShowStatsKey = "viewport/show_stats"; } AppSettings& AppSettings::instance() { @@ -46,12 +47,25 @@ void AppSettings::setGeometryLibrary(const QString& value) { emit geometryLibraryChanged(value); } +bool AppSettings::showStats() const { + return show_stats_; +} + +void AppSettings::setShowStats(bool value) { + if (show_stats_ == value) return; + show_stats_ = value; + persist(); + emit showStatsChanged(value); +} + void AppSettings::load() { QSettings settings; geometry_library_ = settings.value(kGeometryLibraryKey, kGeometryLibraryDefault).toString(); + show_stats_ = settings.value(kShowStatsKey, false).toBool(); } void AppSettings::persist() { QSettings settings; settings.setValue(kGeometryLibraryKey, geometry_library_); + settings.setValue(kShowStatsKey, show_stats_); } diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index 9658c10b95..f70062475c 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -34,8 +34,12 @@ public: QString geometryLibrary() const; void setGeometryLibrary(const QString& value); + bool showStats() const; + void setShowStats(bool value); + signals: void geometryLibraryChanged(const QString& value); + void showStatsChanged(bool value); private: AppSettings(); @@ -43,6 +47,7 @@ private: void persist(); QString geometry_library_; + bool show_stats_ = false; }; #endif // APPSETTINGS_H diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 6eede35353..4abd929b0b 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "MainWindow.h" +#include "AppSettings.h" #include "SettingsWindow.h" #include @@ -43,6 +44,23 @@ MainWindow::MainWindow(QWidget* parent) 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( + QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri") + .arg(s.fps, 0, 'f', 1) + .arg(s.frame_time_ms, 0, 'f', 1) + .arg(s.visible_objects) + .arg(s.total_objects) + .arg(s.visible_triangles) + .arg(s.total_triangles)); + }); + + connect(&AppSettings::instance(), &AppSettings::showStatsChanged, this, [this](bool show) { + stats_label_->setVisible(show); + if (!show) stats_label_->clear(); + }); + connect(&element_poll_timer_, &QTimer::timeout, this, &MainWindow::pollNewElements); element_poll_timer_.setInterval(100); @@ -91,7 +109,10 @@ void MainWindow::setupUi() { progress_bar_->setMaximumWidth(200); progress_bar_->setVisible(false); status_label_ = new QLabel("Ready"); + stats_label_ = new QLabel(); + stats_label_->setVisible(AppSettings::instance().showStats()); statusBar()->addWidget(status_label_, 1); + statusBar()->addPermanentWidget(stats_label_); statusBar()->addPermanentWidget(progress_bar_); } diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h index d5f4c18a39..bbec6ce83d 100644 --- a/src/ifcviewer/MainWindow.h +++ b/src/ifcviewer/MainWindow.h @@ -66,6 +66,7 @@ private: QTableWidget* property_table_ = nullptr; QProgressBar* progress_bar_ = nullptr; QLabel* status_label_ = nullptr; + QLabel* stats_label_ = nullptr; QTimer element_poll_timer_; QElapsedTimer load_timer_; diff --git a/src/ifcviewer/SettingsWindow.cpp b/src/ifcviewer/SettingsWindow.cpp index a24f9bc976..c4ebddc650 100644 --- a/src/ifcviewer/SettingsWindow.cpp +++ b/src/ifcviewer/SettingsWindow.cpp @@ -20,6 +20,7 @@ #include "SettingsWindow.h" #include "AppSettings.h" +#include #include #include #include @@ -40,6 +41,9 @@ void SettingsWindow::setupUi() { geometry_library_edit_->setMinimumWidth(280); form->addRow("Geometry Library", geometry_library_edit_); + show_stats_check_ = new QCheckBox(this); + form->addRow("Show Performance Stats", show_stats_check_); + auto* button_box = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); @@ -60,9 +64,11 @@ void SettingsWindow::showEvent(QShowEvent* event) { void SettingsWindow::syncFromSettings() { geometry_library_edit_->setText(AppSettings::instance().geometryLibrary()); + show_stats_check_->setChecked(AppSettings::instance().showStats()); } void SettingsWindow::onAccepted() { AppSettings::instance().setGeometryLibrary(geometry_library_edit_->text()); + AppSettings::instance().setShowStats(show_stats_check_->isChecked()); accept(); } diff --git a/src/ifcviewer/SettingsWindow.h b/src/ifcviewer/SettingsWindow.h index 77affe7757..ea55252682 100644 --- a/src/ifcviewer/SettingsWindow.h +++ b/src/ifcviewer/SettingsWindow.h @@ -22,6 +22,7 @@ #include +class QCheckBox; class QLineEdit; class QShowEvent; @@ -41,6 +42,7 @@ private: void syncFromSettings(); QLineEdit* geometry_library_edit_ = nullptr; + QCheckBox* show_stats_check_ = nullptr; }; #endif diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 414b9889fa..1ebe988554 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -532,6 +532,7 @@ void ViewportWindow::updateCamera() { void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { visible_counts_.clear(); visible_offsets_.clear(); + visible_triangles_ = 0; std::lock_guard lock(upload_mutex_); if (object_draw_info_.empty()) return; @@ -582,6 +583,7 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { visible_counts_.push_back(static_cast(obj.index_count)); visible_offsets_.push_back(reinterpret_cast( static_cast(obj.index_offset))); + visible_triangles_ += obj.index_count / 3; } } } @@ -617,6 +619,25 @@ void ViewportWindow::render() { renderAxisGizmo(); context_->swapBuffers(this); + + // Compute FPS (updated once per second to avoid flicker). + float dt = frame_clock_.restart() / 1000.0f; + accumulated_time_ += dt; + frame_count_++; + if (accumulated_time_ >= 1.0f) { + last_fps_ = static_cast(frame_count_) / accumulated_time_; + frame_count_ = 0; + accumulated_time_ = 0.0f; + + FrameStats stats; + stats.fps = last_fps_; + stats.frame_time_ms = 1000.0f / last_fps_; + stats.total_objects = static_cast(object_draw_info_.size()); + stats.visible_objects = static_cast(visible_counts_.size()); + stats.total_triangles = total_triangles_; + stats.visible_triangles = visible_triangles_; + emit frameStatsUpdated(stats); + } } void ViewportWindow::renderAxisGizmo() { diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 363158b16f..58a6334321 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -65,9 +65,19 @@ public: void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); + struct FrameStats { + float fps; + float frame_time_ms; + uint32_t total_objects; + uint32_t visible_objects; + uint32_t total_triangles; + uint32_t visible_triangles; + }; + signals: void objectPicked(uint32_t object_id); void initialized(); + void frameStatsUpdated(const ViewportWindow::FrameStats& stats); protected: void exposeEvent(QExposeEvent* event) override; @@ -152,6 +162,10 @@ private: // Stats uint32_t total_triangles_ = 0; + uint32_t visible_triangles_ = 0; + int frame_count_ = 0; + float accumulated_time_ = 0.0f; + float last_fps_ = 0.0f; }; #endif // VIEWPORTWINDOW_H From 83a313127648c8a2e7e1257346b2c20c7aa79479 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 20:49:39 +1000 Subject: [PATCH 007/120] 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(); From 5b4c1089cf44005dbf6fd0ead80d15e11736e86c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 11 Apr 2026 20:55:24 +1000 Subject: [PATCH 008/120] Update README for multi-model support and frustum culling Reflect current architecture: per-model streamers, glMultiDrawElements with frustum culling, 32-byte vertex format with color, multiselect file picker, settings/stats files. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 9c6c52560c..d2fb084a41 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -10,21 +10,22 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine | +----------+ +--------------------------+| | | Element | | 3D Viewport || | | Tree | | (QWindow + OpenGL 4.5) || -| | | | || -| +----------+ | Single VBO/EBO || -| | Property | | DrawElementsBaseVertex || +| | (per- | | || +| | model) | | Single VBO/EBO || +| +----------+ | glMultiDrawElements || +| | Property | | frustum culling || | | Table | | GPU pick pass || | +----------+ +--------------------------+| -| | Status / Progress | +| | Status / Progress / Stats | +-------------------------------------------+ ^ ^ | | element metadata UploadChunks | | +-------------------------------------------+ -| GeometryStreamer (background QThread) | +| GeometryStreamer (one per loaded model) | | IfcGeom::Iterator with N threads | -| (one per CPU core by default) | +| (models loaded sequentially) | +-------------------------------------------+ ``` @@ -32,8 +33,10 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine - **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. +- **Interleaved vertex format**: position (3 floats) + normal (3 floats) + object ID (1 float, bitcast uint32) + color (RGBA8 packed into 1 float) = 32 bytes per vertex. +- **Per-object frustum culling**: each object's AABB is tested against 6 frustum planes each frame. Only visible objects are drawn via `glMultiDrawElements`. - **GPU object picking**: a second render pass writes object IDs to an R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting. +- **Multi-model support**: multiple IFC files can be loaded simultaneously. Each model gets its own `GeometryStreamer` (owning the `ifcopenshell::file` for property lookup). Models are loaded sequentially; geometry from all models coexists in the shared VBO/EBO. Per-model visibility toggle and removal are supported. - **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. @@ -43,9 +46,11 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine | 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 | +| `MainWindow.h/cpp` | Qt main window: multi-model project management, element tree, property table, status bar | +| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, frustum culling, picking | +| `GeometryStreamer.h/cpp` | Background geometry processing: loads IFC, runs iterator, emits chunks (one per model) | +| `AppSettings.h/cpp` | Persisted application preferences (geometry library, show stats) | +| `SettingsWindow.h/cpp` | Settings dialog UI | | `CMakeLists.txt` | Build configuration | ## Dependencies @@ -94,10 +99,10 @@ make -j$(nproc) ## Usage ```sh -# Open a file directly -./IfcViewer model.ifc +# Open one or more files from the command line +./IfcViewer arch.ifc struct.ifc mep.ifc -# Or use File -> Open from the menu +# Or use File -> Add Files from the menu (supports multiselect) ./IfcViewer ``` @@ -114,7 +119,7 @@ make -j$(nproc) | Key | Action | |-----|--------| -| Ctrl+O | Open file | +| Ctrl+O | Add files | | Ctrl+Q | Quit | ## Performance Strategy From 1dace18d26385286b979e628aaebe83c055e0aa5 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 09:09:32 +1000 Subject: [PATCH 009/120] BVH frustum culling, sidecar cache, per-model buffers, progressive upload Phase 2 performance: BVH acceleration with median-split build, per-model trees, and EBO re-sorting for GPU cache coherence. Raw binary .ifcview sidecar stores full geometry + BVH for instant subsequent loads (skip tessellation entirely). Per-model GPU buffers (VAO/VBO/EBO per model) eliminate cross-model buffer copies on growth. Sidecar reads happen on a background thread. Bulk GPU uploads are progressive (48 MB/frame chunks) so the viewport stays interactive while multi-GB models stream in. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/BvhAccel.cpp | 226 +++++++++++ src/ifcviewer/BvhAccel.h | 75 ++++ src/ifcviewer/MainWindow.cpp | 180 ++++++++- src/ifcviewer/MainWindow.h | 7 + src/ifcviewer/README.md | 357 +++++++++++------ src/ifcviewer/SidecarCache.cpp | 196 ++++++++++ src/ifcviewer/SidecarCache.h | 76 ++++ src/ifcviewer/ViewportWindow.cpp | 635 +++++++++++++++++++++++-------- src/ifcviewer/ViewportWindow.h | 116 ++++-- 9 files changed, 1541 insertions(+), 327 deletions(-) create mode 100644 src/ifcviewer/BvhAccel.cpp create mode 100644 src/ifcviewer/BvhAccel.h create mode 100644 src/ifcviewer/SidecarCache.cpp create mode 100644 src/ifcviewer/SidecarCache.h diff --git a/src/ifcviewer/BvhAccel.cpp b/src/ifcviewer/BvhAccel.cpp new file mode 100644 index 0000000000..e0b232a283 --- /dev/null +++ b/src/ifcviewer/BvhAccel.cpp @@ -0,0 +1,226 @@ +/******************************************************************************** + * * + * 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 "BvhAccel.h" + +#include +#include +#include +#include +#include + +namespace { + +struct Centroid { + float x, y, z; +}; + +Centroid computeCentroid(const ObjectDrawInfo& obj) { + return { + (obj.aabb_min[0] + obj.aabb_max[0]) * 0.5f, + (obj.aabb_min[1] + obj.aabb_max[1]) * 0.5f, + (obj.aabb_min[2] + obj.aabb_max[2]) * 0.5f + }; +} + +void computeAABB(const std::vector& draw_info, + const uint32_t* indices, uint32_t count, + float out_min[3], float out_max[3]) { + out_min[0] = out_min[1] = out_min[2] = std::numeric_limits::max(); + out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits::max(); + for (uint32_t i = 0; i < count; ++i) { + const auto& obj = draw_info[indices[i]]; + for (int a = 0; a < 3; ++a) { + if (obj.aabb_min[a] < out_min[a]) out_min[a] = obj.aabb_min[a]; + if (obj.aabb_max[a] > out_max[a]) out_max[a] = obj.aabb_max[a]; + } + } +} + +// Recursive BVH builder. Writes nodes in pre-order DFS into mbvh.nodes. +// object_indices[start..start+count) are the indices to partition. +void buildRecursive(ModelBvh& mbvh, + const std::vector& draw_info, + uint32_t start, uint32_t count) { + uint32_t node_idx = static_cast(mbvh.nodes.size()); + mbvh.nodes.emplace_back(); + BvhNode& node = mbvh.nodes[node_idx]; + + computeAABB(draw_info, &mbvh.object_indices[start], count, + node.aabb_min, node.aabb_max); + + if (count <= BVH_MAX_LEAF_SIZE) { + node.right_or_first = start; + node.count = static_cast(count); + node.axis = 0; + return; + } + + // Find longest axis of node AABB. + float extent[3] = { + node.aabb_max[0] - node.aabb_min[0], + node.aabb_max[1] - node.aabb_min[1], + node.aabb_max[2] - node.aabb_min[2] + }; + int axis = 0; + if (extent[1] > extent[axis]) axis = 1; + if (extent[2] > extent[axis]) axis = 2; + + // Partition at median centroid on the chosen axis. + uint32_t mid = count / 2; + std::nth_element( + mbvh.object_indices.begin() + start, + mbvh.object_indices.begin() + start + mid, + mbvh.object_indices.begin() + start + count, + [&](uint32_t a, uint32_t b) { + Centroid ca = computeCentroid(draw_info[a]); + Centroid cb = computeCentroid(draw_info[b]); + return (&ca.x)[axis] < (&cb.x)[axis]; + }); + + node.count = 0; // interior + node.axis = static_cast(axis); + + // Left child is always node_idx + 1 (implicit in pre-order DFS). + // Build left subtree first. Note: &node is invalidated after this call + // because the vector may reallocate. + buildRecursive(mbvh, draw_info, start, mid); + + // Right child is the next node written after the entire left subtree. + uint32_t right_child_idx = static_cast(mbvh.nodes.size()); + buildRecursive(mbvh, draw_info, start + mid, count - mid); + + // Patch the right child index (left is implicit = node_idx + 1). + mbvh.nodes[node_idx].right_or_first = right_child_idx; +} + +} // anonymous namespace + +ModelBvh buildModelBvh(const std::vector& draw_info, + const std::vector& model_object_indices, + uint32_t model_id) { + ModelBvh mbvh; + mbvh.model_id = model_id; + mbvh.object_indices = model_object_indices; + + uint32_t count = static_cast(model_object_indices.size()); + if (count == 0) return mbvh; + + // Reserve a rough estimate: ~2*n nodes for a balanced binary tree. + mbvh.nodes.reserve(count * 2); + + buildRecursive(mbvh, draw_info, 0, count); + + // Verify: every object appears exactly once in the leaves. + assert(!mbvh.nodes.empty()); + + return mbvh; +} + +std::shared_ptr buildBvhSet(const std::vector& draw_info) { + auto bvh_set = std::make_shared(); + + // Group object indices by model_id. + std::unordered_map> model_objects; + for (uint32_t i = 0; i < static_cast(draw_info.size()); ++i) { + model_objects[draw_info[i].model_id].push_back(i); + } + + // Build per-model BVHs. + for (auto& [model_id, obj_indices] : model_objects) { + if (obj_indices.size() < BVH_MIN_OBJECTS) continue; + + ModelBvh mbvh = buildModelBvh(draw_info, obj_indices, model_id); + bvh_set->bvh_model_ids.insert(model_id); + bvh_set->models[model_id] = std::move(mbvh); + } + + return bvh_set; +} + +EboReorderResult reorderEbo(const BvhSet& bvh_set, + const std::vector& draw_info, + const std::vector& original_ebo) { + EboReorderResult result; + result.reordered_draw_info = draw_info; // copy; we'll update offsets + result.reordered_ebo.reserve(original_ebo.size()); + + // Track which draw_info entries have been placed. + std::vector placed(draw_info.size(), false); + + for (const auto& [model_id, mbvh] : bvh_set.models) { + // DFS traversal of BVH to visit leaves in order. + uint32_t stack[64]; + int sp = 0; + stack[sp++] = 0; + + while (sp > 0) { + uint32_t ni = stack[--sp]; + const BvhNode& node = mbvh.nodes[ni]; + + if (node.count > 0) { + // Leaf: emit objects in order. + for (uint32_t i = 0; i < node.count; ++i) { + uint32_t oi = mbvh.object_indices[node.right_or_first + i]; + if (placed[oi]) continue; + placed[oi] = true; + + const auto& old_info = draw_info[oi]; + uint32_t new_offset = static_cast( + result.reordered_ebo.size() * sizeof(uint32_t)); + + // Copy indices from original EBO. + uint32_t idx_start = old_info.index_offset / sizeof(uint32_t); + uint32_t idx_count = old_info.index_count; + for (uint32_t j = 0; j < idx_count; ++j) { + result.reordered_ebo.push_back(original_ebo[idx_start + j]); + } + + result.reordered_draw_info[oi].index_offset = new_offset; + } + } else { + // Interior: push left (=ni+1) last so it's processed first. + stack[sp++] = node.right_or_first; // right child + stack[sp++] = ni + 1; // left child + } + } + } + + // Append non-BVH objects (models too small for BVH). + for (uint32_t oi = 0; oi < static_cast(draw_info.size()); ++oi) { + if (placed[oi]) continue; + placed[oi] = true; + + const auto& old_info = draw_info[oi]; + uint32_t new_offset = static_cast( + result.reordered_ebo.size() * sizeof(uint32_t)); + + uint32_t idx_start = old_info.index_offset / sizeof(uint32_t); + uint32_t idx_count = old_info.index_count; + for (uint32_t j = 0; j < idx_count; ++j) { + result.reordered_ebo.push_back(original_ebo[idx_start + j]); + } + + result.reordered_draw_info[oi].index_offset = new_offset; + } + + assert(result.reordered_ebo.size() == original_ebo.size()); + + return result; +} diff --git a/src/ifcviewer/BvhAccel.h b/src/ifcviewer/BvhAccel.h new file mode 100644 index 0000000000..21c57c2712 --- /dev/null +++ b/src/ifcviewer/BvhAccel.h @@ -0,0 +1,75 @@ +/******************************************************************************** + * * + * 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 BVHACCEL_H +#define BVHACCEL_H + +#include +#include +#include +#include +#include + +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]; +}; + +static constexpr uint32_t BVH_MAX_LEAF_SIZE = 8; +static constexpr uint32_t BVH_MIN_OBJECTS = 32; + +struct BvhNode { + float aabb_min[3]; + float aabb_max[3]; + uint32_t right_or_first; // interior: right child index (left is always this_index+1); leaf: first object index + uint16_t count; // 0 = interior; >0 = leaf with this many objects + uint16_t axis; // split axis (0/1/2) for interior; unused for leaf +}; +static_assert(sizeof(BvhNode) == 32, "BvhNode must be 32 bytes for cache alignment and sidecar format"); + +struct ModelBvh { + uint32_t model_id = 0; + std::vector nodes; + std::vector object_indices; // indices into object_draw_info_ +}; + +struct BvhSet { + std::unordered_map models; + std::unordered_set bvh_model_ids; +}; + +struct EboReorderResult { + std::vector reordered_ebo; + std::vector reordered_draw_info; +}; + +// Build BVH trees for all models in the given draw info snapshot. +// Only builds the tree structure; does not touch EBO data. +std::shared_ptr buildBvhSet(const std::vector& draw_info); + +// Reorder the EBO so objects within each BVH leaf are contiguous. +// Must be called with the CURRENT run's EBO and draw_info (not cached). +EboReorderResult reorderEbo(const BvhSet& bvh_set, + const std::vector& draw_info, + const std::vector& original_ebo); + +#endif // BVHACCEL_H diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 3b4e58fbac..b5ee3581c4 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -20,6 +20,7 @@ #include "MainWindow.h" #include "AppSettings.h" #include "SettingsWindow.h" +#include "SidecarCache.h" #include #include @@ -61,7 +62,14 @@ MainWindow::MainWindow(QWidget* parent) resize(1400, 900); } -MainWindow::~MainWindow() {} +MainWindow::~MainWindow() { + joinSidecarThread(); +} + +void MainWindow::joinSidecarThread() { + if (sidecar_read_thread_.joinable()) + sidecar_read_thread_.join(); +} void MainWindow::setupUi() { // 3D Viewport as central widget @@ -158,7 +166,7 @@ void MainWindow::addFiles(const QStringList& paths) { } if (loading_model_id_ == 0) { - startNextLoad(); + QTimer::singleShot(0, this, &MainWindow::startNextLoad); } } @@ -184,16 +192,133 @@ void MainWindow::startNextLoad() { 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: " + model.display_name); load_timer_.restart(); - element_poll_timer_.start(); - model.streamer->loadFile( - model.file_path.toStdString(), next_object_id_, loading_model_id_); + status_label_->setText("Loading: " + model.display_name); + + // Try sidecar on a background thread so the UI stays responsive. + std::string ifc_path = model.file_path.toStdString(); + uint64_t file_size = static_cast(QFileInfo(model.file_path).size()); + ModelId mid = loading_model_id_; + + joinSidecarThread(); + sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() { + QElapsedTimer rt; rt.start(); + auto cached = readSidecar(ifc_path, file_size); + qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); + auto result = std::make_shared>(std::move(cached)); + QMetaObject::invokeMethod(this, [this, mid, result]() { + if (*result && !(*result)->draw_info.empty()) { + applySidecarData(mid, std::move(**result)); + } else { + // No sidecar — fall back to streaming from IFC. + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& m = it->second; + connectStreamer(m.streamer); + progress_bar_->setValue(0); + progress_bar_->setVisible(true); + status_label_->setText("Loading: " + m.display_name); + element_poll_timer_.start(); + m.streamer->loadFile( + m.file_path.toStdString(), next_object_id_, loading_model_id_); + } + }, Qt::QueuedConnection); + }); +} + +void MainWindow::applySidecarData(ModelId mid, SidecarData data) { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& model = it->second; + + QElapsedTimer t; + + qDebug("Sidecar hit: %s (%zu objects, %zu verts, %zu indices, %.1f MB)", + model.file_path.toStdString().c_str(), data.draw_info.size(), + data.vertices.size() / 8, data.indices.size(), + (data.vertices.size() * 4 + data.indices.size() * 4) / (1024.0 * 1024.0)); + + // GL upload — fast, single buffer copy. + t.start(); + viewport_->uploadBulk(mid, data.vertices, data.indices, + data.draw_info, std::move(data.bvh_set)); + qDebug(" GL upload: %lld ms", t.elapsed()); + + // Update next_object_id_ past all objects in this model. + for (const auto& elem : data.elements) { + if (elem.object_id >= next_object_id_) + next_object_id_ = elem.object_id + 1; + } + + // Suppress per-item layout recalcs while building the tree. + t.restart(); + element_tree_->setUpdatesEnabled(false); + populateTreeFromSidecar(model, data.elements, data.string_table); + element_tree_->setUpdatesEnabled(true); + qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), data.elements.size()); + + 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("%1 elements across %2 model(s) — loaded from cache in %3") + .arg(element_map_.size()) + .arg(models_.size()) + .arg(elapsed)); + + loading_model_id_ = 0; + QTimer::singleShot(0, this, &MainWindow::startNextLoad); +} + +void MainWindow::populateTreeFromSidecar(ModelHandle& model, + const std::vector& elements, + const std::string& stbl) { + auto str = [&](uint32_t offset, uint32_t length) -> std::string { + if (length == 0 || offset + length > stbl.size()) return {}; + return stbl.substr(offset, length); + }; + + for (const auto& pe : elements) { + ElementInfo info; + info.object_id = pe.object_id; + info.model_id = pe.model_id; + info.ifc_id = pe.ifc_id; + info.parent_id = pe.parent_id; + info.guid = str(pe.guid_offset, pe.guid_length); + info.name = str(pe.name_offset, pe.name_length); + info.type = str(pe.type_offset, pe.type_length); + + element_map_[info.object_id] = info; + scoped_ifc_id_to_object_id_[scopedKey(info.model_id, info.ifc_id)] = info.object_id; + + // Find parent tree item. + 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; + } + } + + QString display_name = QString::fromStdString(info.name); + if (display_name.isEmpty()) { + display_name = QString::fromStdString(info.type) + " #" + QString::number(info.ifc_id); + } + + 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)); + item->setData(0, Qt::UserRole, info.object_id); + + tree_items_[info.object_id] = item; + } } void MainWindow::onProgressChanged(int percent) { @@ -230,6 +355,41 @@ void MainWindow::onStreamingFinished() { .arg(num_models) .arg(elapsed)); + // Build BVH and write sidecar (geometry + metadata + BVH). + if (loading_model_id_ != 0) { + auto it = models_.find(loading_model_id_); + if (it != models_.end()) { + std::string ifc_path = it->second.file_path.toStdString(); + QFileInfo fi(it->second.file_path); + uint64_t file_size = static_cast(fi.size()); + + // Pack element info for the sidecar (only this model's elements). + std::vector packed; + std::string stbl; + for (const auto& [oid, info] : element_map_) { + if (info.model_id != loading_model_id_) continue; + PackedElementInfo pe; + pe.object_id = info.object_id; + pe.model_id = info.model_id; + pe.ifc_id = info.ifc_id; + pe.parent_id = info.parent_id; + pe.guid_offset = static_cast(stbl.size()); + pe.guid_length = static_cast(info.guid.size()); + stbl += info.guid; + pe.name_offset = static_cast(stbl.size()); + pe.name_length = static_cast(info.name.size()); + stbl += info.name; + pe.type_offset = static_cast(stbl.size()); + pe.type_length = static_cast(info.type.size()); + stbl += info.type; + packed.push_back(pe); + } + + viewport_->buildBvhAsync(loading_model_id_, ifc_path, file_size, + std::move(packed), std::move(stbl)); + } + } + // Start next model if queued. startNextLoad(); } diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h index e9bcc37cb6..f60da70b75 100644 --- a/src/ifcviewer/MainWindow.h +++ b/src/ifcviewer/MainWindow.h @@ -31,6 +31,7 @@ #include #include +#include #include #include "ViewportWindow.h" @@ -72,6 +73,11 @@ private: void setupMenus(); void populateProperties(uint32_t object_id); void startNextLoad(); + void applySidecarData(ModelId mid, SidecarData data); + void joinSidecarThread(); + void populateTreeFromSidecar(ModelHandle& model, + const std::vector& elements, + const std::string& string_table); void connectStreamer(GeometryStreamer* streamer); ViewportWindow* viewport_ = nullptr; @@ -91,6 +97,7 @@ private: uint32_t next_object_id_ = 1; // monotonically increasing across all models std::deque load_queue_; ModelId loading_model_id_ = 0; + std::thread sidecar_read_thread_; // Map object_id -> tree item and element info std::unordered_map element_map_; diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index d2fb084a41..d0122d63c8 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -11,16 +11,16 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine | | Element | | 3D Viewport || | | Tree | | (QWindow + OpenGL 4.5) || | | (per- | | || -| | model) | | Single VBO/EBO || +| | model) | | Per-model VAO/VBO/EBO || | +----------+ | glMultiDrawElements || -| | Property | | frustum culling || +| | Property | | BVH frustum culling || | | Table | | GPU pick pass || | +----------+ +--------------------------+| | | Status / Progress / Stats | +-------------------------------------------+ ^ ^ | | - element metadata UploadChunks + element metadata UploadChunks / Sidecar | | +-------------------------------------------+ | GeometryStreamer (one per loaded model) | @@ -32,11 +32,13 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine ### 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. +- **Per-model GPU buffers**: each loaded model gets its own VAO/VBO/EBO. No shared buffer, no cross-model copies on growth. Removing a model frees its GPU memory immediately. - **Interleaved vertex format**: position (3 floats) + normal (3 floats) + object ID (1 float, bitcast uint32) + color (RGBA8 packed into 1 float) = 32 bytes per vertex. -- **Per-object frustum culling**: each object's AABB is tested against 6 frustum planes each frame. Only visible objects are drawn via `glMultiDrawElements`. +- **Progressive GPU upload**: bulk sidecar loads allocate empty GPU buffers, then stream data in 48 MB chunks per frame. VBO uploads first (no objects visible), then EBO (objects appear progressively as their index range lands). The viewport stays interactive throughout — you can orbit already-loaded models while new ones stream in. +- **Non-blocking sidecar loading**: sidecar files are read on a background thread. The heavy disk I/O (potentially gigabytes) never blocks the render loop. Only the final GPU upload and tree population happen on the main thread. +- **BVH frustum culling**: per-model BVH trees cull entire subtrees of objects in one frustum test, reducing per-frame cost from O(N) to O(log N). Falls back to linear scan during progressive upload; BVH activates once the model is fully loaded. - **GPU object picking**: a second render pass writes object IDs to an R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting. -- **Multi-model support**: multiple IFC files can be loaded simultaneously. Each model gets its own `GeometryStreamer` (owning the `ifcopenshell::file` for property lookup). Models are loaded sequentially; geometry from all models coexists in the shared VBO/EBO. Per-model visibility toggle and removal are supported. +- **Multi-model support**: multiple IFC files can be loaded simultaneously. Each model gets its own `GeometryStreamer` (owning the `ifcopenshell::file` for property lookup). Models are loaded sequentially. Per-model visibility toggle and removal are supported. - **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. @@ -47,8 +49,10 @@ A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine |------|---------| | `main.cpp` | Application entry point, GL 4.5 surface format, CLI argument parsing | | `MainWindow.h/cpp` | Qt main window: multi-model project management, element tree, property table, status bar | -| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, frustum culling, picking | +| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, frustum culling, BVH traversal, picking | | `GeometryStreamer.h/cpp` | Background geometry processing: loads IFC, runs iterator, emits chunks (one per model) | +| `BvhAccel.h/cpp` | BVH construction (median-split), per-model trees, EBO reordering | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` sidecar read/write | | `AppSettings.h/cpp` | Persisted application preferences (geometry library, show stats) | | `SettingsWindow.h/cpp` | Settings dialog UI | | `CMakeLists.txt` | Build configuration | @@ -142,8 +146,9 @@ object that enters the GPU buffers: ```cpp struct ObjectDrawInfo { - uint32_t index_offset; // byte offset into the shared EBO + uint32_t index_offset; // byte offset into the model's EBO uint32_t index_count; // number of indices (triangles * 3) + uint32_t model_id; // which model this object belongs to float aabb_min[3]; // world-space axis-aligned bounding box float aabb_max[3]; // (computed from vertex positions at upload time) }; @@ -211,108 +216,207 @@ Phase 1 is sufficient for models up to ~100k objects. Beyond that, the CPU-side frustum test becomes a measurable fraction of the frame budget, motivating phase 3. -### Phase 2: Spatial Tiling (optional, for large models) +### Phase 2: BVH Acceleration (optional, for large models) -For models exceeding ~10k objects, spatial tiling groups nearby objects into -tiles and culls at the tile level rather than per-object. This reduces the -number of frustum tests from N_objects to N_tiles (typically hundreds to low -thousands). +**Status:** Implemented. -#### When tiling activates +For models exceeding ~100 objects, a bounding volume hierarchy (BVH) groups +nearby objects into a binary tree and culls entire subtrees in one frustum +test. This reduces the number of AABB-frustum tests from O(N_objects) to +O(log N) in the best case (camera zoomed into a corner) and gives a constant +overhead for the common case where most of the model is on screen. -Tiling is **optional and non-disruptive**. The system treats a non-tiled model -as the degenerate case of "one tile containing everything" — the rendering loop -always iterates tiles, so no separate code path is needed. +A BVH was chosen over an octree because BIM data is spatially non-uniform — +dense MEP risers in one zone, sparse open atriums in another. An octree +subdivides space uniformly, wasting nodes on empty regions and creating deep +chains in dense ones. A BVH adapts its splits to the actual object +distribution, producing balanced trees regardless of density variation. -Tiling activates in one of three ways: +#### When the BVH activates -1. **Preprocessed cache exists**: If a `.ifcview` sidecar file is found next to - the `.ifc` file, the tile structure is loaded from it instantly. The model - uploads geometry in tile order. -2. **Automatic by size**: If the model has more than a configurable threshold of - objects (default 10k), a background task builds the spatial tree after - initial loading completes. Until it finishes, phase 1 culling handles - visibility. -3. **Explicit user action**: A "preprocess for performance" option builds the - spatial tree and saves the sidecar for future loads. +The BVH is **optional and non-disruptive**. Until it is built, phase 1's +linear scan handles all culling. The rendering loop checks for an active BVH +and falls back to the linear scan for any model that doesn't have one. -#### Spatial subdivision +The BVH activates in one of two ways: -The world-space bounding box of the entire model is subdivided using a -**loose octree**: +1. **Sidecar cache exists**: If a `.ifcview` file is found next to the `.ifc` + file, the BVH is loaded from it instantly (raw memory read, no parsing). + The model uses BVH culling from the first frame after loading. +2. **Automatic build**: After streaming finishes, a background thread builds + the BVH from the per-object AABBs already computed in phase 1. Until it + completes, phase 1 culling handles visibility. On completion, the render + thread picks up the BVH on the next frame. The sidecar is written for + future loads. -- The root node covers the scene AABB. -- Each node is split when it contains more than a threshold number of objects - (e.g. 256). -- Objects are assigned to the smallest node that fully contains their AABB. -- "Loose" bounds (inflated by 1.5x) reduce the number of objects that span - multiple nodes. -- Leaf nodes become tiles. +Models with fewer than 32 objects skip the BVH entirely — the overhead of tree +traversal is worse than a linear scan at that scale. -An octree adapts to non-uniform object density (common in buildings — lots of -detail in MEP risers, sparse in open atriums) better than a uniform grid. +#### BVH node layout -#### EBO re-sorting - -For tile-level culling to translate into contiguous index ranges, the EBO must -be sorted so that all indices for objects in the same tile are adjacent. - -This happens via **deferred compaction**: - -1. During initial load, geometry uploads in iterator order (fast first frame, - phase 1 culling active). -2. After loading completes, a background thread: - a. Builds the octree from the per-object AABBs (already computed in phase 1). - b. Determines the tile for each object. - c. Computes the new index order (sorted by tile, then by object within tile). - d. Builds a new EBO on the CPU. -3. The main thread uploads the new EBO in one `glNamedBufferSubData` call and - swaps in the tile metadata. One frame of stutter, bounded by EBO upload - time. - -The per-tile metadata: +Each node is 32 bytes, so two nodes fit in one 64-byte cache line: ```cpp -struct TileInfo { - float aabb_min[3]; // tile bounding box (union of contained AABBs) - float aabb_max[3]; - uint32_t index_offset; // into the re-sorted EBO - uint32_t index_count; // sum of all contained objects' indices - uint32_t object_count; // for stats / debugging +struct BvhNode { + float aabb_min[3]; // world-space bounding box (12 bytes) + float aabb_max[3]; // (12 bytes) + uint32_t right_or_first; // interior: right child index; leaf: first object index (4 bytes) + uint16_t count; // 0 = interior node; >0 = leaf with this many objects (2 bytes) + uint16_t axis; // split axis for interior (0=x, 1=y, 2=z); unused for leaf (2 bytes) }; ``` -#### Preprocessed sidecar format +Interior nodes store the right child index; the left child is always the +immediately next node in the array (implicit in pre-order DFS layout, no +pointer needed). Leaf nodes reference a contiguous range in a sorted +object-index array. -The `.ifcview` file stores: +The BVH is stored as a flat `std::vector` in pre-order DFS layout. +This means a depth-first traversal (which is what frustum culling does) reads +memory sequentially, maximizing prefetch and cache-line utilization. -- Octree structure (node hierarchy, split planes). -- Per-object tile assignment (object_id → tile_id mapping). -- Per-tile index order (so the EBO can be built in tile order directly during - upload, skipping the compaction pass entirely). -- File hash of the source `.ifc` (invalidation check). +#### Build algorithm: object-median split -This makes second-and-subsequent loads of the same model significantly faster: -the spatial tree doesn't need to be rebuilt, and geometry uploads in tile order -from the start. +1. Compute the centroid of each object's AABB. +2. Find the longest axis of the current node's bounding box. +3. Use `std::nth_element` to partition objects at the median centroid on that + axis. This is O(n) — no full sort needed. +4. Recurse on each half. Terminate when the node contains ≤ 8 objects (leaf). +5. Write nodes into the flat array in pre-order DFS. + +Total build time is O(n log n). For 100k objects this is well under 100 ms on +a single core. + +SAH (Surface Area Heuristic) is the gold standard for ray-tracing BVHs, but +for frustum culling — where we test 6 planes and early-out entire subtrees — +the quality difference vs. median split is negligible. Median split is simpler +and produces reliably balanced trees. + +#### Frustum traversal + +The traversal uses an explicit stack on the C++ stack (no heap allocation, +no recursion): + +``` +stack[64] = {0} // start at root; depth 64 handles billions of objects +while stack not empty: + node = nodes[stack.pop()] + if node AABB outside frustum: continue // cull entire subtree + if leaf: + for each object in node: + if object AABB in frustum: emit to visible list + else: + push right child, push left child // left processed first (DFS) +``` + +When the camera is zoomed into a corner of the model, the traversal skips +large portions of the tree after testing only a handful of interior nodes. +When zoomed out to see everything, the traversal visits all leaves but the +overhead of the interior-node tests is small relative to the leaf work. + +#### Per-model BVH + +Each loaded model gets its own BVH. During frustum culling, the outer loop +iterates over models (skipping hidden/removed ones); the inner loop traverses +that model's BVH. This means hiding or removing a model is free — just skip +its BVH, no tree modification needed. + +```cpp +struct ModelBvh { + uint32_t model_id; + std::vector nodes; // flat BVH node array + std::vector object_indices; // indices into object_draw_info_ +}; +``` + +#### EBO re-sorting + +For BVH culling to maximise GPU cache performance, the EBO is re-sorted so +that objects in the same BVH leaf are contiguous. This happens via **deferred +compaction**: + +1. During initial load, geometry uploads in iterator order (fast first frame, + phase 1 culling active). +2. After the BVH build completes on the background thread: + a. Walk the BVH leaves in DFS order. + b. For each object in each leaf, copy its index data to a new EBO buffer, + updating `ObjectDrawInfo::index_offset` accordingly. + c. Package the reordered EBO + updated draw info as a `BvhBuildResult`. +3. The render thread picks up the result on the next frame: one + `glNamedBufferSubData` call to re-upload the EBO, then swap in the new + draw info and activate the BVH. One frame of stutter, bounded by EBO + upload time (~5 ms for 32 MB). + +#### Async build and render-thread handoff + +The BVH build must not stall the render loop: + +1. `buildBvhAsync()` snapshots `object_draw_info_` under the upload mutex, + then launches a `std::thread`. +2. The thread builds the BVH and reordered EBO, then stores the result in a + `pending_bvh_result_` pointer under a separate mutex. +3. At the top of each `render()` call, `applyBvhResult()` checks for a + pending result. If found, it re-uploads the EBO (requires GL context), + swaps the draw info, and activates the BVH. +4. Until the BVH is ready, phase 1's linear scan runs every frame as before. + +#### Preprocessed sidecar format (`.ifcview`) + +The sidecar is a raw memory dump (Blender `.blend`-style) — no serialization +format, no parsing. It stores everything needed to display the model without +re-tessellating: vertex data, index data, per-object metadata, element tree +info, and the BVH. Loading is just `fread` into vectors → GPU upload → +render. The expensive `IfcGeom::Iterator` tessellation is skipped entirely. + +The IFC file is still parsed on demand (in background) for detailed property +lookup; the sidecar provides the basic properties (name, type, GUID) +immediately. + +``` +SidecarHeader (16 bytes: magic, version, endian, reserved) +uint64_t source_file_size + +uint32_t + float[] vertex data (interleaved, 8 floats/vertex) +uint32_t + uint32_t[] index data (global indices, ready for EBO) +uint32_t + ObjectDrawInfo[] per-object draw metadata +uint32_t + PackedElementInfo[] element tree records (fixed-size) +uint32_t + char[] string table (concatenated UTF-8: guid, name, type) + +uint32_t num_bvh_models +per model: + uint32_t model_id + uint32_t + BvhNode[] BVH node array + uint32_t + uint32_t[] object indices +``` + +Staleness check: `source_file_size` is compared against the actual IFC file +size. If mismatched, the sidecar is stale and is rebuilt. This is cheap and +sufficient for a local cache (no hash computation on multi-GB files). + +Endianness: if the marker reads back as `0x01020304`, the file was written on +the same architecture — just `fread` the structs directly. Otherwise, reject +the sidecar and rebuild. #### Performance characteristics | Metric | Value | |--------|-------| -| Tile count (typical) | 500–5,000 for a large building | -| Per-frame frustum tests | N_tiles instead of N_objects | -| 500k objects, ~2k tiles | ~0.01 ms frustum testing | -| Memory overhead | ~64 bytes/tile + 32 bytes/object (phase 1 metadata retained) | -| Background compaction | 1–5 seconds for 1M objects (single-threaded) | -| Sidecar file size | ~10–50 KB (indices + tree, no geometry) | +| BVH build time (100k objects) | < 100 ms (single-threaded, background) | +| Per-frame traversal (100k objects, 50% visible) | ~0.1 ms | +| Per-frame traversal (100k objects, 5% visible) | ~0.02 ms | +| Memory overhead | 32 bytes/node + 4 bytes/object index (~1.5× object count) | +| EBO reorder (one-time) | 1–5 ms upload for 32 MB EBO | +| Sidecar file size | ~same as geometry data (vertices + indices + metadata) | +| Sidecar read time | bounded by disk I/O (~500 ms for 640 MB, ~2 s for 2.8 GB from NVMe) | +| GPU upload time | progressive: ~48 MB/frame (~1 s for 2.8 GB at 60 fps, non-blocking) | #### Spatial coherence bonus -Beyond culling, tile-sorted EBOs improve GPU cache performance. When the GPU -rasterizes a tile's triangles, the vertices are contiguous in the VBO, so the -post-transform vertex cache hits more often. This can yield 10–20% rasterization -speedup even when nothing is culled (e.g. zoomed out to see the whole model). +Beyond culling, BVH-leaf-sorted EBOs improve GPU cache performance. When the +GPU rasterizes a leaf's triangles, the vertices are close together in the VBO, +so the post-transform vertex cache hits more often. This can yield 10–20% +rasterization speedup even when nothing is culled (e.g. zoomed out to see the +whole model). ### Phase 3: GPU-Driven Indirect Draw @@ -322,20 +426,20 @@ visibility decisions to the GPU via compute shaders and indirect draw commands. #### How it works -Phase 3 is **approach 2 layered on top of approach 3**. It does not replace -tiling — it accelerates it. +Phase 3 builds on the BVH from phase 2. It does not replace the BVH — it +moves the per-frame traversal to the GPU. 1. **Upload phase** (once, at load time): - - Per-tile AABBs are uploaded to a GPU SSBO (`tile_aabbs`). - - One `DrawElementsIndirectCommand` per tile is written to an indirect draw - buffer: + - Per-leaf AABBs from the BVH are uploaded to a GPU SSBO (`leaf_aabbs`). + - One `DrawElementsIndirectCommand` per BVH leaf is written to an indirect + draw buffer: ```c struct DrawElementsIndirectCommand { - uint count; // tile's total index count + uint count; // leaf's total index count uint instanceCount; // 1 - uint firstIndex; // offset into EBO + uint firstIndex; // offset into EBO (from BVH leaf order) uint baseVertex; // 0 (indices are global) - uint baseInstance; // tile_id (available in shader via gl_DrawID) + uint baseInstance; // leaf_id (available in shader via gl_DrawID) }; ``` - A "template" copy of the indirect buffer is kept so the compute shader @@ -343,20 +447,20 @@ tiling — it accelerates it. 2. **Cull phase** (every frame, on the GPU): - The CPU uploads 6 frustum plane vec4s as a uniform or small UBO. - - A compute shader dispatches `ceil(N_tiles / 64)` workgroups: + - A compute shader dispatches `ceil(N_leaves / 64)` workgroups: ```glsl layout(local_size_x = 64) in; void main() { - uint tile_id = gl_GlobalInvocationID.x; - if (tile_id >= tile_count) return; + uint leaf_id = gl_GlobalInvocationID.x; + if (leaf_id >= leaf_count) return; // Copy from template (resets any previously zeroed commands) - commands[tile_id] = template_commands[tile_id]; + commands[leaf_id] = template_commands[leaf_id]; // Frustum test - if (!aabb_vs_frustum(tile_aabbs[tile_id], frustum_planes)) { - commands[tile_id].count = 0; // culled: GPU skips zero-count draws + if (!aabb_vs_frustum(leaf_aabbs[leaf_id], frustum_planes)) { + commands[leaf_id].count = 0; // culled: GPU skips zero-count draws } } ``` @@ -364,7 +468,7 @@ tiling — it accelerates it. 3. **Draw phase** (every frame): - One call: `glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, - nullptr, N_tiles, 0)`. + nullptr, N_leaves, 0)`. - The GPU reads the indirect buffer, skips tiles with `count == 0`, and draws the rest. Zero CPU-side per-object or per-tile work. @@ -382,12 +486,12 @@ That's it. The CPU frame time is essentially constant regardless of model size. Once the compute-based cull pass exists, it's straightforward to add: - **Hierarchical-Z occlusion culling**: render a coarse depth buffer from the - previous frame, then test tile AABBs against it in the compute shader. Tiles - fully behind closer geometry get culled. This handles interior-heavy BIM - models well (most rooms are occluded from any given viewpoint). + previous frame, then test BVH leaf AABBs against it in the compute shader. + Leaves fully behind closer geometry get culled. This handles interior-heavy + BIM models well (most rooms are occluded from any given viewpoint). - **Distance-based LOD**: the compute shader can select different index ranges - (coarse vs. fine tessellation) per tile based on distance to camera. -- **Contribution culling**: tiles whose screen-space projection is below a + (coarse vs. fine tessellation) per leaf based on distance to camera. +- **Contribution culling**: leaves whose screen-space projection is below a pixel threshold get `count = 0`. Removes distant small objects. #### Performance characteristics @@ -395,10 +499,10 @@ Once the compute-based cull pass exists, it's straightforward to add: | Metric | Value | |--------|-------| | CPU per-frame work | ~0.01 ms (constant, independent of model size) | -| GPU compute dispatch | ~0.02 ms for 2k tiles | +| GPU compute dispatch | ~0.02 ms for 2k leaves | | Draw call overhead | 1 indirect multi-draw call | -| GPU memory overhead | ~48 bytes/tile (AABB SSBO) + 20 bytes/tile (indirect commands) × 2 (template + live) | -| Total for 2k tiles | ~176 KB GPU memory | +| GPU memory overhead | ~48 bytes/leaf (AABB SSBO) + 20 bytes/leaf (indirect commands) × 2 (template + live) | +| Total for 2k leaves | ~176 KB GPU memory | | Implementation complexity | High (compute shaders, SSBOs, memory barriers, indirect draw) | #### When to use @@ -411,8 +515,8 @@ Phase 3 is worthwhile when: the viewer requires 4.5). For models under 100k objects, phase 1 alone is sufficient. For 100k–500k, -phase 2 (tiling) keeps CPU culling under 1 ms. Phase 3 is the final step that -makes the CPU frame time constant. +phase 2 (BVH) keeps CPU culling well under 1 ms. Phase 3 is the final step +that makes the CPU frame time constant. ### Summary @@ -429,28 +533,33 @@ The load path: ``` open(model.ifc): - ├─ sidecar exists? - │ ├─ yes: load tile tree from .ifcview - │ │ upload geometry in tile order - │ │ (skip background compaction) - │ └─ no: upload geometry in iterator order (fast first frame) - │ phase 1 culling active immediately - │ if object_count > threshold: - │ background: build octree, re-sort EBO, save .ifcview - │ on completion: swap in tile structure - └─ rendering: - ├─ phase 3 available? → compute cull + indirect multi-draw - └─ else → CPU frustum test + glMultiDrawElements + ├─ sidecar exists (.ifcview)? + │ ├─ yes: background thread reads sidecar file (non-blocking I/O) + │ │ → allocate per-model VAO/VBO/EBO (empty, exact size) + │ │ → progressive GPU upload: 48 MB/frame VBO, then EBO + │ │ → objects appear as EBO chunks land + │ │ → BVH activates once fully loaded + │ │ → viewport interactive throughout + │ └─ no: stream from IFC via GeometryStreamer + │ → uploadChunk() appends to per-model buffers (immediately drawable) + │ → phase 1 linear-scan culling active from first chunk + │ → on completion: background BVH build, re-sort EBO, save .ifcview + └─ rendering (per model, per frame): + ├─ phase 3 available? → compute cull + indirect multi-draw + ├─ BVH available? → BVH traversal + glMultiDrawElements + └─ else / progressive → linear scan of active objects + glMultiDrawElements ``` ## Roadmap - [x] Material color support (per-vertex RGBA8) -- [x] Buffer growth (dynamic VBO/EBO resizing up to 4 GB) +- [x] Per-model GPU buffers (VAO/VBO/EBO per model, no cross-model copies) - [x] Per-object frustum culling (phase 1) -- [ ] Spatial tiling with octree (phase 2) +- [x] BVH acceleration with per-model trees (phase 2) +- [x] Raw binary `.ifcview` sidecar cache (full geometry + BVH, Blender-style) +- [x] Non-blocking sidecar loading (background thread I/O) +- [x] Progressive GPU upload (48 MB/frame chunked VBO/EBO transfer) - [ ] GPU-driven indirect draw (phase 3) -- [ ] Preprocessed `.ifcview` sidecar for fast re-loads - [ ] Hierarchical-Z occlusion culling - [ ] Distance-based LOD selection - [ ] Vulkan/MoltenVK backend for macOS diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp new file mode 100644 index 0000000000..d77095c922 --- /dev/null +++ b/src/ifcviewer/SidecarCache.cpp @@ -0,0 +1,196 @@ +/******************************************************************************** + * * + * 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 "SidecarCache.h" + +#include +#include + +// Binary layout (all multi-byte fields native-endian): +// +// SidecarHeader (16 bytes) +// uint64_t source_file_size +// +// uint32_t num_vertices (count of floats) +// float[num_vertices] vertex data +// +// uint32_t num_indices +// uint32_t[num_indices] index data +// +// uint32_t num_draw_infos +// ObjectDrawInfo[N] draw info array +// +// uint32_t num_elements +// PackedElementInfo[N] element records +// uint32_t string_table_bytes +// char[string_table_bytes] +// +// uint32_t num_bvh_models +// for each model: +// uint32_t model_id +// uint32_t num_nodes +// BvhNode[num_nodes] +// uint32_t num_object_indices +// uint32_t[num_object_indices] + +struct SidecarHeader { + uint32_t magic; + uint32_t version; + uint32_t endian; + uint32_t reserved; +}; + +static std::string sidecarPath(const std::string& ifc_path) { + return ifc_path + ".ifcview"; +} + +template +static bool writeVec(FILE* f, const std::vector& v) { + uint32_t n = static_cast(v.size()); + if (fwrite(&n, 4, 1, f) != 1) return false; + if (n > 0 && fwrite(v.data(), sizeof(T), n, f) != n) return false; + return true; +} + +template +static bool readVec(FILE* f, std::vector& v) { + uint32_t n; + if (fread(&n, 4, 1, f) != 1) return false; + v.resize(n); + if (n > 0 && fread(v.data(), sizeof(T), n, f) != n) return false; + return true; +} + +bool writeSidecar(const std::string& ifc_path, + const SidecarData& data, + uint64_t ifc_file_size) { + std::string path = sidecarPath(ifc_path); + FILE* f = fopen(path.c_str(), "wb"); + if (!f) return false; + + // Header + SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN, 0 }; + fwrite(&hdr, sizeof(hdr), 1, f); + fwrite(&ifc_file_size, 8, 1, f); + + // Geometry + if (!writeVec(f, data.vertices)) { fclose(f); return false; } + if (!writeVec(f, data.indices)) { fclose(f); return false; } + + // Draw info + if (!writeVec(f, data.draw_info)) { fclose(f); return false; } + + // Elements + string table + if (!writeVec(f, data.elements)) { fclose(f); return false; } + uint32_t stbl_len = static_cast(data.string_table.size()); + fwrite(&stbl_len, 4, 1, f); + if (stbl_len > 0) fwrite(data.string_table.data(), 1, stbl_len, f); + + // BVH + uint32_t num_bvh_models = data.bvh_set + ? static_cast(data.bvh_set->models.size()) : 0; + fwrite(&num_bvh_models, 4, 1, f); + + if (data.bvh_set) { + for (const auto& [model_id, mbvh] : data.bvh_set->models) { + fwrite(&model_id, 4, 1, f); + + uint32_t nn = static_cast(mbvh.nodes.size()); + fwrite(&nn, 4, 1, f); + if (nn > 0) fwrite(mbvh.nodes.data(), sizeof(BvhNode), nn, f); + + uint32_t no = static_cast(mbvh.object_indices.size()); + fwrite(&no, 4, 1, f); + if (no > 0) fwrite(mbvh.object_indices.data(), 4, no, f); + } + } + + fclose(f); + return true; +} + +std::optional readSidecar(const std::string& ifc_path, + uint64_t ifc_file_size) { + std::string path = sidecarPath(ifc_path); + FILE* f = fopen(path.c_str(), "rb"); + if (!f) return std::nullopt; + + auto fail = [&]() -> std::optional { fclose(f); return std::nullopt; }; + + // Header + SidecarHeader hdr; + if (fread(&hdr, sizeof(hdr), 1, f) != 1) return fail(); + if (hdr.magic != SIDECAR_MAGIC || + hdr.version != SIDECAR_VERSION || + hdr.endian != SIDECAR_ENDIAN) return fail(); + + uint64_t stored_size; + if (fread(&stored_size, 8, 1, f) != 1) return fail(); + if (stored_size != ifc_file_size) return fail(); + + SidecarData data; + + // Geometry + if (!readVec(f, data.vertices)) return fail(); + if (!readVec(f, data.indices)) return fail(); + + // Draw info + if (!readVec(f, data.draw_info)) return fail(); + + // Elements + string table + if (!readVec(f, data.elements)) return fail(); + uint32_t stbl_len; + if (fread(&stbl_len, 4, 1, f) != 1) return fail(); + data.string_table.resize(stbl_len); + if (stbl_len > 0 && fread(data.string_table.data(), 1, stbl_len, f) != stbl_len) + return fail(); + + // BVH + uint32_t num_bvh_models; + if (fread(&num_bvh_models, 4, 1, f) != 1) return fail(); + + if (num_bvh_models > 0) { + data.bvh_set = std::make_shared(); + for (uint32_t m = 0; m < num_bvh_models; ++m) { + uint32_t model_id; + if (fread(&model_id, 4, 1, f) != 1) return fail(); + + ModelBvh mbvh; + mbvh.model_id = model_id; + + uint32_t nn; + if (fread(&nn, 4, 1, f) != 1) return fail(); + mbvh.nodes.resize(nn); + if (nn > 0 && fread(mbvh.nodes.data(), sizeof(BvhNode), nn, f) != nn) + return fail(); + + uint32_t no; + if (fread(&no, 4, 1, f) != 1) return fail(); + mbvh.object_indices.resize(no); + if (no > 0 && fread(mbvh.object_indices.data(), 4, no, f) != no) + return fail(); + + data.bvh_set->bvh_model_ids.insert(model_id); + data.bvh_set->models[model_id] = std::move(mbvh); + } + } + + fclose(f); + return data; +} diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h new file mode 100644 index 0000000000..49c36dba15 --- /dev/null +++ b/src/ifcviewer/SidecarCache.h @@ -0,0 +1,76 @@ +/******************************************************************************** + * * + * 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 SIDECARCACHE_H +#define SIDECARCACHE_H + +#include "BvhAccel.h" + +#include +#include +#include +#include + +static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" +static constexpr uint32_t SIDECAR_VERSION = 3; +static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; + +// Fixed-size element record for the sidecar. Strings are stored as +// (offset, length) pairs into a separate string table. +struct PackedElementInfo { + uint32_t object_id; + uint32_t model_id; + int32_t ifc_id; + int32_t parent_id; + uint32_t guid_offset; + uint32_t guid_length; + uint32_t name_offset; + uint32_t name_length; + uint32_t type_offset; + uint32_t type_length; +}; + +// Everything the viewer needs to display a model without tessellating. +struct SidecarData { + // GPU geometry (ready to upload as-is) + std::vector vertices; // interleaved, 8 floats per vertex + std::vector indices; // global (already remapped) + + // Per-object metadata + std::vector draw_info; + + // Element tree metadata + std::vector elements; + std::string string_table; // concatenated UTF-8 + + // BVH acceleration + std::shared_ptr bvh_set; +}; + +// Write a full sidecar next to the IFC file. +// Returns true on success. +bool writeSidecar(const std::string& ifc_path, + const SidecarData& data, + uint64_t ifc_file_size); + +// Read a sidecar. Returns nullopt on any failure (missing, stale, corrupt). +std::optional readSidecar(const std::string& ifc_path, + uint64_t ifc_file_size); + +#endif // SIDECARCACHE_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 4217c99742..ae50f6dc44 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "ViewportWindow.h" +#include "SidecarCache.h" #include #include @@ -32,7 +33,6 @@ 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) @@ -192,12 +192,16 @@ ViewportWindow::ViewportWindow(QWindow* parent) } ViewportWindow::~ViewportWindow() { + if (bvh_build_thread_.joinable()) + bvh_build_thread_.join(); 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_); + for (auto& [mid, m] : models_gpu_) { + if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); + if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); + if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); + } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); if (main_program_) gl_->glDeleteProgram(main_program_); @@ -231,46 +235,6 @@ void ViewportWindow::initGL() { 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); @@ -282,6 +246,31 @@ void ViewportWindow::initGL() { emit initialized(); } +void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) { + 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); +} + void ViewportWindow::buildShaders() { { GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, MAIN_VERTEX_SHADER); @@ -301,15 +290,11 @@ void ViewportWindow::buildShaders() { } 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, }; @@ -329,15 +314,11 @@ void ViewportWindow::buildAxisGizmo() { 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; - } +bool ViewportWindow::growModelVbo(ModelGpuData& m, size_t needed_total) { + size_t new_capacity = m.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)); + qWarning("VBO grow request (%zu MB) exceeds cap", new_capacity / (1024 * 1024)); return false; } @@ -345,29 +326,25 @@ bool ViewportWindow::growVbo(size_t needed_total) { 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_); + if (m.vbo_used > 0) { + gl_->glCopyNamedBufferSubData(m.vbo, new_vbo, 0, 0, m.vbo_used); } - gl_->glDeleteBuffers(1, &vbo_); - vbo_ = new_vbo; - vbo_capacity_ = new_capacity; + gl_->glDeleteBuffers(1, &m.vbo); + m.vbo = new_vbo; + m.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)); + gl_->glVertexArrayVertexBuffer(m.vao, 0, m.vbo, 0, VERTEX_STRIDE * sizeof(float)); - qInfo("VBO grew to %zu MB", vbo_capacity_ / (1024 * 1024)); + qInfo("Model VBO grew to %zu MB", m.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; - } +bool ViewportWindow::growModelEbo(ModelGpuData& m, size_t needed_total) { + size_t new_capacity = m.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)); + qWarning("EBO grow request (%zu MB) exceeds cap", new_capacity / (1024 * 1024)); return false; } @@ -375,17 +352,17 @@ bool ViewportWindow::growEbo(size_t needed_total) { 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_); + if (m.ebo_used > 0) { + gl_->glCopyNamedBufferSubData(m.ebo, new_ebo, 0, 0, m.ebo_used); } - gl_->glDeleteBuffers(1, &ebo_); - ebo_ = new_ebo; - ebo_capacity_ = new_capacity; + gl_->glDeleteBuffers(1, &m.ebo); + m.ebo = new_ebo; + m.ebo_capacity = new_capacity; - gl_->glVertexArrayElementBuffer(vao_, ebo_); + gl_->glVertexArrayElementBuffer(m.vao, m.ebo); - qInfo("EBO grew to %zu MB", ebo_capacity_ / (1024 * 1024)); + qInfo("Model EBO grew to %zu MB", m.ebo_capacity / (1024 * 1024)); return true; } @@ -395,37 +372,55 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) { context_->makeCurrent(this); + // Get or create per-model GPU data. + auto it = models_gpu_.find(chunk.model_id); + if (it == models_gpu_.end()) { + ModelGpuData m; + gl_->glCreateVertexArrays(1, &m.vao); + gl_->glCreateBuffers(1, &m.vbo); + gl_->glCreateBuffers(1, &m.ebo); + + m.vbo_capacity = INITIAL_VBO_SIZE; + m.ebo_capacity = INITIAL_EBO_SIZE; + gl_->glNamedBufferStorage(m.vbo, m.vbo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); + gl_->glNamedBufferStorage(m.ebo, m.ebo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); + + setupVaoLayout(m.vao, m.vbo, m.ebo); + it = models_gpu_.emplace(chunk.model_id, std::move(m)).first; + } + + auto& mgpu = it->second; + 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)) { + if (mgpu.vbo_used + vb_size > mgpu.vbo_capacity) { + if (!growModelVbo(mgpu, mgpu.vbo_used + vb_size)) { qWarning("VBO at cap, skipping chunk"); return; } } - if (ebo_used_ + ib_size > ebo_capacity_) { - if (!growEbo(ebo_used_ + ib_size)) { + if (mgpu.ebo_used + ib_size > mgpu.ebo_capacity) { + if (!growModelEbo(mgpu, mgpu.ebo_used + ib_size)) { qWarning("EBO at cap, skipping chunk"); return; } } - uint32_t base_vertex = vertex_count_; + uint32_t base_vertex = mgpu.vertex_count; - gl_->glNamedBufferSubData(vbo_, vbo_used_, vb_size, chunk.vertices.data()); + gl_->glNamedBufferSubData(mgpu.vbo, mgpu.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. + // Remap chunk-local indices into model-local global indices. 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()); + gl_->glNamedBufferSubData(mgpu.ebo, mgpu.ebo_used, ib_size, global_indices.data()); // Compute AABB from vertex positions in this chunk. ObjectDrawInfo info; - info.index_offset = static_cast(ebo_used_); + info.index_offset = static_cast(mgpu.ebo_used); info.index_count = static_cast(chunk.indices.size()); info.model_id = chunk.model_id; @@ -445,46 +440,301 @@ void ViewportWindow::uploadChunk(const UploadChunk& chunk) { info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = 0.0f; } - { - std::lock_guard lock(upload_mutex_); - total_index_count_ += static_cast(chunk.indices.size()); - object_draw_info_.push_back(info); - } + mgpu.draw_info.push_back(info); + mgpu.active_draw_count = static_cast(mgpu.draw_info.size()); // immediately drawable + mgpu.vbo_used += vb_size; + mgpu.ebo_used += ib_size; + mgpu.vertex_count += static_cast(num_verts); + mgpu.total_triangles += static_cast(chunk.indices.size() / 3); +} - 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::uploadBulk(uint32_t model_id, + std::vector vertices, + std::vector indices, + const std::vector& draw_info, + std::shared_ptr bvh_set) { + if (!gl_initialized_) return; + if (vertices.empty() || indices.empty()) return; + + context_->makeCurrent(this); + + size_t vb_size = vertices.size() * sizeof(float); + size_t ib_size = indices.size() * sizeof(uint32_t); + + // Allocate empty buffers at exact size — no data uploaded yet. + ModelGpuData m; + gl_->glCreateVertexArrays(1, &m.vao); + gl_->glCreateBuffers(1, &m.vbo); + gl_->glCreateBuffers(1, &m.ebo); + + m.vbo_capacity = vb_size; + m.ebo_capacity = ib_size; + gl_->glNamedBufferStorage(m.vbo, vb_size, nullptr, GL_DYNAMIC_STORAGE_BIT); + gl_->glNamedBufferStorage(m.ebo, ib_size, nullptr, GL_DYNAMIC_STORAGE_BIT); + + setupVaoLayout(m.vao, m.vbo, m.ebo); + + m.vbo_used = vb_size; + m.ebo_used = ib_size; + m.vertex_count = static_cast(vertices.size() / VERTEX_STRIDE); + m.draw_info = draw_info; + m.active_draw_count = 0; // nothing drawable yet + + uint32_t total_tri = 0; + for (const auto& di : draw_info) total_tri += di.index_count / 3; + m.total_triangles = total_tri; + + // Delete old model data if re-uploading. + auto it = models_gpu_.find(model_id); + if (it != models_gpu_.end()) { + gl_->glDeleteVertexArrays(1, &it->second.vao); + gl_->glDeleteBuffers(1, &it->second.vbo); + gl_->glDeleteBuffers(1, &it->second.ebo); + } + models_gpu_[model_id] = std::move(m); + + // Queue progressive upload — data will stream in over subsequent frames. + PendingUpload pu; + pu.model_id = model_id; + pu.vertices = std::move(vertices); + pu.indices = std::move(indices); + pu.bvh_set = std::move(bvh_set); + pending_uploads_.push_back(std::move(pu)); + + qDebug("Bulk upload queued: model %u, %zu vertices, %zu indices, %zu objects", + model_id, vertices.size() / VERTEX_STRIDE, indices.size(), draw_info.size()); } 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; + if (bvh_build_thread_.joinable()) + bvh_build_thread_.join(); + + context_->makeCurrent(this); + for (auto& [mid, m] : models_gpu_) { + if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); + if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); + if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); + } + models_gpu_.clear(); + model_bvhs_.clear(); + pending_uploads_.clear(); selected_object_id_ = 0; - object_draw_info_.clear(); - hidden_models_.clear(); - removed_models_.clear(); + { + std::lock_guard bvh_lock(bvh_result_mutex_); + pending_bvh_.reset(); + } +} + +static const size_t UPLOAD_CHUNK_BYTES = 48 * 1024 * 1024; // 48 MB per frame + +void ViewportWindow::processPendingUploads() { + if (pending_uploads_.empty()) return; + + auto& pu = pending_uploads_.front(); + auto it = models_gpu_.find(pu.model_id); + if (it == models_gpu_.end()) { + pending_uploads_.pop_front(); + return; + } + auto& mgpu = it->second; + + size_t vbo_total = pu.vertices.size() * sizeof(float); + size_t ebo_total = pu.indices.size() * sizeof(uint32_t); + + // Phase 1: Upload VBO in chunks. + if (pu.vbo_uploaded < vbo_total) { + size_t remaining = vbo_total - pu.vbo_uploaded; + size_t chunk = std::min(remaining, UPLOAD_CHUNK_BYTES); + gl_->glNamedBufferSubData(mgpu.vbo, pu.vbo_uploaded, chunk, + reinterpret_cast(pu.vertices.data()) + pu.vbo_uploaded); + pu.vbo_uploaded += chunk; + + if (pu.vbo_uploaded >= vbo_total) { + // VBO done — free CPU memory. + pu.vertices.clear(); + pu.vertices.shrink_to_fit(); + } + return; // yield to render loop + } + + // Phase 2: Upload EBO in chunks. Objects become drawable as their range lands. + if (pu.ebo_uploaded < ebo_total) { + size_t remaining = ebo_total - pu.ebo_uploaded; + size_t chunk = std::min(remaining, UPLOAD_CHUNK_BYTES); + gl_->glNamedBufferSubData(mgpu.ebo, pu.ebo_uploaded, chunk, + reinterpret_cast(pu.indices.data()) + pu.ebo_uploaded); + pu.ebo_uploaded += chunk; + + // Advance active_draw_count: activate objects whose EBO range is fully uploaded. + while (mgpu.active_draw_count < mgpu.draw_info.size()) { + const auto& obj = mgpu.draw_info[mgpu.active_draw_count]; + size_t obj_end = obj.index_offset + obj.index_count * sizeof(uint32_t); + if (obj_end <= pu.ebo_uploaded) + mgpu.active_draw_count++; + else + break; + } + + if (pu.ebo_uploaded >= ebo_total) { + // EBO done — free CPU memory. + pu.indices.clear(); + pu.indices.shrink_to_fit(); + } else { + return; // yield to render loop + } + } + + // Fully uploaded — activate BVH if present. + mgpu.active_draw_count = static_cast(mgpu.draw_info.size()); + if (pu.bvh_set) { + model_bvhs_[pu.model_id] = std::move(pu.bvh_set); + } + + qDebug("Progressive upload complete: model %u", pu.model_id); + pending_uploads_.pop_front(); } void ViewportWindow::hideModel(uint32_t model_id) { - std::lock_guard lock(upload_mutex_); - hidden_models_.insert(model_id); + auto it = models_gpu_.find(model_id); + if (it != models_gpu_.end()) it->second.hidden = true; } void ViewportWindow::showModel(uint32_t model_id) { - std::lock_guard lock(upload_mutex_); - hidden_models_.erase(model_id); + auto it = models_gpu_.find(model_id); + if (it != models_gpu_.end()) it->second.hidden = false; } void ViewportWindow::removeModel(uint32_t model_id) { - std::lock_guard lock(upload_mutex_); - removed_models_.insert(model_id); + if (!gl_initialized_) return; + context_->makeCurrent(this); + + // Cancel any pending upload for this model. + pending_uploads_.erase( + std::remove_if(pending_uploads_.begin(), pending_uploads_.end(), + [model_id](const PendingUpload& pu) { return pu.model_id == model_id; }), + pending_uploads_.end()); + + auto it = models_gpu_.find(model_id); + if (it != models_gpu_.end()) { + gl_->glDeleteVertexArrays(1, &it->second.vao); + gl_->glDeleteBuffers(1, &it->second.vbo); + gl_->glDeleteBuffers(1, &it->second.ebo); + models_gpu_.erase(it); + } + model_bvhs_.erase(model_id); +} + +std::vector ViewportWindow::readbackEbo(uint32_t model_id) const { + std::vector ebo_data; + auto it = models_gpu_.find(model_id); + if (!gl_ || it == models_gpu_.end() || it->second.ebo_used == 0) return ebo_data; + + const auto& m = it->second; + size_t num_indices = m.ebo_used / sizeof(uint32_t); + ebo_data.resize(num_indices); + gl_->glGetNamedBufferSubData(m.ebo, 0, m.ebo_used, ebo_data.data()); + return ebo_data; +} + +std::vector ViewportWindow::readbackVbo(uint32_t model_id) const { + std::vector vbo_data; + auto it = models_gpu_.find(model_id); + if (!gl_ || it == models_gpu_.end() || it->second.vbo_used == 0) return vbo_data; + + const auto& m = it->second; + size_t num_floats = m.vbo_used / sizeof(float); + vbo_data.resize(num_floats); + gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, vbo_data.data()); + return vbo_data; +} + +void ViewportWindow::buildBvhAsync(uint32_t model_id, + const std::string& ifc_path, + uint64_t ifc_file_size, + std::vector sidecar_elements, + std::string sidecar_string_table) { + if (bvh_build_thread_.joinable()) + bvh_build_thread_.join(); + + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end()) return; + + // Snapshot draw info; read back EBO + VBO on GL thread. + std::vector draw_snapshot = it->second.draw_info; + std::vector ebo_snapshot = readbackEbo(model_id); + std::vector vbo_snapshot; + if (!ifc_path.empty() && !sidecar_elements.empty()) { + vbo_snapshot = readbackVbo(model_id); + } + + if (draw_snapshot.empty() || ebo_snapshot.empty()) return; + + bvh_build_thread_ = std::thread([this, + model_id, + draw_info = std::move(draw_snapshot), + ebo_data = std::move(ebo_snapshot), + vbo_data = std::move(vbo_snapshot), + elements = std::move(sidecar_elements), + string_table = std::move(sidecar_string_table), + ifc_path, ifc_file_size]() { + auto bvh_set = buildBvhSet(draw_info); + + EboReorderResult ebo_result = reorderEbo(*bvh_set, draw_info, ebo_data); + + // Write full sidecar if requested. + if (!ifc_path.empty() && !elements.empty() && !vbo_data.empty()) { + SidecarData sd; + sd.vertices = vbo_data; + sd.indices = ebo_result.reordered_ebo; + sd.draw_info = ebo_result.reordered_draw_info; + sd.elements = std::move(elements); + sd.string_table = std::move(string_table); + sd.bvh_set = bvh_set; + writeSidecar(ifc_path, sd, ifc_file_size); + } + + { + std::lock_guard lock(bvh_result_mutex_); + pending_bvh_ = std::make_unique(); + pending_bvh_->model_id = model_id; + pending_bvh_->bvh_set = std::move(bvh_set); + pending_bvh_->ebo_reorder = std::move(ebo_result); + } + }); +} + +void ViewportWindow::applyBvhResult() { + std::unique_ptr result; + { + std::lock_guard lock(bvh_result_mutex_); + result = std::move(pending_bvh_); + } + if (!result) return; + + auto it = models_gpu_.find(result->model_id); + if (it == models_gpu_.end()) return; + + auto& mgpu = it->second; + + // Re-upload the reordered EBO into this model's buffer. + if (!result->ebo_reorder.reordered_ebo.empty()) { + size_t ebo_bytes = result->ebo_reorder.reordered_ebo.size() * sizeof(uint32_t); + if (ebo_bytes <= mgpu.ebo_capacity) { + gl_->glNamedBufferSubData(mgpu.ebo, 0, ebo_bytes, + result->ebo_reorder.reordered_ebo.data()); + } + } + + // Swap draw info. + if (result->ebo_reorder.reordered_draw_info.size() == mgpu.draw_info.size()) { + mgpu.draw_info = std::move(result->ebo_reorder.reordered_draw_info); + } + + model_bvhs_[result->model_id] = std::move(result->bvh_set); + + qDebug("BVH activated for model %u", result->model_id); } void ViewportWindow::setSelectedObjectId(uint32_t id) { @@ -499,7 +749,6 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { 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_); @@ -533,7 +782,6 @@ 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)); @@ -547,17 +795,61 @@ void ViewportWindow::updateCamera() { proj_matrix_.perspective(45.0f, aspect, 0.1f, camera_distance_ * 10.0f); } +bool ViewportWindow::aabbInFrustum(const float aabb_min[3], const float aabb_max[3], + const float planes[6][4]) { + for (int p = 0; p < 6; ++p) { + float px = planes[p][0] >= 0.0f ? aabb_max[0] : aabb_min[0]; + float py = planes[p][1] >= 0.0f ? aabb_max[1] : aabb_min[1]; + float pz = planes[p][2] >= 0.0f ? aabb_max[2] : aabb_min[2]; + float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3]; + if (dist < 0.0f) return false; + } + return true; +} + +void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu, + const float planes[6][4]) { + if (mbvh.nodes.empty()) return; + + uint32_t stack[64]; + int sp = 0; + stack[sp++] = 0; // root + + // Get the current model's draw command being built. + auto& cmd = frame_draw_cmds_.back(); + + while (sp > 0) { + uint32_t ni = stack[--sp]; + const BvhNode& node = mbvh.nodes[ni]; + + if (!aabbInFrustum(node.aabb_min, node.aabb_max, planes)) + continue; + + if (node.count > 0) { + for (uint32_t i = 0; i < node.count; ++i) { + uint32_t oi = mbvh.object_indices[node.right_or_first + i]; + const auto& obj = mgpu.draw_info[oi]; + if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) { + cmd.counts.push_back(static_cast(obj.index_count)); + cmd.offsets.push_back(reinterpret_cast( + static_cast(obj.index_offset))); + visible_triangles_ += obj.index_count / 3; + } + } + } else { + if (sp < 63) { + stack[sp++] = node.right_or_first; + stack[sp++] = ni + 1; + } + } + } +} + void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { - visible_counts_.clear(); - visible_offsets_.clear(); + frame_draw_cmds_.clear(); visible_triangles_ = 0; - std::lock_guard lock(upload_mutex_); - if (object_draw_info_.empty()) return; - // Extract 6 frustum planes from the view-projection matrix. - // Each plane is (a, b, c, d) where ax + by + cz + d >= 0 is inside. - // QMatrix4x4 is stored column-major; operator(row, col) gives element. float planes[6][4]; for (int i = 0; i < 4; ++i) { planes[0][i] = vp(3, i) + vp(0, i); // left @@ -567,7 +859,6 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { planes[4][i] = vp(3, i) + vp(2, i); // near planes[5][i] = vp(3, i) - vp(2, i); // far } - // Normalize planes. for (int p = 0; p < 6; ++p) { float len = std::sqrt(planes[p][0] * planes[p][0] + planes[p][1] * planes[p][1] + @@ -581,31 +872,40 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { } } - visible_counts_.reserve(object_draw_info_.size()); - visible_offsets_.reserve(object_draw_info_.size()); + for (auto& [model_id, mgpu] : models_gpu_) { + if (mgpu.hidden || mgpu.active_draw_count == 0) continue; - 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; + frame_draw_cmds_.push_back({mgpu.vao, {}, {}}); + auto& cmd = frame_draw_cmds_.back(); + cmd.counts.reserve(mgpu.active_draw_count); + cmd.offsets.reserve(mgpu.active_draw_count); - bool visible = true; - for (int p = 0; p < 6; ++p) { - // p-vertex: the AABB corner most in the direction of the plane normal. - float px = planes[p][0] >= 0.0f ? obj.aabb_max[0] : obj.aabb_min[0]; - float py = planes[p][1] >= 0.0f ? obj.aabb_max[1] : obj.aabb_min[1]; - float pz = planes[p][2] >= 0.0f ? obj.aabb_max[2] : obj.aabb_min[2]; - float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3]; - if (dist < 0.0f) { - visible = false; - break; + bool fully_loaded = (mgpu.active_draw_count == mgpu.draw_info.size()); + auto bvh_it = model_bvhs_.find(model_id); + + // Only use BVH if model is fully uploaded; during progressive upload, + // fall back to linear scan of active objects. + if (fully_loaded && bvh_it != model_bvhs_.end() && bvh_it->second) { + const auto& bvh_set = *bvh_it->second; + auto mbvh_it = bvh_set.models.find(model_id); + if (mbvh_it != bvh_set.models.end()) { + traverseBvh(mbvh_it->second, mgpu, planes); + } + } else { + // Linear scan of active objects only. + for (uint32_t i = 0; i < mgpu.active_draw_count; ++i) { + const auto& obj = mgpu.draw_info[i]; + if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) { + cmd.counts.push_back(static_cast(obj.index_count)); + cmd.offsets.push_back(reinterpret_cast( + static_cast(obj.index_offset))); + visible_triangles_ += obj.index_count / 3; + } } } - if (visible) { - visible_counts_.push_back(static_cast(obj.index_count)); - visible_offsets_.push_back(reinterpret_cast( - static_cast(obj.index_offset))); - visible_triangles_ += obj.index_count / 3; + + if (cmd.counts.empty()) { + frame_draw_cmds_.pop_back(); } } } @@ -614,6 +914,8 @@ void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; context_->makeCurrent(this); + applyBvhResult(); + processPendingUploads(); updateCamera(); int w = width() * devicePixelRatio(); @@ -628,21 +930,20 @@ void ViewportWindow::render() { 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_); - buildVisibleList(vp); - if (!visible_counts_.empty()) { + for (const auto& cmd : frame_draw_cmds_) { + gl_->glBindVertexArray(cmd.vao); gl_->glMultiDrawElements(GL_TRIANGLES, - visible_counts_.data(), GL_UNSIGNED_INT, - visible_offsets_.data(), - static_cast(visible_counts_.size())); + cmd.counts.data(), GL_UNSIGNED_INT, + cmd.offsets.data(), + static_cast(cmd.counts.size())); } renderAxisGizmo(); context_->swapBuffers(this); - // Compute FPS (updated once per second to avoid flicker). + // Compute FPS. float dt = frame_clock_.restart() / 1000.0f; accumulated_time_ += dt; frame_count_++; @@ -651,12 +952,23 @@ void ViewportWindow::render() { frame_count_ = 0; accumulated_time_ = 0.0f; + uint32_t total_obj = 0, total_tri = 0, vis_obj = 0; + for (const auto& [mid, m] : models_gpu_) { + if (!m.hidden) { + total_obj += static_cast(m.draw_info.size()); + total_tri += m.total_triangles; + } + } + for (const auto& cmd : frame_draw_cmds_) { + vis_obj += static_cast(cmd.counts.size()); + } + FrameStats stats; stats.fps = last_fps_; stats.frame_time_ms = 1000.0f / last_fps_; - stats.total_objects = static_cast(object_draw_info_.size()); - stats.visible_objects = static_cast(visible_counts_.size()); - stats.total_triangles = total_triangles_; + stats.total_objects = total_obj; + stats.visible_objects = vis_obj; + stats.total_triangles = total_tri; stats.visible_triangles = visible_triangles_; emit frameStatsUpdated(stats); } @@ -672,8 +984,6 @@ void ViewportWindow::renderAxisGizmo() { 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_); @@ -693,7 +1003,7 @@ void ViewportWindow::renderAxisGizmo() { 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_->glLineWidth(2.5f); gl_->glBindVertexArray(axis_vao_); gl_->glDrawArrays(GL_LINES, 0, 6); @@ -712,14 +1022,13 @@ void ViewportWindow::renderPickPass() { gl_->glUseProgram(pick_program_); gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(pick_program_, "u_view_projection"), 1, GL_FALSE, vp.constData()); - gl_->glBindVertexArray(vao_); - // Reuse the visible list from the most recent render() call. - if (!visible_counts_.empty()) { + for (const auto& cmd : frame_draw_cmds_) { + gl_->glBindVertexArray(cmd.vao); gl_->glMultiDrawElements(GL_TRIANGLES, - visible_counts_.data(), GL_UNSIGNED_INT, - visible_offsets_.data(), - static_cast(visible_counts_.size())); + cmd.counts.data(), GL_UNSIGNED_INT, + cmd.offsets.data(), + static_cast(cmd.counts.size())); } gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); @@ -774,7 +1083,6 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { 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_); @@ -786,7 +1094,6 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { 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); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index fda82a1db5..62abc48002 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -28,23 +28,23 @@ #include #include +#include #include #include +#include #include #include +#include +#include +#include + +#include "BvhAccel.h" +#include "SidecarCache.h" struct MaterialInfo { float r = 0.75f, g = 0.75f, b = 0.78f, a = 1.0f; }; -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]; -}; - 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) @@ -56,6 +56,32 @@ struct UploadChunk { uint32_t model_id = 0; }; +// Per-model GPU state: own VAO, VBO, EBO, draw info, BVH. +struct ModelGpuData { + GLuint vao = 0; + GLuint vbo = 0; + GLuint ebo = 0; + size_t vbo_capacity = 0; + size_t ebo_capacity = 0; + size_t vbo_used = 0; // bytes + size_t ebo_used = 0; // bytes + uint32_t vertex_count = 0; + uint32_t total_triangles = 0; + std::vector draw_info; + uint32_t active_draw_count = 0; // how many objects are drawable (progressive upload) + bool hidden = false; +}; + +// Pending progressive upload — VBO first, then EBO. +struct PendingUpload { + uint32_t model_id = 0; + std::vector vertices; + std::vector indices; + std::shared_ptr bvh_set; + size_t vbo_uploaded = 0; // bytes + size_t ebo_uploaded = 0; // bytes +}; + class ViewportWindow : public QWindow { Q_OBJECT public: @@ -65,10 +91,29 @@ public: void uploadChunk(const UploadChunk& chunk); void resetScene(); + // Bulk upload pre-built geometry from a sidecar cache. + // Creates a perfectly-sized per-model buffer set. No copy. + void uploadBulk(uint32_t model_id, + std::vector vertices, + std::vector indices, + const std::vector& draw_info, + std::shared_ptr bvh_set); + void hideModel(uint32_t model_id); void showModel(uint32_t model_id); void removeModel(uint32_t model_id); + // Build BVH and optionally write a sidecar cache. + void buildBvhAsync(uint32_t model_id, + const std::string& ifc_path = "", + uint64_t ifc_file_size = 0, + std::vector sidecar_elements = {}, + std::string sidecar_string_table = {}); + + // Read snapshots of a model's GPU buffers into CPU vectors. + std::vector readbackEbo(uint32_t model_id) const; + std::vector readbackVbo(uint32_t model_id) const; + void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); @@ -99,9 +144,16 @@ private: void updateCamera(); void buildShaders(); void buildAxisGizmo(); - bool growVbo(size_t needed_total); - bool growEbo(size_t needed_total); + void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo); + bool growModelVbo(ModelGpuData& m, size_t needed_total); + bool growModelEbo(ModelGpuData& m, size_t needed_total); void buildVisibleList(const QMatrix4x4& vp); + void traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu, + const float planes[6][4]); + static bool aabbInFrustum(const float aabb_min[3], const float aabb_max[3], + const float planes[6][4]); + void applyBvhResult(); + void processPendingUploads(); // Mouse interaction void handleMousePress(QMouseEvent* event); @@ -124,15 +176,9 @@ private: 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; + // Per-model GPU data + std::unordered_map models_gpu_; + std::mutex models_mutex_; // Pick framebuffer GLuint pick_fbo_ = 0; @@ -141,16 +187,20 @@ private: int pick_width_ = 0; int pick_height_ = 0; - // 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_; + // Per-model BVH + std::unordered_map> model_bvhs_; + + // Progressive upload queue + std::deque pending_uploads_; // Scratch buffers reused each frame to avoid allocation. - std::vector visible_counts_; - std::vector visible_offsets_; + struct ModelDrawCmd { + GLuint vao; + std::vector counts; + std::vector offsets; + }; + std::vector frame_draw_cmds_; + uint32_t visible_triangles_ = 0; // Camera QVector3D camera_target_{0, 0, 0}; @@ -169,9 +219,17 @@ private: bool pick_requested_ = false; int pick_x_ = 0, pick_y_ = 0; + // BVH build (phase 2) + struct PendingBvh { + uint32_t model_id; + std::shared_ptr bvh_set; + EboReorderResult ebo_reorder; + }; + std::unique_ptr pending_bvh_; + std::mutex bvh_result_mutex_; + std::thread bvh_build_thread_; + // Stats - uint32_t total_triangles_ = 0; - uint32_t visible_triangles_ = 0; int frame_count_ = 0; float accumulated_time_ = 0.0f; float last_fps_ = 0.0f; From 36fa53122a4792b34a4004853dd5a82203c3e295 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 19:01:43 +1000 Subject: [PATCH 010/120] Add profiling for VRAM, FPS ratios, and instancing analysis Per-second frame log reports fps/ms, visible/total object & triangle ratios, VRAM breakdown (VBO+EBO), model count, and pending uploads. Upload-complete log includes per-model VBO/EBO MB and scene total VRAM. Streamer runs an instancing analysis keyed on geom.id(): total shapes, unique representations, dedup ratio, theoretical VBO/EBO/SSBO sizes if instanced, potential savings, and top-5 most-duplicated representations. Used to validate whether GPU instancing is worth the architectural rewrite for a given dataset. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/GeometryStreamer.cpp | 103 +++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.cpp | 41 ++++++++++-- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 7235bced9f..54b37df70c 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -27,6 +27,9 @@ #include #include +#include +#include + GeometryStreamer::GeometryStreamer(QObject* parent) : QObject(parent) { @@ -126,6 +129,20 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { int last_progress = 0; + // Instancing analysis: count shapes grouped by representation id. + struct GeomStat { + uint32_t count = 0; + size_t vertex_count = 0; + size_t index_count = 0; + std::string example_type; + }; + std::unordered_map geom_stats; + uint32_t total_shapes = 0; + size_t total_vertices = 0; + size_t total_indices = 0; + QElapsedTimer stream_timer; + stream_timer.start(); + do { if (cancel_requested_.load()) break; @@ -147,6 +164,24 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { info.type = tri_elem->type(); info.parent_id = tri_elem->parent_id(); + // Instancing stats: key by representation id, count unique vs repeated. + const auto& geom = tri_elem->geometry(); + const std::string& geom_id = geom.id(); + size_t nv = geom.verts().size() / 3; + size_t ni = geom.faces().size(); + if (!geom_id.empty()) { + auto& gs = geom_stats[geom_id]; + gs.count++; + if (gs.count == 1) { + gs.vertex_count = nv; + gs.index_count = ni; + gs.example_type = info.type; + } + } + total_shapes++; + total_vertices += nv; + total_indices += ni; + { std::lock_guard lock(elements_mutex_); pending_elements_.push_back(std::move(info)); @@ -168,6 +203,74 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { progress_ = 100; emit progressChanged(100); + + // === Instancing report === + { + size_t unique_geoms = geom_stats.size(); + size_t unique_vertices = 0; + size_t unique_indices = 0; + size_t repeated_shapes = 0; // total shapes that share a repr with another + for (const auto& [gid, gs] : geom_stats) { + unique_vertices += gs.vertex_count; + unique_indices += gs.index_count; + if (gs.count > 1) repeated_shapes += gs.count; + } + + // Bytes assuming current layout (32 B/vertex, 4 B/index). + size_t baked_vbo_bytes = total_vertices * 32; + size_t baked_ebo_bytes = total_indices * 4; + size_t instanced_vbo_bytes = unique_vertices * 32; + size_t instanced_ebo_bytes = unique_indices * 4; + // Per-instance data: 64 B transform + 8 B (object_id + color). + size_t per_instance_bytes = 72; + size_t instance_ssbo_bytes = total_shapes * per_instance_bytes; + + double dedup_ratio = unique_geoms > 0 + ? static_cast(total_shapes) / static_cast(unique_geoms) + : 1.0; + + qDebug("=== Instancing analysis: %s ===", path.c_str()); + qDebug(" Stream time: %.2f s", stream_timer.elapsed() / 1000.0); + qDebug(" Total shapes: %u", total_shapes); + qDebug(" Unique geometries: %zu (dedup ratio %.2fx)", + unique_geoms, dedup_ratio); + qDebug(" Repeated shapes: %zu (%.1f%% of total)", + repeated_shapes, + total_shapes > 0 ? 100.0 * repeated_shapes / total_shapes : 0.0); + qDebug(" Baked geometry: VBO %.1f MB + EBO %.1f MB = %.1f MB", + baked_vbo_bytes / (1024.0*1024.0), + baked_ebo_bytes / (1024.0*1024.0), + (baked_vbo_bytes + baked_ebo_bytes) / (1024.0*1024.0)); + qDebug(" If instanced: VBO %.1f MB + EBO %.1f MB + SSBO %.1f MB = %.1f MB", + instanced_vbo_bytes / (1024.0*1024.0), + instanced_ebo_bytes / (1024.0*1024.0), + instance_ssbo_bytes / (1024.0*1024.0), + (instanced_vbo_bytes + instanced_ebo_bytes + instance_ssbo_bytes) + / (1024.0*1024.0)); + size_t baked_total = baked_vbo_bytes + baked_ebo_bytes; + size_t inst_total = instanced_vbo_bytes + instanced_ebo_bytes + instance_ssbo_bytes; + if (inst_total > 0 && baked_total > inst_total) { + qDebug(" Potential savings: %.1f MB (%.1f%%)", + (baked_total - inst_total) / (1024.0*1024.0), + 100.0 * (baked_total - inst_total) / baked_total); + } else { + qDebug(" Potential savings: none (instance overhead exceeds dedup win)"); + } + + // Top-5 most duplicated representations. + std::vector> sorted(geom_stats.begin(), geom_stats.end()); + std::partial_sort(sorted.begin(), + sorted.begin() + std::min(5, sorted.size()), + sorted.end(), + [](const auto& a, const auto& b) { return a.second.count > b.second.count; }); + qDebug(" Top duplicated representations:"); + for (size_t i = 0; i < std::min(5, sorted.size()); ++i) { + const auto& [gid, gs] = sorted[i]; + qDebug(" [%zu] count=%u verts=%zu type=%s repr_id=%s", + i + 1, gs.count, gs.vertex_count, + gs.example_type.c_str(), gid.c_str()); + } + } } static MaterialInfo materialFromStyle(const ifcopenshell::geometry::taxonomy::style::ptr& style) { diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index ae50f6dc44..c872f799b6 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -592,7 +592,19 @@ void ViewportWindow::processPendingUploads() { model_bvhs_[pu.model_id] = std::move(pu.bvh_set); } - qDebug("Progressive upload complete: model %u", pu.model_id); + size_t total_vbo = 0, total_ebo = 0; + for (const auto& [mid, mg] : models_gpu_) { + total_vbo += mg.vbo_capacity; + total_ebo += mg.ebo_capacity; + } + qDebug("Progressive upload complete: model %u (this: vbo %.1f MB + ebo %.1f MB, " + "%u objects, %u triangles) scene total vram %.1f MB", + pu.model_id, + mgpu.vbo_capacity / (1024.0 * 1024.0), + mgpu.ebo_capacity / (1024.0 * 1024.0), + static_cast(mgpu.draw_info.size()), + mgpu.total_triangles, + (total_vbo + total_ebo) / (1024.0 * 1024.0)); pending_uploads_.pop_front(); } @@ -953,12 +965,17 @@ void ViewportWindow::render() { accumulated_time_ = 0.0f; uint32_t total_obj = 0, total_tri = 0, vis_obj = 0; + size_t total_vram = 0, total_vbo = 0, total_ebo = 0; + size_t num_models = 0, num_hidden = 0; for (const auto& [mid, m] : models_gpu_) { - if (!m.hidden) { - total_obj += static_cast(m.draw_info.size()); - total_tri += m.total_triangles; - } + num_models++; + if (m.hidden) { num_hidden++; continue; } + total_obj += static_cast(m.draw_info.size()); + total_tri += m.total_triangles; + total_vbo += m.vbo_capacity; + total_ebo += m.ebo_capacity; } + total_vram = total_vbo + total_ebo; for (const auto& cmd : frame_draw_cmds_) { vis_obj += static_cast(cmd.counts.size()); } @@ -971,6 +988,20 @@ void ViewportWindow::render() { stats.total_triangles = total_tri; stats.visible_triangles = visible_triangles_; emit frameStatsUpdated(stats); + + double vis_obj_pct = total_obj > 0 ? 100.0 * vis_obj / total_obj : 0.0; + double vis_tri_pct = total_tri > 0 ? 100.0 * visible_triangles_ / total_tri : 0.0; + qDebug("[frame] %.1f fps %.2f ms obj %u/%u (%.1f%%) tri %u/%u (%.1f%%) " + "vram %.1f MB (vbo %.1f + ebo %.1f) models %zu (%zu hidden) draws %zu pending_uploads %zu", + last_fps_, 1000.0f / last_fps_, + vis_obj, total_obj, vis_obj_pct, + visible_triangles_, total_tri, vis_tri_pct, + total_vram / (1024.0 * 1024.0), + total_vbo / (1024.0 * 1024.0), + total_ebo / (1024.0 * 1024.0), + num_models, num_hidden, + frame_draw_cmds_.size(), + pending_uploads_.size()); } } From 8c8ef5c32bb87fe92f64231ad747cd556c9c11c7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 19:17:44 +1000 Subject: [PATCH 011/120] Leaf-batched BVH draw commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a BVH leaf passes the frustum test, emit a single glMultiDrawElements record covering the leaf's entire index range instead of one per object. Leaves are contiguous in the EBO after reorderEbo, so the range is just [first_object.index_offset, sum(index_count)]. Cuts draw calls by ~8x (BVH_MAX_LEAF_SIZE) and shifts the bottleneck from CPU/driver per-draw overhead toward GPU vertex throughput. Per-object features (selection highlight, per-vertex color, object_id picking) are unchanged — they operate on vertex attributes, not draw state. Future per-object hide/override will use SSBO lookups sampled by object_id in the fragment shader. Slight overdraw from skipping per-object frustum tests within a leaf is negligible given median-split BVH tightness and spare tri throughput. Also adds visible_objects_ counter so stats still report true object counts (not leaf counts), plus leaf_draws/model_draws breakdown in the per-second frame log. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 40 +++++++++++++++++++++----------- src/ifcviewer/ViewportWindow.h | 1 + 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index c872f799b6..1c6ab78625 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -838,16 +838,25 @@ void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu, continue; if (node.count > 0) { + // Leaf-batched draw: after reorderEbo, a leaf's objects occupy a + // contiguous EBO range. Emit one draw command covering all of them + // instead of N per-object tests/draws. The leaf AABB test above is + // already a conservative cull; any overdraw (up to BVH_MAX_LEAF_SIZE + // objects that may be fully outside the frustum but inside the leaf + // AABB) costs far less than the per-draw CPU/driver overhead we save. + uint32_t first_oi = mbvh.object_indices[node.right_or_first]; + const auto& first_obj = mgpu.draw_info[first_oi]; + uint32_t leaf_offset = first_obj.index_offset; + uint32_t leaf_count = 0; for (uint32_t i = 0; i < node.count; ++i) { uint32_t oi = mbvh.object_indices[node.right_or_first + i]; - const auto& obj = mgpu.draw_info[oi]; - if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) { - cmd.counts.push_back(static_cast(obj.index_count)); - cmd.offsets.push_back(reinterpret_cast( - static_cast(obj.index_offset))); - visible_triangles_ += obj.index_count / 3; - } + leaf_count += mgpu.draw_info[oi].index_count; } + cmd.counts.push_back(static_cast(leaf_count)); + cmd.offsets.push_back(reinterpret_cast( + static_cast(leaf_offset))); + visible_triangles_ += leaf_count / 3; + visible_objects_ += node.count; } else { if (sp < 63) { stack[sp++] = node.right_or_first; @@ -860,6 +869,7 @@ void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu, void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { frame_draw_cmds_.clear(); visible_triangles_ = 0; + visible_objects_ = 0; // Extract 6 frustum planes from the view-projection matrix. float planes[6][4]; @@ -912,6 +922,7 @@ void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { cmd.offsets.push_back(reinterpret_cast( static_cast(obj.index_offset))); visible_triangles_ += obj.index_count / 3; + visible_objects_++; } } } @@ -964,9 +975,10 @@ void ViewportWindow::render() { frame_count_ = 0; accumulated_time_ = 0.0f; - uint32_t total_obj = 0, total_tri = 0, vis_obj = 0; + uint32_t total_obj = 0, total_tri = 0; size_t total_vram = 0, total_vbo = 0, total_ebo = 0; size_t num_models = 0, num_hidden = 0; + size_t total_leaf_draws = 0; for (const auto& [mid, m] : models_gpu_) { num_models++; if (m.hidden) { num_hidden++; continue; } @@ -977,29 +989,31 @@ void ViewportWindow::render() { } total_vram = total_vbo + total_ebo; for (const auto& cmd : frame_draw_cmds_) { - vis_obj += static_cast(cmd.counts.size()); + total_leaf_draws += cmd.counts.size(); } FrameStats stats; stats.fps = last_fps_; stats.frame_time_ms = 1000.0f / last_fps_; stats.total_objects = total_obj; - stats.visible_objects = vis_obj; + stats.visible_objects = visible_objects_; stats.total_triangles = total_tri; stats.visible_triangles = visible_triangles_; emit frameStatsUpdated(stats); - double vis_obj_pct = total_obj > 0 ? 100.0 * vis_obj / total_obj : 0.0; + double vis_obj_pct = total_obj > 0 ? 100.0 * visible_objects_ / total_obj : 0.0; double vis_tri_pct = total_tri > 0 ? 100.0 * visible_triangles_ / total_tri : 0.0; qDebug("[frame] %.1f fps %.2f ms obj %u/%u (%.1f%%) tri %u/%u (%.1f%%) " - "vram %.1f MB (vbo %.1f + ebo %.1f) models %zu (%zu hidden) draws %zu pending_uploads %zu", + "vram %.1f MB (vbo %.1f + ebo %.1f) models %zu (%zu hidden) " + "leaf_draws %zu model_draws %zu pending_uploads %zu", last_fps_, 1000.0f / last_fps_, - vis_obj, total_obj, vis_obj_pct, + visible_objects_, total_obj, vis_obj_pct, visible_triangles_, total_tri, vis_tri_pct, total_vram / (1024.0 * 1024.0), total_vbo / (1024.0 * 1024.0), total_ebo / (1024.0 * 1024.0), num_models, num_hidden, + total_leaf_draws, frame_draw_cmds_.size(), pending_uploads_.size()); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 62abc48002..97925e6e2e 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -201,6 +201,7 @@ private: }; std::vector frame_draw_cmds_; uint32_t visible_triangles_ = 0; + uint32_t visible_objects_ = 0; // Camera QVector3D camera_target_{0, 0, 0}; From 07a5c593598093e9350d0ef540672091c02d5b0f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 19:53:06 +1000 Subject: [PATCH 012/120] GPU instancing: streamer, viewport, shaders rewritten Commit A of the instancing migration (Phase 3a). The streamer now runs the iterator with use-world-coords=false and dedupes by the geometry's representation id, emitting a MeshChunk once per unique geometry and an InstanceChunk per placement. The viewport keeps geometry in local coordinates (28 B/vertex, down from 32) and applies the per-instance transform in the vertex shader via an std430 SSBO indexed by gl_InstanceID + a per-draw uniform offset. After streaming finishes finalizeModel() stable-sorts instances by mesh_id, assigns each mesh a contiguous range, and uploads the SSBO; render then issues one glDrawElementsInstancedBaseVertex per mesh. BvhAccel is reshaped to operate on a generic BvhItem (world AABB + model_id) so it can drive instance-level culling, but the path is not wired in yet -- every instance is drawn every frame in this commit. Progressive-during-streaming rendering is likewise disabled: a model appears when its SSBO is uploaded, not incrementally. Sidecar cache is stubbed (reads miss, writes are no-ops); the v4 on-disk format with MeshInfo + InstanceGpu sections lands in Commit B. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/BvhAccel.cpp | 171 ++--- src/ifcviewer/BvhAccel.h | 43 +- src/ifcviewer/GeometryStreamer.cpp | 447 ++++++------ src/ifcviewer/GeometryStreamer.h | 11 +- src/ifcviewer/InstancedGeometry.h | 103 +++ src/ifcviewer/MainWindow.cpp | 100 +-- src/ifcviewer/MainWindow.h | 3 +- src/ifcviewer/SidecarCache.cpp | 182 +---- src/ifcviewer/SidecarCache.h | 39 +- src/ifcviewer/ViewportWindow.cpp | 1039 ++++++++++------------------ src/ifcviewer/ViewportWindow.h | 130 +--- 11 files changed, 836 insertions(+), 1432 deletions(-) create mode 100644 src/ifcviewer/InstancedGeometry.h diff --git a/src/ifcviewer/BvhAccel.cpp b/src/ifcviewer/BvhAccel.cpp index e0b232a283..c285f1fbfe 100644 --- a/src/ifcviewer/BvhAccel.cpp +++ b/src/ifcviewer/BvhAccel.cpp @@ -23,7 +23,6 @@ #include #include #include -#include namespace { @@ -31,38 +30,36 @@ struct Centroid { float x, y, z; }; -Centroid computeCentroid(const ObjectDrawInfo& obj) { +Centroid computeCentroid(const BvhItem& it) { return { - (obj.aabb_min[0] + obj.aabb_max[0]) * 0.5f, - (obj.aabb_min[1] + obj.aabb_max[1]) * 0.5f, - (obj.aabb_min[2] + obj.aabb_max[2]) * 0.5f + (it.aabb_min[0] + it.aabb_max[0]) * 0.5f, + (it.aabb_min[1] + it.aabb_max[1]) * 0.5f, + (it.aabb_min[2] + it.aabb_max[2]) * 0.5f }; } -void computeAABB(const std::vector& draw_info, +void computeAABB(const std::vector& items, const uint32_t* indices, uint32_t count, float out_min[3], float out_max[3]) { out_min[0] = out_min[1] = out_min[2] = std::numeric_limits::max(); out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits::max(); for (uint32_t i = 0; i < count; ++i) { - const auto& obj = draw_info[indices[i]]; + const auto& it = items[indices[i]]; for (int a = 0; a < 3; ++a) { - if (obj.aabb_min[a] < out_min[a]) out_min[a] = obj.aabb_min[a]; - if (obj.aabb_max[a] > out_max[a]) out_max[a] = obj.aabb_max[a]; + if (it.aabb_min[a] < out_min[a]) out_min[a] = it.aabb_min[a]; + if (it.aabb_max[a] > out_max[a]) out_max[a] = it.aabb_max[a]; } } } -// Recursive BVH builder. Writes nodes in pre-order DFS into mbvh.nodes. -// object_indices[start..start+count) are the indices to partition. void buildRecursive(ModelBvh& mbvh, - const std::vector& draw_info, + const std::vector& items, uint32_t start, uint32_t count) { uint32_t node_idx = static_cast(mbvh.nodes.size()); mbvh.nodes.emplace_back(); BvhNode& node = mbvh.nodes[node_idx]; - computeAABB(draw_info, &mbvh.object_indices[start], count, + computeAABB(items, &mbvh.item_indices[start], count, node.aabb_min, node.aabb_max); if (count <= BVH_MAX_LEAF_SIZE) { @@ -72,7 +69,6 @@ void buildRecursive(ModelBvh& mbvh, return; } - // Find longest axis of node AABB. float extent[3] = { node.aabb_max[0] - node.aabb_min[0], node.aabb_max[1] - node.aabb_min[1], @@ -82,145 +78,62 @@ void buildRecursive(ModelBvh& mbvh, if (extent[1] > extent[axis]) axis = 1; if (extent[2] > extent[axis]) axis = 2; - // Partition at median centroid on the chosen axis. uint32_t mid = count / 2; std::nth_element( - mbvh.object_indices.begin() + start, - mbvh.object_indices.begin() + start + mid, - mbvh.object_indices.begin() + start + count, + mbvh.item_indices.begin() + start, + mbvh.item_indices.begin() + start + mid, + mbvh.item_indices.begin() + start + count, [&](uint32_t a, uint32_t b) { - Centroid ca = computeCentroid(draw_info[a]); - Centroid cb = computeCentroid(draw_info[b]); + Centroid ca = computeCentroid(items[a]); + Centroid cb = computeCentroid(items[b]); return (&ca.x)[axis] < (&cb.x)[axis]; }); - node.count = 0; // interior + node.count = 0; node.axis = static_cast(axis); - // Left child is always node_idx + 1 (implicit in pre-order DFS). - // Build left subtree first. Note: &node is invalidated after this call - // because the vector may reallocate. - buildRecursive(mbvh, draw_info, start, mid); + buildRecursive(mbvh, items, start, mid); - // Right child is the next node written after the entire left subtree. uint32_t right_child_idx = static_cast(mbvh.nodes.size()); - buildRecursive(mbvh, draw_info, start + mid, count - mid); + buildRecursive(mbvh, items, start + mid, count - mid); - // Patch the right child index (left is implicit = node_idx + 1). mbvh.nodes[node_idx].right_or_first = right_child_idx; } +ModelBvh buildModelBvh(const std::vector& items, + const std::vector& model_item_indices, + uint32_t model_id) { + ModelBvh mbvh; + mbvh.model_id = model_id; + mbvh.item_indices = model_item_indices; + + uint32_t count = static_cast(model_item_indices.size()); + if (count == 0) return mbvh; + + mbvh.nodes.reserve(count * 2); + buildRecursive(mbvh, items, 0, count); + + assert(!mbvh.nodes.empty()); + return mbvh; +} + } // anonymous namespace -ModelBvh buildModelBvh(const std::vector& draw_info, - const std::vector& model_object_indices, - uint32_t model_id) { - ModelBvh mbvh; - mbvh.model_id = model_id; - mbvh.object_indices = model_object_indices; - - uint32_t count = static_cast(model_object_indices.size()); - if (count == 0) return mbvh; - - // Reserve a rough estimate: ~2*n nodes for a balanced binary tree. - mbvh.nodes.reserve(count * 2); - - buildRecursive(mbvh, draw_info, 0, count); - - // Verify: every object appears exactly once in the leaves. - assert(!mbvh.nodes.empty()); - - return mbvh; -} - -std::shared_ptr buildBvhSet(const std::vector& draw_info) { +std::shared_ptr buildBvhSet(const std::vector& items) { auto bvh_set = std::make_shared(); - // Group object indices by model_id. - std::unordered_map> model_objects; - for (uint32_t i = 0; i < static_cast(draw_info.size()); ++i) { - model_objects[draw_info[i].model_id].push_back(i); + std::unordered_map> model_items; + for (uint32_t i = 0; i < static_cast(items.size()); ++i) { + model_items[items[i].model_id].push_back(i); } - // Build per-model BVHs. - for (auto& [model_id, obj_indices] : model_objects) { - if (obj_indices.size() < BVH_MIN_OBJECTS) continue; + for (auto& [model_id, idxs] : model_items) { + if (idxs.size() < BVH_MIN_OBJECTS) continue; - ModelBvh mbvh = buildModelBvh(draw_info, obj_indices, model_id); + ModelBvh mbvh = buildModelBvh(items, idxs, model_id); bvh_set->bvh_model_ids.insert(model_id); bvh_set->models[model_id] = std::move(mbvh); } return bvh_set; } - -EboReorderResult reorderEbo(const BvhSet& bvh_set, - const std::vector& draw_info, - const std::vector& original_ebo) { - EboReorderResult result; - result.reordered_draw_info = draw_info; // copy; we'll update offsets - result.reordered_ebo.reserve(original_ebo.size()); - - // Track which draw_info entries have been placed. - std::vector placed(draw_info.size(), false); - - for (const auto& [model_id, mbvh] : bvh_set.models) { - // DFS traversal of BVH to visit leaves in order. - uint32_t stack[64]; - int sp = 0; - stack[sp++] = 0; - - while (sp > 0) { - uint32_t ni = stack[--sp]; - const BvhNode& node = mbvh.nodes[ni]; - - if (node.count > 0) { - // Leaf: emit objects in order. - for (uint32_t i = 0; i < node.count; ++i) { - uint32_t oi = mbvh.object_indices[node.right_or_first + i]; - if (placed[oi]) continue; - placed[oi] = true; - - const auto& old_info = draw_info[oi]; - uint32_t new_offset = static_cast( - result.reordered_ebo.size() * sizeof(uint32_t)); - - // Copy indices from original EBO. - uint32_t idx_start = old_info.index_offset / sizeof(uint32_t); - uint32_t idx_count = old_info.index_count; - for (uint32_t j = 0; j < idx_count; ++j) { - result.reordered_ebo.push_back(original_ebo[idx_start + j]); - } - - result.reordered_draw_info[oi].index_offset = new_offset; - } - } else { - // Interior: push left (=ni+1) last so it's processed first. - stack[sp++] = node.right_or_first; // right child - stack[sp++] = ni + 1; // left child - } - } - } - - // Append non-BVH objects (models too small for BVH). - for (uint32_t oi = 0; oi < static_cast(draw_info.size()); ++oi) { - if (placed[oi]) continue; - placed[oi] = true; - - const auto& old_info = draw_info[oi]; - uint32_t new_offset = static_cast( - result.reordered_ebo.size() * sizeof(uint32_t)); - - uint32_t idx_start = old_info.index_offset / sizeof(uint32_t); - uint32_t idx_count = old_info.index_count; - for (uint32_t j = 0; j < idx_count; ++j) { - result.reordered_ebo.push_back(original_ebo[idx_start + j]); - } - - result.reordered_draw_info[oi].index_offset = new_offset; - } - - assert(result.reordered_ebo.size() == original_ebo.size()); - - return result; -} diff --git a/src/ifcviewer/BvhAccel.h b/src/ifcviewer/BvhAccel.h index 21c57c2712..a2cb6a1316 100644 --- a/src/ifcviewer/BvhAccel.h +++ b/src/ifcviewer/BvhAccel.h @@ -26,22 +26,22 @@ #include #include -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]; +// Generic BVH item — anything with a world AABB and a model_id. +// For the instanced renderer each item represents one InstanceCpu. +struct BvhItem { + float aabb_min[3]; + float aabb_max[3]; + uint32_t model_id; }; static constexpr uint32_t BVH_MAX_LEAF_SIZE = 8; -static constexpr uint32_t BVH_MIN_OBJECTS = 32; +static constexpr uint32_t BVH_MIN_OBJECTS = 32; struct BvhNode { - float aabb_min[3]; - float aabb_max[3]; - uint32_t right_or_first; // interior: right child index (left is always this_index+1); leaf: first object index - uint16_t count; // 0 = interior; >0 = leaf with this many objects + float aabb_min[3]; + float aabb_max[3]; + uint32_t right_or_first; // interior: right child index (left is always this_index+1); leaf: first item index + uint16_t count; // 0 = interior; >0 = leaf with this many items uint16_t axis; // split axis (0/1/2) for interior; unused for leaf }; static_assert(sizeof(BvhNode) == 32, "BvhNode must be 32 bytes for cache alignment and sidecar format"); @@ -49,7 +49,7 @@ static_assert(sizeof(BvhNode) == 32, "BvhNode must be 32 bytes for cache alignme struct ModelBvh { uint32_t model_id = 0; std::vector nodes; - std::vector object_indices; // indices into object_draw_info_ + std::vector item_indices; // indices into the model's InstanceCpu array }; struct BvhSet { @@ -57,19 +57,10 @@ struct BvhSet { std::unordered_set bvh_model_ids; }; -struct EboReorderResult { - std::vector reordered_ebo; - std::vector reordered_draw_info; -}; - -// Build BVH trees for all models in the given draw info snapshot. -// Only builds the tree structure; does not touch EBO data. -std::shared_ptr buildBvhSet(const std::vector& draw_info); - -// Reorder the EBO so objects within each BVH leaf are contiguous. -// Must be called with the CURRENT run's EBO and draw_info (not cached). -EboReorderResult reorderEbo(const BvhSet& bvh_set, - const std::vector& draw_info, - const std::vector& original_ebo); +// Build BVH trees for all models in the given item snapshot. +// Items are expected to already be grouped/filtered by caller if needed. +// item_indices in the result reference positions within the full `items` +// vector — callers providing a single model's items will see 0..N-1. +std::shared_ptr buildBvhSet(const std::vector& items); #endif // BVHACCEL_H diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 54b37df70c..226fb0808c 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -20,16 +20,52 @@ #include "GeometryStreamer.h" #include "AppSettings.h" #include "../ifcgeom/hybrid_kernel.h" +#include "../ifcgeom/taxonomy.h" + +#include #include #include #include #include #include +#include #include #include +struct MaterialInfo { + float r = 0.75f, g = 0.75f, b = 0.78f, a = 1.0f; +}; + +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); + // Little-endian byte layout [r,g,b,a] for GL_UNSIGNED_BYTE * 4 normalized. + return r | (g << 8) | (b << 16) | (a << 24); +} + GeometryStreamer::GeometryStreamer(QObject* parent) : QObject(parent) { @@ -96,6 +132,130 @@ std::vector GeometryStreamer::drainElements() { return result; } +// Build a mesh chunk (local coords, 28-byte interleaved vertices) from a +// TriangulationElement. Per-vertex color is baked from material_ids so that +// triangulations with per-face materials still render correctly. +static MeshChunk buildMeshChunk(uint32_t model_id, + uint32_t local_mesh_id, + const IfcGeom::TriangulationElement* elem) { + MeshChunk chunk; + chunk.model_id = model_id; + chunk.local_mesh_id = local_mesh_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; + + const size_t num_verts_src = verts.size() / 3; + const size_t num_tris = faces.size() / 3; + const bool have_per_tri_material = (material_ids.size() == num_tris); + + // Dedupe (original vertex index, material id) so vertices shared across + // triangles of the same material stay shared; vertices spanning multiple + // materials are split (per-face color demands it). + 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_src); + + chunk.vertices.reserve(num_verts_src * INSTANCED_VERTEX_STRIDE_FLOATS); + chunk.indices.reserve(faces.size()); + + // Track local AABB as we emit vertices. + float amin[3] = { std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max() }; + float amax[3] = { -std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max() }; + + 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() / INSTANCED_VERTEX_STRIDE_FLOATS); + + float px = static_cast(verts[orig_idx * 3 + 0]); + float py = static_cast(verts[orig_idx * 3 + 1]); + float pz = static_cast(verts[orig_idx * 3 + 2]); + chunk.vertices.push_back(px); + chunk.vertices.push_back(py); + chunk.vertices.push_back(pz); + if (px < amin[0]) amin[0] = px; if (px > amax[0]) amax[0] = px; + if (py < amin[1]) amin[1] = py; if (py > amax[1]) amax[1] = py; + if (pz < amin[2]) amin[2] = pz; if (pz > amax[2]) amax[2] = pz; + + 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); + } + + 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)); + } + + if (chunk.vertices.empty()) { + for (int a = 0; a < 3; ++a) amin[a] = amax[a] = 0.0f; + } + for (int a = 0; a < 3; ++a) { + chunk.local_aabb_min[a] = amin[a]; + chunk.local_aabb_max[a] = amax[a]; + } + return chunk; +} + +// Compute the world-space AABB by transforming the 8 corners of the local +// AABB through the column-major 4x4 transform. +static void worldAabbFromLocal(const float local_min[3], + const float local_max[3], + const float M[16], + float out_min[3], float out_max[3]) { + out_min[0] = out_min[1] = out_min[2] = std::numeric_limits::max(); + out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits::max(); + for (int c = 0; c < 8; ++c) { + float x = (c & 1) ? local_max[0] : local_min[0]; + float y = (c & 2) ? local_max[1] : local_min[1]; + float z = (c & 4) ? local_max[2] : local_min[2]; + // Column-major: world = M * [x,y,z,1]. + float wx = M[0]*x + M[4]*y + M[8]*z + M[12]; + float wy = M[1]*x + M[5]*y + M[9]*z + M[13]; + float wz = M[2]*x + M[6]*y + M[10]*z + M[14]; + if (wx < out_min[0]) out_min[0] = wx; if (wx > out_max[0]) out_max[0] = wx; + if (wy < out_min[1]) out_min[1] = wy; if (wy > out_max[1]) out_max[1] = wy; + if (wz < out_min[2]) out_min[2] = wz; if (wz > out_max[2]) out_max[2] = wz; + } +} + void GeometryStreamer::run(const std::string& path, int num_threads) { try { ifc_file_ = std::make_unique(path); @@ -105,7 +265,9 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } ifcopenshell::geometry::Settings settings; - settings.set("use-world-coords", true); + // Instancing path: geometry stays in local coords; the transform is + // applied on the GPU per instance. + settings.set("use-world-coords", false); settings.set("weld-vertices", false); settings.set("apply-default-materials", true); @@ -129,17 +291,14 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { int last_progress = 0; - // Instancing analysis: count shapes grouped by representation id. - struct GeomStat { - uint32_t count = 0; - size_t vertex_count = 0; - size_t index_count = 0; - std::string example_type; - }; - std::unordered_map geom_stats; + // geom.id() → local_mesh_id within this model. + std::unordered_map geom_to_local_mesh_id; + // local_mesh_id → (local AABB) so we can derive world AABBs for later instances. + struct MeshAabb { float lmin[3], lmax[3]; }; + std::vector mesh_aabbs; + uint32_t total_shapes = 0; - size_t total_vertices = 0; - size_t total_indices = 0; + uint32_t total_meshes = 0; QElapsedTimer stream_timer; stream_timer.start(); @@ -152,9 +311,12 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { const auto* tri_elem = dynamic_cast(elem); if (!tri_elem) continue; + const auto& geom = tri_elem->geometry(); + if (geom.verts().empty() || geom.faces().empty()) continue; + uint32_t object_id = next_object_id_++; - // Record element metadata + // Element metadata. ElementInfo info; info.object_id = object_id; info.model_id = model_id_; @@ -163,36 +325,62 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { info.name = tri_elem->name(); info.type = tri_elem->type(); info.parent_id = tri_elem->parent_id(); - - // Instancing stats: key by representation id, count unique vs repeated. - const auto& geom = tri_elem->geometry(); - const std::string& geom_id = geom.id(); - size_t nv = geom.verts().size() / 3; - size_t ni = geom.faces().size(); - if (!geom_id.empty()) { - auto& gs = geom_stats[geom_id]; - gs.count++; - if (gs.count == 1) { - gs.vertex_count = nv; - gs.index_count = ni; - gs.example_type = info.type; - } - } - total_shapes++; - total_vertices += nv; - total_indices += ni; - { 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)); + // Representation dedup. + const std::string& geom_id = geom.id(); + uint32_t local_mesh_id; + bool first_sight = false; + if (geom_id.empty()) { + // No representation key — treat as unique. + local_mesh_id = total_meshes++; + first_sight = true; + } else { + auto it = geom_to_local_mesh_id.find(geom_id); + if (it == geom_to_local_mesh_id.end()) { + local_mesh_id = total_meshes++; + geom_to_local_mesh_id.emplace(geom_id, local_mesh_id); + first_sight = true; + } else { + local_mesh_id = it->second; + } } + if (first_sight) { + MeshChunk mesh_chunk = buildMeshChunk(model_id_, local_mesh_id, tri_elem); + MeshAabb ma; + for (int a = 0; a < 3; ++a) { + ma.lmin[a] = mesh_chunk.local_aabb_min[a]; + ma.lmax[a] = mesh_chunk.local_aabb_max[a]; + } + if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1); + mesh_aabbs[local_mesh_id] = ma; + if (!mesh_chunk.indices.empty()) { + emit meshReady(std::move(mesh_chunk)); + } + } + + // Transform (column-major 4x4, cast to float). + const Eigen::Matrix4d& mat_d = tri_elem->transformation().data()->ccomponents(); + InstanceChunk inst; + inst.model_id = model_id_; + inst.local_mesh_id = local_mesh_id; + inst.object_id = object_id; + inst.color_override_rgba8 = 0; // 0 = use baked vertex color + for (int i = 0; i < 16; ++i) { + inst.transform[i] = static_cast(mat_d.data()[i]); + } + + const MeshAabb& ma = mesh_aabbs[local_mesh_id]; + worldAabbFromLocal(ma.lmin, ma.lmax, inst.transform, + inst.world_aabb_min, inst.world_aabb_max); + + emit instanceReady(std::move(inst)); + total_shapes++; + int p = iterator->progress(); if (p != last_progress) { last_progress = p; @@ -204,188 +392,9 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { progress_ = 100; emit progressChanged(100); - // === Instancing report === - { - size_t unique_geoms = geom_stats.size(); - size_t unique_vertices = 0; - size_t unique_indices = 0; - size_t repeated_shapes = 0; // total shapes that share a repr with another - for (const auto& [gid, gs] : geom_stats) { - unique_vertices += gs.vertex_count; - unique_indices += gs.index_count; - if (gs.count > 1) repeated_shapes += gs.count; - } - - // Bytes assuming current layout (32 B/vertex, 4 B/index). - size_t baked_vbo_bytes = total_vertices * 32; - size_t baked_ebo_bytes = total_indices * 4; - size_t instanced_vbo_bytes = unique_vertices * 32; - size_t instanced_ebo_bytes = unique_indices * 4; - // Per-instance data: 64 B transform + 8 B (object_id + color). - size_t per_instance_bytes = 72; - size_t instance_ssbo_bytes = total_shapes * per_instance_bytes; - - double dedup_ratio = unique_geoms > 0 - ? static_cast(total_shapes) / static_cast(unique_geoms) - : 1.0; - - qDebug("=== Instancing analysis: %s ===", path.c_str()); - qDebug(" Stream time: %.2f s", stream_timer.elapsed() / 1000.0); - qDebug(" Total shapes: %u", total_shapes); - qDebug(" Unique geometries: %zu (dedup ratio %.2fx)", - unique_geoms, dedup_ratio); - qDebug(" Repeated shapes: %zu (%.1f%% of total)", - repeated_shapes, - total_shapes > 0 ? 100.0 * repeated_shapes / total_shapes : 0.0); - qDebug(" Baked geometry: VBO %.1f MB + EBO %.1f MB = %.1f MB", - baked_vbo_bytes / (1024.0*1024.0), - baked_ebo_bytes / (1024.0*1024.0), - (baked_vbo_bytes + baked_ebo_bytes) / (1024.0*1024.0)); - qDebug(" If instanced: VBO %.1f MB + EBO %.1f MB + SSBO %.1f MB = %.1f MB", - instanced_vbo_bytes / (1024.0*1024.0), - instanced_ebo_bytes / (1024.0*1024.0), - instance_ssbo_bytes / (1024.0*1024.0), - (instanced_vbo_bytes + instanced_ebo_bytes + instance_ssbo_bytes) - / (1024.0*1024.0)); - size_t baked_total = baked_vbo_bytes + baked_ebo_bytes; - size_t inst_total = instanced_vbo_bytes + instanced_ebo_bytes + instance_ssbo_bytes; - if (inst_total > 0 && baked_total > inst_total) { - qDebug(" Potential savings: %.1f MB (%.1f%%)", - (baked_total - inst_total) / (1024.0*1024.0), - 100.0 * (baked_total - inst_total) / baked_total); - } else { - qDebug(" Potential savings: none (instance overhead exceeds dedup win)"); - } - - // Top-5 most duplicated representations. - std::vector> sorted(geom_stats.begin(), geom_stats.end()); - std::partial_sort(sorted.begin(), - sorted.begin() + std::min(5, sorted.size()), - sorted.end(), - [](const auto& a, const auto& b) { return a.second.count > b.second.count; }); - qDebug(" Top duplicated representations:"); - for (size_t i = 0; i < std::min(5, sorted.size()); ++i) { - const auto& [gid, gs] = sorted[i]; - qDebug(" [%zu] count=%u verts=%zu type=%s repr_id=%s", - i + 1, gs.count, gs.vertex_count, - gs.example_type.c_str(), gid.c_str()); - } - } -} - -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; - chunk.model_id = model_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; + double dedup_ratio = total_meshes > 0 + ? static_cast(total_shapes) / static_cast(total_meshes) : 1.0; + qDebug("Streamer done: %s %.2fs shapes=%u unique_meshes=%u dedup=%.2fx", + path.c_str(), stream_timer.elapsed() / 1000.0, + total_shapes, total_meshes, dedup_ratio); } diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h index 0d49a12ca7..f6201517ad 100644 --- a/src/ifcviewer/GeometryStreamer.h +++ b/src/ifcviewer/GeometryStreamer.h @@ -26,15 +26,13 @@ #include #include #include -#include #include #include -#include #include "../ifcparse/file.h" #include "../ifcgeom/Iterator.h" -#include "ViewportWindow.h" +#include "InstancedGeometry.h" struct ElementInfo { uint32_t object_id; @@ -67,15 +65,14 @@ public: signals: void progressChanged(int percent); - void elementReady(UploadChunk chunk); + void meshReady(MeshChunk chunk); + void instanceReady(InstanceChunk 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}; @@ -85,7 +82,7 @@ private: std::mutex elements_mutex_; std::vector pending_elements_; - uint32_t next_object_id_ = 1; // 0 = no object + uint32_t next_object_id_ = 1; uint32_t model_id_ = 0; }; diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h new file mode 100644 index 0000000000..1c027976ef --- /dev/null +++ b/src/ifcviewer/InstancedGeometry.h @@ -0,0 +1,103 @@ +/******************************************************************************** + * * + * 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 INSTANCEDGEOMETRY_H +#define INSTANCEDGEOMETRY_H + +#include +#include +#include + +// Per-vertex layout for instanced meshes, stored in local coordinates. +// 28 bytes per vertex: +// pos(3 float) -- 12 B +// normal(3 float) -- 12 B +// color(4 bytes RGBA8, read as GL_UNSIGNED_BYTE*4 normalized) -- 4 B +static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 28; +static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7; + +// Per-mesh metadata on the CPU side. Meshes own a slice of the model's +// VBO and EBO (both local-coords/mesh-local indices). +struct MeshInfo { + uint32_t vbo_byte_offset = 0; // where this mesh's vertices start + uint32_t vertex_count = 0; + uint32_t ebo_byte_offset = 0; // where this mesh's indices start + uint32_t index_count = 0; + float local_aabb_min[3]{}; + float local_aabb_max[3]{}; + uint32_t first_instance = 0; // index into per-model instances array + uint32_t instance_count = 0; +}; +static_assert(sizeof(MeshInfo) == 48, "MeshInfo must be 48 bytes"); + +// Per-instance record uploaded to an SSBO and read by the vertex shader. +// Layout deliberately matches std430 expectations: +// mat4 transform (64 B column-major) +// uint object_id +// uint color_override_rgba8 -- 0 = use baked vertex color, else override +// uint _pad0, _pad1 -- align to 16 for std430 +struct alignas(16) InstanceGpu { + float transform[16]; + uint32_t object_id = 0; + uint32_t color_override_rgba8 = 0; + uint32_t _pad0 = 0; + uint32_t _pad1 = 0; +}; +static_assert(sizeof(InstanceGpu) == 80, "InstanceGpu must be 80 bytes"); + +// CPU-side per-instance data. The GPU record above is derived from this; +// we also retain the world AABB for BVH construction and the mesh_id. +struct InstanceCpu { + uint32_t mesh_id = 0; // index into meshes array + uint32_t object_id = 0; + uint32_t color_override_rgba8 = 0; + uint32_t model_id = 0; + float transform[16]{}; + float world_aabb_min[3]{}; + float world_aabb_max[3]{}; +}; + +// Chunks emitted by the streamer to the viewport (main thread). + +// Emitted the first time a representation id is seen. Carries the mesh +// geometry in local coords. `local_mesh_id` is the streamer-assigned id +// within this model. +struct MeshChunk { + uint32_t model_id = 0; + uint32_t local_mesh_id = 0; + std::vector vertices; // 7 floats * N_verts (pos3+norm3+color1_packed) + std::vector indices; + float local_aabb_min[3]{}; + float local_aabb_max[3]{}; +}; + +// Emitted for every placement (every triangulation element from the +// iterator). For the first instance of a mesh, the MeshChunk is emitted +// just before this. +struct InstanceChunk { + uint32_t model_id = 0; + uint32_t local_mesh_id = 0; + uint32_t object_id = 0; + uint32_t color_override_rgba8 = 0; + float transform[16]{}; + float world_aabb_min[3]{}; + float world_aabb_max[3]{}; +}; + +#endif // INSTANCEDGEOMETRY_H diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index b5ee3581c4..86a787a0e2 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -173,8 +173,10 @@ void MainWindow::addFiles(const QStringList& paths) { 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::meshReady, + this, &MainWindow::onMeshReady, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::instanceReady, + this, &MainWindow::onInstanceReady, Qt::QueuedConnection); connect(streamer, &GeometryStreamer::finished, this, &MainWindow::onStreamingFinished, Qt::QueuedConnection); connect(streamer, &GeometryStreamer::errorOccurred, this, [this](const QString& msg) { @@ -208,7 +210,7 @@ void MainWindow::startNextLoad() { qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); auto result = std::make_shared>(std::move(cached)); QMetaObject::invokeMethod(this, [this, mid, result]() { - if (*result && !(*result)->draw_info.empty()) { + if (*result && !(*result)->meshes.empty()) { applySidecarData(mid, std::move(**result)); } else { // No sidecar — fall back to streaming from IFC. @@ -227,51 +229,10 @@ void MainWindow::startNextLoad() { }); } -void MainWindow::applySidecarData(ModelId mid, SidecarData data) { - auto it = models_.find(mid); - if (it == models_.end()) return; - auto& model = it->second; - - QElapsedTimer t; - - qDebug("Sidecar hit: %s (%zu objects, %zu verts, %zu indices, %.1f MB)", - model.file_path.toStdString().c_str(), data.draw_info.size(), - data.vertices.size() / 8, data.indices.size(), - (data.vertices.size() * 4 + data.indices.size() * 4) / (1024.0 * 1024.0)); - - // GL upload — fast, single buffer copy. - t.start(); - viewport_->uploadBulk(mid, data.vertices, data.indices, - data.draw_info, std::move(data.bvh_set)); - qDebug(" GL upload: %lld ms", t.elapsed()); - - // Update next_object_id_ past all objects in this model. - for (const auto& elem : data.elements) { - if (elem.object_id >= next_object_id_) - next_object_id_ = elem.object_id + 1; - } - - // Suppress per-item layout recalcs while building the tree. - t.restart(); - element_tree_->setUpdatesEnabled(false); - populateTreeFromSidecar(model, data.elements, data.string_table); - element_tree_->setUpdatesEnabled(true); - qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), data.elements.size()); - - 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("%1 elements across %2 model(s) — loaded from cache in %3") - .arg(element_map_.size()) - .arg(models_.size()) - .arg(elapsed)); - - loading_model_id_ = 0; - QTimer::singleShot(0, this, &MainWindow::startNextLoad); +void MainWindow::applySidecarData(ModelId /*mid*/, SidecarData /*data*/) { + // Commit A: readSidecar() always returns nullopt, so this is unreachable. + // Restored in Commit B along with the v4 on-disk format. + qWarning("applySidecarData called but sidecar is disabled in Commit A"); } void MainWindow::populateTreeFromSidecar(ModelHandle& model, @@ -325,8 +286,12 @@ void MainWindow::onProgressChanged(int percent) { progress_bar_->setValue(percent); } -void MainWindow::onElementReady(UploadChunk chunk) { - viewport_->uploadChunk(chunk); +void MainWindow::onMeshReady(MeshChunk chunk) { + viewport_->uploadMeshChunk(chunk); +} + +void MainWindow::onInstanceReady(InstanceChunk chunk) { + viewport_->uploadInstanceChunk(chunk); } void MainWindow::onStreamingFinished() { @@ -355,39 +320,10 @@ void MainWindow::onStreamingFinished() { .arg(num_models) .arg(elapsed)); - // Build BVH and write sidecar (geometry + metadata + BVH). + // Sort instances by mesh and upload the per-model instance SSBO. + // Sidecar write is stubbed in Commit A. if (loading_model_id_ != 0) { - auto it = models_.find(loading_model_id_); - if (it != models_.end()) { - std::string ifc_path = it->second.file_path.toStdString(); - QFileInfo fi(it->second.file_path); - uint64_t file_size = static_cast(fi.size()); - - // Pack element info for the sidecar (only this model's elements). - std::vector packed; - std::string stbl; - for (const auto& [oid, info] : element_map_) { - if (info.model_id != loading_model_id_) continue; - PackedElementInfo pe; - pe.object_id = info.object_id; - pe.model_id = info.model_id; - pe.ifc_id = info.ifc_id; - pe.parent_id = info.parent_id; - pe.guid_offset = static_cast(stbl.size()); - pe.guid_length = static_cast(info.guid.size()); - stbl += info.guid; - pe.name_offset = static_cast(stbl.size()); - pe.name_length = static_cast(info.name.size()); - stbl += info.name; - pe.type_offset = static_cast(stbl.size()); - pe.type_length = static_cast(info.type.size()); - stbl += info.type; - packed.push_back(pe); - } - - viewport_->buildBvhAsync(loading_model_id_, ifc_path, file_size, - std::move(packed), std::move(stbl)); - } + viewport_->finalizeModel(loading_model_id_); } // Start next model if queued. diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h index f60da70b75..5270676af5 100644 --- a/src/ifcviewer/MainWindow.h +++ b/src/ifcviewer/MainWindow.h @@ -62,7 +62,8 @@ private slots: void onFileOpen(); void onFileSettings(); void onProgressChanged(int percent); - void onElementReady(UploadChunk chunk); + void onMeshReady(MeshChunk chunk); + void onInstanceReady(InstanceChunk chunk); void onStreamingFinished(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index d77095c922..be19c8698f 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,180 +17,20 @@ * * ********************************************************************************/ +// Commit A: sidecar cache is temporarily disabled. The on-disk format is +// being rewritten from v3 (monolithic world-coord geometry) to v4 (instanced +// meshes + per-instance records). Until v4 is finalised, loads always go +// through the streaming path and writes are no-ops. + #include "SidecarCache.h" -#include -#include - -// Binary layout (all multi-byte fields native-endian): -// -// SidecarHeader (16 bytes) -// uint64_t source_file_size -// -// uint32_t num_vertices (count of floats) -// float[num_vertices] vertex data -// -// uint32_t num_indices -// uint32_t[num_indices] index data -// -// uint32_t num_draw_infos -// ObjectDrawInfo[N] draw info array -// -// uint32_t num_elements -// PackedElementInfo[N] element records -// uint32_t string_table_bytes -// char[string_table_bytes] -// -// uint32_t num_bvh_models -// for each model: -// uint32_t model_id -// uint32_t num_nodes -// BvhNode[num_nodes] -// uint32_t num_object_indices -// uint32_t[num_object_indices] - -struct SidecarHeader { - uint32_t magic; - uint32_t version; - uint32_t endian; - uint32_t reserved; -}; - -static std::string sidecarPath(const std::string& ifc_path) { - return ifc_path + ".ifcview"; -} - -template -static bool writeVec(FILE* f, const std::vector& v) { - uint32_t n = static_cast(v.size()); - if (fwrite(&n, 4, 1, f) != 1) return false; - if (n > 0 && fwrite(v.data(), sizeof(T), n, f) != n) return false; +bool writeSidecar(const std::string& /*ifc_path*/, + const SidecarData& /*data*/, + uint64_t /*ifc_file_size*/) { return true; } -template -static bool readVec(FILE* f, std::vector& v) { - uint32_t n; - if (fread(&n, 4, 1, f) != 1) return false; - v.resize(n); - if (n > 0 && fread(v.data(), sizeof(T), n, f) != n) return false; - return true; -} - -bool writeSidecar(const std::string& ifc_path, - const SidecarData& data, - uint64_t ifc_file_size) { - std::string path = sidecarPath(ifc_path); - FILE* f = fopen(path.c_str(), "wb"); - if (!f) return false; - - // Header - SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN, 0 }; - fwrite(&hdr, sizeof(hdr), 1, f); - fwrite(&ifc_file_size, 8, 1, f); - - // Geometry - if (!writeVec(f, data.vertices)) { fclose(f); return false; } - if (!writeVec(f, data.indices)) { fclose(f); return false; } - - // Draw info - if (!writeVec(f, data.draw_info)) { fclose(f); return false; } - - // Elements + string table - if (!writeVec(f, data.elements)) { fclose(f); return false; } - uint32_t stbl_len = static_cast(data.string_table.size()); - fwrite(&stbl_len, 4, 1, f); - if (stbl_len > 0) fwrite(data.string_table.data(), 1, stbl_len, f); - - // BVH - uint32_t num_bvh_models = data.bvh_set - ? static_cast(data.bvh_set->models.size()) : 0; - fwrite(&num_bvh_models, 4, 1, f); - - if (data.bvh_set) { - for (const auto& [model_id, mbvh] : data.bvh_set->models) { - fwrite(&model_id, 4, 1, f); - - uint32_t nn = static_cast(mbvh.nodes.size()); - fwrite(&nn, 4, 1, f); - if (nn > 0) fwrite(mbvh.nodes.data(), sizeof(BvhNode), nn, f); - - uint32_t no = static_cast(mbvh.object_indices.size()); - fwrite(&no, 4, 1, f); - if (no > 0) fwrite(mbvh.object_indices.data(), 4, no, f); - } - } - - fclose(f); - return true; -} - -std::optional readSidecar(const std::string& ifc_path, - uint64_t ifc_file_size) { - std::string path = sidecarPath(ifc_path); - FILE* f = fopen(path.c_str(), "rb"); - if (!f) return std::nullopt; - - auto fail = [&]() -> std::optional { fclose(f); return std::nullopt; }; - - // Header - SidecarHeader hdr; - if (fread(&hdr, sizeof(hdr), 1, f) != 1) return fail(); - if (hdr.magic != SIDECAR_MAGIC || - hdr.version != SIDECAR_VERSION || - hdr.endian != SIDECAR_ENDIAN) return fail(); - - uint64_t stored_size; - if (fread(&stored_size, 8, 1, f) != 1) return fail(); - if (stored_size != ifc_file_size) return fail(); - - SidecarData data; - - // Geometry - if (!readVec(f, data.vertices)) return fail(); - if (!readVec(f, data.indices)) return fail(); - - // Draw info - if (!readVec(f, data.draw_info)) return fail(); - - // Elements + string table - if (!readVec(f, data.elements)) return fail(); - uint32_t stbl_len; - if (fread(&stbl_len, 4, 1, f) != 1) return fail(); - data.string_table.resize(stbl_len); - if (stbl_len > 0 && fread(data.string_table.data(), 1, stbl_len, f) != stbl_len) - return fail(); - - // BVH - uint32_t num_bvh_models; - if (fread(&num_bvh_models, 4, 1, f) != 1) return fail(); - - if (num_bvh_models > 0) { - data.bvh_set = std::make_shared(); - for (uint32_t m = 0; m < num_bvh_models; ++m) { - uint32_t model_id; - if (fread(&model_id, 4, 1, f) != 1) return fail(); - - ModelBvh mbvh; - mbvh.model_id = model_id; - - uint32_t nn; - if (fread(&nn, 4, 1, f) != 1) return fail(); - mbvh.nodes.resize(nn); - if (nn > 0 && fread(mbvh.nodes.data(), sizeof(BvhNode), nn, f) != nn) - return fail(); - - uint32_t no; - if (fread(&no, 4, 1, f) != 1) return fail(); - mbvh.object_indices.resize(no); - if (no > 0 && fread(mbvh.object_indices.data(), 4, no, f) != no) - return fail(); - - data.bvh_set->bvh_model_ids.insert(model_id); - data.bvh_set->models[model_id] = std::move(mbvh); - } - } - - fclose(f); - return data; +std::optional readSidecar(const std::string& /*ifc_path*/, + uint64_t /*ifc_file_size*/) { + return std::nullopt; } diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 49c36dba15..e14eb9d256 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -17,22 +17,28 @@ * * ********************************************************************************/ +// NOTE: Sidecar format v3 is being rewritten to v4 (instanced geometry layout). +// During the instancing rewrite (Commit A) the cache is a no-op: reads always +// miss and writes always succeed without producing a file. Commit B will +// re-introduce the on-disk format with MeshInfo + InstanceGpu sections. + #ifndef SIDECARCACHE_H #define SIDECARCACHE_H -#include "BvhAccel.h" +#include "InstancedGeometry.h" #include #include #include #include +#include static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" -static constexpr uint32_t SIDECAR_VERSION = 3; +static constexpr uint32_t SIDECAR_VERSION = 4; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; -// Fixed-size element record for the sidecar. Strings are stored as -// (offset, length) pairs into a separate string table. +// Fixed-size element record. Strings are stored as (offset, length) pairs +// into a separate string table. struct PackedElementInfo { uint32_t object_id; uint32_t model_id; @@ -46,30 +52,27 @@ struct PackedElementInfo { uint32_t type_length; }; -// Everything the viewer needs to display a model without tessellating. +// Everything needed to display an already-tessellated model without +// re-running the iterator. v4 schema: instanced geometry. struct SidecarData { - // GPU geometry (ready to upload as-is) - std::vector vertices; // interleaved, 8 floats per vertex - std::vector indices; // global (already remapped) + // Per-model GPU geometry (local coords). 28 bytes/vertex. + std::vector vertices; + std::vector indices; - // Per-object metadata - std::vector draw_info; + // Mesh dictionary and per-instance data. + std::vector meshes; // indexed by local_mesh_id + std::vector instances; // sorted by mesh_id - // Element tree metadata + // Element tree metadata. std::vector elements; - std::string string_table; // concatenated UTF-8 - - // BVH acceleration - std::shared_ptr bvh_set; + std::string string_table; }; -// Write a full sidecar next to the IFC file. -// Returns true on success. +// v4 writer/reader are stubbed for Commit A — no disk I/O happens. bool writeSidecar(const std::string& ifc_path, const SidecarData& data, uint64_t ifc_file_size); -// Read a sidecar. Returns nullopt on any failure (missing, stale, corrupt). std::optional readSidecar(const std::string& ifc_path, uint64_t ifc_file_size); diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 1c6ab78625..e264f990e4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -18,7 +18,6 @@ ********************************************************************************/ #include "ViewportWindow.h" -#include "SidecarCache.h" #include #include @@ -31,33 +30,75 @@ #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 -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 size_t INITIAL_VBO_SIZE = 64 * 1024 * 1024; // 64 MB +static const size_t INITIAL_EBO_SIZE = 32 * 1024 * 1024; // 32 MB +static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB + +// ----------------------------------------------------------------------------- +// Shaders +// ----------------------------------------------------------------------------- +// +// Vertex layout (GL side, 28 bytes): +// location 0: vec3 a_position (local coords) +// location 1: vec3 a_normal (local) +// location 2: vec4 a_color (GL_UNSIGNED_BYTE * 4 normalized) +// +// Per-instance record in SSBO std430 (80 bytes): +// mat4 transform +// uint object_id +// uint color_override_rgba8 -- 0 => use baked a_color +// uint _pad0, _pad1 +// +// The draw calls pass `u_instance_offset = mesh.first_instance`; the shader +// reads `instances[u_instance_offset + gl_InstanceID]`. 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; +layout(location = 2) in vec4 a_color; + +struct InstanceRecord { + mat4 transform; + uint object_id; + uint color_override; + uint _pad0; + uint _pad1; +}; +layout(std430, binding = 0) readonly buffer Instances { + InstanceRecord instances[]; +}; uniform mat4 u_view_projection; +uniform uint u_instance_offset; 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); + InstanceRecord inst = instances[u_instance_offset + uint(gl_InstanceID)]; + vec4 world = inst.transform * vec4(a_position, 1.0); + gl_Position = u_view_projection * world; + + // Rotate the normal by the upper-3x3 of the transform. For the vast + // majority of BIM placements this is a rigid rotation (+ uniform scale), + // so we skip the inverse-transpose. + v_normal = normalize(mat3(inst.transform) * a_normal); + + vec4 baked = a_color; + if (inst.color_override != 0u) { + float r = float((inst.color_override ) & 0xFFu) / 255.0; + float g = float((inst.color_override >> 8) & 0xFFu) / 255.0; + float b = float((inst.color_override >> 16) & 0xFFu) / 255.0; + float a = float((inst.color_override >> 24) & 0xFFu) / 255.0; + if (a > 0.0) baked = vec4(r, g, b, a); + } + v_color = baked; + + v_object_id = inst.object_id; v_selected = (v_object_id == u_selected_id) ? 1u : 0u; } )"; @@ -65,7 +106,6 @@ void main() { 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; @@ -80,11 +120,7 @@ void main() { 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); - } - + if (v_selected == 1u) color = mix(color, vec3(0.2, 0.6, 1.0), 0.5); frag_color = vec4(color, v_color.a); } )"; @@ -92,39 +128,43 @@ void main() { 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; + +struct InstanceRecord { + mat4 transform; + uint object_id; + uint color_override; + uint _pad0; + uint _pad1; +}; +layout(std430, binding = 0) readonly buffer Instances { + InstanceRecord instances[]; +}; uniform mat4 u_view_projection; +uniform uint u_instance_offset; 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); + InstanceRecord inst = instances[u_instance_offset + uint(gl_InstanceID)]; + gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0); + v_object_id = inst.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; -} +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; @@ -135,10 +175,7 @@ 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); -} +void main() { frag_color = vec4(v_color, 1.0); } )"; static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) { @@ -148,7 +185,7 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch GLint ok = 0; gl->glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); if (!ok) { - char log[1024]; + char log[2048]; gl->glGetShaderInfoLog(shader, sizeof(log), nullptr, log); qWarning("Shader compile error: %s", log); } @@ -163,7 +200,7 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra GLint ok = 0; gl->glGetProgramiv(prog, GL_LINK_STATUS, &ok); if (!ok) { - char log[1024]; + char log[2048]; gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log); qWarning("Program link error: %s", log); } @@ -172,6 +209,8 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra return prog; } +// ----------------------------------------------------------------------------- + ViewportWindow::ViewportWindow(QWindow* parent) : QWindow(parent) { @@ -188,26 +227,25 @@ ViewportWindow::ViewportWindow(QWindow* parent) connect(&render_timer_, &QTimer::timeout, this, [this]() { if (isExposed()) render(); }); - render_timer_.setInterval(16); // ~60 fps + render_timer_.setInterval(16); } ViewportWindow::~ViewportWindow() { - if (bvh_build_thread_.joinable()) - bvh_build_thread_.join(); if (context_) { context_->makeCurrent(this); if (gl_) { for (auto& [mid, m] : models_gpu_) { - if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); - if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); - if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); + if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); + if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); + if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); + if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); } - 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 (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_); } @@ -220,17 +258,11 @@ void ViewportWindow::initGL() { context_ = new QOpenGLContext(this); context_->setFormat(requestedFormat()); - if (!context_->create()) { - qFatal("Failed to create OpenGL context"); - return; - } + 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; - } + if (!gl_) { qWarning("OpenGL 4.5 not available"); return; } buildShaders(); buildAxisGizmo(); @@ -247,28 +279,23 @@ void ViewportWindow::initGL() { } void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) { - gl_->glVertexArrayVertexBuffer(vao, 0, vbo, 0, VERTEX_STRIDE * sizeof(float)); + gl_->glVertexArrayVertexBuffer(vao, 0, vbo, 0, INSTANCED_VERTEX_STRIDE_BYTES); gl_->glVertexArrayElementBuffer(vao, ebo); - // position + // position (3 float @ 0) gl_->glEnableVertexArrayAttrib(vao, 0); gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0); gl_->glVertexArrayAttribBinding(vao, 0, 0); - // normal + // normal (3 float @ 12) gl_->glEnableVertexArrayAttrib(vao, 1); - gl_->glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float)); + gl_->glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 12); gl_->glVertexArrayAttribBinding(vao, 1, 0); - // object_id (passed as float, decoded in shader via floatBitsToUint) + // color (4 ubyte @ 24, normalized) gl_->glEnableVertexArrayAttrib(vao, 2); - gl_->glVertexArrayAttribFormat(vao, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float)); + gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, 24); 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); } void ViewportWindow::buildShaders() { @@ -291,24 +318,20 @@ void ViewportWindow::buildShaders() { void ViewportWindow::buildAxisGizmo() { static const float axis_data[] = { - 0.0f, 0.0f, 0.0f, 1.0f, 0.25f, 0.25f, - 1.0f, 0.0f, 0.0f, 1.0f, 0.25f, 0.25f, - 0.0f, 0.0f, 0.0f, 0.30f, 0.95f, 0.30f, - 0.0f, 1.0f, 0.0f, 0.30f, 0.95f, 0.30f, - 0.0f, 0.0f, 0.0f, 0.30f, 0.55f, 1.0f, - 0.0f, 0.0f, 1.0f, 0.30f, 0.55f, 1.0f, + 0,0,0, 1.0f,0.25f,0.25f, + 1,0,0, 1.0f,0.25f,0.25f, + 0,0,0, 0.30f,0.95f,0.30f, + 0,1,0, 0.30f,0.95f,0.30f, + 0,0,0, 0.30f,0.55f,1.0f, + 0,0,1, 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); @@ -318,25 +341,20 @@ bool ViewportWindow::growModelVbo(ModelGpuData& m, size_t needed_total) { size_t new_capacity = m.vbo_capacity; while (new_capacity < needed_total) new_capacity *= 2; if (new_capacity > MAX_BUFFER_SIZE) { - qWarning("VBO grow request (%zu MB) exceeds cap", new_capacity / (1024 * 1024)); + qWarning("VBO grow request (%zu MB) exceeds cap", new_capacity / (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 (m.vbo_used > 0) { gl_->glCopyNamedBufferSubData(m.vbo, new_vbo, 0, 0, m.vbo_used); } - gl_->glDeleteBuffers(1, &m.vbo); m.vbo = new_vbo; m.vbo_capacity = new_capacity; - - gl_->glVertexArrayVertexBuffer(m.vao, 0, m.vbo, 0, VERTEX_STRIDE * sizeof(float)); - - qInfo("Model VBO grew to %zu MB", m.vbo_capacity / (1024 * 1024)); + gl_->glVertexArrayVertexBuffer(m.vao, 0, m.vbo, 0, INSTANCED_VERTEX_STRIDE_BYTES); + qInfo("Model VBO grew to %zu MB", m.vbo_capacity / (1024*1024)); return true; } @@ -344,268 +362,178 @@ bool ViewportWindow::growModelEbo(ModelGpuData& m, size_t needed_total) { size_t new_capacity = m.ebo_capacity; while (new_capacity < needed_total) new_capacity *= 2; if (new_capacity > MAX_BUFFER_SIZE) { - qWarning("EBO grow request (%zu MB) exceeds cap", new_capacity / (1024 * 1024)); + qWarning("EBO grow request (%zu MB) exceeds cap", new_capacity / (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 (m.ebo_used > 0) { gl_->glCopyNamedBufferSubData(m.ebo, new_ebo, 0, 0, m.ebo_used); } - gl_->glDeleteBuffers(1, &m.ebo); m.ebo = new_ebo; m.ebo_capacity = new_capacity; - gl_->glVertexArrayElementBuffer(m.vao, m.ebo); - - qInfo("Model EBO grew to %zu MB", m.ebo_capacity / (1024 * 1024)); + qInfo("Model EBO grew to %zu MB", m.ebo_capacity / (1024*1024)); return true; } -void ViewportWindow::uploadChunk(const UploadChunk& chunk) { - if (!gl_initialized_) return; - if (chunk.vertices.empty() || chunk.indices.empty()) return; +ModelGpuData& ViewportWindow::getOrCreateModel(uint32_t model_id) { + auto it = models_gpu_.find(model_id); + if (it != models_gpu_.end()) return it->second; - context_->makeCurrent(this); - - // Get or create per-model GPU data. - auto it = models_gpu_.find(chunk.model_id); - if (it == models_gpu_.end()) { - ModelGpuData m; - gl_->glCreateVertexArrays(1, &m.vao); - gl_->glCreateBuffers(1, &m.vbo); - gl_->glCreateBuffers(1, &m.ebo); - - m.vbo_capacity = INITIAL_VBO_SIZE; - m.ebo_capacity = INITIAL_EBO_SIZE; - gl_->glNamedBufferStorage(m.vbo, m.vbo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); - gl_->glNamedBufferStorage(m.ebo, m.ebo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); - - setupVaoLayout(m.vao, m.vbo, m.ebo); - it = models_gpu_.emplace(chunk.model_id, std::move(m)).first; - } - - auto& mgpu = it->second; - - size_t vb_size = chunk.vertices.size() * sizeof(float); - size_t ib_size = chunk.indices.size() * sizeof(uint32_t); - - if (mgpu.vbo_used + vb_size > mgpu.vbo_capacity) { - if (!growModelVbo(mgpu, mgpu.vbo_used + vb_size)) { - qWarning("VBO at cap, skipping chunk"); - return; - } - } - if (mgpu.ebo_used + ib_size > mgpu.ebo_capacity) { - if (!growModelEbo(mgpu, mgpu.ebo_used + ib_size)) { - qWarning("EBO at cap, skipping chunk"); - return; - } - } - - uint32_t base_vertex = mgpu.vertex_count; - - gl_->glNamedBufferSubData(mgpu.vbo, mgpu.vbo_used, vb_size, chunk.vertices.data()); - - // Remap chunk-local indices into model-local global indices. - 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(mgpu.ebo, mgpu.ebo_used, ib_size, global_indices.data()); - - // Compute AABB from vertex positions in this chunk. - ObjectDrawInfo info; - info.index_offset = static_cast(mgpu.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) { - info.aabb_min[0] = info.aabb_min[1] = info.aabb_min[2] = std::numeric_limits::max(); - info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = -std::numeric_limits::max(); - for (size_t v = 0; v < num_verts; ++v) { - const float* pos = &chunk.vertices[v * VERTEX_STRIDE]; - for (int a = 0; a < 3; ++a) { - if (pos[a] < info.aabb_min[a]) info.aabb_min[a] = pos[a]; - if (pos[a] > info.aabb_max[a]) info.aabb_max[a] = pos[a]; - } - } - } else { - info.aabb_min[0] = info.aabb_min[1] = info.aabb_min[2] = 0.0f; - info.aabb_max[0] = info.aabb_max[1] = info.aabb_max[2] = 0.0f; - } - - mgpu.draw_info.push_back(info); - mgpu.active_draw_count = static_cast(mgpu.draw_info.size()); // immediately drawable - mgpu.vbo_used += vb_size; - mgpu.ebo_used += ib_size; - mgpu.vertex_count += static_cast(num_verts); - mgpu.total_triangles += static_cast(chunk.indices.size() / 3); -} - -void ViewportWindow::uploadBulk(uint32_t model_id, - std::vector vertices, - std::vector indices, - const std::vector& draw_info, - std::shared_ptr bvh_set) { - if (!gl_initialized_) return; - if (vertices.empty() || indices.empty()) return; - - context_->makeCurrent(this); - - size_t vb_size = vertices.size() * sizeof(float); - size_t ib_size = indices.size() * sizeof(uint32_t); - - // Allocate empty buffers at exact size — no data uploaded yet. ModelGpuData m; gl_->glCreateVertexArrays(1, &m.vao); gl_->glCreateBuffers(1, &m.vbo); gl_->glCreateBuffers(1, &m.ebo); - m.vbo_capacity = vb_size; - m.ebo_capacity = ib_size; - gl_->glNamedBufferStorage(m.vbo, vb_size, nullptr, GL_DYNAMIC_STORAGE_BIT); - gl_->glNamedBufferStorage(m.ebo, ib_size, nullptr, GL_DYNAMIC_STORAGE_BIT); - + m.vbo_capacity = INITIAL_VBO_SIZE; + m.ebo_capacity = INITIAL_EBO_SIZE; + gl_->glNamedBufferStorage(m.vbo, m.vbo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); + gl_->glNamedBufferStorage(m.ebo, m.ebo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); setupVaoLayout(m.vao, m.vbo, m.ebo); - m.vbo_used = vb_size; - m.ebo_used = ib_size; - m.vertex_count = static_cast(vertices.size() / VERTEX_STRIDE); - m.draw_info = draw_info; - m.active_draw_count = 0; // nothing drawable yet + return models_gpu_.emplace(model_id, std::move(m)).first->second; +} - uint32_t total_tri = 0; - for (const auto& di : draw_info) total_tri += di.index_count / 3; - m.total_triangles = total_tri; +void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { + if (!gl_initialized_) return; + if (chunk.vertices.empty() || chunk.indices.empty()) return; + context_->makeCurrent(this); - // Delete old model data if re-uploading. - auto it = models_gpu_.find(model_id); - if (it != models_gpu_.end()) { - gl_->glDeleteVertexArrays(1, &it->second.vao); - gl_->glDeleteBuffers(1, &it->second.vbo); - gl_->glDeleteBuffers(1, &it->second.ebo); + ModelGpuData& m = getOrCreateModel(chunk.model_id); + + const size_t vb_size = chunk.vertices.size() * sizeof(float); + const size_t ib_size = chunk.indices.size() * sizeof(uint32_t); + + if (m.vbo_used + vb_size > m.vbo_capacity) { + if (!growModelVbo(m, m.vbo_used + vb_size)) return; + } + if (m.ebo_used + ib_size > m.ebo_capacity) { + if (!growModelEbo(m, m.ebo_used + ib_size)) return; } - models_gpu_[model_id] = std::move(m); - // Queue progressive upload — data will stream in over subsequent frames. - PendingUpload pu; - pu.model_id = model_id; - pu.vertices = std::move(vertices); - pu.indices = std::move(indices); - pu.bvh_set = std::move(bvh_set); - pending_uploads_.push_back(std::move(pu)); + MeshInfo info; + info.vbo_byte_offset = static_cast(m.vbo_used); + info.vertex_count = static_cast( + chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); + info.ebo_byte_offset = static_cast(m.ebo_used); + info.index_count = static_cast(chunk.indices.size()); + for (int a = 0; a < 3; ++a) { + info.local_aabb_min[a] = chunk.local_aabb_min[a]; + info.local_aabb_max[a] = chunk.local_aabb_max[a]; + } + info.first_instance = 0; + info.instance_count = 0; - qDebug("Bulk upload queued: model %u, %zu vertices, %zu indices, %zu objects", - model_id, vertices.size() / VERTEX_STRIDE, indices.size(), draw_info.size()); + gl_->glNamedBufferSubData(m.vbo, m.vbo_used, vb_size, chunk.vertices.data()); + gl_->glNamedBufferSubData(m.ebo, m.ebo_used, ib_size, chunk.indices.data()); + m.vbo_used += vb_size; + m.ebo_used += ib_size; + m.vertex_count += info.vertex_count; + + if (m.meshes.size() <= chunk.local_mesh_id) m.meshes.resize(chunk.local_mesh_id + 1); + m.meshes[chunk.local_mesh_id] = info; +} + +void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { + if (!gl_initialized_) return; + // We don't need a GL context here since we're only touching CPU state, + // but the signal may fire on the render thread so keep it simple. + ModelGpuData& m = getOrCreateModel(chunk.model_id); + + InstanceCpu inst; + inst.mesh_id = chunk.local_mesh_id; + inst.object_id = chunk.object_id; + inst.color_override_rgba8 = chunk.color_override_rgba8; + inst.model_id = chunk.model_id; + std::memcpy(inst.transform, chunk.transform, sizeof(inst.transform)); + std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min)); + std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max)); + m.instances.push_back(inst); + + if (chunk.local_mesh_id < m.meshes.size()) { + m.total_triangles += m.meshes[chunk.local_mesh_id].index_count / 3; + } +} + +void ViewportWindow::finalizeModel(uint32_t model_id) { + if (!gl_initialized_) return; + context_->makeCurrent(this); + + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end()) return; + ModelGpuData& m = it->second; + if (m.instances.empty()) { m.finalized = true; return; } + + // Sort instances by mesh_id (stable for deterministic ordering). + std::stable_sort(m.instances.begin(), m.instances.end(), + [](const InstanceCpu& a, const InstanceCpu& b) { + return a.mesh_id < b.mesh_id; + }); + + // Assign per-mesh contiguous range. + for (auto& mesh : m.meshes) { mesh.first_instance = 0; mesh.instance_count = 0; } + uint32_t current = UINT32_MAX; + uint32_t run_start = 0; + for (uint32_t i = 0; i < m.instances.size(); ++i) { + uint32_t mid = m.instances[i].mesh_id; + if (mid != current) { + if (current != UINT32_MAX && current < m.meshes.size()) { + m.meshes[current].first_instance = run_start; + m.meshes[current].instance_count = i - run_start; + } + current = mid; + run_start = i; + } + } + if (current != UINT32_MAX && current < m.meshes.size()) { + m.meshes[current].first_instance = run_start; + m.meshes[current].instance_count = static_cast(m.instances.size()) - run_start; + } + + // Build GPU-layout array. + std::vector gpu(m.instances.size()); + for (size_t i = 0; i < m.instances.size(); ++i) { + const InstanceCpu& src = m.instances[i]; + InstanceGpu& dst = gpu[i]; + std::memcpy(dst.transform, src.transform, sizeof(dst.transform)); + dst.object_id = src.object_id; + dst.color_override_rgba8 = src.color_override_rgba8; + dst._pad0 = 0; + dst._pad1 = 0; + } + + // Allocate and upload SSBO. + if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + gl_->glCreateBuffers(1, &m.ssbo); + const size_t ssbo_bytes = gpu.size() * sizeof(InstanceGpu); + gl_->glNamedBufferStorage(m.ssbo, ssbo_bytes, gpu.data(), 0); + m.ssbo_instance_count = static_cast(gpu.size()); + + m.finalized = true; + + qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram " + "(vbo %.1f + ebo %.1f + ssbo %.1f)", + model_id, size_t(m.vertex_count), m.meshes.size(), m.instances.size(), + (m.vbo_capacity + m.ebo_capacity + ssbo_bytes) / (1024.0*1024.0), + m.vbo_capacity / (1024.0*1024.0), + m.ebo_capacity / (1024.0*1024.0), + ssbo_bytes / (1024.0*1024.0)); } void ViewportWindow::resetScene() { if (!gl_initialized_) return; - - if (bvh_build_thread_.joinable()) - bvh_build_thread_.join(); - context_->makeCurrent(this); for (auto& [mid, m] : models_gpu_) { - if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); - if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); - if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); + if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); + if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); + if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); + if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); } models_gpu_.clear(); - model_bvhs_.clear(); - pending_uploads_.clear(); selected_object_id_ = 0; - { - std::lock_guard bvh_lock(bvh_result_mutex_); - pending_bvh_.reset(); - } -} - -static const size_t UPLOAD_CHUNK_BYTES = 48 * 1024 * 1024; // 48 MB per frame - -void ViewportWindow::processPendingUploads() { - if (pending_uploads_.empty()) return; - - auto& pu = pending_uploads_.front(); - auto it = models_gpu_.find(pu.model_id); - if (it == models_gpu_.end()) { - pending_uploads_.pop_front(); - return; - } - auto& mgpu = it->second; - - size_t vbo_total = pu.vertices.size() * sizeof(float); - size_t ebo_total = pu.indices.size() * sizeof(uint32_t); - - // Phase 1: Upload VBO in chunks. - if (pu.vbo_uploaded < vbo_total) { - size_t remaining = vbo_total - pu.vbo_uploaded; - size_t chunk = std::min(remaining, UPLOAD_CHUNK_BYTES); - gl_->glNamedBufferSubData(mgpu.vbo, pu.vbo_uploaded, chunk, - reinterpret_cast(pu.vertices.data()) + pu.vbo_uploaded); - pu.vbo_uploaded += chunk; - - if (pu.vbo_uploaded >= vbo_total) { - // VBO done — free CPU memory. - pu.vertices.clear(); - pu.vertices.shrink_to_fit(); - } - return; // yield to render loop - } - - // Phase 2: Upload EBO in chunks. Objects become drawable as their range lands. - if (pu.ebo_uploaded < ebo_total) { - size_t remaining = ebo_total - pu.ebo_uploaded; - size_t chunk = std::min(remaining, UPLOAD_CHUNK_BYTES); - gl_->glNamedBufferSubData(mgpu.ebo, pu.ebo_uploaded, chunk, - reinterpret_cast(pu.indices.data()) + pu.ebo_uploaded); - pu.ebo_uploaded += chunk; - - // Advance active_draw_count: activate objects whose EBO range is fully uploaded. - while (mgpu.active_draw_count < mgpu.draw_info.size()) { - const auto& obj = mgpu.draw_info[mgpu.active_draw_count]; - size_t obj_end = obj.index_offset + obj.index_count * sizeof(uint32_t); - if (obj_end <= pu.ebo_uploaded) - mgpu.active_draw_count++; - else - break; - } - - if (pu.ebo_uploaded >= ebo_total) { - // EBO done — free CPU memory. - pu.indices.clear(); - pu.indices.shrink_to_fit(); - } else { - return; // yield to render loop - } - } - - // Fully uploaded — activate BVH if present. - mgpu.active_draw_count = static_cast(mgpu.draw_info.size()); - if (pu.bvh_set) { - model_bvhs_[pu.model_id] = std::move(pu.bvh_set); - } - - size_t total_vbo = 0, total_ebo = 0; - for (const auto& [mid, mg] : models_gpu_) { - total_vbo += mg.vbo_capacity; - total_ebo += mg.ebo_capacity; - } - qDebug("Progressive upload complete: model %u (this: vbo %.1f MB + ebo %.1f MB, " - "%u objects, %u triangles) scene total vram %.1f MB", - pu.model_id, - mgpu.vbo_capacity / (1024.0 * 1024.0), - mgpu.ebo_capacity / (1024.0 * 1024.0), - static_cast(mgpu.draw_info.size()), - mgpu.total_triangles, - (total_vbo + total_ebo) / (1024.0 * 1024.0)); - pending_uploads_.pop_front(); } void ViewportWindow::hideModel(uint32_t model_id) { @@ -621,161 +549,35 @@ void ViewportWindow::showModel(uint32_t model_id) { void ViewportWindow::removeModel(uint32_t model_id) { if (!gl_initialized_) return; context_->makeCurrent(this); - - // Cancel any pending upload for this model. - pending_uploads_.erase( - std::remove_if(pending_uploads_.begin(), pending_uploads_.end(), - [model_id](const PendingUpload& pu) { return pu.model_id == model_id; }), - pending_uploads_.end()); - auto it = models_gpu_.find(model_id); if (it != models_gpu_.end()) { - gl_->glDeleteVertexArrays(1, &it->second.vao); - gl_->glDeleteBuffers(1, &it->second.vbo); - gl_->glDeleteBuffers(1, &it->second.ebo); + if (it->second.vao) gl_->glDeleteVertexArrays(1, &it->second.vao); + if (it->second.vbo) gl_->glDeleteBuffers(1, &it->second.vbo); + if (it->second.ebo) gl_->glDeleteBuffers(1, &it->second.ebo); + if (it->second.ssbo) gl_->glDeleteBuffers(1, &it->second.ssbo); models_gpu_.erase(it); } - model_bvhs_.erase(model_id); } -std::vector ViewportWindow::readbackEbo(uint32_t model_id) const { - std::vector ebo_data; - auto it = models_gpu_.find(model_id); - if (!gl_ || it == models_gpu_.end() || it->second.ebo_used == 0) return ebo_data; - - const auto& m = it->second; - size_t num_indices = m.ebo_used / sizeof(uint32_t); - ebo_data.resize(num_indices); - gl_->glGetNamedBufferSubData(m.ebo, 0, m.ebo_used, ebo_data.data()); - return ebo_data; -} - -std::vector ViewportWindow::readbackVbo(uint32_t model_id) const { - std::vector vbo_data; - auto it = models_gpu_.find(model_id); - if (!gl_ || it == models_gpu_.end() || it->second.vbo_used == 0) return vbo_data; - - const auto& m = it->second; - size_t num_floats = m.vbo_used / sizeof(float); - vbo_data.resize(num_floats); - gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, vbo_data.data()); - return vbo_data; -} - -void ViewportWindow::buildBvhAsync(uint32_t model_id, - const std::string& ifc_path, - uint64_t ifc_file_size, - std::vector sidecar_elements, - std::string sidecar_string_table) { - if (bvh_build_thread_.joinable()) - bvh_build_thread_.join(); - - auto it = models_gpu_.find(model_id); - if (it == models_gpu_.end()) return; - - // Snapshot draw info; read back EBO + VBO on GL thread. - std::vector draw_snapshot = it->second.draw_info; - std::vector ebo_snapshot = readbackEbo(model_id); - std::vector vbo_snapshot; - if (!ifc_path.empty() && !sidecar_elements.empty()) { - vbo_snapshot = readbackVbo(model_id); - } - - if (draw_snapshot.empty() || ebo_snapshot.empty()) return; - - bvh_build_thread_ = std::thread([this, - model_id, - draw_info = std::move(draw_snapshot), - ebo_data = std::move(ebo_snapshot), - vbo_data = std::move(vbo_snapshot), - elements = std::move(sidecar_elements), - string_table = std::move(sidecar_string_table), - ifc_path, ifc_file_size]() { - auto bvh_set = buildBvhSet(draw_info); - - EboReorderResult ebo_result = reorderEbo(*bvh_set, draw_info, ebo_data); - - // Write full sidecar if requested. - if (!ifc_path.empty() && !elements.empty() && !vbo_data.empty()) { - SidecarData sd; - sd.vertices = vbo_data; - sd.indices = ebo_result.reordered_ebo; - sd.draw_info = ebo_result.reordered_draw_info; - sd.elements = std::move(elements); - sd.string_table = std::move(string_table); - sd.bvh_set = bvh_set; - writeSidecar(ifc_path, sd, ifc_file_size); - } - - { - std::lock_guard lock(bvh_result_mutex_); - pending_bvh_ = std::make_unique(); - pending_bvh_->model_id = model_id; - pending_bvh_->bvh_set = std::move(bvh_set); - pending_bvh_->ebo_reorder = std::move(ebo_result); - } - }); -} - -void ViewportWindow::applyBvhResult() { - std::unique_ptr result; - { - std::lock_guard lock(bvh_result_mutex_); - result = std::move(pending_bvh_); - } - if (!result) return; - - auto it = models_gpu_.find(result->model_id); - if (it == models_gpu_.end()) return; - - auto& mgpu = it->second; - - // Re-upload the reordered EBO into this model's buffer. - if (!result->ebo_reorder.reordered_ebo.empty()) { - size_t ebo_bytes = result->ebo_reorder.reordered_ebo.size() * sizeof(uint32_t); - if (ebo_bytes <= mgpu.ebo_capacity) { - gl_->glNamedBufferSubData(mgpu.ebo, 0, ebo_bytes, - result->ebo_reorder.reordered_ebo.data()); - } - } - - // Swap draw info. - if (result->ebo_reorder.reordered_draw_info.size() == mgpu.draw_info.size()) { - mgpu.draw_info = std::move(result->ebo_reorder.reordered_draw_info); - } - - model_bvhs_[result->model_id] = std::move(result->bvh_set); - - qDebug("BVH activated for model %u", result->model_id); -} - -void ViewportWindow::setSelectedObjectId(uint32_t id) { - selected_object_id_ = id; -} +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(); - 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; } @@ -785,163 +587,32 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { 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); - + 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_); - 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); } -bool ViewportWindow::aabbInFrustum(const float aabb_min[3], const float aabb_max[3], - const float planes[6][4]) { - for (int p = 0; p < 6; ++p) { - float px = planes[p][0] >= 0.0f ? aabb_max[0] : aabb_min[0]; - float py = planes[p][1] >= 0.0f ? aabb_max[1] : aabb_min[1]; - float pz = planes[p][2] >= 0.0f ? aabb_max[2] : aabb_min[2]; - float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3]; - if (dist < 0.0f) return false; - } - return true; -} - -void ViewportWindow::traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu, - const float planes[6][4]) { - if (mbvh.nodes.empty()) return; - - uint32_t stack[64]; - int sp = 0; - stack[sp++] = 0; // root - - // Get the current model's draw command being built. - auto& cmd = frame_draw_cmds_.back(); - - while (sp > 0) { - uint32_t ni = stack[--sp]; - const BvhNode& node = mbvh.nodes[ni]; - - if (!aabbInFrustum(node.aabb_min, node.aabb_max, planes)) - continue; - - if (node.count > 0) { - // Leaf-batched draw: after reorderEbo, a leaf's objects occupy a - // contiguous EBO range. Emit one draw command covering all of them - // instead of N per-object tests/draws. The leaf AABB test above is - // already a conservative cull; any overdraw (up to BVH_MAX_LEAF_SIZE - // objects that may be fully outside the frustum but inside the leaf - // AABB) costs far less than the per-draw CPU/driver overhead we save. - uint32_t first_oi = mbvh.object_indices[node.right_or_first]; - const auto& first_obj = mgpu.draw_info[first_oi]; - uint32_t leaf_offset = first_obj.index_offset; - uint32_t leaf_count = 0; - for (uint32_t i = 0; i < node.count; ++i) { - uint32_t oi = mbvh.object_indices[node.right_or_first + i]; - leaf_count += mgpu.draw_info[oi].index_count; - } - cmd.counts.push_back(static_cast(leaf_count)); - cmd.offsets.push_back(reinterpret_cast( - static_cast(leaf_offset))); - visible_triangles_ += leaf_count / 3; - visible_objects_ += node.count; - } else { - if (sp < 63) { - stack[sp++] = node.right_or_first; - stack[sp++] = ni + 1; - } - } - } -} - -void ViewportWindow::buildVisibleList(const QMatrix4x4& vp) { - frame_draw_cmds_.clear(); - visible_triangles_ = 0; - visible_objects_ = 0; - - // Extract 6 frustum planes from the view-projection matrix. - float planes[6][4]; - for (int i = 0; i < 4; ++i) { - planes[0][i] = vp(3, i) + vp(0, i); // left - planes[1][i] = vp(3, i) - vp(0, i); // right - planes[2][i] = vp(3, i) + vp(1, i); // bottom - planes[3][i] = vp(3, i) - vp(1, i); // top - planes[4][i] = vp(3, i) + vp(2, i); // near - planes[5][i] = vp(3, i) - vp(2, i); // far - } - for (int p = 0; p < 6; ++p) { - float len = std::sqrt(planes[p][0] * planes[p][0] + - planes[p][1] * planes[p][1] + - planes[p][2] * planes[p][2]); - if (len > 0.0f) { - float inv = 1.0f / len; - planes[p][0] *= inv; - planes[p][1] *= inv; - planes[p][2] *= inv; - planes[p][3] *= inv; - } - } - - for (auto& [model_id, mgpu] : models_gpu_) { - if (mgpu.hidden || mgpu.active_draw_count == 0) continue; - - frame_draw_cmds_.push_back({mgpu.vao, {}, {}}); - auto& cmd = frame_draw_cmds_.back(); - cmd.counts.reserve(mgpu.active_draw_count); - cmd.offsets.reserve(mgpu.active_draw_count); - - bool fully_loaded = (mgpu.active_draw_count == mgpu.draw_info.size()); - auto bvh_it = model_bvhs_.find(model_id); - - // Only use BVH if model is fully uploaded; during progressive upload, - // fall back to linear scan of active objects. - if (fully_loaded && bvh_it != model_bvhs_.end() && bvh_it->second) { - const auto& bvh_set = *bvh_it->second; - auto mbvh_it = bvh_set.models.find(model_id); - if (mbvh_it != bvh_set.models.end()) { - traverseBvh(mbvh_it->second, mgpu, planes); - } - } else { - // Linear scan of active objects only. - for (uint32_t i = 0; i < mgpu.active_draw_count; ++i) { - const auto& obj = mgpu.draw_info[i]; - if (aabbInFrustum(obj.aabb_min, obj.aabb_max, planes)) { - cmd.counts.push_back(static_cast(obj.index_count)); - cmd.offsets.push_back(reinterpret_cast( - static_cast(obj.index_offset))); - visible_triangles_ += obj.index_count / 3; - visible_objects_++; - } - } - } - - if (cmd.counts.empty()) { - frame_draw_cmds_.pop_back(); - } - } -} - void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; context_->makeCurrent(this); - applyBvhResult(); - processPendingUploads(); updateCamera(); - int w = width() * devicePixelRatio(); + int w = width() * devicePixelRatio(); int h = height() * devicePixelRatio(); gl_->glViewport(0, 0, w, h); gl_->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); @@ -949,24 +620,43 @@ void ViewportWindow::render() { 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_); + GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); + GLint u_light = gl_->glGetUniformLocation(main_program_, "u_light_dir"); + GLint u_sel = gl_->glGetUniformLocation(main_program_, "u_selected_id"); + GLint u_inst_off = gl_->glGetUniformLocation(main_program_, "u_instance_offset"); + gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); + gl_->glUniform3f(u_light, 0.3f, 0.5f, 0.8f); + gl_->glUniform1ui(u_sel, selected_object_id_); - buildVisibleList(vp); - for (const auto& cmd : frame_draw_cmds_) { - gl_->glBindVertexArray(cmd.vao); - gl_->glMultiDrawElements(GL_TRIANGLES, - cmd.counts.data(), GL_UNSIGNED_INT, - cmd.offsets.data(), - static_cast(cmd.counts.size())); + visible_triangles_ = 0; + visible_objects_ = 0; + instanced_draws_ = 0; + + for (auto& [model_id, m] : models_gpu_) { + if (m.hidden || !m.finalized || !m.ssbo) continue; + gl_->glBindVertexArray(m.vao); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); + + for (const auto& mesh : m.meshes) { + if (mesh.instance_count == 0 || mesh.index_count == 0) continue; + gl_->glUniform1ui(u_inst_off, mesh.first_instance); + gl_->glDrawElementsInstancedBaseVertex( + GL_TRIANGLES, + static_cast(mesh.index_count), + GL_UNSIGNED_INT, + reinterpret_cast(static_cast(mesh.ebo_byte_offset)), + static_cast(mesh.instance_count), + static_cast(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES)); + visible_triangles_ += (mesh.index_count / 3) * mesh.instance_count; + visible_objects_ += mesh.instance_count; + ++instanced_draws_; + } } renderAxisGizmo(); context_->swapBuffers(this); - // Compute FPS. float dt = frame_clock_.restart() / 1000.0f; accumulated_time_ += dt; frame_count_++; @@ -975,21 +665,18 @@ void ViewportWindow::render() { frame_count_ = 0; accumulated_time_ = 0.0f; - uint32_t total_obj = 0, total_tri = 0; - size_t total_vram = 0, total_vbo = 0, total_ebo = 0; + uint32_t total_obj = 0, total_tri = 0, total_meshes = 0; + size_t total_vbo = 0, total_ebo = 0, total_ssbo = 0; size_t num_models = 0, num_hidden = 0; - size_t total_leaf_draws = 0; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [mid, mm] : models_gpu_) { num_models++; - if (m.hidden) { num_hidden++; continue; } - total_obj += static_cast(m.draw_info.size()); - total_tri += m.total_triangles; - total_vbo += m.vbo_capacity; - total_ebo += m.ebo_capacity; - } - total_vram = total_vbo + total_ebo; - for (const auto& cmd : frame_draw_cmds_) { - total_leaf_draws += cmd.counts.size(); + if (mm.hidden || !mm.finalized) { num_hidden++; continue; } + total_obj += static_cast(mm.instances.size()); + total_tri += mm.total_triangles; + total_meshes += static_cast(mm.meshes.size()); + total_vbo += mm.vbo_capacity; + total_ebo += mm.ebo_capacity; + total_ssbo += mm.ssbo_instance_count * sizeof(InstanceGpu); } FrameStats stats; @@ -999,112 +686,95 @@ void ViewportWindow::render() { stats.visible_objects = visible_objects_; stats.total_triangles = total_tri; stats.visible_triangles = visible_triangles_; + stats.unique_meshes = total_meshes; + stats.instanced_draws = instanced_draws_; emit frameStatsUpdated(stats); - double vis_obj_pct = total_obj > 0 ? 100.0 * visible_objects_ / total_obj : 0.0; - double vis_tri_pct = total_tri > 0 ? 100.0 * visible_triangles_ / total_tri : 0.0; - qDebug("[frame] %.1f fps %.2f ms obj %u/%u (%.1f%%) tri %u/%u (%.1f%%) " - "vram %.1f MB (vbo %.1f + ebo %.1f) models %zu (%zu hidden) " - "leaf_draws %zu model_draws %zu pending_uploads %zu", + qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " + "meshes %u inst_draws %u " + "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, - visible_objects_, total_obj, vis_obj_pct, - visible_triangles_, total_tri, vis_tri_pct, - total_vram / (1024.0 * 1024.0), - total_vbo / (1024.0 * 1024.0), - total_ebo / (1024.0 * 1024.0), - num_models, num_hidden, - total_leaf_draws, - frame_draw_cmds_.size(), - pending_uploads_.size()); + visible_objects_, total_obj, + visible_triangles_, total_tri, + total_meshes, instanced_draws_, + (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), + total_vbo / (1024.0*1024.0), + total_ebo / (1024.0*1024.0), + total_ssbo / (1024.0*1024.0), + num_models, num_hidden); } } -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); - - 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); - 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()); + GLint u_vp = gl_->glGetUniformLocation(pick_program_, "u_view_projection"); + GLint u_inst_off = gl_->glGetUniformLocation(pick_program_, "u_instance_offset"); + gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); - // Reuse the visible list from the most recent render() call. - for (const auto& cmd : frame_draw_cmds_) { - gl_->glBindVertexArray(cmd.vao); - gl_->glMultiDrawElements(GL_TRIANGLES, - cmd.counts.data(), GL_UNSIGNED_INT, - cmd.offsets.data(), - static_cast(cmd.counts.size())); + for (auto& [model_id, m] : models_gpu_) { + if (m.hidden || !m.finalized || !m.ssbo) continue; + gl_->glBindVertexArray(m.vao); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); + for (const auto& mesh : m.meshes) { + if (mesh.instance_count == 0 || mesh.index_count == 0) continue; + gl_->glUniform1ui(u_inst_off, mesh.first_instance); + gl_->glDrawElementsInstancedBaseVertex( + GL_TRIANGLES, + static_cast(mesh.index_count), + GL_UNSIGNED_INT, + reinterpret_cast(static_cast(mesh.ebo_byte_offset)), + static_cast(mesh.instance_count), + static_cast(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES)); + } } - gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); } -void ViewportWindow::exposeEvent(QExposeEvent*) { - if (isExposed() && !gl_initialized_) { - initGL(); - } +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); + + float yaw_rad = qDegreesToRadians(camera_yaw_); + float pitch_rad = qDegreesToRadians(camera_pitch_); + QVector3D eye_dir(cosf(pitch_rad) * cosf(yaw_rad), + cosf(pitch_rad) * sinf(yaw_rad), + sinf(pitch_rad)); + QMatrix4x4 gv; gv.lookAt(eye_dir * 3.0f, QVector3D(0,0,0), QVector3D(0,0,1)); + QMatrix4x4 gp; gp.ortho(-1.4f, 1.4f, -1.4f, 1.4f, 0.1f, 10.0f); + QMatrix4x4 mvp = gp * gv; + + gl_->glUseProgram(axis_program_); + gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(axis_program_, "u_mvp"), 1, GL_FALSE, mvp.constData()); + gl_->glLineWidth(2.5f); + gl_->glBindVertexArray(axis_vao_); + gl_->glDrawArrays(GL_LINES, 0, 6); + gl_->glEnable(GL_DEPTH_TEST); } +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); + 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); } } @@ -1112,7 +782,6 @@ 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()); @@ -1121,21 +790,18 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { } 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) { 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)); + 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 { @@ -1145,7 +811,6 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { } } } - void ViewportWindow::handleWheel(QWheelEvent* e) { float factor = e->angleDelta().y() > 0 ? 0.9f : 1.1f; camera_distance_ *= factor; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 97925e6e2e..9fbdcf054b 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -28,58 +28,43 @@ #include #include -#include #include -#include #include #include #include -#include #include -#include -#include "BvhAccel.h" +#include "InstancedGeometry.h" #include "SidecarCache.h" -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; - uint32_t model_id = 0; -}; - -// Per-model GPU state: own VAO, VBO, EBO, draw info, BVH. +// Per-model GPU state for the instanced render path. +// +// VBO: local-coord interleaved verts (pos3 + normal3 + color1_packed) — 28 B. +// EBO: mesh-local indices (uint32). +// meshes[]: per-unique-representation metadata; indexed by local_mesh_id. +// instances[]: CPU-side per-instance records; sorted by mesh_id at finalize. +// ssbo: InstanceGpu[]; populated at finalize. +// +// A model is drawable once `finalized == true`. struct ModelGpuData { GLuint vao = 0; GLuint vbo = 0; GLuint ebo = 0; + GLuint ssbo = 0; + size_t vbo_capacity = 0; size_t ebo_capacity = 0; - size_t vbo_used = 0; // bytes - size_t ebo_used = 0; // bytes - uint32_t vertex_count = 0; + size_t vbo_used = 0; + size_t ebo_used = 0; + uint32_t vertex_count = 0; // total (across all meshes) uint32_t total_triangles = 0; - std::vector draw_info; - uint32_t active_draw_count = 0; // how many objects are drawable (progressive upload) - bool hidden = false; -}; -// Pending progressive upload — VBO first, then EBO. -struct PendingUpload { - uint32_t model_id = 0; - std::vector vertices; - std::vector indices; - std::shared_ptr bvh_set; - size_t vbo_uploaded = 0; // bytes - size_t ebo_uploaded = 0; // bytes + std::vector meshes; + std::vector instances; // unsorted until finalize + uint32_t ssbo_instance_count = 0; + + bool finalized = false; + bool hidden = false; }; class ViewportWindow : public QWindow { @@ -88,32 +73,21 @@ public: explicit ViewportWindow(QWindow* parent = nullptr); ~ViewportWindow(); - void uploadChunk(const UploadChunk& chunk); - void resetScene(); + // Streaming ingress. + void uploadMeshChunk(const MeshChunk& chunk); + void uploadInstanceChunk(const InstanceChunk& chunk); - // Bulk upload pre-built geometry from a sidecar cache. - // Creates a perfectly-sized per-model buffer set. No copy. - void uploadBulk(uint32_t model_id, - std::vector vertices, - std::vector indices, - const std::vector& draw_info, - std::shared_ptr bvh_set); + // Called once all chunks for a model have arrived: sorts instances by + // mesh_id, assigns each mesh its contiguous range, and uploads the + // instance SSBO. The model becomes drawable. + void finalizeModel(uint32_t model_id); + + void resetScene(); void hideModel(uint32_t model_id); void showModel(uint32_t model_id); void removeModel(uint32_t model_id); - // Build BVH and optionally write a sidecar cache. - void buildBvhAsync(uint32_t model_id, - const std::string& ifc_path = "", - uint64_t ifc_file_size = 0, - std::vector sidecar_elements = {}, - std::string sidecar_string_table = {}); - - // Read snapshots of a model's GPU buffers into CPU vectors. - std::vector readbackEbo(uint32_t model_id) const; - std::vector readbackVbo(uint32_t model_id) const; - void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); @@ -124,6 +98,8 @@ public: uint32_t visible_objects; uint32_t total_triangles; uint32_t visible_triangles; + uint32_t unique_meshes; + uint32_t instanced_draws; }; signals: @@ -147,13 +123,7 @@ private: void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo); bool growModelVbo(ModelGpuData& m, size_t needed_total); bool growModelEbo(ModelGpuData& m, size_t needed_total); - void buildVisibleList(const QMatrix4x4& vp); - void traverseBvh(const ModelBvh& mbvh, const ModelGpuData& mgpu, - const float planes[6][4]); - static bool aabbInFrustum(const float aabb_min[3], const float aabb_max[3], - const float planes[6][4]); - void applyBvhResult(); - void processPendingUploads(); + ModelGpuData& getOrCreateModel(uint32_t model_id); // Mouse interaction void handleMousePress(QMouseEvent* event); @@ -172,13 +142,12 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Axis gizmo (separate VAO/VBO since vertex layout differs from scene) + // Axis gizmo GLuint axis_vao_ = 0; GLuint axis_vbo_ = 0; // Per-model GPU data std::unordered_map models_gpu_; - std::mutex models_mutex_; // Pick framebuffer GLuint pick_fbo_ = 0; @@ -187,21 +156,10 @@ private: int pick_width_ = 0; int pick_height_ = 0; - // Per-model BVH - std::unordered_map> model_bvhs_; - - // Progressive upload queue - std::deque pending_uploads_; - - // Scratch buffers reused each frame to avoid allocation. - struct ModelDrawCmd { - GLuint vao; - std::vector counts; - std::vector offsets; - }; - std::vector frame_draw_cmds_; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; + uint32_t instanced_draws_ = 0; // Camera QVector3D camera_target_{0, 0, 0}; @@ -211,26 +169,14 @@ private: QMatrix4x4 view_matrix_; QMatrix4x4 proj_matrix_; - // Mouse state + // Mouse 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; - // BVH build (phase 2) - struct PendingBvh { - uint32_t model_id; - std::shared_ptr bvh_set; - EboReorderResult ebo_reorder; - }; - std::unique_ptr pending_bvh_; - std::mutex bvh_result_mutex_; - std::thread bvh_build_thread_; - - // Stats + // FPS smoothing int frame_count_ = 0; float accumulated_time_ = 0.0f; float last_fps_ = 0.0f; From ababb49ae7536f2e60d995f7d99a4948fcf2498a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 20:10:20 +1000 Subject: [PATCH 013/120] Sidecar v4: persist instanced geometry + metadata Commit B of the instancing migration. The sidecar on-disk format is reintroduced at version 4 with MeshInfo + InstanceCpu sections in place of v3's flat per-object draw-info array. After streaming finishes, MainWindow asks the viewport for a post- finalise snapshot (VBO + EBO are read back from the GPU, meshes and instances come from the CPU-side arrays) and writes it alongside PackedElementInfo + the string table. On a subsequent load, readSidecar rehydrates the whole struct and ViewportWindow:: applyCachedModel uploads VBO/EBO/SSBO in a single step, bypassing the iterator entirely. Staleness check is still by source file size. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/MainWindow.cpp | 92 ++++++++++++++++++++++-- src/ifcviewer/SidecarCache.cpp | 118 ++++++++++++++++++++++++++++--- src/ifcviewer/ViewportWindow.cpp | 97 +++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 10 +++ 4 files changed, 300 insertions(+), 17 deletions(-) diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 86a787a0e2..ceeedc8cbd 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -210,7 +210,7 @@ void MainWindow::startNextLoad() { qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); auto result = std::make_shared>(std::move(cached)); QMetaObject::invokeMethod(this, [this, mid, result]() { - if (*result && !(*result)->meshes.empty()) { + if (*result && !(*result)->instances.empty()) { applySidecarData(mid, std::move(**result)); } else { // No sidecar — fall back to streaming from IFC. @@ -229,10 +229,54 @@ void MainWindow::startNextLoad() { }); } -void MainWindow::applySidecarData(ModelId /*mid*/, SidecarData /*data*/) { - // Commit A: readSidecar() always returns nullopt, so this is unreachable. - // Restored in Commit B along with the v4 on-disk format. - qWarning("applySidecarData called but sidecar is disabled in Commit A"); +void MainWindow::applySidecarData(ModelId mid, SidecarData data) { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& model = it->second; + + qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", + model.file_path.toStdString().c_str(), + data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS, + data.indices.size(), + data.meshes.size(), + data.instances.size(), + data.elements.size()); + + QElapsedTimer t; + t.start(); + + // Update next_object_id_ past all objects in this model before the + // extracted `elements` is moved out of `data`. + for (const auto& elem : data.elements) { + if (elem.object_id >= next_object_id_) + next_object_id_ = elem.object_id + 1; + } + + // Hand off geometry to GPU in a single call. + std::vector elements = std::move(data.elements); + std::string stbl = std::move(data.string_table); + viewport_->applyCachedModel(mid, std::move(data)); + qDebug(" GL upload: %lld ms", t.elapsed()); + + t.restart(); + element_tree_->setUpdatesEnabled(false); + populateTreeFromSidecar(model, elements, stbl); + element_tree_->setUpdatesEnabled(true); + qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), elements.size()); + + 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("%1 elements across %2 model(s) — loaded from cache in %3") + .arg(element_map_.size()) + .arg(models_.size()) + .arg(elapsed)); + + loading_model_id_ = 0; + QTimer::singleShot(0, this, &MainWindow::startNextLoad); } void MainWindow::populateTreeFromSidecar(ModelHandle& model, @@ -320,10 +364,44 @@ void MainWindow::onStreamingFinished() { .arg(num_models) .arg(elapsed)); - // Sort instances by mesh and upload the per-model instance SSBO. - // Sidecar write is stubbed in Commit A. + // Sort instances by mesh, upload the per-model instance SSBO, and + // persist a v4 sidecar for next load. if (loading_model_id_ != 0) { viewport_->finalizeModel(loading_model_id_); + + auto it = models_.find(loading_model_id_); + if (it != models_.end()) { + SidecarData sd; + if (viewport_->snapshotModel(loading_model_id_, sd)) { + // Pack this model's element metadata + string table. + for (const auto& [oid, info] : element_map_) { + if (info.model_id != loading_model_id_) continue; + PackedElementInfo pe; + pe.object_id = info.object_id; + pe.model_id = info.model_id; + pe.ifc_id = info.ifc_id; + pe.parent_id = info.parent_id; + pe.guid_offset = static_cast(sd.string_table.size()); + pe.guid_length = static_cast(info.guid.size()); + sd.string_table += info.guid; + pe.name_offset = static_cast(sd.string_table.size()); + pe.name_length = static_cast(info.name.size()); + sd.string_table += info.name; + pe.type_offset = static_cast(sd.string_table.size()); + pe.type_length = static_cast(info.type.size()); + sd.string_table += info.type; + sd.elements.push_back(pe); + } + + std::string ifc_path = it->second.file_path.toStdString(); + uint64_t file_size = static_cast( + QFileInfo(it->second.file_path).size()); + QElapsedTimer t; t.start(); + bool ok = writeSidecar(ifc_path, sd, file_size); + qDebug(" Sidecar write: %lld ms (%s)", + t.elapsed(), ok ? "ok" : "FAILED"); + } + } } // Start next model if queued. diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index be19c8698f..3c5ca9cd8d 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,20 +17,118 @@ * * ********************************************************************************/ -// Commit A: sidecar cache is temporarily disabled. The on-disk format is -// being rewritten from v3 (monolithic world-coord geometry) to v4 (instanced -// meshes + per-instance records). Until v4 is finalised, loads always go -// through the streaming path and writes are no-ops. +// v4 layout (all multi-byte fields native-endian; endianness marker in header): +// +// SidecarHeader (16 bytes) +// uint64_t source_file_size +// +// uint32_t num_vertices_floats +// float[] vertex data (28 B/vertex: pos3 + normal3 + color1_packed) +// uint32_t num_indices +// uint32_t[] index data (mesh-local indices; base_vertex applied at draw time) +// +// uint32_t num_meshes +// MeshInfo[num_meshes] +// +// uint32_t num_instances +// InstanceCpu[num_instances] (already sorted by mesh_id) +// +// uint32_t num_elements +// PackedElementInfo[num_elements] +// uint32_t string_table_bytes +// char[string_table_bytes] #include "SidecarCache.h" -bool writeSidecar(const std::string& /*ifc_path*/, - const SidecarData& /*data*/, - uint64_t /*ifc_file_size*/) { +#include +#include + +struct SidecarHeader { + uint32_t magic; + uint32_t version; + uint32_t endian; + uint32_t reserved; +}; + +static std::string sidecarPath(const std::string& ifc_path) { + return ifc_path + ".ifcview"; +} + +template +static bool writeVec(FILE* f, const std::vector& v) { + uint32_t n = static_cast(v.size()); + if (fwrite(&n, 4, 1, f) != 1) return false; + if (n > 0 && fwrite(v.data(), sizeof(T), n, f) != n) return false; return true; } -std::optional readSidecar(const std::string& /*ifc_path*/, - uint64_t /*ifc_file_size*/) { - return std::nullopt; +template +static bool readVec(FILE* f, std::vector& v) { + uint32_t n; + if (fread(&n, 4, 1, f) != 1) return false; + v.resize(n); + if (n > 0 && fread(v.data(), sizeof(T), n, f) != n) return false; + return true; +} + +bool writeSidecar(const std::string& ifc_path, + const SidecarData& data, + uint64_t ifc_file_size) { + std::string path = sidecarPath(ifc_path); + FILE* f = fopen(path.c_str(), "wb"); + if (!f) return false; + + SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN, 0 }; + if (fwrite(&hdr, sizeof(hdr), 1, f) != 1) { fclose(f); return false; } + if (fwrite(&ifc_file_size, 8, 1, f) != 1) { fclose(f); return false; } + + if (!writeVec(f, data.vertices)) { fclose(f); return false; } + if (!writeVec(f, data.indices)) { fclose(f); return false; } + if (!writeVec(f, data.meshes)) { fclose(f); return false; } + if (!writeVec(f, data.instances)) { fclose(f); return false; } + if (!writeVec(f, data.elements)) { fclose(f); return false; } + + uint32_t stbl_len = static_cast(data.string_table.size()); + if (fwrite(&stbl_len, 4, 1, f) != 1) { fclose(f); return false; } + if (stbl_len > 0 && fwrite(data.string_table.data(), 1, stbl_len, f) != stbl_len) { + fclose(f); return false; + } + + fclose(f); + return true; +} + +std::optional readSidecar(const std::string& ifc_path, + uint64_t ifc_file_size) { + std::string path = sidecarPath(ifc_path); + FILE* f = fopen(path.c_str(), "rb"); + if (!f) return std::nullopt; + + auto fail = [&]() -> std::optional { fclose(f); return std::nullopt; }; + + SidecarHeader hdr; + if (fread(&hdr, sizeof(hdr), 1, f) != 1) return fail(); + if (hdr.magic != SIDECAR_MAGIC || + hdr.version != SIDECAR_VERSION || + hdr.endian != SIDECAR_ENDIAN) return fail(); + + uint64_t stored_size; + if (fread(&stored_size, 8, 1, f) != 1) return fail(); + if (stored_size != ifc_file_size) return fail(); + + SidecarData data; + if (!readVec(f, data.vertices)) return fail(); + if (!readVec(f, data.indices)) return fail(); + if (!readVec(f, data.meshes)) return fail(); + if (!readVec(f, data.instances)) return fail(); + if (!readVec(f, data.elements)) return fail(); + + uint32_t stbl_len; + if (fread(&stbl_len, 4, 1, f) != 1) return fail(); + data.string_table.resize(stbl_len); + if (stbl_len > 0 && fread(data.string_table.data(), 1, stbl_len, f) != stbl_len) + return fail(); + + fclose(f); + return data; } diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index e264f990e4..48558fc64f 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -523,6 +523,103 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { ssbo_bytes / (1024.0*1024.0)); } +bool ViewportWindow::snapshotModel(uint32_t model_id, SidecarData& out) const { + auto it = models_gpu_.find(model_id); + if (!gl_ || it == models_gpu_.end()) return false; + const auto& m = it->second; + if (!m.finalized) return false; + + // GPU readback of the packed VBO/EBO ranges actually in use. + if (m.vbo_used > 0) { + out.vertices.resize(m.vbo_used / sizeof(float)); + gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, out.vertices.data()); + } + if (m.ebo_used > 0) { + out.indices.resize(m.ebo_used / sizeof(uint32_t)); + gl_->glGetNamedBufferSubData(m.ebo, 0, m.ebo_used, out.indices.data()); + } + + out.meshes = m.meshes; + out.instances = m.instances; + return true; +} + +void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { + if (!gl_initialized_) return; + context_->makeCurrent(this); + + // Drop any existing state for this model_id. + auto existing = models_gpu_.find(model_id); + if (existing != models_gpu_.end()) { + if (existing->second.vao) gl_->glDeleteVertexArrays(1, &existing->second.vao); + if (existing->second.vbo) gl_->glDeleteBuffers(1, &existing->second.vbo); + if (existing->second.ebo) gl_->glDeleteBuffers(1, &existing->second.ebo); + if (existing->second.ssbo) gl_->glDeleteBuffers(1, &existing->second.ssbo); + models_gpu_.erase(existing); + } + + ModelGpuData m; + gl_->glCreateVertexArrays(1, &m.vao); + gl_->glCreateBuffers(1, &m.vbo); + gl_->glCreateBuffers(1, &m.ebo); + + const size_t vb_bytes = data.vertices.size() * sizeof(float); + const size_t ib_bytes = data.indices.size() * sizeof(uint32_t); + m.vbo_capacity = std::max(vb_bytes, 1); + m.ebo_capacity = std::max(ib_bytes, 1); + gl_->glNamedBufferStorage(m.vbo, m.vbo_capacity, + vb_bytes ? data.vertices.data() : nullptr, + GL_DYNAMIC_STORAGE_BIT); + gl_->glNamedBufferStorage(m.ebo, m.ebo_capacity, + ib_bytes ? data.indices.data() : nullptr, + GL_DYNAMIC_STORAGE_BIT); + setupVaoLayout(m.vao, m.vbo, m.ebo); + + m.vbo_used = vb_bytes; + m.ebo_used = ib_bytes; + m.vertex_count = static_cast( + data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); + m.meshes = std::move(data.meshes); + m.instances = std::move(data.instances); + + uint32_t total_tri = 0; + for (const auto& mesh : m.meshes) { + total_tri += (mesh.index_count / 3) * mesh.instance_count; + } + m.total_triangles = total_tri; + + // Build and upload the instance SSBO. + std::vector gpu(m.instances.size()); + for (size_t i = 0; i < m.instances.size(); ++i) { + const InstanceCpu& src = m.instances[i]; + InstanceGpu& dst = gpu[i]; + std::memcpy(dst.transform, src.transform, sizeof(dst.transform)); + dst.object_id = src.object_id; + dst.color_override_rgba8 = src.color_override_rgba8; + dst._pad0 = 0; + dst._pad1 = 0; + } + gl_->glCreateBuffers(1, &m.ssbo); + const size_t ssbo_bytes = gpu.size() * sizeof(InstanceGpu); + if (ssbo_bytes > 0) { + gl_->glNamedBufferStorage(m.ssbo, ssbo_bytes, gpu.data(), 0); + } + m.ssbo_instance_count = static_cast(gpu.size()); + + m.finalized = true; + models_gpu_.emplace(model_id, std::move(m)); + + qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances " + "%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)", + model_id, data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS, + models_gpu_[model_id].meshes.size(), + models_gpu_[model_id].instances.size(), + (vb_bytes + ib_bytes + ssbo_bytes) / (1024.0*1024.0), + vb_bytes / (1024.0*1024.0), + ib_bytes / (1024.0*1024.0), + ssbo_bytes / (1024.0*1024.0)); +} + void ViewportWindow::resetScene() { if (!gl_initialized_) return; context_->makeCurrent(this); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 9fbdcf054b..65b15412e9 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -84,6 +84,16 @@ public: void resetScene(); + // Snapshot the finalised model into a SidecarData struct for caching. + // Vertices + indices are read back from the GPU; meshes/instances come + // from the CPU-side vectors. Leaves `elements` and `string_table` empty + // for the caller to fill in. + bool snapshotModel(uint32_t model_id, SidecarData& out) const; + + // Restore a finalised model from a cached SidecarData struct. Replaces + // any existing state for model_id and marks it drawable. + void applyCachedModel(uint32_t model_id, SidecarData data); + void hideModel(uint32_t model_id); void showModel(uint32_t model_id); void removeModel(uint32_t model_id); From 1f17d73f3e10dfce85f388f013a86b2d41b91c17 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 20:23:50 +1000 Subject: [PATCH 014/120] BVH frustum culling over instances Re-wires the BVH acceleration structure on top of the new instanced renderer. Per model, build a BVH over per-instance world AABBs at finalize (and on sidecar apply). Each frame, traverse the BVH against the camera frustum to produce a visible-instance index list, bucket by mesh_id, and upload to a per-model SSBO at binding=1. The main and pick vertex shaders do a double-indirection `instances[visible[u_offset + gl_InstanceID]]` so draws only touch instances that passed the frustum test. Models with fewer than BVH_MIN_OBJECTS instances skip the BVH build and fall back to a linear per-instance frustum test. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/BvhAccel.cpp | 6 + src/ifcviewer/BvhAccel.h | 4 + src/ifcviewer/ViewportWindow.cpp | 181 +++++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 25 +++++ 4 files changed, 204 insertions(+), 12 deletions(-) diff --git a/src/ifcviewer/BvhAccel.cpp b/src/ifcviewer/BvhAccel.cpp index c285f1fbfe..4b115bfa4c 100644 --- a/src/ifcviewer/BvhAccel.cpp +++ b/src/ifcviewer/BvhAccel.cpp @@ -119,6 +119,12 @@ ModelBvh buildModelBvh(const std::vector& items, } // anonymous namespace +ModelBvh buildModelBvhOne(const std::vector& items, uint32_t model_id) { + std::vector idxs(items.size()); + for (uint32_t i = 0; i < items.size(); ++i) idxs[i] = i; + return buildModelBvh(items, idxs, model_id); +} + std::shared_ptr buildBvhSet(const std::vector& items) { auto bvh_set = std::make_shared(); diff --git a/src/ifcviewer/BvhAccel.h b/src/ifcviewer/BvhAccel.h index a2cb6a1316..7281dff511 100644 --- a/src/ifcviewer/BvhAccel.h +++ b/src/ifcviewer/BvhAccel.h @@ -63,4 +63,8 @@ struct BvhSet { // vector — callers providing a single model's items will see 0..N-1. std::shared_ptr buildBvhSet(const std::vector& items); +// Build a single-model BVH over `items`. model_id is stored on the result +// for identification; item_indices will be 0..items.size()-1. +ModelBvh buildModelBvhOne(const std::vector& items, uint32_t model_id); + #endif // BVHACCEL_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 48558fc64f..7011ec9d38 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -68,6 +68,9 @@ struct InstanceRecord { layout(std430, binding = 0) readonly buffer Instances { InstanceRecord instances[]; }; +layout(std430, binding = 1) readonly buffer VisibleIndices { + uint visible[]; +}; uniform mat4 u_view_projection; uniform uint u_instance_offset; @@ -79,7 +82,8 @@ flat out uint v_object_id; flat out uint v_selected; void main() { - InstanceRecord inst = instances[u_instance_offset + uint(gl_InstanceID)]; + uint iid = visible[u_instance_offset + uint(gl_InstanceID)]; + InstanceRecord inst = instances[iid]; vec4 world = inst.transform * vec4(a_position, 1.0); gl_Position = u_view_projection * world; @@ -139,6 +143,9 @@ struct InstanceRecord { layout(std430, binding = 0) readonly buffer Instances { InstanceRecord instances[]; }; +layout(std430, binding = 1) readonly buffer VisibleIndices { + uint visible[]; +}; uniform mat4 u_view_projection; uniform uint u_instance_offset; @@ -146,7 +153,8 @@ uniform uint u_instance_offset; flat out uint v_object_id; void main() { - InstanceRecord inst = instances[u_instance_offset + uint(gl_InstanceID)]; + uint iid = visible[u_instance_offset + uint(gl_InstanceID)]; + InstanceRecord inst = instances[iid]; gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0); v_object_id = inst.object_id; } @@ -211,6 +219,59 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra // ----------------------------------------------------------------------------- +static bool aabbInFrustum(const float aabb_min[3], const float aabb_max[3], + const float planes[6][4]) { + for (int p = 0; p < 6; ++p) { + float px = planes[p][0] >= 0.0f ? aabb_max[0] : aabb_min[0]; + float py = planes[p][1] >= 0.0f ? aabb_max[1] : aabb_min[1]; + float pz = planes[p][2] >= 0.0f ? aabb_max[2] : aabb_min[2]; + float dist = planes[p][0] * px + planes[p][1] * py + planes[p][2] * pz + planes[p][3]; + if (dist < 0.0f) return false; + } + return true; +} + +static void extractFrustumPlanes(const QMatrix4x4& vp, float planes[6][4]) { + for (int i = 0; i < 4; ++i) { + planes[0][i] = vp(3, i) + vp(0, i); + planes[1][i] = vp(3, i) - vp(0, i); + planes[2][i] = vp(3, i) + vp(1, i); + planes[3][i] = vp(3, i) - vp(1, i); + planes[4][i] = vp(3, i) + vp(2, i); + planes[5][i] = vp(3, i) - vp(2, i); + } + for (int p = 0; p < 6; ++p) { + float len = std::sqrt(planes[p][0]*planes[p][0] + + planes[p][1]*planes[p][1] + + planes[p][2]*planes[p][2]); + if (len > 0.0f) { + float inv = 1.0f / len; + planes[p][0] *= inv; planes[p][1] *= inv; + planes[p][2] *= inv; planes[p][3] *= inv; + } + } +} + +// Build bvh_items (one per instance, 1:1 ordering) and a per-model BVH. +// Items with instances.size() < BVH_MIN_OBJECTS leave bvh empty — the +// render path falls back to drawing every instance. +static void buildBvhForModel(ModelGpuData& m, uint32_t model_id) { + m.bvh_items.clear(); + m.bvh_items.reserve(m.instances.size()); + for (const auto& inst : m.instances) { + BvhItem it; + std::memcpy(it.aabb_min, inst.world_aabb_min, sizeof(it.aabb_min)); + std::memcpy(it.aabb_max, inst.world_aabb_max, sizeof(it.aabb_max)); + it.model_id = inst.model_id; + m.bvh_items.push_back(it); + } + if (m.bvh_items.size() >= BVH_MIN_OBJECTS) { + m.bvh = buildModelBvhOne(m.bvh_items, model_id); + } else { + m.bvh = ModelBvh{}; + } +} + ViewportWindow::ViewportWindow(QWindow* parent) : QWindow(parent) { @@ -239,6 +300,7 @@ ViewportWindow::~ViewportWindow() { if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -512,6 +574,8 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { gl_->glNamedBufferStorage(m.ssbo, ssbo_bytes, gpu.data(), 0); m.ssbo_instance_count = static_cast(gpu.size()); + buildBvhForModel(m, model_id); + m.finalized = true; qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram " @@ -555,6 +619,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.vbo) gl_->glDeleteBuffers(1, &existing->second.vbo); if (existing->second.ebo) gl_->glDeleteBuffers(1, &existing->second.ebo); if (existing->second.ssbo) gl_->glDeleteBuffers(1, &existing->second.ssbo); + if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); models_gpu_.erase(existing); } @@ -606,6 +671,8 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } m.ssbo_instance_count = static_cast(gpu.size()); + buildBvhForModel(m, model_id); + m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); @@ -628,6 +695,7 @@ void ViewportWindow::resetScene() { if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); } models_gpu_.clear(); selected_object_id_ = 0; @@ -652,6 +720,7 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.vbo) gl_->glDeleteBuffers(1, &it->second.vbo); if (it->second.ebo) gl_->glDeleteBuffers(1, &it->second.ebo); if (it->second.ssbo) gl_->glDeleteBuffers(1, &it->second.ssbo); + if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); models_gpu_.erase(it); } } @@ -689,6 +758,74 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { return pixel; } +void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]) { + // Ensure per-mesh scratch sized. + if (visible_by_mesh_.size() < m.meshes.size()) visible_by_mesh_.resize(m.meshes.size()); + for (size_t i = 0; i < m.meshes.size(); ++i) visible_by_mesh_[i].clear(); + + auto test_and_push = [&](uint32_t inst_idx) { + const InstanceCpu& inst = m.instances[inst_idx]; + if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; + if (inst.mesh_id < visible_by_mesh_.size()) + visible_by_mesh_[inst.mesh_id].push_back(inst_idx); + }; + + if (!m.bvh.nodes.empty()) { + uint32_t stack[64]; + int sp = 0; + stack[sp++] = 0; + while (sp > 0) { + uint32_t ni = stack[--sp]; + const BvhNode& n = m.bvh.nodes[ni]; + if (!aabbInFrustum(n.aabb_min, n.aabb_max, planes)) continue; + if (n.count > 0) { + for (uint32_t k = 0; k < n.count; ++k) { + uint32_t item_idx = m.bvh.item_indices[n.right_or_first + k]; + test_and_push(item_idx); + } + } else { + // Left child = ni + 1, right child = n.right_or_first. + // Push right first so left is popped next (DFS order). + if (sp + 2 <= 64) { + stack[sp++] = n.right_or_first; + stack[sp++] = ni + 1; + } + } + } + } else { + for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i); + } + + // Flatten into visible_flat_ and record per-mesh ranges. + visible_flat_.clear(); + m.mesh_vis_first.assign(m.meshes.size(), 0); + m.mesh_vis_count.assign(m.meshes.size(), 0); + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + m.mesh_vis_first[mi] = static_cast(visible_flat_.size()); + m.mesh_vis_count[mi] = static_cast(visible_by_mesh_[mi].size()); + visible_flat_.insert(visible_flat_.end(), + visible_by_mesh_[mi].begin(), + visible_by_mesh_[mi].end()); + } + + // Grow/create visible SSBO as needed. Keep at least 4 bytes so the binding + // is always valid even when nothing is visible. + size_t bytes = std::max(visible_flat_.size() * sizeof(uint32_t), + sizeof(uint32_t)); + if (m.visible_ssbo == 0 || m.visible_ssbo_capacity < bytes) { + if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); + size_t new_cap = m.visible_ssbo_capacity ? m.visible_ssbo_capacity : 4096; + while (new_cap < bytes) new_cap *= 2; + gl_->glCreateBuffers(1, &m.visible_ssbo); + gl_->glNamedBufferStorage(m.visible_ssbo, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); + m.visible_ssbo_capacity = new_cap; + } + if (!visible_flat_.empty()) { + gl_->glNamedBufferSubData(m.visible_ssbo, 0, + visible_flat_.size() * sizeof(uint32_t), visible_flat_.data()); + } +} + void ViewportWindow::updateCamera() { float yaw_rad = qDegreesToRadians(camera_yaw_); float pitch_rad = qDegreesToRadians(camera_pitch_); @@ -715,6 +852,8 @@ void ViewportWindow::render() { gl_->glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); QMatrix4x4 vp = proj_matrix_ * view_matrix_; + float planes[6][4]; + extractFrustumPlanes(vp, planes); gl_->glUseProgram(main_program_); GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); @@ -731,21 +870,28 @@ void ViewportWindow::render() { for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.finalized || !m.ssbo) continue; + + cullAndUploadVisible(m, planes); + if (visible_flat_.empty()) continue; + gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); - for (const auto& mesh : m.meshes) { - if (mesh.instance_count == 0 || mesh.index_count == 0) continue; - gl_->glUniform1ui(u_inst_off, mesh.first_instance); + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + const auto& mesh = m.meshes[mi]; + uint32_t vis_count = m.mesh_vis_count[mi]; + if (vis_count == 0 || mesh.index_count == 0) continue; + gl_->glUniform1ui(u_inst_off, m.mesh_vis_first[mi]); gl_->glDrawElementsInstancedBaseVertex( GL_TRIANGLES, static_cast(mesh.index_count), GL_UNSIGNED_INT, reinterpret_cast(static_cast(mesh.ebo_byte_offset)), - static_cast(mesh.instance_count), + static_cast(vis_count), static_cast(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES)); - visible_triangles_ += (mesh.index_count / 3) * mesh.instance_count; - visible_objects_ += mesh.instance_count; + visible_triangles_ += (mesh.index_count / 3) * vis_count; + visible_objects_ += vis_count; ++instanced_draws_; } } @@ -810,6 +956,9 @@ void ViewportWindow::renderPickPass() { gl_->glClear(GL_DEPTH_BUFFER_BIT); QMatrix4x4 vp = proj_matrix_ * view_matrix_; + float planes[6][4]; + extractFrustumPlanes(vp, planes); + gl_->glUseProgram(pick_program_); GLint u_vp = gl_->glGetUniformLocation(pick_program_, "u_view_projection"); GLint u_inst_off = gl_->glGetUniformLocation(pick_program_, "u_instance_offset"); @@ -817,17 +966,25 @@ void ViewportWindow::renderPickPass() { for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.finalized || !m.ssbo) continue; + + cullAndUploadVisible(m, planes); + if (visible_flat_.empty()) continue; + gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); - for (const auto& mesh : m.meshes) { - if (mesh.instance_count == 0 || mesh.index_count == 0) continue; - gl_->glUniform1ui(u_inst_off, mesh.first_instance); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); + + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + const auto& mesh = m.meshes[mi]; + uint32_t vis_count = m.mesh_vis_count[mi]; + if (vis_count == 0 || mesh.index_count == 0) continue; + gl_->glUniform1ui(u_inst_off, m.mesh_vis_first[mi]); gl_->glDrawElementsInstancedBaseVertex( GL_TRIANGLES, static_cast(mesh.index_count), GL_UNSIGNED_INT, reinterpret_cast(static_cast(mesh.ebo_byte_offset)), - static_cast(mesh.instance_count), + static_cast(vis_count), static_cast(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES)); } } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 65b15412e9..5a086fd774 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -34,6 +34,7 @@ #include #include +#include "BvhAccel.h" #include "InstancedGeometry.h" #include "SidecarCache.h" @@ -63,6 +64,20 @@ struct ModelGpuData { std::vector instances; // unsorted until finalize uint32_t ssbo_instance_count = 0; + // Per-instance world AABB + BVH (built at finalize). The BVH is the + // same ordering as `instances`; bvh_items[i] corresponds to instances[i]. + std::vector bvh_items; + ModelBvh bvh; + + // Dynamic visible-instance index buffer (std430, binding = 1). + // Re-uploaded each frame from frame_visible_scratch_. + GLuint visible_ssbo = 0; + size_t visible_ssbo_capacity = 0; // bytes + + // Per-mesh visible-list offset/count, rebuilt each frame. + std::vector mesh_vis_first; + std::vector mesh_vis_count; + bool finalized = false; bool hidden = false; }; @@ -135,6 +150,10 @@ private: bool growModelEbo(ModelGpuData& m, size_t needed_total); ModelGpuData& getOrCreateModel(uint32_t model_id); + // Populate m.mesh_vis_first / mesh_vis_count and upload visible indices + // to m.visible_ssbo. Uses BVH when available, else linear scan. + void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]); + // Mouse interaction void handleMousePress(QMouseEvent* event); void handleMouseRelease(QMouseEvent* event); @@ -171,6 +190,12 @@ private: uint32_t visible_objects_ = 0; uint32_t instanced_draws_ = 0; + // Reused scratch: visible-instance index lists per mesh, flattened into + // `visible_flat_` for upload. Both live in the parent object to avoid + // per-frame allocation. + std::vector> visible_by_mesh_; + std::vector visible_flat_; + // Camera QVector3D camera_target_{0, 0, 0}; float camera_distance_ = 50.0f; From 298eca0ab6ad3fe26d3b1646d02aeb49f3d7f7cb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 20:29:16 +1000 Subject: [PATCH 015/120] Progressive rendering during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-allocate the instance SSBO on model creation (4 MB, grow-on-demand) and append each arriving InstanceChunk directly to the GPU-side InstanceGpu array in uploadInstanceChunk. This makes a model drawable as soon as its first mesh + first instance chunk land, rather than waiting for finalizeModel. The visible-list architecture already decouples SSBO order from the draw path, so appending in insertion order is correct — no sorting required. finalizeModel collapses to: - compute per-mesh instance counts (for stats + sidecar round-trip) - build the per-model BVH over instance world AABBs Render / pick loops now gate on ssbo_instance_count > 0 rather than the finalized flag. Stats include in-progress models in totals (excluding only hidden). Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 118 +++++++++++++++++-------------- src/ifcviewer/ViewportWindow.h | 2 + 2 files changed, 66 insertions(+), 54 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 7011ec9d38..b70e2bf832 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -30,9 +30,10 @@ #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 -static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB +static const size_t INITIAL_VBO_SIZE = 64 * 1024 * 1024; // 64 MB +static const size_t INITIAL_EBO_SIZE = 32 * 1024 * 1024; // 32 MB +static const size_t INITIAL_SSBO_SIZE = 4 * 1024 * 1024; // 4 MB (~52k instances) +static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB // ----------------------------------------------------------------------------- // Shaders @@ -420,6 +421,27 @@ bool ViewportWindow::growModelVbo(ModelGpuData& m, size_t needed_total) { return true; } +bool ViewportWindow::growModelSsbo(ModelGpuData& m, size_t needed_total) { + size_t new_capacity = m.ssbo_capacity ? m.ssbo_capacity : INITIAL_SSBO_SIZE; + while (new_capacity < needed_total) new_capacity *= 2; + if (new_capacity > MAX_BUFFER_SIZE) { + qWarning("Instance SSBO grow request (%zu MB) exceeds cap", new_capacity / (1024*1024)); + return false; + } + GLuint new_ssbo = 0; + gl_->glCreateBuffers(1, &new_ssbo); + gl_->glNamedBufferStorage(new_ssbo, new_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); + const size_t used = m.ssbo_instance_count * sizeof(InstanceGpu); + if (m.ssbo && used > 0) { + gl_->glCopyNamedBufferSubData(m.ssbo, new_ssbo, 0, 0, used); + } + if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + m.ssbo = new_ssbo; + m.ssbo_capacity = new_capacity; + qInfo("Model instance SSBO grew to %zu MB", m.ssbo_capacity / (1024*1024)); + return true; +} + bool ViewportWindow::growModelEbo(ModelGpuData& m, size_t needed_total) { size_t new_capacity = m.ebo_capacity; while (new_capacity < needed_total) new_capacity *= 2; @@ -456,6 +478,11 @@ ModelGpuData& ViewportWindow::getOrCreateModel(uint32_t model_id) { gl_->glNamedBufferStorage(m.ebo, m.ebo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); setupVaoLayout(m.vao, m.vbo, m.ebo); + // Pre-allocate instance SSBO so we can append during streaming. + gl_->glCreateBuffers(1, &m.ssbo); + m.ssbo_capacity = INITIAL_SSBO_SIZE; + gl_->glNamedBufferStorage(m.ssbo, m.ssbo_capacity, nullptr, GL_DYNAMIC_STORAGE_BIT); + return models_gpu_.emplace(model_id, std::move(m)).first->second; } @@ -501,8 +528,8 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { if (!gl_initialized_) return; - // We don't need a GL context here since we're only touching CPU state, - // but the signal may fire on the render thread so keep it simple. + context_->makeCurrent(this); + ModelGpuData& m = getOrCreateModel(chunk.model_id); InstanceCpu inst; @@ -515,6 +542,23 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max)); m.instances.push_back(inst); + // Append the GPU record to the instance SSBO so the model is drawable + // immediately, without waiting for finalizeModel. The visible-list + // architecture means SSBO order is irrelevant to correctness. + InstanceGpu gpu; + std::memcpy(gpu.transform, inst.transform, sizeof(gpu.transform)); + gpu.object_id = inst.object_id; + gpu.color_override_rgba8 = inst.color_override_rgba8; + gpu._pad0 = 0; + gpu._pad1 = 0; + + const size_t offset = m.ssbo_instance_count * sizeof(InstanceGpu); + if (offset + sizeof(InstanceGpu) > m.ssbo_capacity) { + if (!growModelSsbo(m, offset + sizeof(InstanceGpu))) return; + } + gl_->glNamedBufferSubData(m.ssbo, offset, sizeof(InstanceGpu), &gpu); + m.ssbo_instance_count++; + if (chunk.local_mesh_id < m.meshes.size()) { m.total_triangles += m.meshes[chunk.local_mesh_id].index_count / 3; } @@ -527,64 +571,30 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { auto it = models_gpu_.find(model_id); if (it == models_gpu_.end()) return; ModelGpuData& m = it->second; - if (m.instances.empty()) { m.finalized = true; return; } - // Sort instances by mesh_id (stable for deterministic ordering). - std::stable_sort(m.instances.begin(), m.instances.end(), - [](const InstanceCpu& a, const InstanceCpu& b) { - return a.mesh_id < b.mesh_id; - }); - - // Assign per-mesh contiguous range. + // Instance SSBO has been populated incrementally during streaming, so + // we don't re-upload here. What finalize still does: + // (1) compute per-mesh instance counts — used by stats and the sidecar + // round-trip (first_instance is unused by the visible-list renderer), + // (2) build the per-model BVH over instance world AABBs. for (auto& mesh : m.meshes) { mesh.first_instance = 0; mesh.instance_count = 0; } - uint32_t current = UINT32_MAX; - uint32_t run_start = 0; - for (uint32_t i = 0; i < m.instances.size(); ++i) { - uint32_t mid = m.instances[i].mesh_id; - if (mid != current) { - if (current != UINT32_MAX && current < m.meshes.size()) { - m.meshes[current].first_instance = run_start; - m.meshes[current].instance_count = i - run_start; - } - current = mid; - run_start = i; - } + for (const auto& inst : m.instances) { + if (inst.mesh_id < m.meshes.size()) ++m.meshes[inst.mesh_id].instance_count; } - if (current != UINT32_MAX && current < m.meshes.size()) { - m.meshes[current].first_instance = run_start; - m.meshes[current].instance_count = static_cast(m.instances.size()) - run_start; - } - - // Build GPU-layout array. - std::vector gpu(m.instances.size()); - for (size_t i = 0; i < m.instances.size(); ++i) { - const InstanceCpu& src = m.instances[i]; - InstanceGpu& dst = gpu[i]; - std::memcpy(dst.transform, src.transform, sizeof(dst.transform)); - dst.object_id = src.object_id; - dst.color_override_rgba8 = src.color_override_rgba8; - dst._pad0 = 0; - dst._pad1 = 0; - } - - // Allocate and upload SSBO. - if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); - gl_->glCreateBuffers(1, &m.ssbo); - const size_t ssbo_bytes = gpu.size() * sizeof(InstanceGpu); - gl_->glNamedBufferStorage(m.ssbo, ssbo_bytes, gpu.data(), 0); - m.ssbo_instance_count = static_cast(gpu.size()); buildBvhForModel(m, model_id); m.finalized = true; + const size_t ssbo_bytes = m.ssbo_instance_count * sizeof(InstanceGpu); qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram " - "(vbo %.1f + ebo %.1f + ssbo %.1f)", + "(vbo %.1f + ebo %.1f + ssbo-used %.1f / %.1f cap)", model_id, size_t(m.vertex_count), m.meshes.size(), m.instances.size(), - (m.vbo_capacity + m.ebo_capacity + ssbo_bytes) / (1024.0*1024.0), + (m.vbo_capacity + m.ebo_capacity + m.ssbo_capacity) / (1024.0*1024.0), m.vbo_capacity / (1024.0*1024.0), m.ebo_capacity / (1024.0*1024.0), - ssbo_bytes / (1024.0*1024.0)); + ssbo_bytes / (1024.0*1024.0), + m.ssbo_capacity / (1024.0*1024.0)); } bool ViewportWindow::snapshotModel(uint32_t model_id, SidecarData& out) const { @@ -869,7 +879,7 @@ void ViewportWindow::render() { instanced_draws_ = 0; for (auto& [model_id, m] : models_gpu_) { - if (m.hidden || !m.finalized || !m.ssbo) continue; + if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; cullAndUploadVisible(m, planes); if (visible_flat_.empty()) continue; @@ -913,7 +923,7 @@ void ViewportWindow::render() { size_t num_models = 0, num_hidden = 0; for (const auto& [mid, mm] : models_gpu_) { num_models++; - if (mm.hidden || !mm.finalized) { num_hidden++; continue; } + if (mm.hidden) { num_hidden++; continue; } total_obj += static_cast(mm.instances.size()); total_tri += mm.total_triangles; total_meshes += static_cast(mm.meshes.size()); @@ -965,7 +975,7 @@ void ViewportWindow::renderPickPass() { gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); for (auto& [model_id, m] : models_gpu_) { - if (m.hidden || !m.finalized || !m.ssbo) continue; + if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; cullAndUploadVisible(m, planes); if (visible_flat_.empty()) continue; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 5a086fd774..fd21bb7641 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -55,6 +55,7 @@ struct ModelGpuData { size_t vbo_capacity = 0; size_t ebo_capacity = 0; + size_t ssbo_capacity = 0; // bytes size_t vbo_used = 0; size_t ebo_used = 0; uint32_t vertex_count = 0; // total (across all meshes) @@ -148,6 +149,7 @@ private: void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo); bool growModelVbo(ModelGpuData& m, size_t needed_total); bool growModelEbo(ModelGpuData& m, size_t needed_total); + bool growModelSsbo(ModelGpuData& m, size_t needed_total); ModelGpuData& getOrCreateModel(uint32_t model_id); // Populate m.mesh_vis_first / mesh_vis_count and upload visible indices From f0e3056d0a16b5796f73958d5e83d2d2e4630c86 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 20:34:47 +1000 Subject: [PATCH 016/120] Collapse per-mesh draws into glMultiDrawElementsIndirect Each visible model now issues a single glMultiDrawElementsIndirect call instead of one glDrawElementsInstancedBaseVertex per mesh. The CPU BVH cull populates an array of DrawElementsIndirectCommand records plus the flat visible-instance list, uploads both, and draws the whole model in one GL call. Vertex shaders switch from a uniform u_instance_offset to gl_BaseInstanceARB (ARB_shader_draw_parameters), so per-draw offset comes from the indirect command's baseInstance field. Draw-call counts for BIM scenes with hundreds of unique meshes drop from hundreds-per-frame to one-per-model, cutting driver overhead. This also sets up the plumbing for the follow-up compute-shader cull that will populate the indirect buffer entirely on-GPU. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 110 +++++++++++++++++-------------- src/ifcviewer/ViewportWindow.h | 33 +++++++--- 2 files changed, 86 insertions(+), 57 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index b70e2bf832..b24ff7e3b3 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -35,6 +35,8 @@ static const size_t INITIAL_EBO_SIZE = 32 * 1024 * 1024; // 32 MB static const size_t INITIAL_SSBO_SIZE = 4 * 1024 * 1024; // 4 MB (~52k instances) static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB +static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 20 bytes"); + // ----------------------------------------------------------------------------- // Shaders // ----------------------------------------------------------------------------- @@ -55,6 +57,7 @@ static const size_t MAX_BUFFER_SIZE = 4ull * 1024 * 1024 * 1024; // 4 GB static const char* MAIN_VERTEX_SHADER = R"( #version 450 core +#extension GL_ARB_shader_draw_parameters : require layout(location = 0) in vec3 a_position; layout(location = 1) in vec3 a_normal; layout(location = 2) in vec4 a_color; @@ -74,7 +77,6 @@ layout(std430, binding = 1) readonly buffer VisibleIndices { }; uniform mat4 u_view_projection; -uniform uint u_instance_offset; uniform uint u_selected_id; out vec3 v_normal; @@ -83,7 +85,8 @@ flat out uint v_object_id; flat out uint v_selected; void main() { - uint iid = visible[u_instance_offset + uint(gl_InstanceID)]; + uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); + uint iid = visible[slot]; InstanceRecord inst = instances[iid]; vec4 world = inst.transform * vec4(a_position, 1.0); gl_Position = u_view_projection * world; @@ -132,6 +135,7 @@ void main() { static const char* PICK_VERTEX_SHADER = R"( #version 450 core +#extension GL_ARB_shader_draw_parameters : require layout(location = 0) in vec3 a_position; struct InstanceRecord { @@ -149,12 +153,12 @@ layout(std430, binding = 1) readonly buffer VisibleIndices { }; uniform mat4 u_view_projection; -uniform uint u_instance_offset; flat out uint v_object_id; void main() { - uint iid = visible[u_instance_offset + uint(gl_InstanceID)]; + uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); + uint iid = visible[slot]; InstanceRecord inst = instances[iid]; gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0); v_object_id = inst.object_id; @@ -302,6 +306,7 @@ ViewportWindow::~ViewportWindow() { if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); + if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -630,6 +635,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.ebo) gl_->glDeleteBuffers(1, &existing->second.ebo); if (existing->second.ssbo) gl_->glDeleteBuffers(1, &existing->second.ssbo); if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); + if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); models_gpu_.erase(existing); } @@ -706,6 +712,7 @@ void ViewportWindow::resetScene() { if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); + if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); } models_gpu_.clear(); selected_object_id_ = 0; @@ -731,6 +738,7 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.ebo) gl_->glDeleteBuffers(1, &it->second.ebo); if (it->second.ssbo) gl_->glDeleteBuffers(1, &it->second.ssbo); if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); + if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); models_gpu_.erase(it); } } @@ -806,26 +814,36 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i); } - // Flatten into visible_flat_ and record per-mesh ranges. + // Flatten into visible_flat_ and build one DrawElementsIndirectCommand + // per non-empty mesh. visible_flat_.clear(); - m.mesh_vis_first.assign(m.meshes.size(), 0); - m.mesh_vis_count.assign(m.meshes.size(), 0); + indirect_scratch_.clear(); for (size_t mi = 0; mi < m.meshes.size(); ++mi) { - m.mesh_vis_first[mi] = static_cast(visible_flat_.size()); - m.mesh_vis_count[mi] = static_cast(visible_by_mesh_[mi].size()); + const auto& mesh = m.meshes[mi]; + const uint32_t vis_count = static_cast(visible_by_mesh_[mi].size()); + if (vis_count == 0 || mesh.index_count == 0) continue; + + DrawElementsIndirectCommand cmd; + cmd.count = mesh.index_count; + cmd.instanceCount = vis_count; + cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = static_cast(visible_flat_.size()); + indirect_scratch_.push_back(cmd); + visible_flat_.insert(visible_flat_.end(), visible_by_mesh_[mi].begin(), visible_by_mesh_[mi].end()); } + m.indirect_command_count = static_cast(indirect_scratch_.size()); - // Grow/create visible SSBO as needed. Keep at least 4 bytes so the binding - // is always valid even when nothing is visible. - size_t bytes = std::max(visible_flat_.size() * sizeof(uint32_t), - sizeof(uint32_t)); - if (m.visible_ssbo == 0 || m.visible_ssbo_capacity < bytes) { + // Upload visible list (keep binding alive even when empty). + size_t vis_bytes = std::max(visible_flat_.size() * sizeof(uint32_t), + sizeof(uint32_t)); + if (m.visible_ssbo == 0 || m.visible_ssbo_capacity < vis_bytes) { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); size_t new_cap = m.visible_ssbo_capacity ? m.visible_ssbo_capacity : 4096; - while (new_cap < bytes) new_cap *= 2; + while (new_cap < vis_bytes) new_cap *= 2; gl_->glCreateBuffers(1, &m.visible_ssbo); gl_->glNamedBufferStorage(m.visible_ssbo, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); m.visible_ssbo_capacity = new_cap; @@ -834,6 +852,19 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] gl_->glNamedBufferSubData(m.visible_ssbo, 0, visible_flat_.size() * sizeof(uint32_t), visible_flat_.data()); } + + // Upload indirect command buffer. + size_t ind_bytes = indirect_scratch_.size() * sizeof(DrawElementsIndirectCommand); + if (ind_bytes == 0) return; + if (m.indirect_buffer == 0 || m.indirect_capacity < ind_bytes) { + if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); + size_t new_cap = m.indirect_capacity ? m.indirect_capacity : 4096; + while (new_cap < ind_bytes) new_cap *= 2; + gl_->glCreateBuffers(1, &m.indirect_buffer); + gl_->glNamedBufferStorage(m.indirect_buffer, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); + m.indirect_capacity = new_cap; + } + gl_->glNamedBufferSubData(m.indirect_buffer, 0, ind_bytes, indirect_scratch_.data()); } void ViewportWindow::updateCamera() { @@ -869,7 +900,6 @@ void ViewportWindow::render() { GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); GLint u_light = gl_->glGetUniformLocation(main_program_, "u_light_dir"); GLint u_sel = gl_->glGetUniformLocation(main_program_, "u_selected_id"); - GLint u_inst_off = gl_->glGetUniformLocation(main_program_, "u_instance_offset"); gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); gl_->glUniform3f(u_light, 0.3f, 0.5f, 0.8f); gl_->glUniform1ui(u_sel, selected_object_id_); @@ -882,29 +912,23 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; cullAndUploadVisible(m, planes); - if (visible_flat_.empty()) continue; + if (m.indirect_command_count == 0) continue; gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(m.indirect_command_count), 0); - for (size_t mi = 0; mi < m.meshes.size(); ++mi) { - const auto& mesh = m.meshes[mi]; - uint32_t vis_count = m.mesh_vis_count[mi]; - if (vis_count == 0 || mesh.index_count == 0) continue; - gl_->glUniform1ui(u_inst_off, m.mesh_vis_first[mi]); - gl_->glDrawElementsInstancedBaseVertex( - GL_TRIANGLES, - static_cast(mesh.index_count), - GL_UNSIGNED_INT, - reinterpret_cast(static_cast(mesh.ebo_byte_offset)), - static_cast(vis_count), - static_cast(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES)); - visible_triangles_ += (mesh.index_count / 3) * vis_count; - visible_objects_ += vis_count; - ++instanced_draws_; + for (const auto& cmd : indirect_scratch_) { + visible_triangles_ += (cmd.count / 3) * cmd.instanceCount; + visible_objects_ += cmd.instanceCount; } + instanced_draws_ += m.indirect_command_count; } + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); renderAxisGizmo(); @@ -971,33 +995,23 @@ void ViewportWindow::renderPickPass() { gl_->glUseProgram(pick_program_); GLint u_vp = gl_->glGetUniformLocation(pick_program_, "u_view_projection"); - GLint u_inst_off = gl_->glGetUniformLocation(pick_program_, "u_instance_offset"); gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; cullAndUploadVisible(m, planes); - if (visible_flat_.empty()) continue; + if (m.indirect_command_count == 0) continue; gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); - - for (size_t mi = 0; mi < m.meshes.size(); ++mi) { - const auto& mesh = m.meshes[mi]; - uint32_t vis_count = m.mesh_vis_count[mi]; - if (vis_count == 0 || mesh.index_count == 0) continue; - gl_->glUniform1ui(u_inst_off, m.mesh_vis_first[mi]); - gl_->glDrawElementsInstancedBaseVertex( - GL_TRIANGLES, - static_cast(mesh.index_count), - GL_UNSIGNED_INT, - reinterpret_cast(static_cast(mesh.ebo_byte_offset)), - static_cast(vis_count), - static_cast(mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES)); - } + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(m.indirect_command_count), 0); } + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index fd21bb7641..966761eeaf 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -38,6 +38,15 @@ #include "InstancedGeometry.h" #include "SidecarCache.h" +// Matches GL_DRAW_INDIRECT_BUFFER layout for glMultiDrawElementsIndirect. +struct DrawElementsIndirectCommand { + uint32_t count; + uint32_t instanceCount; + uint32_t firstIndex; + uint32_t baseVertex; + uint32_t baseInstance; +}; + // Per-model GPU state for the instanced render path. // // VBO: local-coord interleaved verts (pos3 + normal3 + color1_packed) — 28 B. @@ -71,13 +80,15 @@ struct ModelGpuData { ModelBvh bvh; // Dynamic visible-instance index buffer (std430, binding = 1). - // Re-uploaded each frame from frame_visible_scratch_. + // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; size_t visible_ssbo_capacity = 0; // bytes - // Per-mesh visible-list offset/count, rebuilt each frame. - std::vector mesh_vis_first; - std::vector mesh_vis_count; + // GL_DRAW_INDIRECT_BUFFER of DrawElementsIndirectCommand[], one per + // non-empty mesh. Re-uploaded each frame. + GLuint indirect_buffer = 0; + size_t indirect_capacity = 0; // bytes + uint32_t indirect_command_count = 0; // valid commands this frame bool finalized = false; bool hidden = false; @@ -152,8 +163,9 @@ private: bool growModelSsbo(ModelGpuData& m, size_t needed_total); ModelGpuData& getOrCreateModel(uint32_t model_id); - // Populate m.mesh_vis_first / mesh_vis_count and upload visible indices - // to m.visible_ssbo. Uses BVH when available, else linear scan. + // Frustum-cull m's instances (BVH if available, else linear scan), + // build the per-mesh DrawElementsIndirectCommand array + flat visible + // list, and upload both to m.indirect_buffer / m.visible_ssbo. void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]); // Mouse interaction @@ -194,9 +206,12 @@ private: // Reused scratch: visible-instance index lists per mesh, flattened into // `visible_flat_` for upload. Both live in the parent object to avoid - // per-frame allocation. - std::vector> visible_by_mesh_; - std::vector visible_flat_; + // per-frame allocation. indirect_scratch_ is the matching array of + // DrawElementsIndirectCommand records — forward-declared as bytes so + // the header doesn't need the struct definition. + std::vector> visible_by_mesh_; + std::vector visible_flat_; + std::vector indirect_scratch_; // Camera QVector3D camera_target_{0, 0, 0}; From f532624dc1604ab9c4b575e7f1a7fdad3e3893ba Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 21:10:27 +1000 Subject: [PATCH 017/120] Two-sided lighting, rename misleading draw-count stat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs conflated as "weird colors": 1. Two-sided lighting. IFC placements often embed reflection matrices (mirrored families). Transforming a_normal by mat3(inst.transform) produces a normal pointing the wrong way on those instances, and max(n·L, 0) then clamps the surface to pure ambient — reads as dark / washed out. Use gl_FrontFacing to flip n in the fragment shader so both winding orientations shade correctly. The proper fix (ship an inverse-transpose normal matrix or a det-sign bit per instance) is still owed; that would unlock re-enabling GL_CULL_FACE for a big fragment- work win on closed solids. 2. Stats label "inst_draws" was counting indirect sub-draws, not actual GL draw calls — misleading since MDI collapses N sub- draws into one glMultiDrawElementsIndirect. Split into gl_draw_calls (real GL calls, = drawn-model count) and indirect_sub_draws (packed sub-commands). For a BIM model with 47k unique meshes at full view this now correctly reads "1 gl_draws (47092 sub)" rather than suggesting 47k driver dispatches. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/MainWindow.cpp | 6 ++++-- src/ifcviewer/ViewportWindow.cpp | 19 ++++++++++++++----- src/ifcviewer/ViewportWindow.h | 6 ++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index ceeedc8cbd..8b63f3bdf6 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -41,13 +41,15 @@ MainWindow::MainWindow(QWidget* parent) connect(viewport_, &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& s) { if (!stats_label_->isVisible()) return; stats_label_->setText( - QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri") + QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 gl_draws (%8 sub)") .arg(s.fps, 0, 'f', 1) .arg(s.frame_time_ms, 0, 'f', 1) .arg(s.visible_objects) .arg(s.total_objects) .arg(s.visible_triangles) - .arg(s.total_triangles)); + .arg(s.total_triangles) + .arg(s.gl_draw_calls) + .arg(s.indirect_sub_draws)); }); connect(&AppSettings::instance(), &AppSettings::showStatsChanged, this, [this](bool show) { diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index b24ff7e3b3..d58f192733 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -123,7 +123,13 @@ uniform vec3 u_light_dir; out vec4 frag_color; void main() { + // Two-sided lighting: IFC placements frequently embed reflections + // (mirrored families), which flip triangle winding and invert the + // transformed normal. Taking abs(dot) — or equivalently flipping n + // based on gl_FrontFacing — makes both sides shade correctly + // regardless of winding / reflection state. vec3 n = normalize(v_normal); + if (!gl_FrontFacing) n = -n; float ndotl = max(dot(n, u_light_dir), 0.0); float ambient = 0.25; float diffuse = 0.75 * ndotl; @@ -906,7 +912,8 @@ void ViewportWindow::render() { visible_triangles_ = 0; visible_objects_ = 0; - instanced_draws_ = 0; + gl_draw_calls_ = 0; + indirect_sub_draws_ = 0; for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; @@ -926,7 +933,8 @@ void ViewportWindow::render() { visible_triangles_ += (cmd.count / 3) * cmd.instanceCount; visible_objects_ += cmd.instanceCount; } - instanced_draws_ += m.indirect_command_count; + indirect_sub_draws_ += m.indirect_command_count; + ++gl_draw_calls_; } gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); @@ -964,16 +972,17 @@ void ViewportWindow::render() { stats.total_triangles = total_tri; stats.visible_triangles = visible_triangles_; stats.unique_meshes = total_meshes; - stats.instanced_draws = instanced_draws_; + stats.gl_draw_calls = gl_draw_calls_; + stats.indirect_sub_draws = indirect_sub_draws_; emit frameStatsUpdated(stats); qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " - "meshes %u inst_draws %u " + "meshes %u gl_draws %u sub_draws %u " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, visible_triangles_, total_tri, - total_meshes, instanced_draws_, + total_meshes, gl_draw_calls_, indirect_sub_draws_, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 966761eeaf..2c3019eb15 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -136,7 +136,8 @@ public: uint32_t total_triangles; uint32_t visible_triangles; uint32_t unique_meshes; - uint32_t instanced_draws; + uint32_t gl_draw_calls; // actual glMultiDrawElementsIndirect issues per frame + uint32_t indirect_sub_draws; // total commands packed into those indirect buffers }; signals: @@ -202,7 +203,8 @@ private: // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; - uint32_t instanced_draws_ = 0; + uint32_t gl_draw_calls_ = 0; + uint32_t indirect_sub_draws_ = 0; // Reused scratch: visible-instance index lists per mesh, flattened into // `visible_flat_` for upload. Both live in the parent object to avoid From 0e2a62d3b704aab8af39d7b7210219b385e06b3b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 22:06:58 +1000 Subject: [PATCH 018/120] Enable reorient-shells in geometry iterator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IFC files routinely have IfcConnectedFaceSets whose faces point inconsistently within the same shell — the result under per-vertex normals is dark inside-out patches, and under GL_CULL_FACE it's swiss-cheese. reorient-shells fixes the face winding at geometry generation time, which is the only place it can be fixed correctly; no shader trick can recover from a mesh whose triangles disagree among themselves. Off by default in IfcOpenShell because it adds iterator time, but we cache the result in the sidecar so it's a one-shot cost per file. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/GeometryStreamer.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 226fb0808c..d3edcce19f 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -270,6 +270,11 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { settings.set("use-world-coords", false); settings.set("weld-vertices", false); settings.set("apply-default-materials", true); + // Off by default in IfcOpenShell — makes face winding consistent within + // each shell, which we need for GL_CULL_FACE and for per-vertex normals + // to shade a solid without dark inside-out patches. Costs some iterator + // time, but results are cached in the sidecar so it's a one-shot hit. + settings.set("reorient-shells", true); std::unique_ptr iterator; try { From 3110c98429674dd25bde20d6b4b75b9f198ec444 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 22:07:19 +1000 Subject: [PATCH 019/120] Backface culling with reflection-aware two-pass MDI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables GL_CULL_FACE by default (user-toggleable in Settings) so closed solids skip shading their back halves. The catch is that IFC placements can contain reflections (mat4 with det<0 — mirrored families, symmetric instances). Naively culling would make every mirrored instance vanish because the rasterizer sees its screen-space winding as backwards. Fix: detect reflections at upload time via determinant sign, bucket visible instances into forward (det>=0) and reverse (det<0) per mesh during culling, and issue two glMultiDrawElementsIndirect calls per model with glFrontFace toggled CCW/CW between them. The indirect buffer is still one buffer — just split into a forward slice followed by a reverse slice, with m.indirect_forward_count recording the split. Vertex shader flips the normal when the transform has negative determinant, keeping lighting correct on mirrored instances. The fragment shader keeps the gl_FrontFacing fallback as a safety net when culling is disabled (e.g. for files with open shells). Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/AppSettings.cpp | 14 +++ src/ifcviewer/AppSettings.h | 5 + src/ifcviewer/SettingsWindow.cpp | 8 ++ src/ifcviewer/SettingsWindow.h | 1 + src/ifcviewer/ViewportWindow.cpp | 172 ++++++++++++++++++++++++------- src/ifcviewer/ViewportWindow.h | 13 ++- 6 files changed, 172 insertions(+), 41 deletions(-) diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index af1edfa36f..ff8d3bb3f1 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -25,6 +25,7 @@ namespace { constexpr const char* kGeometryLibraryKey = "geometry/library"; constexpr const char* kGeometryLibraryDefault = "hybrid-cgal-simple-opencascade"; constexpr const char* kShowStatsKey = "viewport/show_stats"; +constexpr const char* kBackfaceCullingKey = "viewport/backface_culling"; } AppSettings& AppSettings::instance() { @@ -58,14 +59,27 @@ void AppSettings::setShowStats(bool value) { emit showStatsChanged(value); } +bool AppSettings::backfaceCulling() const { + return backface_culling_; +} + +void AppSettings::setBackfaceCulling(bool value) { + if (backface_culling_ == value) return; + backface_culling_ = value; + persist(); + emit backfaceCullingChanged(value); +} + void AppSettings::load() { QSettings settings; geometry_library_ = settings.value(kGeometryLibraryKey, kGeometryLibraryDefault).toString(); show_stats_ = settings.value(kShowStatsKey, false).toBool(); + backface_culling_ = settings.value(kBackfaceCullingKey, true).toBool(); } void AppSettings::persist() { QSettings settings; settings.setValue(kGeometryLibraryKey, geometry_library_); settings.setValue(kShowStatsKey, show_stats_); + settings.setValue(kBackfaceCullingKey, backface_culling_); } diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index f70062475c..8b38c61a33 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -37,9 +37,13 @@ public: bool showStats() const; void setShowStats(bool value); + bool backfaceCulling() const; + void setBackfaceCulling(bool value); + signals: void geometryLibraryChanged(const QString& value); void showStatsChanged(bool value); + void backfaceCullingChanged(bool value); private: AppSettings(); @@ -48,6 +52,7 @@ private: QString geometry_library_; bool show_stats_ = false; + bool backface_culling_ = true; }; #endif // APPSETTINGS_H diff --git a/src/ifcviewer/SettingsWindow.cpp b/src/ifcviewer/SettingsWindow.cpp index c4ebddc650..69e1f025b8 100644 --- a/src/ifcviewer/SettingsWindow.cpp +++ b/src/ifcviewer/SettingsWindow.cpp @@ -44,6 +44,12 @@ void SettingsWindow::setupUi() { show_stats_check_ = new QCheckBox(this); form->addRow("Show Performance Stats", show_stats_check_); + backface_culling_check_ = new QCheckBox(this); + backface_culling_check_->setToolTip( + "Skip triangles facing away from the camera. Big FPS win on " + "closed solids; disable if you see holes in open geometry."); + form->addRow("Backface Culling", backface_culling_check_); + auto* button_box = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); @@ -65,10 +71,12 @@ void SettingsWindow::showEvent(QShowEvent* event) { void SettingsWindow::syncFromSettings() { geometry_library_edit_->setText(AppSettings::instance().geometryLibrary()); show_stats_check_->setChecked(AppSettings::instance().showStats()); + backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling()); } void SettingsWindow::onAccepted() { AppSettings::instance().setGeometryLibrary(geometry_library_edit_->text()); AppSettings::instance().setShowStats(show_stats_check_->isChecked()); + AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked()); accept(); } diff --git a/src/ifcviewer/SettingsWindow.h b/src/ifcviewer/SettingsWindow.h index ea55252682..967938b4a2 100644 --- a/src/ifcviewer/SettingsWindow.h +++ b/src/ifcviewer/SettingsWindow.h @@ -43,6 +43,7 @@ private: QLineEdit* geometry_library_edit_ = nullptr; QCheckBox* show_stats_check_ = nullptr; + QCheckBox* backface_culling_check_ = nullptr; }; #endif diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index d58f192733..84778f3f2f 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -19,6 +19,8 @@ #include "ViewportWindow.h" +#include "AppSettings.h" + #include #include #include @@ -91,10 +93,17 @@ void main() { vec4 world = inst.transform * vec4(a_position, 1.0); gl_Position = u_view_projection * world; - // Rotate the normal by the upper-3x3 of the transform. For the vast - // majority of BIM placements this is a rigid rotation (+ uniform scale), - // so we skip the inverse-transpose. - v_normal = normalize(mat3(inst.transform) * a_normal); + // Rotate the normal by the upper-3x3 of the transform. BIM placements + // are overwhelmingly rigid rotations (+ optional uniform scale + + // optional reflection), so we skip the full inverse-transpose but do + // need to flip the normal when the transform contains a reflection, + // otherwise mirrored instances shade as if inside-out. The same + // determinant sign is what GL_CULL_FACE uses to decide winding, so + // keeping them in agreement means backface culling is safe to enable. + mat3 rot = mat3(inst.transform); + vec3 n = rot * a_normal; + if (determinant(rot) < 0.0) n = -n; + v_normal = normalize(n); vec4 baked = a_color; if (inst.color_override != 0u) { @@ -123,11 +132,11 @@ uniform vec3 u_light_dir; out vec4 frag_color; void main() { - // Two-sided lighting: IFC placements frequently embed reflections - // (mirrored families), which flip triangle winding and invert the - // transformed normal. Taking abs(dot) — or equivalently flipping n - // based on gl_FrontFacing — makes both sides shade correctly - // regardless of winding / reflection state. + // v_normal already has the reflection flip applied in the vertex + // shader. When backface culling is off, open shells let us see the + // "wrong" side of a face — flip based on gl_FrontFacing so both + // sides light correctly. When culling is on this branch is always + // true and has no effect. vec3 n = normalize(v_normal); if (!gl_FrontFacing) n = -n; float ndotl = max(dot(n, u_light_dir), 0.0); @@ -230,6 +239,17 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra // ----------------------------------------------------------------------------- +// Determinant of the upper-left 3x3 of a column-major mat4 stored as 16 floats. +// Sign tells us whether the transform contains a reflection, which is what +// decides which glFrontFace winding to draw the instance with. +static bool transformIsReflected(const float t[16]) { + const float det = + t[0] * (t[5] * t[10] - t[9] * t[6]) + - t[4] * (t[1] * t[10] - t[9] * t[2]) + + t[8] * (t[1] * t[6] - t[5] * t[2]); + return det < 0.0f; +} + static bool aabbInFrustum(const float aabb_min[3], const float aabb_max[3], const float planes[6][4]) { for (int p = 0; p < 6; ++p) { @@ -344,6 +364,19 @@ void ViewportWindow::initGL() { gl_->glEnable(GL_DEPTH_TEST); gl_->glEnable(GL_MULTISAMPLE); gl_->glClearColor(0.18f, 0.20f, 0.22f, 1.0f); + gl_->glCullFace(GL_BACK); + if (AppSettings::instance().backfaceCulling()) gl_->glEnable(GL_CULL_FACE); + else gl_->glDisable(GL_CULL_FACE); + + // Hot-toggle cull state when the setting changes. Queued so we touch GL + // state only when render() is about to run. + connect(&AppSettings::instance(), &AppSettings::backfaceCullingChanged, + this, [this](bool on) { + if (!gl_initialized_ || !gl_) return; + context_->makeCurrent(this); + if (on) gl_->glEnable(GL_CULL_FACE); + else gl_->glDisable(GL_CULL_FACE); + }); gl_initialized_ = true; frame_clock_.start(); @@ -552,6 +585,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min)); std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max)); m.instances.push_back(inst); + m.instance_reflected.push_back(transformIsReflected(inst.transform) ? 1 : 0); // Append the GPU record to the instance SSBO so the model is drawable // immediately, without waiting for finalizeModel. The visible-list @@ -693,6 +727,13 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } m.ssbo_instance_count = static_cast(gpu.size()); + // Recompute the reflection flag from each instance's transform — the + // sidecar only caches InstanceCpu, not the parallel reflection flags. + m.instance_reflected.resize(m.instances.size()); + for (size_t i = 0; i < m.instances.size(); ++i) { + m.instance_reflected[i] = transformIsReflected(m.instances[i].transform) ? 1 : 0; + } + buildBvhForModel(m, model_id); m.finalized = true; @@ -783,15 +824,25 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { } void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]) { - // Ensure per-mesh scratch sized. - if (visible_by_mesh_.size() < m.meshes.size()) visible_by_mesh_.resize(m.meshes.size()); - for (size_t i = 0; i < m.meshes.size(); ++i) visible_by_mesh_[i].clear(); + // Per-mesh scratch, split by winding: fwd = non-reflected (CCW in screen + // space), rev = reflected (CW in screen space). Splitting lets the draw + // pass toggle glFrontFace once between two MDI calls so GL_CULL_FACE does + // the right thing for both. + if (visible_by_mesh_fwd_.size() < m.meshes.size()) visible_by_mesh_fwd_.resize(m.meshes.size()); + if (visible_by_mesh_rev_.size() < m.meshes.size()) visible_by_mesh_rev_.resize(m.meshes.size()); + for (size_t i = 0; i < m.meshes.size(); ++i) { + visible_by_mesh_fwd_[i].clear(); + visible_by_mesh_rev_[i].clear(); + } auto test_and_push = [&](uint32_t inst_idx) { const InstanceCpu& inst = m.instances[inst_idx]; if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; - if (inst.mesh_id < visible_by_mesh_.size()) - visible_by_mesh_[inst.mesh_id].push_back(inst_idx); + if (inst.mesh_id >= m.meshes.size()) return; + const bool reflected = inst_idx < m.instance_reflected.size() + && m.instance_reflected[inst_idx] != 0; + if (reflected) visible_by_mesh_rev_[inst.mesh_id].push_back(inst_idx); + else visible_by_mesh_fwd_[inst.mesh_id].push_back(inst_idx); }; if (!m.bvh.nodes.empty()) { @@ -820,27 +871,34 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i); } - // Flatten into visible_flat_ and build one DrawElementsIndirectCommand - // per non-empty mesh. + // Flatten fwd-slice first, then rev-slice, into visible_flat_. Build + // matching DrawElementsIndirectCommands; commands for the fwd slice fill + // [0, indirect_forward_count), rev fills [indirect_forward_count, end). visible_flat_.clear(); indirect_scratch_.clear(); - for (size_t mi = 0; mi < m.meshes.size(); ++mi) { - const auto& mesh = m.meshes[mi]; - const uint32_t vis_count = static_cast(visible_by_mesh_[mi].size()); - if (vis_count == 0 || mesh.index_count == 0) continue; - DrawElementsIndirectCommand cmd; - cmd.count = mesh.index_count; - cmd.instanceCount = vis_count; - cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = static_cast(visible_flat_.size()); - indirect_scratch_.push_back(cmd); + auto emit_slice = [&](std::vector>& by_mesh) { + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + const auto& mesh = m.meshes[mi]; + const uint32_t vis_count = static_cast(by_mesh[mi].size()); + if (vis_count == 0 || mesh.index_count == 0) continue; - visible_flat_.insert(visible_flat_.end(), - visible_by_mesh_[mi].begin(), - visible_by_mesh_[mi].end()); - } + DrawElementsIndirectCommand cmd; + cmd.count = mesh.index_count; + cmd.instanceCount = vis_count; + cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = static_cast(visible_flat_.size()); + indirect_scratch_.push_back(cmd); + + visible_flat_.insert(visible_flat_.end(), + by_mesh[mi].begin(), by_mesh[mi].end()); + } + }; + + emit_slice(visible_by_mesh_fwd_); + m.indirect_forward_count = static_cast(indirect_scratch_.size()); + emit_slice(visible_by_mesh_rev_); m.indirect_command_count = static_cast(indirect_scratch_.size()); // Upload visible list (keep binding alive even when empty). @@ -915,6 +973,10 @@ void ViewportWindow::render() { gl_draw_calls_ = 0; indirect_sub_draws_ = 0; + // Start each frame with CCW-is-front; the two-pass draw below flips + // back and forth. Harmless when culling is off. + gl_->glFrontFace(GL_CCW); + for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; @@ -925,16 +987,34 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(m.indirect_command_count), 0); + + const uint32_t fwd = m.indirect_forward_count; + const uint32_t rev = m.indirect_command_count - fwd; + // Forward pass: non-reflected instances, standard CCW winding. + if (fwd > 0) { + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(fwd), 0); + ++gl_draw_calls_; + } + // Reverse pass: reflected instances — their world-space winding is + // flipped, so telling GL the front is CW keeps cull-back working. + if (rev > 0) { + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(fwd * sizeof(DrawElementsIndirectCommand)), + static_cast(rev), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CCW); + } for (const auto& cmd : indirect_scratch_) { visible_triangles_ += (cmd.count / 3) * cmd.instanceCount; visible_objects_ += cmd.instanceCount; } indirect_sub_draws_ += m.indirect_command_count; - ++gl_draw_calls_; } gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); @@ -1006,6 +1086,8 @@ void ViewportWindow::renderPickPass() { GLint u_vp = gl_->glGetUniformLocation(pick_program_, "u_view_projection"); gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); + gl_->glFrontFace(GL_CCW); + for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; @@ -1016,9 +1098,23 @@ void ViewportWindow::renderPickPass() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(m.indirect_command_count), 0); + + const uint32_t fwd = m.indirect_forward_count; + const uint32_t rev = m.indirect_command_count - fwd; + if (fwd > 0) { + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(fwd), 0); + } + if (rev > 0) { + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(fwd * sizeof(DrawElementsIndirectCommand)), + static_cast(rev), 0); + gl_->glFrontFace(GL_CCW); + } } gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 2c3019eb15..1bbc44c97c 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -71,7 +71,12 @@ struct ModelGpuData { uint32_t total_triangles = 0; std::vector meshes; - std::vector instances; // unsorted until finalize + std::vector instances; // unsorted + // 1:1 with instances[] — true when the instance transform has + // det < 0 (a reflection). Reflected instances need their + // triangle winding treated as reversed so GL_CULL_FACE culls + // the correct side. + std::vector instance_reflected; uint32_t ssbo_instance_count = 0; // Per-instance world AABB + BVH (built at finalize). The BVH is the @@ -88,7 +93,8 @@ struct ModelGpuData { // non-empty mesh. Re-uploaded each frame. GLuint indirect_buffer = 0; size_t indirect_capacity = 0; // bytes - uint32_t indirect_command_count = 0; // valid commands this frame + uint32_t indirect_command_count = 0; // total valid commands this frame + uint32_t indirect_forward_count = 0; // first N are CCW-winding draws bool finalized = false; bool hidden = false; @@ -211,7 +217,8 @@ private: // per-frame allocation. indirect_scratch_ is the matching array of // DrawElementsIndirectCommand records — forward-declared as bytes so // the header doesn't need the struct definition. - std::vector> visible_by_mesh_; + std::vector> visible_by_mesh_fwd_; + std::vector> visible_by_mesh_rev_; std::vector visible_flat_; std::vector indirect_scratch_; From cd77c557e9b7c13638f665f2b33920adcf7e9b71 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 12 Apr 2026 23:16:38 +1000 Subject: [PATCH 020/120] Rewrite README for instancing pipeline and refocus Phase 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous README described a pre-instancing world (32-byte world- coord vertices with per-vertex object_id, ObjectDrawInfo structs, EBO reordering after BVH build, and a Phase 3 plan built around moving draw submission to the GPU). Most of that is either gone or already solved: - Vertices are now 28 B local-coord; per-instance transforms live in an SSBO read through a visible-index SSBO and gl_BaseInstanceARB. - ObjectDrawInfo is replaced by MeshInfo + InstanceCpu + InstanceGpu. - No EBO reorder on BVH build — the BVH is over instance AABBs and the mesh/EBO layout is orthogonal. - Draw-call submission is already one glMultiDrawElementsIndirect per model; the old Phase 3 goal is met. New content worth keeping: - GPU instancing section documents the mesh/instance/visible/indirect buffer contract the whole renderer hangs off of. - Reflection-aware two-pass draw is documented (det<0 placements, forward/reverse slice split, glFrontFace toggle). - reorient-shells and backface culling are called out as correctness + perf levers with their tradeoffs. - Phase 3 is rewritten around the actual bottleneck surfaced by profiling: per-frame glNamedBufferSubData stalls on the visible and indirect buffers. Includes the diagnostic methodology (empty- screen jump to 60 fps, window/MSAA invariance, upload-comment-out experiment) so future-me remembers why this is the next step. - 3A (persistent mapped ring buffers, near-term) and 3B (GPU-side compute cull, longer-term) split out with scope estimates. - Roadmap updated: instancing / MDI / reflections / reorient-shells / backface cull all ticked; 3A surfaced as the next open item. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 741 +++++++++++++++++----------------------- 1 file changed, 314 insertions(+), 427 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index d0122d63c8..4966d27d5e 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -1,75 +1,115 @@ # IfcViewer -A high-performance native IFC viewer built on IfcOpenShell's C++ geometry engine with a Qt6 interface and OpenGL 4.5 rendering. +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) || -| | (per- | | || -| | model) | | Per-model VAO/VBO/EBO || -| +----------+ | glMultiDrawElements || -| | Property | | BVH frustum culling || -| | Table | | GPU pick pass || -| +----------+ +--------------------------+| -| | Status / Progress / Stats | -+-------------------------------------------+ - ^ ^ - | | - element metadata UploadChunks / Sidecar - | | -+-------------------------------------------+ -| GeometryStreamer (one per loaded model) | -| IfcGeom::Iterator with N threads | -| (models loaded sequentially) | -+-------------------------------------------+ ++---------------------------------------------------+ +| Qt6 Application (MainWindow) | +| +----------+ +----------------------------------+| +| | Element | | 3D Viewport || +| | Tree | | (QWindow + OpenGL 4.5 Core) || +| | (per- | | || +| | model) | | Per-model: VAO/VBO/EBO || +| +----------+ | instance SSBO || +| | Property | | visible SSBO || +| | Table | | indirect buffer || +| +----------+ | glMultiDrawElementsIndirect || +| | Status / Progress / Stats | ++---------------------------------------------------+ + ^ ^ + | | + element metadata MeshChunk / InstanceChunk / Sidecar + | | ++---------------------------------------------------+ +| GeometryStreamer (one per loaded model) | +| IfcGeom::Iterator with N threads | +| Dedups representations -> MeshChunk | +| Emits one InstanceChunk per placement | ++---------------------------------------------------+ ``` ### Key design decisions -- **QWindow viewport** embedded via `QWidget::createWindowContainer()`. This gives us a raw native surface for OpenGL, bypassing `QOpenGLWidget`'s compositor overhead. -- **Per-model GPU buffers**: each loaded model gets its own VAO/VBO/EBO. No shared buffer, no cross-model copies on growth. Removing a model frees its GPU memory immediately. -- **Interleaved vertex format**: position (3 floats) + normal (3 floats) + object ID (1 float, bitcast uint32) + color (RGBA8 packed into 1 float) = 32 bytes per vertex. -- **Progressive GPU upload**: bulk sidecar loads allocate empty GPU buffers, then stream data in 48 MB chunks per frame. VBO uploads first (no objects visible), then EBO (objects appear progressively as their index range lands). The viewport stays interactive throughout — you can orbit already-loaded models while new ones stream in. -- **Non-blocking sidecar loading**: sidecar files are read on a background thread. The heavy disk I/O (potentially gigabytes) never blocks the render loop. Only the final GPU upload and tree population happen on the main thread. -- **BVH frustum culling**: per-model BVH trees cull entire subtrees of objects in one frustum test, reducing per-frame cost from O(N) to O(log N). Falls back to linear scan during progressive upload; BVH activates once the model is fully loaded. -- **GPU object picking**: a second render pass writes object IDs to an R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting. -- **Multi-model support**: multiple IFC files can be loaded simultaneously. Each model gets its own `GeometryStreamer` (owning the `ifcopenshell::file` for property lookup). Models are loaded sequentially. Per-model visibility toggle and removal are supported. -- **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. +- **QWindow viewport** embedded via `QWidget::createWindowContainer()`. Gives + us a raw native surface for OpenGL, bypassing `QOpenGLWidget`'s compositor + overhead. +- **GPU instancing as the central pillar.** IFC models are dominated by + repeated geometry — identical doors, windows, studs, pipes placed at + different transforms. IfcOpenShell's iterator surfaces representation + identity, so we upload each unique mesh exactly once and keep per-placement + data (transform, object id, optional colour override) in a separate SSBO. + For real projects this collapses tens of millions of triangles of duplicate + vertex data into a few hundred MB of unique meshes. +- **Per-model GPU buffers**: each loaded model gets its own + VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model + growth copies. Removing a model frees its GPU memory immediately. +- **Local-coordinate vertex format (28 B):** position (3 floats) + normal + (3 floats) + packed RGBA8 colour (1 uint). The per-instance transform is + applied in the vertex shader via an SSBO lookup. No world-baked vertex data. +- **Multi-draw indirect:** every frame the CPU builds a flat list of visible + instance indices and one `DrawElementsIndirectCommand` per non-empty mesh, + then issues a single `glMultiDrawElementsIndirect` per model. 50k visible + instances across 8k unique meshes collapse to one driver-side command + submission per model. +- **BVH frustum culling over instances**: per-model BVH trees cull whole + subtrees of placements with one frustum test. Falls back to a linear scan + during progressive upload and for very small models (< 32 instances). +- **Reflection-aware two-pass draw:** IFC placements can have negative- + determinant transforms (mirrored families). These flip the screen-space + winding of their triangles, which would make them vanish under + `GL_CULL_FACE`. The cull pass buckets visible instances into forward + (det ≥ 0) and reverse (det < 0) slices and the renderer issues two MDI + calls per model with `glFrontFace` toggled between them. +- **`reorient-shells` enabled in the iterator:** makes face winding + consistent within a shell at geometry-gen time — the only place this can + actually be fixed. Without it, files with inside-out faces produce dark + patches and swiss-cheese under backface culling. Costs iterator time but + is cached in the sidecar. +- **Progressive rendering during streaming:** the viewport is drawable + before `finalizeModel()`. Instances are pushed to the SSBO one at a time + via `glNamedBufferSubData` as they arrive, and the linear-scan cull path + handles them until the BVH is built. Orbit and pan remain interactive + through load. +- **Non-blocking sidecar loading**: sidecars are read on a background + thread; only the final GPU upload touches the main thread. +- **GPU object picking**: a second render pass writes object IDs into an + R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting. +- **Multi-model support**: multiple IFCs can be loaded simultaneously. + Each gets its own `GeometryStreamer` (which owns the `ifcopenshell::file` + for property lookup). Models load sequentially. Per-model + hide/show/remove. ### Files | File | Purpose | |------|---------| -| `main.cpp` | Application entry point, GL 4.5 surface format, CLI argument parsing | -| `MainWindow.h/cpp` | Qt main window: multi-model project management, element tree, property table, status bar | -| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffer management, camera, frustum culling, BVH traversal, picking | -| `GeometryStreamer.h/cpp` | Background geometry processing: loads IFC, runs iterator, emits chunks (one per model) | -| `BvhAccel.h/cpp` | BVH construction (median-split), per-model trees, EBO reordering | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` sidecar read/write | -| `AppSettings.h/cpp` | Persisted application preferences (geometry library, show stats) | -| `SettingsWindow.h/cpp` | Settings dialog UI | +| `main.cpp` | Application entry, GL 4.5 surface format, CLI argument parsing | +| `MainWindow.h/cpp` | Qt main window: multi-model project, element tree, properties, status | +| `ViewportWindow.h/cpp` | OpenGL 4.5 Core renderer: shaders, buffers, camera, culling, MDI draw, picking | +| `GeometryStreamer.h/cpp` | Background iterator runner; emits `MeshChunk` + `InstanceChunk` | +| `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | +| `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v4) sidecar read/write | +| `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | +| `SettingsWindow.h/cpp` | Settings dialog | | `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) +- **Qt6** (Core, Gui, Widgets, OpenGL) +- **OpenGL 4.5** with `GL_ARB_direct_state_access` and + `GL_ARB_shader_draw_parameters` — available on Windows and Linux. macOS + will need a Vulkan/MoltenVK backend (not yet implemented; macOS caps out + at GL 4.1). +- **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: +IfcViewer is part of the IfcOpenShell CMake project. From the repo root: ```sh mkdir build && cd build @@ -89,25 +129,13 @@ cmake ../cmake \ 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 one or more files from the command line ./IfcViewer arch.ifc struct.ifc mep.ifc - -# Or use File -> Add Files from the menu (supports multiselect) -./IfcViewer +./IfcViewer # then File -> Add Files ``` ### Controls @@ -117,449 +145,308 @@ make -j$(nproc) | Middle mouse drag | Orbit camera | | Shift + middle mouse drag | Pan camera | | Scroll wheel | Zoom | -| Left click | Select object (highlights in viewport and tree) | +| Left click | Select object | -### Keyboard shortcuts +### Keyboard | Key | Action | |-----|--------| | Ctrl+O | Add files | | Ctrl+Q | Quit | +### Settings + +- **Geometry Library** — kernel string passed to IfcOpenShell (default + `hybrid-cgal-simple-opencascade`). +- **Show Performance Stats** — overlay FPS / object / triangle / draw + counts in the status bar. +- **Backface Culling** — `GL_CULL_FACE` on closed solids. Default on. + Disable if a model uses open shells and you see missing faces. + ## Performance Strategy -The viewer targets smooth orbiting at 60 fps on models up to 1 million IFC objects. -Rendering performance is addressed in three phases. Each phase builds on the -previous one, and the system is designed so that smaller models never pay for -optimizations they don't need. +The viewer targets smooth orbiting at 60 fps on real-world multi-discipline +BIM projects (a "real job" being ~50 models, several million placements, +hundreds of millions of rasterised triangles when everything is in view). -### Phase 1: Per-Object Frustum Culling (CPU) +Rendering performance has evolved in phases. Each builds on the previous, +and smaller models never pay for optimisations they don't need. -**Status:** Implemented. +### Phase 1 — Per-object Frustum Culling -The simplest win: don't draw what's off screen. +**Status:** implemented (and still the fallback for small models / during +streaming). -#### Data model +Six view-frustum planes are extracted from the view-projection matrix each +frame. Each instance's world AABB is tested with the p-vertex / n-vertex +method (one dot product + one compare per plane, 6 planes). -During `uploadChunk()`, the viewport records a small metadata struct for every -object that enters the GPU buffers: +Surviving instance indices are written into a per-mesh bucket, then +flattened into a single `uint[]` (the "visible SSBO", binding = 1) and +accompanied by one `DrawElementsIndirectCommand` per non-empty mesh. +One `glMultiDrawElementsIndirect` call per model draws everything. -```cpp -struct ObjectDrawInfo { - uint32_t index_offset; // byte offset into the model's EBO - uint32_t index_count; // number of indices (triangles * 3) - uint32_t model_id; // which model this object belongs to - float aabb_min[3]; // world-space axis-aligned bounding box - float aabb_max[3]; // (computed from vertex positions at upload time) -}; -``` +Cost: ~6 dot products per instance per frame. Fine up to ~100 k instances +per frame; above that the linear scan shows up in profiles, motivating +Phase 2. -This costs 32 bytes per object. For 1M objects that's ~32 MB of CPU-side -metadata — negligible next to the vertex data. +### Phase 2 — BVH Acceleration + Sidecar Cache -#### Frustum extraction +**Status:** implemented. -Each frame, before drawing, six clip planes are extracted from the -view-projection matrix (`VP = proj * view`). The standard Griess-Hartmann -method pulls them directly from the matrix rows: +For models exceeding ~32 instances, a bounding volume hierarchy groups +nearby placements into a binary tree and culls entire subtrees with a +single frustum test. This reduces per-frame work from O(N) to O(log N) in +the best case (camera zoomed to a corner) and remains well under 1 ms for +100 k instances in the worst case (everything on screen). -``` -left = VP[3] + VP[0] -right = VP[3] - VP[0] -bottom = VP[3] + VP[1] -top = VP[3] - VP[1] -near = VP[3] + VP[2] -far = VP[3] - VP[2] -``` +A BVH was chosen over an octree because BIM data is spatially non-uniform +— dense MEP risers in one zone, sparse open atria in another. An octree +subdivides space uniformly, wasting nodes on empty regions and creating +deep chains in dense ones. A BVH adapts its splits to the actual +placement distribution. -Each plane is stored as (a, b, c, d) and normalized so that -`a*x + b*y + c*z + d` gives the signed distance from the plane. +#### Activation -#### AABB-frustum test +The BVH is optional and non-disruptive. Until it is built, the Phase 1 +linear scan handles culling. The renderer checks for a BVH per model and +falls back to the scan for any model that doesn't have one. -For each object, the AABB is tested against all six planes using the -"p-vertex / n-vertex" method: +It activates in one of two ways: -- For each plane, find the AABB corner most in the direction of the plane - normal (the p-vertex). -- If the p-vertex is on the negative side of the plane, the entire AABB is - outside the frustum → cull. -- If any plane culls the object, skip it. +1. **Sidecar hit** — the `.ifcview` file next to the `.ifc` is found and + valid; its instance data is uploaded and the BVH rebuilt on the fly + from the restored AABBs (cheap — `< 100 ms` for 100 k placements). +2. **After streaming** — `finalizeModel()` builds the BVH synchronously + once all chunks are in (instances already live on the GPU, so there's + no EBO re-sort to do). The sidecar is written afterwards. -This test is conservative: it never culls a visible object, but may -occasionally keep an invisible one (when the AABB straddles a frustum corner). -That's fine — false positives just cost a few extra triangles. +Models under 32 instances skip the BVH. -#### Drawing visible objects - -The surviving objects' `(index_count, index_offset)` pairs are passed to -`glMultiDrawElements()` in a single call. This replaces the previous single -`glDrawElements()` that drew everything. The GPU processes only the index -ranges that survived the frustum test. - -Alternatively, for the pick pass (which runs less frequently), the same -visibility list is reused — objects culled from the main pass are also culled -from picking. - -#### Performance characteristics - -| Metric | Value | -|--------|-------| -| Per-object cost | ~6 dot products + 6 comparisons per frame | -| 50k objects | ~0.3 ms on a modern CPU core | -| 500k objects | ~3 ms (starts to matter at 60 fps) | -| 1M objects | ~6 ms (too expensive — need phase 3) | -| Memory overhead | 32 bytes/object | -| Load-time overhead | Near zero (AABB computed during existing upload) | - -Phase 1 is sufficient for models up to ~100k objects. Beyond that, the CPU-side -frustum test becomes a measurable fraction of the frame budget, motivating -phase 3. - -### Phase 2: BVH Acceleration (optional, for large models) - -**Status:** Implemented. - -For models exceeding ~100 objects, a bounding volume hierarchy (BVH) groups -nearby objects into a binary tree and culls entire subtrees in one frustum -test. This reduces the number of AABB-frustum tests from O(N_objects) to -O(log N) in the best case (camera zoomed into a corner) and gives a constant -overhead for the common case where most of the model is on screen. - -A BVH was chosen over an octree because BIM data is spatially non-uniform — -dense MEP risers in one zone, sparse open atriums in another. An octree -subdivides space uniformly, wasting nodes on empty regions and creating deep -chains in dense ones. A BVH adapts its splits to the actual object -distribution, producing balanced trees regardless of density variation. - -#### When the BVH activates - -The BVH is **optional and non-disruptive**. Until it is built, phase 1's -linear scan handles all culling. The rendering loop checks for an active BVH -and falls back to the linear scan for any model that doesn't have one. - -The BVH activates in one of two ways: - -1. **Sidecar cache exists**: If a `.ifcview` file is found next to the `.ifc` - file, the BVH is loaded from it instantly (raw memory read, no parsing). - The model uses BVH culling from the first frame after loading. -2. **Automatic build**: After streaming finishes, a background thread builds - the BVH from the per-object AABBs already computed in phase 1. Until it - completes, phase 1 culling handles visibility. On completion, the render - thread picks up the BVH on the next frame. The sidecar is written for - future loads. - -Models with fewer than 32 objects skip the BVH entirely — the overhead of tree -traversal is worse than a linear scan at that scale. - -#### BVH node layout - -Each node is 32 bytes, so two nodes fit in one 64-byte cache line: +#### BVH node layout (32 B, two per cache line) ```cpp struct BvhNode { - float aabb_min[3]; // world-space bounding box (12 bytes) - float aabb_max[3]; // (12 bytes) - uint32_t right_or_first; // interior: right child index; leaf: first object index (4 bytes) - uint16_t count; // 0 = interior node; >0 = leaf with this many objects (2 bytes) - uint16_t axis; // split axis for interior (0=x, 1=y, 2=z); unused for leaf (2 bytes) + float aabb_min[3]; // 12 B + float aabb_max[3]; // 12 B + uint32_t right_or_first; // interior: right child index; leaf: first item index + uint16_t count; // 0 = interior, >0 = leaf + uint16_t axis; // 0/1/2 for interior; unused for leaf }; ``` -Interior nodes store the right child index; the left child is always the -immediately next node in the array (implicit in pre-order DFS layout, no -pointer needed). Leaf nodes reference a contiguous range in a sorted -object-index array. +Left child is always the next node (pre-order DFS). Leaf items are +indices into the per-model `instances` array; the parallel `bvh_items[]` +array carries the world AABBs. -The BVH is stored as a flat `std::vector` in pre-order DFS layout. -This means a depth-first traversal (which is what frustum culling does) reads -memory sequentially, maximizing prefetch and cache-line utilization. +#### Build: object-median split -#### Build algorithm: object-median split +1. Compute centroid of each item's AABB. +2. Pick the longest axis of the node's AABB. +3. `std::nth_element` partitions at the median on that axis — O(n). +4. Recurse until a leaf holds ≤ 8 items. -1. Compute the centroid of each object's AABB. -2. Find the longest axis of the current node's bounding box. -3. Use `std::nth_element` to partition objects at the median centroid on that - axis. This is O(n) — no full sort needed. -4. Recurse on each half. Terminate when the node contains ≤ 8 objects (leaf). -5. Write nodes into the flat array in pre-order DFS. +O(n log n) total. No SAH — for frustum culling (6-plane tests, early +subtree reject) the quality difference vs median is negligible. -Total build time is O(n log n). For 100k objects this is well under 100 ms on -a single core. - -SAH (Surface Area Heuristic) is the gold standard for ray-tracing BVHs, but -for frustum culling — where we test 6 planes and early-out entire subtrees — -the quality difference vs. median split is negligible. Median split is simpler -and produces reliably balanced trees. - -#### Frustum traversal - -The traversal uses an explicit stack on the C++ stack (no heap allocation, -no recursion): +#### Traversal: stack-based, no recursion ``` -stack[64] = {0} // start at root; depth 64 handles billions of objects +stack[64] = { 0 } // root while stack not empty: node = nodes[stack.pop()] - if node AABB outside frustum: continue // cull entire subtree + if node.aabb outside frustum: continue if leaf: - for each object in node: - if object AABB in frustum: emit to visible list + for each item in node: + if item.aabb in frustum: emit to visible list else: - push right child, push left child // left processed first (DFS) + push right child, push left child // left processed first (DFS) ``` -When the camera is zoomed into a corner of the model, the traversal skips -large portions of the tree after testing only a handful of interior nodes. -When zoomed out to see everything, the traversal visits all leaves but the -overhead of the interior-node tests is small relative to the leaf work. +Depth 64 is enough for billions of items on any balanced tree. The stack +is on the C++ stack, zero per-frame allocation. -#### Per-model BVH +#### Sidecar format (`.ifcview`, v4) -Each loaded model gets its own BVH. During frustum culling, the outer loop -iterates over models (skipping hidden/removed ones); the inner loop traverses -that model's BVH. This means hiding or removing a model is free — just skip -its BVH, no tree modification needed. +Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. +Stores everything needed to skip the `IfcGeom::Iterator` pass: -```cpp -struct ModelBvh { - uint32_t model_id; - std::vector nodes; // flat BVH node array - std::vector object_indices; // indices into object_draw_info_ +``` +SidecarHeader (magic "IFVW", version, endian, ...) +uint64_t source_file_size +uint32_t + float[] vertex data (7 floats × N_verts, local coords) +uint32_t + uint32_t[] index data (mesh-local) +uint32_t + MeshInfo[] per-unique-mesh metadata (48 B each) +uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids) +uint32_t + PackedElementInfo[] element tree records +uint32_t + char[] string table +``` + +Staleness check: `source_file_size` vs actual file size. Mismatched → +reject and rebuild. Endianness marker rejects cross-arch caches. + +### GPU Instancing pipeline (the central pillar) + +Everything above plugs into a single data-flow, worth documenting on its +own because it's what makes the whole thing fast. + +Per-model state on the GPU: + +| Buffer | Contents | Lifetime | +|--------|----------|----------| +| `VBO` | Interleaved local-coord vertex data (28 B/vert). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | +| `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. | +| `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. | +| `visible SSBO` (binding 1) | `uint32[]` — flat list of visible instance indices, ordered by mesh, uploaded each frame. | Rewritten every frame. | +| Draw-indirect buffer | `DrawElementsIndirectCommand[]` — one per non-empty mesh, uploaded each frame. | Rewritten every frame. | + +Draw command: + +```c +struct DrawElementsIndirectCommand { + uint32_t count; // mesh.index_count + uint32_t instanceCount; // visible-list length for this mesh + uint32_t firstIndex; // mesh.ebo_byte_offset / 4 + uint32_t baseVertex; // mesh.vbo_byte_offset / 28 + uint32_t baseInstance; // offset into the flat visible-index array }; ``` -#### EBO re-sorting +The vertex shader reads `visible[gl_BaseInstanceARB + gl_InstanceID]` to +get the real instance id, then indexes into the instance SSBO: -For BVH culling to maximise GPU cache performance, the EBO is re-sorted so -that objects in the same BVH leaf are contiguous. This happens via **deferred -compaction**: - -1. During initial load, geometry uploads in iterator order (fast first frame, - phase 1 culling active). -2. After the BVH build completes on the background thread: - a. Walk the BVH leaves in DFS order. - b. For each object in each leaf, copy its index data to a new EBO buffer, - updating `ObjectDrawInfo::index_offset` accordingly. - c. Package the reordered EBO + updated draw info as a `BvhBuildResult`. -3. The render thread picks up the result on the next frame: one - `glNamedBufferSubData` call to re-upload the EBO, then swap in the new - draw info and activate the BVH. One frame of stutter, bounded by EBO - upload time (~5 ms for 32 MB). - -#### Async build and render-thread handoff - -The BVH build must not stall the render loop: - -1. `buildBvhAsync()` snapshots `object_draw_info_` under the upload mutex, - then launches a `std::thread`. -2. The thread builds the BVH and reordered EBO, then stores the result in a - `pending_bvh_result_` pointer under a separate mutex. -3. At the top of each `render()` call, `applyBvhResult()` checks for a - pending result. If found, it re-uploads the EBO (requires GL context), - swaps the draw info, and activates the BVH. -4. Until the BVH is ready, phase 1's linear scan runs every frame as before. - -#### Preprocessed sidecar format (`.ifcview`) - -The sidecar is a raw memory dump (Blender `.blend`-style) — no serialization -format, no parsing. It stores everything needed to display the model without -re-tessellating: vertex data, index data, per-object metadata, element tree -info, and the BVH. Loading is just `fread` into vectors → GPU upload → -render. The expensive `IfcGeom::Iterator` tessellation is skipped entirely. - -The IFC file is still parsed on demand (in background) for detailed property -lookup; the sidecar provides the basic properties (name, type, GUID) -immediately. - -``` -SidecarHeader (16 bytes: magic, version, endian, reserved) -uint64_t source_file_size - -uint32_t + float[] vertex data (interleaved, 8 floats/vertex) -uint32_t + uint32_t[] index data (global indices, ready for EBO) -uint32_t + ObjectDrawInfo[] per-object draw metadata -uint32_t + PackedElementInfo[] element tree records (fixed-size) -uint32_t + char[] string table (concatenated UTF-8: guid, name, type) - -uint32_t num_bvh_models -per model: - uint32_t model_id - uint32_t + BvhNode[] BVH node array - uint32_t + uint32_t[] object indices +```glsl +uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); +uint iid = visible[slot]; +InstanceRecord inst = instances[iid]; +gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0); ``` -Staleness check: `source_file_size` is compared against the actual IFC file -size. If mismatched, the sidecar is stale and is rebuilt. This is cheap and -sufficient for a local cache (no hash computation on multi-GB files). +`gl_BaseInstanceARB` requires `GL_ARB_shader_draw_parameters`, which is +available on all GL-4.6-capable drivers. -Endianness: if the marker reads back as `0x01020304`, the file was written on -the same architecture — just `fread` the structs directly. Otherwise, reject -the sidecar and rebuild. +Reflection handling: at upload time we store a parallel +`instance_reflected[]` byte array (1 if the transform's upper-3×3 has +det < 0). The cull pass produces two flat visible-list slices — fwd +(non-reflected) first, rev (reflected) after — concatenated into one +buffer. The renderer issues MDI twice: fwd with `glFrontFace(GL_CCW)`, +rev with `glFrontFace(GL_CW)`. `GL_CULL_FACE` stays on and does the +right thing in both passes. -#### Performance characteristics +### Current bottleneck — Phase 3 as designed is already obsolete + +The original README's Phase 3 ("GPU-driven indirect draw") described +moving draw submission to the GPU via compute. In the meantime, GPU +instancing and MDI made the CPU-side draw cost essentially free (10 +`glMultiDrawElementsIndirect` calls per frame for 10 models). **That +goal is met.** The real Phase 3 problem is different. + +#### Diagnosed on a 10-model / 379 k-instance / 128 M-triangle scene + +Observed numbers (everything in view, no movement): | Metric | Value | |--------|-------| -| BVH build time (100k objects) | < 100 ms (single-threaded, background) | -| Per-frame traversal (100k objects, 50% visible) | ~0.1 ms | -| Per-frame traversal (100k objects, 5% visible) | ~0.02 ms | -| Memory overhead | 32 bytes/node + 4 bytes/object index (~1.5× object count) | -| EBO reorder (one-time) | 1–5 ms upload for 32 MB EBO | -| Sidecar file size | ~same as geometry data (vertices + indices + metadata) | -| Sidecar read time | bounded by disk I/O (~500 ms for 640 MB, ~2 s for 2.8 GB from NVMe) | -| GPU upload time | progressive: ~48 MB/frame (~1 s for 2.8 GB at 60 fps, non-blocking) | +| FPS | 10 | +| Frame time | ~100 ms | +| gl_draws | 10 | +| Sub-draws packed in indirect buffers | 67 037 | -#### Spatial coherence bonus +Elimination experiments: -Beyond culling, BVH-leaf-sorted EBOs improve GPU cache performance. When the -GPU rasterizes a leaf's triangles, the vertices are close together in the VBO, -so the post-transform vertex cache hits more often. This can yield 10–20% -rasterization speedup even when nothing is culled (e.g. zoomed out to see the -whole model). +| Probe | Result | Interpretation | +|-------|--------|----------------| +| Camera off-screen (nothing visible) | → 60 fps | GPU is idle; CPU path is cheap | +| Resize window to 1/4 area | no change | Not fragment/raster bound | +| `setSamples(4)` → `setSamples(1)` | no change | Not MSAA/resolve bound | +| Comment out the two `glNamedBufferSubData` in `cullAndUploadVisible` | → 60 fps (screen blank) | **The per-frame uploads are the bottleneck.** | -### Phase 3: GPU-Driven Indirect Draw +So the bottleneck is two `glNamedBufferSubData` calls per model per +frame uploading ~1.5 MB (visible list) + ~1.3 MB (indirect buffer). +3 MB/frame / 60 fps = 180 MB/s — trivial for the bus, but `glNamedBufferSubData` +against a buffer the GPU is still reading forces the driver to stall +the CPU or orphan/reallocate the backing store, and we're hitting that +on 20 buffers per frame. -For models with 500k+ objects, even tile-level CPU culling is fast, but the -real bottleneck shifts to draw call submission. Phase 3 moves all per-frame -visibility decisions to the GPU via compute shaders and indirect draw commands. +### Phase 3 (proposed) — Eliminate per-frame upload stalls -#### How it works +Two ways to attack it, in ascending order of effort: -Phase 3 builds on the BVH from phase 2. It does not replace the BVH — it -moves the per-frame traversal to the GPU. +#### 3A. Persistent mapped ring buffers (near-term) -1. **Upload phase** (once, at load time): - - Per-leaf AABBs from the BVH are uploaded to a GPU SSBO (`leaf_aabbs`). - - One `DrawElementsIndirectCommand` per BVH leaf is written to an indirect - draw buffer: - ```c - struct DrawElementsIndirectCommand { - uint count; // leaf's total index count - uint instanceCount; // 1 - uint firstIndex; // offset into EBO (from BVH leaf order) - uint baseVertex; // 0 (indices are global) - uint baseInstance; // leaf_id (available in shader via gl_DrawID) - }; - ``` - - A "template" copy of the indirect buffer is kept so the compute shader - can reset culled commands each frame without re-uploading from CPU. +Allocate each of the per-frame-written buffers with +`glBufferStorage(GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT | GL_MAP_WRITE_BIT)` +at 3× the needed size. Keep one `void*` from `glMapBufferRange` forever. +Each frame, write the CPU-side data into slice `frame % 3` and bind +that slice via `glBindBufferRange`. The GPU reads slice N−1 while the +CPU writes slice N — no driver sync, no orphan, no stall. -2. **Cull phase** (every frame, on the GPU): - - The CPU uploads 6 frustum plane vec4s as a uniform or small UBO. - - A compute shader dispatches `ceil(N_leaves / 64)` workgroups: - ```glsl - layout(local_size_x = 64) in; +Scope: ~80 lines across `ModelGpuData` + `cullAndUploadVisible` + +binding in `render()` / `renderPickPass()`. No algorithmic change, no +shader change. Expected result on the stats scene: 10 fps → ~60 fps +(the measured ceiling once uploads are removed). - void main() { - uint leaf_id = gl_GlobalInvocationID.x; - if (leaf_id >= leaf_count) return; +#### 3B. GPU-side culling (longer-term) - // Copy from template (resets any previously zeroed commands) - commands[leaf_id] = template_commands[leaf_id]; +Push culling itself to the GPU. A compute shader reads the +`InstanceCpu`-equivalent SSBO + frustum planes, builds the visible list +and indirect commands in-place via atomics. Zero CPU→GPU per-frame +bytes. Also lays the foundation for occlusion and contribution culling +(both want to run on the GPU anyway, with access to the depth buffer +or screen-space projection). - // Frustum test - if (!aabb_vs_frustum(leaf_aabbs[leaf_id], frustum_planes)) { - commands[leaf_id].count = 0; // culled: GPU skips zero-count draws - } - } - ``` - - A memory barrier ensures the indirect buffer is visible to the draw stage. +Scope: compute shader + atomic counter + BVH-traversal-on-GPU (or a +linear compute scan — simpler and still gains most of the win since +traversal isn't the bottleneck once upload is gone). Bigger change; +worth doing after 3A is measured, because 3A may be enough for a long +while. -3. **Draw phase** (every frame): - - One call: `glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_INT, - nullptr, N_leaves, 0)`. - - The GPU reads the indirect buffer, skips tiles with `count == 0`, and - draws the rest. Zero CPU-side per-object or per-tile work. +### Planned follow-ups (post-Phase-3) -#### What the CPU does per frame +- **Screen-space contribution cull.** Reject instances whose projected + screen-space AABB is below a pixel threshold. Cheap CPU-side filter + that eliminates distant MEP detail. Big win on unfiltered plant-room + scenes. +- **Hierarchical-Z occlusion culling.** Render large occluders, build a + depth pyramid, test BVH / instance AABBs against it. In dense BIM, + most geometry is behind other geometry from any given viewpoint; this + is historically a 3–10× reduction in drawn instances. +- **Distance / contribution LOD.** Unique meshes pre-simplified at load + time; compute shader selects an LOD per instance per frame based on + screen-space size. Same visible-SSBO plumbing, different `firstIndex`. +- **Mesh shaders / meshlets.** Ceiling-raising but overkill until the + above are exhausted. -1. Upload 6 vec4 frustum planes (96 bytes). -2. Dispatch one compute shader. -3. Issue one `glMultiDrawElementsIndirect`. -4. Swap buffers. - -That's it. The CPU frame time is essentially constant regardless of model size. - -#### Future extensions (enabled by this architecture) - -Once the compute-based cull pass exists, it's straightforward to add: - -- **Hierarchical-Z occlusion culling**: render a coarse depth buffer from the - previous frame, then test BVH leaf AABBs against it in the compute shader. - Leaves fully behind closer geometry get culled. This handles interior-heavy - BIM models well (most rooms are occluded from any given viewpoint). -- **Distance-based LOD**: the compute shader can select different index ranges - (coarse vs. fine tessellation) per leaf based on distance to camera. -- **Contribution culling**: leaves whose screen-space projection is below a - pixel threshold get `count = 0`. Removes distant small objects. - -#### Performance characteristics - -| Metric | Value | -|--------|-------| -| CPU per-frame work | ~0.01 ms (constant, independent of model size) | -| GPU compute dispatch | ~0.02 ms for 2k leaves | -| Draw call overhead | 1 indirect multi-draw call | -| GPU memory overhead | ~48 bytes/leaf (AABB SSBO) + 20 bytes/leaf (indirect commands) × 2 (template + live) | -| Total for 2k leaves | ~176 KB GPU memory | -| Implementation complexity | High (compute shaders, SSBOs, memory barriers, indirect draw) | - -#### When to use - -Phase 3 is worthwhile when: - -- The model has 500k+ objects (CPU frustum testing > 3 ms). -- Smooth 60 fps orbiting is required during interaction. -- The GPU has compute shader support (OpenGL 4.3+, which is guaranteed since - the viewer requires 4.5). - -For models under 100k objects, phase 1 alone is sufficient. For 100k–500k, -phase 2 (BVH) keeps CPU culling well under 1 ms. Phase 3 is the final step -that makes the CPU frame time constant. - -### Summary +## Summary table ``` -Model size Active phases CPU cull cost Draw calls -───────────── ────────────── ────────────── ────────── -< 10k objects Phase 1 ~0.06 ms 1 multi-draw -10k–100k Phase 1 ~0.6 ms 1 multi-draw -100k–500k Phase 1 + 2 ~0.01 ms 1 multi-draw -500k–1M+ Phase 1 + 2 + 3 ~0 (GPU) 1 indirect multi-draw -``` - -The load path: - -``` -open(model.ifc): - ├─ sidecar exists (.ifcview)? - │ ├─ yes: background thread reads sidecar file (non-blocking I/O) - │ │ → allocate per-model VAO/VBO/EBO (empty, exact size) - │ │ → progressive GPU upload: 48 MB/frame VBO, then EBO - │ │ → objects appear as EBO chunks land - │ │ → BVH activates once fully loaded - │ │ → viewport interactive throughout - │ └─ no: stream from IFC via GeometryStreamer - │ → uploadChunk() appends to per-model buffers (immediately drawable) - │ → phase 1 linear-scan culling active from first chunk - │ → on completion: background BVH build, re-sort EBO, save .ifcview - └─ rendering (per model, per frame): - ├─ phase 3 available? → compute cull + indirect multi-draw - ├─ BVH available? → BVH traversal + glMultiDrawElements - └─ else / progressive → linear scan of active objects + glMultiDrawElements +Scene size Bottleneck Fix +----------- ---------- --- +< 100k instances CPU cull scan Phase 1 only (current) +100k–500k CPU cull scan BVH (Phase 2) — done +500k+ across many models visible/indirect Phase 3A mapped rings + buffer uploads (next) +--- --- --- +multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD ``` ## Roadmap -- [x] Material color support (per-vertex RGBA8) +- [x] Material colour support (per-vertex RGBA8) - [x] Per-model GPU buffers (VAO/VBO/EBO per model, no cross-model copies) -- [x] Per-object frustum culling (phase 1) -- [x] BVH acceleration with per-model trees (phase 2) -- [x] Raw binary `.ifcview` sidecar cache (full geometry + BVH, Blender-style) +- [x] Per-object frustum culling (Phase 1) +- [x] BVH acceleration with per-model trees (Phase 2) +- [x] Raw binary `.ifcview` sidecar cache - [x] Non-blocking sidecar loading (background thread I/O) -- [x] Progressive GPU upload (48 MB/frame chunked VBO/EBO transfer) -- [ ] GPU-driven indirect draw (phase 3) +- [x] Progressive GPU upload (VBO/EBO growth + streaming-time instance appends) +- [x] GPU instancing (unique meshes + per-placement SSBO) +- [x] `glMultiDrawElementsIndirect` draw path +- [x] Reflection-aware two-pass draw for mirrored placements +- [x] Backface culling (user-toggleable, default on) +- [x] `reorient-shells` enabled in iterator +- [ ] **Phase 3A — persistent-mapped ring buffers for visible + indirect** (next) +- [ ] Phase 3B — GPU-side compute-shader culling +- [ ] Screen-space contribution culling - [ ] Hierarchical-Z occlusion culling - [ ] Distance-based LOD selection - [ ] Vulkan/MoltenVK backend for macOS From d3c21d7a81d303aec92596367b58d182723ba9cf Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Apr 2026 09:33:21 +1000 Subject: [PATCH 021/120] Pivot Phase 3: diagnose as draw-bound, not upload-bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier probes pointed at per-frame glNamedBufferSubData uploads as the bottleneck (60 fps when those two calls were commented out). That was a false reading — zeroing the uploads also emptied the indirect buffer, so MDI drew nothing. "No upload" and "no draw" were indistinguishable. Two new diagnostic env vars in render() isolate the real costs: IFC_SKIP_MDI=1 keep cull + upload + binds, skip only the MDI draws. Gives 62 fps with everything else running, confirming the non-draw path fits in ~16 ms. IFC_MAX_SUBDRAWS=N cap each MDI's drawcount. 67k -> 30k sub-draws saves 0 ms, confirming sub-draw count itself is not the bottleneck; the long tail of sub-draws carries ~no triangles. On a GTX 1650 with 128 M triangles in view, nvidia-smi sits at 95 % GPU util and FPS scales with triangle work, not sub-draw count. The card is simply rasterising at ~850 M tri/s. No CPU-side or upload trick recovers it. Revised Phase 3 is therefore shedding triangles, not bytes: 3A screen-space contribution culling (next) 3B LOD 3C HiZ occlusion 3D GPU-side compute culling README Phase 3 section rewritten around the diagnosis, including the false lead, so future work doesn't re-tread the upload path. The aborted staging+resident ring-buffer implementation was reverted (the uncommitted working tree is gone — pure glNamedBufferSubData retained for the visible + indirect buffers, which we now know is fine). Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 187 +++++++++++++++++++------------ src/ifcviewer/ViewportWindow.cpp | 33 +++++- 2 files changed, 145 insertions(+), 75 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 4966d27d5e..4af736bfad 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -333,101 +333,148 @@ buffer. The renderer issues MDI twice: fwd with `glFrontFace(GL_CCW)`, rev with `glFrontFace(GL_CW)`. `GL_CULL_FACE` stays on and does the right thing in both passes. -### Current bottleneck — Phase 3 as designed is already obsolete +### Current bottleneck — draw-bound, not upload-bound The original README's Phase 3 ("GPU-driven indirect draw") described moving draw submission to the GPU via compute. In the meantime, GPU instancing and MDI made the CPU-side draw cost essentially free (10 `glMultiDrawElementsIndirect` calls per frame for 10 models). **That -goal is met.** The real Phase 3 problem is different. +goal is met.** The real ceiling lies elsewhere, and it took a couple of +bad hypotheses to pin down. -#### Diagnosed on a 10-model / 379 k-instance / 128 M-triangle scene +#### Profiled scene -Observed numbers (everything in view, no movement): +10 models / 379 k instances / 128 M triangles, everything in view, no +camera motion, GTX 1650 (PCIe dGPU, 4 GB VRAM): | Metric | Value | |--------|-------| -| FPS | 10 | -| Frame time | ~100 ms | +| FPS | 6.7 | +| Frame time | 149 ms | | gl_draws | 10 | | Sub-draws packed in indirect buffers | 67 037 | -Elimination experiments: +`nvidia-smi` reports 95 % GPU utilisation during render — the GPU is +the thing that's pinned. -| Probe | Result | Interpretation | -|-------|--------|----------------| -| Camera off-screen (nothing visible) | → 60 fps | GPU is idle; CPU path is cheap | -| Resize window to 1/4 area | no change | Not fragment/raster bound | -| `setSamples(4)` → `setSamples(1)` | no change | Not MSAA/resolve bound | -| Comment out the two `glNamedBufferSubData` in `cullAndUploadVisible` | → 60 fps (screen blank) | **The per-frame uploads are the bottleneck.** | +#### False lead: "the per-frame uploads are the bottleneck" -So the bottleneck is two `glNamedBufferSubData` calls per model per -frame uploading ~1.5 MB (visible list) + ~1.3 MB (indirect buffer). -3 MB/frame / 60 fps = 180 MB/s — trivial for the bus, but `glNamedBufferSubData` -against a buffer the GPU is still reading forces the driver to stall -the CPU or orphan/reallocate the backing store, and we're hitting that -on 20 buffers per frame. +The first round of probes pointed at the two `glNamedBufferSubData` +calls per model per frame (visible list ~1.5 MB + indirect buffer +~1.3 MB): -### Phase 3 (proposed) — Eliminate per-frame upload stalls +| Probe | Result | Initial interpretation | +|-------|--------|------------------------| +| Camera off-screen (nothing visible) | 60 fps | GPU idle → CPU path cheap | +| Comment out the two `glNamedBufferSubData` | 60 fps, blank screen | Uploads are the bottleneck | -Two ways to attack it, in ascending order of effort: +This led to an aborted Phase 3A implementation of persistent-mapped +triple-buffered rings (and then staging + VRAM-resident with +`glCopyNamedBufferSubData`). Neither moved the FPS needle — both still +sat at 6.7 fps. -#### 3A. Persistent mapped ring buffers (near-term) +The probe was wrong: **commenting out the uploads emptied the indirect +buffer, so MDI drew zero triangles. "No upload" and "no draw" were +indistinguishable in the test.** -Allocate each of the per-frame-written buffers with -`glBufferStorage(GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT | GL_MAP_WRITE_BIT)` -at 3× the needed size. Keep one `void*` from `glMapBufferRange` forever. -Each frame, write the CPU-side data into slice `frame % 3` and bind -that slice via `glBindBufferRange`. The GPU reads slice N−1 while the -CPU writes slice N — no driver sync, no orphan, no stall. +#### What actually isolates the draw cost -Scope: ~80 lines across `ModelGpuData` + `cullAndUploadVisible` + -binding in `render()` / `renderPickPass()`. No algorithmic change, no -shader change. Expected result on the stats scene: 10 fps → ~60 fps -(the measured ceiling once uploads are removed). +Two diagnostic env vars now live in `render()`: -#### 3B. GPU-side culling (longer-term) +- `IFC_SKIP_MDI=1` — keep everything (cull, upload, binds) but skip the + actual `glMultiDrawElementsIndirect` calls. +- `IFC_MAX_SUBDRAWS=N` — truncate each MDI's drawcount to N while still + running the rest of the frame. -Push culling itself to the GPU. A compute shader reads the -`InstanceCpu`-equivalent SSBO + frustum planes, builds the visible list -and indirect commands in-place via atomics. Zero CPU→GPU per-frame -bytes. Also lays the foundation for occlusion and contribution culling -(both want to run on the GPU anyway, with access to the depth buffer -or screen-space projection). +Results on the profiled scene: -Scope: compute shader + atomic counter + BVH-traversal-on-GPU (or a -linear compute scan — simpler and still gains most of the win since -traversal isn't the bottleneck once upload is gone). Bigger change; -worth doing after 3A is measured, because 3A may be enough for a long -while. +| Probe | FPS | Frame time | +|-------|-----|-----------| +| baseline | 6.7 | 149 ms | +| `IFC_SKIP_MDI=1` | 62.5 | 16 ms | +| `IFC_MAX_SUBDRAWS=30000` | 6.7 | 149 ms | +| `IFC_MAX_SUBDRAWS=10000` | 7.5 | 133 ms | +| `IFC_MAX_SUBDRAWS=1000` | 20.2 | 49 ms | + +Readings: + +1. `SKIP_MDI` gives 62 fps with all upload/bind machinery still running + — the non-draw path fits in ~16 ms easily. **Not upload-bound.** +2. Halving the sub-draw count (67 k → 30 k) saves 0 ms. If per-sub-draw + command-processor overhead were material, dropping 37 k sub-draws + would save measurable time no matter which sub-draws were dropped. + It doesn't. **67 k sub-draws is not the bottleneck** — the long tail + carries almost no triangles, and the heavyweights dominate. +3. Time only starts coming down once the cap is low enough to shed bulk + triangle work (1000 sub-draws → 49 ms). The curve is consistent with + a long-tailed distribution: a handful of very big meshes × instance + counts do most of the rasterisation. + +**Conclusion: the GTX 1650 is rasterising 128 M triangles at ~850 M +tri/s, and that eats ~133 ms of the 149 ms frame.** No CPU-side or +upload-side work will recover it. The only way forward is to draw +fewer triangles. + +### Phase 3 (revised) — Shed triangles, not bytes + +In order of effort/payoff for BIM workloads: + +#### 3A. Screen-space contribution culling (near-term) + +Project each visible-instance AABB to screen space during BVH +traversal. Reject instances whose projected size is below a threshold +(~4 px). In BIM this is the single biggest win: at viewer zoom levels +that encompass a whole building, most MEP fittings, fixings, furniture +legs, door hardware etc. occupy < 1 px and contribute nothing. + +Scope: a projection + pixel-area test inside +`ViewportWindow::cullAndUploadVisible`. Zero new GPU state. Expect +10–30× reduction in drawn triangles on plant/MEP-dense scenes; full +buildings viewed in overview should approach 60 fps. + +#### 3B. Distance / contribution LOD (medium-term) + +Pre-simplify unique representations at ingress time (store LOD 0 / 1 / +2 meshes in the VBO/EBO with offsets), select LOD per instance per +frame by the same projected-size metric as 3A. The visible-SSBO +plumbing and MDI structure don't change — only `firstIndex`/`count` in +the indirect command does. Ingress side needs a decimation pass +(`meshoptimizer` or similar); GPU side is nearly free. + +#### 3C. Hierarchical-Z occlusion culling (longer-term) + +Render large occluders first, build a depth pyramid, test instance +AABBs against it. In dense BIM most geometry is behind other geometry +from any given interior viewpoint; historically a 3–10× reduction in +drawn instances. Most valuable *after* 3A+3B, which together handle +the far-away and small-detail cases. Pairs naturally with GPU-side +culling (a compute shader doing the HiZ test and writing the visible +list + indirect buffer in place). + +#### 3D. GPU-side culling via compute (longer-term) + +Push the cull loop to a compute shader reading the per-instance SSBO + +frustum planes + HiZ pyramid, emitting the visible list and indirect +commands with atomic counters. Eliminates all CPU→GPU per-frame bytes +and lets 3C scale to millions of instances. Worth doing once 3A–3C +have stabilised the CPU-side algorithm we'd be porting. ### Planned follow-ups (post-Phase-3) -- **Screen-space contribution cull.** Reject instances whose projected - screen-space AABB is below a pixel threshold. Cheap CPU-side filter - that eliminates distant MEP detail. Big win on unfiltered plant-room - scenes. -- **Hierarchical-Z occlusion culling.** Render large occluders, build a - depth pyramid, test BVH / instance AABBs against it. In dense BIM, - most geometry is behind other geometry from any given viewpoint; this - is historically a 3–10× reduction in drawn instances. -- **Distance / contribution LOD.** Unique meshes pre-simplified at load - time; compute shader selects an LOD per instance per frame based on - screen-space size. Same visible-SSBO plumbing, different `firstIndex`. -- **Mesh shaders / meshlets.** Ceiling-raising but overkill until the - above are exhausted. +- **Mesh shaders / meshlets.** Ceiling-raising, but overkill until the + above are exhausted and we've hit silicon limits on vertex/raster + throughput. ## Summary table ``` -Scene size Bottleneck Fix ------------ ---------- --- -< 100k instances CPU cull scan Phase 1 only (current) -100k–500k CPU cull scan BVH (Phase 2) — done -500k+ across many models visible/indirect Phase 3A mapped rings - buffer uploads (next) ---- --- --- -multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD +Scene size Bottleneck Fix +----------- ---------- --- +< 100k instances CPU cull scan Phase 1 only +100k–500k CPU cull scan BVH (Phase 2) — done +500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull + (+ 3B LOD for close-ups) +multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion ``` ## Roadmap @@ -444,10 +491,10 @@ multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [ ] **Phase 3A — persistent-mapped ring buffers for visible + indirect** (next) -- [ ] Phase 3B — GPU-side compute-shader culling -- [ ] Screen-space contribution culling -- [ ] Hierarchical-Z occlusion culling -- [ ] Distance-based LOD selection +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`) +- [ ] **Phase 3A — screen-space contribution culling** (next) +- [ ] Phase 3B — distance / contribution LOD +- [ ] Phase 3C — Hierarchical-Z occlusion culling +- [ ] Phase 3D — GPU-side compute-shader culling - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 84778f3f2f..4680e188bb 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -988,10 +989,32 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); - const uint32_t fwd = m.indirect_forward_count; - const uint32_t rev = m.indirect_command_count - fwd; + uint32_t fwd = m.indirect_forward_count; + uint32_t rev = m.indirect_command_count - fwd; + // Perf diagnostics (confirmed 2026-04 on GTX 1650 @ 128M tris: + // draw-bound, not upload-bound — see README Phase 3): + // IFC_SKIP_MDI=1 skip the actual MDI draws (keeps cull + + // upload + binds). FPS jump == draw-bound. + // IFC_MAX_SUBDRAWS=N truncate drawcount to N per MDI. Lets + // you distinguish per-subdraw command- + // processor overhead from raw tri work. + static const bool skip_mdi = []{ + const char* e = std::getenv("IFC_SKIP_MDI"); + return e && e[0] == '1'; + }(); + static const uint32_t max_subdraws = []{ + const char* e = std::getenv("IFC_MAX_SUBDRAWS"); + return (e && *e) ? static_cast(std::atoi(e)) + : std::numeric_limits::max(); + }(); + if (max_subdraws < m.indirect_command_count) { + // Keep the fwd/rev ratio so the workload mix is preserved. + const uint32_t total = m.indirect_command_count; + fwd = static_cast((uint64_t)fwd * max_subdraws / total); + rev = max_subdraws - fwd; + } // Forward pass: non-reflected instances, standard CCW winding. - if (fwd > 0) { + if (fwd > 0 && !skip_mdi) { gl_->glFrontFace(GL_CCW); gl_->glMultiDrawElementsIndirect( GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, @@ -1000,11 +1023,11 @@ void ViewportWindow::render() { } // Reverse pass: reflected instances — their world-space winding is // flipped, so telling GL the front is CW keeps cull-back working. - if (rev > 0) { + if (rev > 0 && !skip_mdi) { gl_->glFrontFace(GL_CW); gl_->glMultiDrawElementsIndirect( GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(fwd * sizeof(DrawElementsIndirectCommand)), + reinterpret_cast(m.indirect_forward_count * sizeof(DrawElementsIndirectCommand)), static_cast(rev), 0); ++gl_draw_calls_; gl_->glFrontFace(GL_CCW); From 90366f8236a17bcef82bb7f30bf50ccb07dd1057 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Apr 2026 09:51:09 +1000 Subject: [PATCH 022/120] Phase 3A: screen-space contribution culling Reject frustum-visible objects whose bounding sphere projects below a pixel-radius threshold. Applied at both BVH-node level (whole subtrees pruned) and per-instance level; short-circuits when the camera is inside the AABB so nothing-you're-standing-next-to is ever lost. Pick pass passes threshold 0 so sub-pixel objects stay clickable. Threshold defaults to 2 px (radius), overridable via IFC_MIN_PX env var. Measured on the 128 M-tri test scene (GTX 1650): 0 px (off): 6.7 fps, 128 M tris 2 px: 20.2 fps, 40 M tris (31%) 4 px: 30.3 fps, 15 M tris (12%) The metric is sphere-based (cheap: one sqrt per test) rather than AABB-corner projection; loses a little precision on very elongated bounds but costs ~5x less per test and the BVH-node pre-cull means the long-tail-of-small-things case is already handled by subtree pruning before we touch individual instances. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 64 ++++++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 10 ++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 4680e188bb..db002c1870 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -824,7 +824,8 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { return pixel; } -void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]) { +void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4], + float focal_px, float min_pixel_radius) { // Per-mesh scratch, split by winding: fwd = non-reflected (CCW in screen // space), rev = reflected (CW in screen space). Splitting lets the draw // pass toggle glFrontFace once between two MDI calls so GL_CULL_FACE does @@ -836,9 +837,44 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] visible_by_mesh_rev_[i].clear(); } + // Bounding-sphere contribution test: approximate an AABB by its enclosing + // sphere (centre = midpoint, radius = half-diagonal). Project radius to + // pixels as r_px = focal_px * r / distance (perspective). Reject if + // smaller than the threshold. Returns true when the node/instance + // should be kept. + // + // If the camera is inside the AABB the sphere-radius test would reject + // by distance going to zero / negative — we handle that by skipping the + // test whenever the camera lies within an inflated AABB. Cheap and + // conservative: never drops things you're standing next to. + const float cx = camera_eye_.x(); + const float cy = camera_eye_.y(); + const float cz = camera_eye_.z(); + auto contributionPasses = [&](const float mn[3], const float mx[3]) -> bool { + if (min_pixel_radius <= 0.0f) return true; + // Camera inside AABB? Always keep. + if (cx >= mn[0] && cx <= mx[0] && + cy >= mn[1] && cy <= mx[1] && + cz >= mn[2] && cz <= mx[2]) { + return true; + } + float ex = 0.5f * (mx[0] - mn[0]); + float ey = 0.5f * (mx[1] - mn[1]); + float ez = 0.5f * (mx[2] - mn[2]); + float radius = std::sqrt(ex*ex + ey*ey + ez*ez); + float dx = 0.5f * (mx[0] + mn[0]) - cx; + float dy = 0.5f * (mx[1] + mn[1]) - cy; + float dz = 0.5f * (mx[2] + mn[2]) - cz; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + // r_px = focal_px * radius / dist; compare r_px >= min_pixel_radius, + // rearranged to avoid the divide. + return focal_px * radius >= min_pixel_radius * dist; + }; + auto test_and_push = [&](uint32_t inst_idx) { const InstanceCpu& inst = m.instances[inst_idx]; if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; + if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return; if (inst.mesh_id >= m.meshes.size()) return; const bool reflected = inst_idx < m.instance_reflected.size() && m.instance_reflected[inst_idx] != 0; @@ -854,6 +890,9 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] uint32_t ni = stack[--sp]; const BvhNode& n = m.bvh.nodes[ni]; if (!aabbInFrustum(n.aabb_min, n.aabb_max, planes)) continue; + // Contribution cull the whole subtree: if the node's enclosing + // sphere is below threshold, every child is too. + if (!contributionPasses(n.aabb_min, n.aabb_max)) continue; if (n.count > 0) { for (uint32_t k = 0; k < n.count; ++k) { uint32_t item_idx = m.bvh.item_indices[n.right_or_first + k]; @@ -939,11 +978,12 @@ void ViewportWindow::updateCamera() { 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)); + camera_eye_ = eye; 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); + proj_matrix_.perspective(camera_fov_y_deg_, aspect, 0.1f, camera_distance_ * 10.0f); } void ViewportWindow::render() { @@ -961,6 +1001,20 @@ void ViewportWindow::render() { float planes[6][4]; extractFrustumPlanes(vp, planes); + // Pixels-per-radian vertical focal length. Combined with per-instance + // world-space radius this gives screen-space pixel size for contribution + // culling below. + const float focal_px = 0.5f * static_cast(h) / + std::tan(qDegreesToRadians(0.5f * camera_fov_y_deg_)); + // Drop frustum-visible objects smaller than this many pixels. Override + // with IFC_MIN_PX (0 = disabled). 2 px radius = ~4x4 pixels, well below + // what's meaningful at normal viewing distances and eliminates the long + // tail of distant MEP/fixings that dominate BIM triangle counts. + static const float min_pixel_radius = []{ + const char* e = std::getenv("IFC_MIN_PX"); + return (e && *e) ? static_cast(std::atof(e)) : 2.0f; + }(); + gl_->glUseProgram(main_program_); GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); GLint u_light = gl_->glGetUniformLocation(main_program_, "u_light_dir"); @@ -981,7 +1035,7 @@ void ViewportWindow::render() { for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; - cullAndUploadVisible(m, planes); + cullAndUploadVisible(m, planes, focal_px, min_pixel_radius); if (m.indirect_command_count == 0) continue; gl_->glBindVertexArray(m.vao); @@ -1114,7 +1168,9 @@ void ViewportWindow::renderPickPass() { for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; - cullAndUploadVisible(m, planes); + // Pick pass: contribution-cull disabled (0.0 threshold) so every + // frustum-visible object is clickable, even sub-pixel ones. + cullAndUploadVisible(m, planes, 1.0f, 0.0f); if (m.indirect_command_count == 0) continue; gl_->glBindVertexArray(m.vao); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 1bbc44c97c..a8d696121a 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -173,7 +173,13 @@ private: // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible // list, and upload both to m.indirect_buffer / m.visible_ssbo. - void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4]); + // + // `min_pixel_radius` controls contribution culling: instances (and BVH + // subtrees) whose projected bounding-sphere radius would be below this + // many pixels are dropped. 0 = disabled (all frustum-visible kept), + // which is what the pick pass uses so clickable targets aren't filtered. + void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4], + float focal_px, float min_pixel_radius); // Mouse interaction void handleMousePress(QMouseEvent* event); @@ -224,9 +230,11 @@ private: // Camera QVector3D camera_target_{0, 0, 0}; + QVector3D camera_eye_{0, 0, 0}; // world-space eye, set in updateCamera float camera_distance_ = 50.0f; float camera_yaw_ = 45.0f; float camera_pitch_ = 30.0f; + float camera_fov_y_deg_ = 45.0f; QMatrix4x4 view_matrix_; QMatrix4x4 proj_matrix_; From 68fea7bd457708655a7ad9bc2f293059c34e69d8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Apr 2026 10:06:37 +1000 Subject: [PATCH 023/120] README: mark Phase 3A done with measured numbers Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 43 ++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 4af736bfad..7bb6972b83 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -419,18 +419,35 @@ fewer triangles. In order of effort/payoff for BIM workloads: -#### 3A. Screen-space contribution culling (near-term) +#### 3A. Screen-space contribution culling — ✅ done -Project each visible-instance AABB to screen space during BVH -traversal. Reject instances whose projected size is below a threshold -(~4 px). In BIM this is the single biggest win: at viewer zoom levels -that encompass a whole building, most MEP fittings, fixings, furniture -legs, door hardware etc. occupy < 1 px and contribute nothing. +Reject frustum-visible objects whose bounding-sphere projects below a +pixel-radius threshold. Applied both at BVH-node level (whole subtrees +pruned, so distant parts of the model never touch per-instance tests) +and per-instance level. Short-circuits when the camera is inside the +AABB so nothing-you're-standing-next-to is ever lost. Pick pass uses +threshold 0 so sub-pixel objects remain clickable. -Scope: a projection + pixel-area test inside -`ViewportWindow::cullAndUploadVisible`. Zero new GPU state. Expect -10–30× reduction in drawn triangles on plant/MEP-dense scenes; full -buildings viewed in overview should approach 60 fps. +Sphere-based (centre = AABB midpoint, radius = half-diagonal, +r_px = focal_px · radius / distance). Loses a little precision on +very elongated bounds vs. 8-corner projection, but costs ~5× less per +test, and because BVH-node pre-cull handles the long tail in one shot +it doesn't matter. + +Threshold defaults to 2 px radius, overridable via `IFC_MIN_PX` env +var. Measured on the 10-model / 128 M-tri test scene (GTX 1650): + +| Threshold | FPS | Triangles drawn | Objects drawn | +|-----------|-----|-----------------|---------------| +| 0 px (off) | 6.7 | 128 M | 379 k | +| 2 px | 20.2 | 40 M (31 %) | 89 k (24 %) | +| 4 px | 30.3 | 15 M (12 %) | 29 k (8 %) | + +At 4 px, frame time breakdown matches: ~16 ms non-draw baseline (from +`IFC_SKIP_MDI=1`) + ~18 ms of raster (15 M tris / 850 M tri/s) ≈ 34 ms += observed 33 ms. The ceiling is now genuinely vertex/raster +throughput on the post-cull geometry — next steps (LOD, HiZ) attack +that directly. #### 3B. Distance / contribution LOD (medium-term) @@ -491,9 +508,9 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`) -- [ ] **Phase 3A — screen-space contribution culling** (next) -- [ ] Phase 3B — distance / contribution LOD +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`) +- [x] Phase 3A — screen-space contribution culling +- [ ] **Phase 3B — distance / contribution LOD** (next) - [ ] Phase 3C — Hierarchical-Z occlusion culling - [ ] Phase 3D — GPU-side compute-shader culling - [ ] Vulkan/MoltenVK backend for macOS From c78e16eafbf13d3d5f88c53005a5d92b69c32657 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Apr 2026 18:31:43 +1000 Subject: [PATCH 024/120] Phase 3B: per-instance LOD via meshoptimizer simplifySloppy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decimate each unique mesh once at sidecar-build time and swap to the reduced index slice per-instance per-frame when projected sphere radius drops below IFC_LOD1_PX (default 30). Same VBO, same SSBO, just a different firstIndex/count in the indirect command. Extends MeshInfo (48→56 B) with lod1_ebo_byte_offset + lod1_index_count and bumps the sidecar to v5. buildLods() runs inside onStreamingFinished, appends decimated indices to sd.indices, applyLodExtension pushes the EBO suffix to the live GPU state, and the sidecar is written with LOD1 baked in. simplifySloppy (voxel clustering) is used instead of the default edge-collapse meshopt_simplify because BIM brep output is per-triangle- unwelded and non-manifold after welding — simplify returned the input unchanged for every mesh tested. Sloppy ignores topology. Knobs (IFC_LOD_SLOPPY, IFC_LOD_ERROR, IFC_LOD_RATIO, IFC_LOD_MIN_SAVINGS, IFC_LOD_LOCK_BORDER, IFC_LOD_DEBUG) are available for A/B tuning. Result on the 128M-tri 10-model test scene (GTX 1650, 2px contribution cull): 20.2 → 43.2 fps, 40M → 14M visible triangles, no change in object count. LOD build adds 100–600 ms per model on first open, cached thereafter. README Phase 3B section is now a full writeup of pipeline, selection, decimator-choice rationale, env vars, and measured numbers. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/CMakeLists.txt | 2 + src/ifcviewer/InstancedGeometry.h | 18 ++- src/ifcviewer/LodBuilder.cpp | 203 ++++++++++++++++++++++++++++++ src/ifcviewer/LodBuilder.h | 56 +++++++++ src/ifcviewer/MainWindow.cpp | 14 +++ src/ifcviewer/README.md | 135 +++++++++++++++++--- src/ifcviewer/SidecarCache.cpp | 6 +- src/ifcviewer/SidecarCache.h | 5 +- src/ifcviewer/ViewportWindow.cpp | 117 ++++++++++++++--- src/ifcviewer/ViewportWindow.h | 16 ++- 10 files changed, 532 insertions(+), 40 deletions(-) create mode 100644 src/ifcviewer/LodBuilder.cpp create mode 100644 src/ifcviewer/LodBuilder.h diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index 9f1c4dac50..70642acabf 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -26,6 +26,7 @@ set(QT_VERSION 6 CACHE STRING "Qt version") find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets OpenGL REQUIRED PATHS ${QT_DIR}) find_package(OpenGL REQUIRED) +find_package(meshoptimizer REQUIRED) file(GLOB IFCVIEWER_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) file(GLOB IFCVIEWER_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h) @@ -51,6 +52,7 @@ target_link_libraries(IfcViewer PRIVATE Qt${QT_VERSION}::Widgets Qt${QT_VERSION}::OpenGL OpenGL::GL + meshoptimizer::meshoptimizer ) if(UNIX AND NOT APPLE) diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h index 1c027976ef..ef79751806 100644 --- a/src/ifcviewer/InstancedGeometry.h +++ b/src/ifcviewer/InstancedGeometry.h @@ -33,18 +33,28 @@ static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 28; static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7; // Per-mesh metadata on the CPU side. Meshes own a slice of the model's -// VBO and EBO (both local-coords/mesh-local indices). +// VBO (shared across LODs) and one or more slices of the EBO, one per LOD. +// +// LOD0 is the original, full-resolution tessellation — the fields +// `ebo_byte_offset` / `index_count` describe it. +// +// LOD1 is an optional decimated copy of the same triangles referencing the +// same vertex buffer. Built at sidecar time via meshoptimizer for meshes +// whose triangle count crosses a threshold. `lod1_index_count == 0` +// means no LOD1 was built; the renderer must use LOD0 at every distance. struct MeshInfo { uint32_t vbo_byte_offset = 0; // where this mesh's vertices start uint32_t vertex_count = 0; - uint32_t ebo_byte_offset = 0; // where this mesh's indices start - uint32_t index_count = 0; + uint32_t ebo_byte_offset = 0; // LOD0 indices + uint32_t index_count = 0; // LOD0 index count float local_aabb_min[3]{}; float local_aabb_max[3]{}; uint32_t first_instance = 0; // index into per-model instances array uint32_t instance_count = 0; + uint32_t lod1_ebo_byte_offset = 0; + uint32_t lod1_index_count = 0; // 0 = no LOD1 available }; -static_assert(sizeof(MeshInfo) == 48, "MeshInfo must be 48 bytes"); +static_assert(sizeof(MeshInfo) == 56, "MeshInfo must be 56 bytes"); // Per-instance record uploaded to an SSBO and read by the vertex shader. // Layout deliberately matches std430 expectations: diff --git a/src/ifcviewer/LodBuilder.cpp b/src/ifcviewer/LodBuilder.cpp new file mode 100644 index 0000000000..88b8c9f046 --- /dev/null +++ b/src/ifcviewer/LodBuilder.cpp @@ -0,0 +1,203 @@ +/******************************************************************************** + * * + * 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 "LodBuilder.h" + +#include + +#include +#include +#include +#include +#include + +void buildLods(SidecarData& sd, + int min_triangles, + float target_ratio, + float target_error) { + if (sd.meshes.empty() || sd.vertices.empty() || sd.indices.empty()) return; + + const size_t vtx_stride_bytes = INSTANCED_VERTEX_STRIDE_BYTES; + const size_t vtx_stride_floats = INSTANCED_VERTEX_STRIDE_FLOATS; + const size_t total_vertex_count = sd.vertices.size() / vtx_stride_floats; + + // Env var knobs so we can tune without rebuilding. + // IFC_LOD_LOCK_BORDER=1 re-enable LockBorder (off by default: BIM + // geometry is often non-manifold so locking + // borders prevents any collapse). + // IFC_LOD_ERROR= override target_error (default 0.05 → 0.2). + // IFC_LOD_RATIO= override target_ratio. + // IFC_LOD_MIN_SAVINGS=<0..1> minimum fraction of tris saved to accept + // (default 0.25). + // IFC_LOD_DEBUG=1 print per-mesh diagnostics for the first + // few meshes of each call. + // IFC_LOD_SLOPPY=0 disable sloppy (clustering) decimator. + // Default ON: BIM brep output is usually + // non-manifold, so edge-collapse simplify + // returns the input unchanged. + const char* env_lock = std::getenv("IFC_LOD_LOCK_BORDER"); + const char* env_err = std::getenv("IFC_LOD_ERROR"); + const char* env_ratio = std::getenv("IFC_LOD_RATIO"); + const char* env_savings = std::getenv("IFC_LOD_MIN_SAVINGS"); + const char* env_debug = std::getenv("IFC_LOD_DEBUG"); + const char* env_sloppy = std::getenv("IFC_LOD_SLOPPY"); + + const bool lock_border = env_lock && env_lock[0] == '1'; + const bool use_sloppy = !(env_sloppy && env_sloppy[0] == '0'); + if (env_err) target_error = static_cast(std::atof(env_err)); + if (env_ratio) target_ratio = static_cast(std::atof(env_ratio)); + float min_savings = 0.25f; + if (env_savings) min_savings = static_cast(std::atof(env_savings)); + const bool debug = env_debug && env_debug[0] == '1'; + + // Loosened defaults: BIM meshes are non-manifold; LockBorder ≈ zero + // collapses. A 0.2 error budget still looks fine at sub-4px. + if (target_error < 0.2f) target_error = 0.2f; + + // Scratch buffers reused across meshes so we only allocate once. + std::vector simplified; + std::vector shadow; + simplified.reserve(1024); + shadow.reserve(1024); + + int dbg_printed = 0; + int dbg_rejected_savings = 0; + int dbg_rejected_noreduce = 0; + int dbg_accepted = 0; + + for (auto& mesh : sd.meshes) { + mesh.lod1_ebo_byte_offset = 0; + mesh.lod1_index_count = 0; + + const uint32_t tri_count = mesh.index_count / 3; + if (static_cast(tri_count) < min_triangles) continue; + if (mesh.vertex_count == 0) continue; + + // meshopt wants a pointer to the *first position* and a vertex_count + // equal to the number of referenced vertices (i.e. the absolute upper + // bound on indices we might see). Indices in `sd.indices` for this + // mesh are mesh-local (0..mesh.vertex_count). Pass the base-vertex + // as an offset into sd.vertices so meshopt reads positions at the + // right place. + const uint32_t base_vertex = mesh.vbo_byte_offset / vtx_stride_bytes; + if (base_vertex + mesh.vertex_count > total_vertex_count) continue; + + const uint32_t first_index = mesh.ebo_byte_offset / sizeof(uint32_t); + if (first_index + mesh.index_count > sd.indices.size()) continue; + + const float* positions = + sd.vertices.data() + base_vertex * vtx_stride_floats; + const uint32_t* indices = sd.indices.data() + first_index; + + const size_t target_index_count = std::max( + 3, static_cast(mesh.index_count * target_ratio) / 3 * 3); + + // The instanced VBO stores each triangle's vertices separately, so the + // mesh's index buffer is topologically disconnected — every edge is + // boundary, every vertex is unique, and meshopt_simplify can't collapse + // anything. Build a shadow index buffer that welds by position, so + // shared-position vertices share an ID; then simplify on that. Output + // indices are still valid mesh-local IDs (canonical representatives), + // usable directly as LOD1 indices against the same VBO. + shadow.resize(mesh.index_count); + meshopt_generateShadowIndexBuffer( + shadow.data(), + indices, mesh.index_count, + positions, mesh.vertex_count, + sizeof(float) * 3, // compare only xyz + vtx_stride_bytes); + + simplified.resize(mesh.index_count); + float result_error = 0.0f; + size_t new_index_count = 0; + + if (use_sloppy) { + // Cluster-based decimator. Ignores topology entirely; great for + // BIM brep output which is usually non-manifold / has T-junctions. + // Operates directly on the original indices — welding isn't + // needed since it quantises positions into voxel cells. + new_index_count = meshopt_simplifySloppy( + simplified.data(), + indices, mesh.index_count, + positions, mesh.vertex_count, vtx_stride_bytes, + target_index_count, target_error, + &result_error); + } else { + const unsigned int options = + lock_border ? static_cast(meshopt_SimplifyLockBorder) : 0u; + new_index_count = meshopt_simplify( + simplified.data(), + shadow.data(), mesh.index_count, + positions, mesh.vertex_count, vtx_stride_bytes, + target_index_count, target_error, + options, &result_error); + } + + if (debug && dbg_printed < 8) { + std::fprintf(stderr, + " [lod] mesh tris=%u target=%zu got=%zu err=%.4f\n", + tri_count, target_index_count / 3, + new_index_count / 3, result_error); + ++dbg_printed; + } + + // Accept only if we actually saved a meaningful chunk of tris. + if (new_index_count == 0 || new_index_count >= mesh.index_count) { + ++dbg_rejected_noreduce; + continue; + } + + const uint32_t saved = mesh.index_count - static_cast(new_index_count); + if (static_cast(saved) < min_savings * static_cast(mesh.index_count)) { + ++dbg_rejected_savings; + continue; + } + ++dbg_accepted; + + // Append the surviving indices to sd.indices; record the offset. + const size_t append_offset_bytes = sd.indices.size() * sizeof(uint32_t); + sd.indices.insert(sd.indices.end(), + simplified.begin(), + simplified.begin() + new_index_count); + mesh.lod1_ebo_byte_offset = static_cast(append_offset_bytes); + mesh.lod1_index_count = static_cast(new_index_count); + } + + if (debug) { + std::fprintf(stderr, + " [lod] summary: accepted=%d rejected_noreduce=%d rejected_savings=%d " + "(lock_border=%d target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n", + dbg_accepted, dbg_rejected_noreduce, dbg_rejected_savings, + lock_border ? 1 : 0, target_error, target_ratio, min_savings); + } +} + +LodStats summariseLods(const SidecarData& sd) { + LodStats s; + s.meshes_total = static_cast(sd.meshes.size()); + for (const auto& m : sd.meshes) { + s.tris_lod0 += m.index_count / 3; + if (m.lod1_index_count > 0) { + ++s.meshes_with_lod1; + s.tris_lod1 += m.lod1_index_count / 3; + s.tris_lod0_for_lod1 += m.index_count / 3; + } + } + return s; +} diff --git a/src/ifcviewer/LodBuilder.h b/src/ifcviewer/LodBuilder.h new file mode 100644 index 0000000000..a937ae4987 --- /dev/null +++ b/src/ifcviewer/LodBuilder.h @@ -0,0 +1,56 @@ +/******************************************************************************** + * * + * 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 LODBUILDER_H +#define LODBUILDER_H + +#include "SidecarCache.h" + +// Build a LOD1 index slice for every mesh in `sd` whose triangle count is +// above `min_triangles`, using meshoptimizer's edge-collapse decimator. The +// LOD1 indices are appended to `sd.indices`; each MeshInfo's +// lod1_ebo_byte_offset + lod1_index_count are populated to point at the +// appended range. Meshes that don't qualify (too small) or where the +// decimator couldn't meet the target within the error budget have +// lod1_index_count left at 0 (renderer falls back to LOD0). +// +// Defaults match the Phase 3B first-iteration design: +// min_triangles = 500 — below this the overhead dominates +// target_ratio = 0.25 — aim for 25% of original tris +// target_error = 0.05 — stop if relative error exceeds 5% +// +// `sd.vertices` is read (position is the first 3 floats of each +// INSTANCED_VERTEX_STRIDE_FLOATS-wide vertex) but not modified — LOD1 +// reuses the same vertex buffer, just with a different index list. +void buildLods(SidecarData& sd, + int min_triangles = 500, + float target_ratio = 0.25f, + float target_error = 0.05f); + +// Cheap summary for logging. Safe to call before or after buildLods. +struct LodStats { + uint32_t meshes_total = 0; + uint32_t meshes_with_lod1 = 0; + uint32_t tris_lod0 = 0; // sum across all meshes + uint32_t tris_lod1 = 0; // only for meshes that got LOD1 + uint32_t tris_lod0_for_lod1 = 0; // LOD0 tris of the meshes that got LOD1 +}; +LodStats summariseLods(const SidecarData& sd); + +#endif // LODBUILDER_H diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 8b63f3bdf6..7dc5454700 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -20,6 +20,7 @@ #include "MainWindow.h" #include "AppSettings.h" #include "SettingsWindow.h" +#include "LodBuilder.h" #include "SidecarCache.h" #include @@ -395,6 +396,19 @@ void MainWindow::onStreamingFinished() { sd.elements.push_back(pe); } + // Build LOD1 for eligible meshes (extends sd.indices and + // populates MeshInfo::lod1_*), push the extension onto the + // live GPU state so this session benefits too, then cache. + QElapsedTimer t_lod; t_lod.start(); + buildLods(sd); + LodStats ls = summariseLods(sd); + qDebug(" LOD build: %lld ms — %u/%u meshes got LOD1 " + "(%u tris → %u tris for those meshes)", + t_lod.elapsed(), + ls.meshes_with_lod1, ls.meshes_total, + ls.tris_lod0_for_lod1, ls.tris_lod1); + viewport_->applyLodExtension(loading_model_id_, sd); + std::string ifc_path = it->second.file_path.toStdString(); uint64_t file_size = static_cast( QFileInfo(it->second.file_path).size()); diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 7bb6972b83..82bd89555c 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -92,7 +92,8 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. | `GeometryStreamer.h/cpp` | Background iterator runner; emits `MeshChunk` + `InstanceChunk` | | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v4) sidecar read/write | +| `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v5) sidecar read/write | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `SettingsWindow.h/cpp` | Settings dialog | | `CMakeLists.txt` | Build configuration | @@ -106,6 +107,9 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. at GL 4.1). - **IfcOpenShell C++ libraries** (IfcParse, IfcGeom, and their dependencies: Open CASCADE, Boost, Eigen3, optionally CGAL). +- **[meshoptimizer](https://github.com/zeux/meshoptimizer)** — linked via + `find_package(meshoptimizer REQUIRED)`. Used at sidecar-build time for LOD + decimation; not needed at runtime once a sidecar exists. ## Building @@ -266,7 +270,7 @@ while stack not empty: Depth 64 is enough for billions of items on any balanced tree. The stack is on the C++ stack, zero per-frame allocation. -#### Sidecar format (`.ifcview`, v4) +#### Sidecar format (`.ifcview`, v5) Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Stores everything needed to skip the `IfcGeom::Iterator` pass: @@ -276,7 +280,7 @@ SidecarHeader (magic "IFVW", version, endian, ...) uint64_t source_file_size uint32_t + float[] vertex data (7 floats × N_verts, local coords) uint32_t + uint32_t[] index data (mesh-local) -uint32_t + MeshInfo[] per-unique-mesh metadata (48 B each) +uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids) uint32_t + PackedElementInfo[] element tree records uint32_t + char[] string table @@ -449,14 +453,117 @@ At 4 px, frame time breakdown matches: ~16 ms non-draw baseline (from throughput on the post-cull geometry — next steps (LOD, HiZ) attack that directly. -#### 3B. Distance / contribution LOD (medium-term) +#### 3B. Distance / contribution LOD — ✅ done -Pre-simplify unique representations at ingress time (store LOD 0 / 1 / -2 meshes in the VBO/EBO with offsets), select LOD per instance per -frame by the same projected-size metric as 3A. The visible-SSBO -plumbing and MDI structure don't change — only `firstIndex`/`count` in -the indirect command does. Ingress side needs a decimation pass -(`meshoptimizer` or similar); GPU side is nearly free. +Decimate each unique representation once (at sidecar-build time), store +the reduced index slice in the same EBO, and switch to it per-instance +per-frame whenever the projected sphere radius is small enough that the +reduced silhouette is indistinguishable from the original. + +##### Pipeline + +1. **After streaming finishes**, `MainWindow` calls `buildLods(sd)` on + the snapshotted `SidecarData`. Each eligible mesh's decimated index + list is appended to `sd.indices`; the per-mesh `MeshInfo` gains two + new fields: + + ```cpp + uint32_t lod1_ebo_byte_offset; // appended slice, same VBO + uint32_t lod1_index_count; // 0 = no LOD1 was built + ``` + + `MeshInfo` grew from 48 to 56 bytes, which also bumps the sidecar + format to v5. + +2. `viewport_->applyLodExtension(model_id, sd)` pushes the new index + suffix onto the live EBO via `glNamedBufferSubData` and replaces the + CPU-side `m.meshes` vector. The VBO and instance SSBO are untouched + — LOD1 reuses the same vertices, only the indices differ. + +3. The sidecar is then written with both LOD0 and LOD1 indices baked in, + so subsequent loads of the same file pick up LOD1 for free. + +##### Selection + +The contribution-cull pass already computes each instance's projected +pixel radius. LOD1 is selected when that radius falls below +`IFC_LOD1_PX` (default 30 px) and the mesh has a non-empty LOD1 slice. +Camera-inside-AABB short-circuits select LOD0 (treated as "infinite +radius") so you never accidentally see the reduced mesh up close. + +The visible-instance pipeline gains two more buckets (`fwd_lod1_`, +`rev_lod1_`), so the four-way split is now `{fwd, rev} × {LOD0, LOD1}`. +LOD0/LOD1 within a winding slice are contiguous — only winding requires +`glFrontFace` to flip between MDI calls, LOD does not. `firstIndex` / +`count` in the `DrawElementsIndirectCommand` pick which slice of the EBO +to walk; everything else (base vertex, base instance, SSBO bindings, +shader) is unchanged. + +##### Decimator choice: `meshopt_simplifySloppy` + +The first attempt used `meshopt_simplify`, which is an edge-collapse +decimator. It returned every input mesh unchanged (`err = 0.0`) for two +reasons, both inherent to BIM brep output: + +1. **Per-triangle vertex duplication.** The instanced VBO stores each + triangle's vertices separately so that hard-edge normals can differ + across triangles. Topologically there are no shared vertices, so no + edges exist for `meshopt_simplify` to collapse. A + `meshopt_generateShadowIndexBuffer` welding pass (hash xyz only, + ignore the interleaved normal/colour) fixes this half cheaply — the + VBO isn't touched, only a per-call shadow index buffer is built. +2. **Non-manifold topology even after welding.** BIM brep output has + T-junctions, coplanar slivers, separate solids meeting at a plane, + and multi-material cuts. `meshopt_simplify` needs valid 2-manifold + edge pairs to score collapses; it refuses the non-manifold ones, the + priority queue never fires, and it returns the input untouched. + +`meshopt_simplifySloppy` is a **voxel-clustering decimator** — it +quantises positions into cells and merges everything in a cell to a +single point. Topology is irrelevant, so it works directly on the +original indices (welding isn't even needed). The trade-off is that it +rounds off sharp corners and can produce slightly degenerate triangles, +so it doesn't look great at mid-screen size. For a LOD1 that only +activates below 30 px projected radius that's invisible in practice. If +you ever want LOD1 to remain active at larger sizes, the only robust +fix is to pre-process BIM meshes into manifold form (fuse coplanar +faces, split at T-junctions) — a significant project unto itself. + +##### Tuning knobs (env vars) + +| Var | Default | Effect | +|-----|---------|--------| +| `IFC_LOD1_PX` | `30` | Projected sphere radius (px) below which LOD1 kicks in. `0` disables LOD1 entirely. | +| `IFC_LOD_SLOPPY` | `1` | `0` falls back to edge-collapse (`meshopt_simplify`) on shadow-welded indices. Typically produces zero LOD1 output for BIM — useful only for A/B comparison. | +| `IFC_LOD_ERROR` | `0.2` | Target relative error passed to meshopt. | +| `IFC_LOD_RATIO` | `0.25` | Target triangle-count ratio (LOD1 aims for 25 % of LOD0 tris). | +| `IFC_LOD_MIN_SAVINGS` | `0.25` | Reject the LOD1 result if it doesn't shave at least this fraction of triangles. | +| `IFC_LOD_LOCK_BORDER` | `0` | `1` re-enables `meshopt_SimplifyLockBorder` (only meaningful with `IFC_LOD_SLOPPY=0`). | +| `IFC_LOD_DEBUG` | `0` | `1` prints per-mesh `tris / target / got / err` for the first 8 candidate meshes plus an accept/reject summary per model. | + +##### Measured results + +Same 10-model / 128 M-tri scene as Phase 3A (GTX 1650), 2 px contribution +threshold, overview camera, all models finalised with LOD1 built: + +| Build | FPS | Frame time | Visible tris | Visible objs | +|-------|-----|-----------|--------------|--------------| +| Phase 3A alone (2 px) | 20.2 | 49 ms | 40 M | 89 k | +| Phase 3A + 3B (LOD1 ≤ 30 px) | **43.2** | **23 ms** | 14 M | 81 k | + +Roughly half the remaining frame time, same object count (LOD is +lossless w.r.t. visibility — swapping index slice doesn't hide +anything). The triangle reduction on meshes that qualified for LOD1 is +~80 %: e.g. 4.17 M → 0.82 M tris for the 3618 eligible meshes of Model +1, 3.25 M → 0.65 M for Model 2, etc. Only about 20 % of unique meshes +qualify (the threshold is 500 tris — below that the indirect-command +overhead dominates), but those are the fat tail carrying most of the +rasterisation cost. + +LOD build itself runs on the main thread inside `onStreamingFinished`; +typical cost is 100–600 ms per model, folded into the already-visible +"finalizing" step. Cached into the sidecar afterwards, so subsequent +opens skip it entirely. #### 3C. Hierarchical-Z occlusion culling (longer-term) @@ -490,7 +597,7 @@ Scene size Bottleneck Fix < 100k instances CPU cull scan Phase 1 only 100k–500k CPU cull scan BVH (Phase 2) — done 500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull - (+ 3B LOD for close-ups) + + Phase 3B LOD (done) multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion ``` @@ -508,10 +615,10 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`) +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`) - [x] Phase 3A — screen-space contribution culling -- [ ] **Phase 3B — distance / contribution LOD** (next) -- [ ] Phase 3C — Hierarchical-Z occlusion culling +- [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) +- [ ] **Phase 3C — Hierarchical-Z occlusion culling** (next) - [ ] Phase 3D — GPU-side compute-shader culling - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index 3c5ca9cd8d..da3943988d 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,7 +17,11 @@ * * ********************************************************************************/ -// v4 layout (all multi-byte fields native-endian; endianness marker in header): +// v5 layout (all multi-byte fields native-endian; endianness marker in header). +// Same sequence as v4; the only change is that MeshInfo grew two uint32_ts +// (lod1_ebo_byte_offset + lod1_index_count) and `indices` may contain extra +// appended LOD1 slices pointed at by those offsets. +// // // SidecarHeader (16 bytes) // uint64_t source_file_size diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index e14eb9d256..332abdc802 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -34,7 +34,10 @@ #include static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" -static constexpr uint32_t SIDECAR_VERSION = 4; +// v5 = MeshInfo extended with lod1_ebo_byte_offset + lod1_index_count (56 B). +// sd.indices may contain an appended LOD1 index slice for each mesh +// where meshoptimizer decimation produced useful output. +static constexpr uint32_t SIDECAR_VERSION = 5; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index db002c1870..2606ffd3f3 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -751,6 +751,34 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { ssbo_bytes / (1024.0*1024.0)); } +void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) { + if (!gl_initialized_) return; + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end() || !it->second.finalized) return; + ModelGpuData& m = it->second; + + const size_t total_ib_bytes = sd.indices.size() * sizeof(uint32_t); + if (total_ib_bytes <= m.ebo_used) { + // buildLods didn't add anything; just refresh the meshes vector in + // case lod1_* fields were touched. + m.meshes = sd.meshes; + return; + } + + context_->makeCurrent(this); + if (total_ib_bytes > m.ebo_capacity) { + if (!growModelEbo(m, total_ib_bytes)) return; + } + const size_t append_bytes = total_ib_bytes - m.ebo_used; + const uint32_t* appended_src = + sd.indices.data() + (m.ebo_used / sizeof(uint32_t)); + gl_->glNamedBufferSubData(m.ebo, m.ebo_used, append_bytes, appended_src); + m.ebo_used = total_ib_bytes; + + // Replace mesh metadata so cullAndUploadVisible sees the new lod1_ fields. + m.meshes = sd.meshes; +} + void ViewportWindow::resetScene() { if (!gl_initialized_) return; context_->makeCurrent(this); @@ -826,17 +854,33 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4], float focal_px, float min_pixel_radius) { - // Per-mesh scratch, split by winding: fwd = non-reflected (CCW in screen - // space), rev = reflected (CW in screen space). Splitting lets the draw + // Per-mesh scratch, split by winding × LOD. Winding split lets the draw // pass toggle glFrontFace once between two MDI calls so GL_CULL_FACE does - // the right thing for both. - if (visible_by_mesh_fwd_.size() < m.meshes.size()) visible_by_mesh_fwd_.resize(m.meshes.size()); - if (visible_by_mesh_rev_.size() < m.meshes.size()) visible_by_mesh_rev_.resize(m.meshes.size()); + // the right thing for both. LOD split means instances that want the + // decimated mesh go into a different bucket that emits against + // mesh.lod1_ebo_byte_offset / lod1_index_count. + auto resize_if = [&](std::vector>& v) { + if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); + }; + resize_if(visible_by_mesh_fwd_lod0_); + resize_if(visible_by_mesh_fwd_lod1_); + resize_if(visible_by_mesh_rev_lod0_); + resize_if(visible_by_mesh_rev_lod1_); for (size_t i = 0; i < m.meshes.size(); ++i) { - visible_by_mesh_fwd_[i].clear(); - visible_by_mesh_rev_[i].clear(); + visible_by_mesh_fwd_lod0_[i].clear(); + visible_by_mesh_fwd_lod1_[i].clear(); + visible_by_mesh_rev_lod0_[i].clear(); + visible_by_mesh_rev_lod1_[i].clear(); } + // LOD1 switches in when projected sphere radius (in pixels) drops below + // this threshold. Overridable for tuning. Set to 0 to disable LOD1 + // entirely (always draw LOD0). + static const float lod1_px_threshold = []{ + const char* e = std::getenv("IFC_LOD1_PX"); + return (e && *e) ? static_cast(std::atof(e)) : 30.0f; + }(); + // Bounding-sphere contribution test: approximate an AABB by its enclosing // sphere (centre = midpoint, radius = half-diagonal). Project radius to // pixels as r_px = focal_px * r / distance (perspective). Reject if @@ -871,15 +915,44 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] return focal_px * radius >= min_pixel_radius * dist; }; + // Returns projected sphere radius in pixels (or +inf when camera is + // inside the AABB). Shares the geometry with contributionPasses; this + // version returns the value so we can also use it for LOD selection. + auto pixelRadius = [&](const float mn[3], const float mx[3]) -> float { + if (cx >= mn[0] && cx <= mx[0] && + cy >= mn[1] && cy <= mx[1] && + cz >= mn[2] && cz <= mx[2]) { + return std::numeric_limits::infinity(); + } + float ex = 0.5f * (mx[0] - mn[0]); + float ey = 0.5f * (mx[1] - mn[1]); + float ez = 0.5f * (mx[2] - mn[2]); + float radius = std::sqrt(ex*ex + ey*ey + ez*ez); + float dx = 0.5f * (mx[0] + mn[0]) - cx; + float dy = 0.5f * (mx[1] + mn[1]) - cy; + float dz = 0.5f * (mx[2] + mn[2]) - cz; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + return dist > 0.0f ? focal_px * radius / dist + : std::numeric_limits::infinity(); + }; + auto test_and_push = [&](uint32_t inst_idx) { const InstanceCpu& inst = m.instances[inst_idx]; if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return; if (inst.mesh_id >= m.meshes.size()) return; + const MeshInfo& mesh = m.meshes[inst.mesh_id]; + const bool want_lod1 = mesh.lod1_index_count > 0 && + lod1_px_threshold > 0.0f && + pixelRadius(inst.world_aabb_min, inst.world_aabb_max) < lod1_px_threshold; const bool reflected = inst_idx < m.instance_reflected.size() && m.instance_reflected[inst_idx] != 0; - if (reflected) visible_by_mesh_rev_[inst.mesh_id].push_back(inst_idx); - else visible_by_mesh_fwd_[inst.mesh_id].push_back(inst_idx); + auto& bucket = + reflected ? (want_lod1 ? visible_by_mesh_rev_lod1_ + : visible_by_mesh_rev_lod0_) + : (want_lod1 ? visible_by_mesh_fwd_lod1_ + : visible_by_mesh_fwd_lod0_); + bucket[inst.mesh_id].push_back(inst_idx); }; if (!m.bvh.nodes.empty()) { @@ -911,22 +984,28 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i); } - // Flatten fwd-slice first, then rev-slice, into visible_flat_. Build - // matching DrawElementsIndirectCommands; commands for the fwd slice fill - // [0, indirect_forward_count), rev fills [indirect_forward_count, end). + // Flatten fwd-slice first (LOD0 then LOD1), then rev-slice (ditto), into + // visible_flat_. Commands for the fwd slice fill [0, indirect_forward_count), + // rev fills [indirect_forward_count, end). LOD0/LOD1 within a winding + // slice are contiguous — winding is what requires glFrontFace to flip + // between MDI calls, LOD is not. visible_flat_.clear(); indirect_scratch_.clear(); - auto emit_slice = [&](std::vector>& by_mesh) { + auto emit_slice = [&](std::vector>& by_mesh, int lod) { for (size_t mi = 0; mi < m.meshes.size(); ++mi) { const auto& mesh = m.meshes[mi]; const uint32_t vis_count = static_cast(by_mesh[mi].size()); - if (vis_count == 0 || mesh.index_count == 0) continue; + const uint32_t idx_count = + (lod == 1) ? mesh.lod1_index_count : mesh.index_count; + const uint32_t ebo_off = + (lod == 1) ? mesh.lod1_ebo_byte_offset : mesh.ebo_byte_offset; + if (vis_count == 0 || idx_count == 0) continue; DrawElementsIndirectCommand cmd; - cmd.count = mesh.index_count; + cmd.count = idx_count; cmd.instanceCount = vis_count; - cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.firstIndex = ebo_off / sizeof(uint32_t); cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; cmd.baseInstance = static_cast(visible_flat_.size()); indirect_scratch_.push_back(cmd); @@ -936,9 +1015,11 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] } }; - emit_slice(visible_by_mesh_fwd_); + emit_slice(visible_by_mesh_fwd_lod0_, 0); + emit_slice(visible_by_mesh_fwd_lod1_, 1); m.indirect_forward_count = static_cast(indirect_scratch_.size()); - emit_slice(visible_by_mesh_rev_); + emit_slice(visible_by_mesh_rev_lod0_, 0); + emit_slice(visible_by_mesh_rev_lod1_, 1); m.indirect_command_count = static_cast(indirect_scratch_.size()); // Upload visible list (keep binding alive even when empty). diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index a8d696121a..fe54cce921 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -127,6 +127,13 @@ public: // any existing state for model_id and marks it drawable. void applyCachedModel(uint32_t model_id, SidecarData data); + // After buildLods() has extended sd.indices + populated lod1_* fields, + // push just the appended index slice + the refreshed mesh metadata onto + // the live GPU state for model_id. VBO / SSBO / instance array are left + // alone; only the EBO grows and m.meshes is replaced. No-op if the + // model isn't finalised on the viewport. + void applyLodExtension(uint32_t model_id, const SidecarData& sd); + void hideModel(uint32_t model_id); void showModel(uint32_t model_id); void removeModel(uint32_t model_id); @@ -223,8 +230,13 @@ private: // per-frame allocation. indirect_scratch_ is the matching array of // DrawElementsIndirectCommand records — forward-declared as bytes so // the header doesn't need the struct definition. - std::vector> visible_by_mesh_fwd_; - std::vector> visible_by_mesh_rev_; + // Four buckets = {fwd, rev} × {LOD0, LOD1}. LOD1 buckets are only + // populated when the mesh has lod1_index_count > 0 and the projected + // pixel radius is below the LOD switch threshold. + std::vector> visible_by_mesh_fwd_lod0_; + std::vector> visible_by_mesh_fwd_lod1_; + std::vector> visible_by_mesh_rev_lod0_; + std::vector> visible_by_mesh_rev_lod1_; std::vector visible_flat_; std::vector indirect_scratch_; From 8596d53a4aa75eb88a862f538813dae798ad54a0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 13 Apr 2026 23:25:33 +1000 Subject: [PATCH 025/120] Phase 3C: Hierarchical-Z occlusion culling (CPU-side v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the main draw, blit the MSAA default-framebuffer depth to a single-sample 256×128 depth texture, read it back, and build a CPU max-reduced mip pyramid. Next frame's cullAndUploadVisible projects each BVH node / instance AABB through the previous frame's VP and compares the AABB's nearest depth against the pyramid's deepest value at the matching mip level; strictly-beyond AABBs are rejected. Conservative direction (aabb_near > hiz_max) — never wrongly rejects a visible instance, so no flicker. BVH subtree-level test lets a single 8-corner projection reject up to a leaf's worth of instances. Tuning knobs: IFC_NO_HIZ=1 disables; IFC_HIZ_SIZE overrides base width. New stats counter hiz_rej shows rejects/frame. Measured: big win on interior views (GPU-bound), roughly zero net effect on exterior overviews (CPU-bound on cull traversal, so the saved GPU work is masked). Tried a 3-deep PBO ring for async readback and reverted — the extra frame of staleness produced visible flicker on fast orbit, and the synchronous readback wasn't actually a measured bottleneck at 256×128. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 129 +++++++++++++-- src/ifcviewer/ViewportWindow.cpp | 264 ++++++++++++++++++++++++++++++- src/ifcviewer/ViewportWindow.h | 40 +++++ 3 files changed, 420 insertions(+), 13 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 82bd89555c..afa2042610 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -565,15 +565,120 @@ typical cost is 100–600 ms per model, folded into the already-visible "finalizing" step. Cached into the sidecar afterwards, so subsequent opens skip it entirely. -#### 3C. Hierarchical-Z occlusion culling (longer-term) +#### 3C. Hierarchical-Z occlusion culling — ✅ done (v1, CPU-side) -Render large occluders first, build a depth pyramid, test instance -AABBs against it. In dense BIM most geometry is behind other geometry -from any given interior viewpoint; historically a 3–10× reduction in -drawn instances. Most valuable *after* 3A+3B, which together handle -the far-away and small-detail cases. Pairs naturally with GPU-side -culling (a compute shader doing the HiZ test and writing the visible -list + indirect buffer in place). +Reject frustum-visible instances whose AABB is fully behind something +already drawn. The last drawn frame's depth buffer is the oracle — if a +region's deepest rasterised fragment is closer than an AABB's nearest +point, nothing in that AABB can win the depth test. + +In dense BIM this matters most on interior views: standing inside a +building, 80–95 % of the model sits behind the walls of the current +room and contributes nothing to the frame. Phase 3A drops the +*distant-and-small* geometry, 3B drops its triangle count when kept, +and 3C drops the *close-and-big-but-hidden* bulk that neither of those +can touch. On an outdoor overview shot (nothing is occluded) 3C does +almost nothing — which is fine, 3A+3B already cover that case. + +##### Pipeline (v1: CPU-side, 1-frame stale) + +``` +render(): + draw main scene into MSAA default fb + axis gizmo + buildHizPyramid(): <-- new + glBlitFramebuffer MSAA depth → single-sample depth tex (256×128) + glReadPixels depth tex → CPU + max-reduce mip chain on CPU (8–9 levels) + store the VP that produced this frame + swapBuffers + +cullAndUploadVisible(): + per BVH node: frustum ∧ contribution ∧ hiz (subtree early-out) + per instance: frustum ∧ contribution ∧ hiz +``` + +The pyramid is always the *previous* frame's depth. On a newly loaded +scene or after a camera jump the cull is conservatively too permissive +for a frame or two (draws the occluded stuff by accident) and then +settles. No flicker because we never *wrongly reject* a visible +instance — the comparison is `aabb_near_depth > hiz_max`, so the +worst case is a kept instance that was actually occluded. + +##### Why CPU-side? + +Because the readback is cheap at this resolution (~128 KB / frame, +single glReadPixels ≈ 0.5 ms on PCIe) and the test itself is trivial +— ~100 k AABBs × 8 corners × a small mip lookup is well under a +millisecond on one thread. Phase 3D will port the cull to a compute +shader reading the pyramid as a texture, eliminating the readback; but +Phase 3C's CPU implementation was small enough to do first and +measure. + +No MSAA complication on the write side: we just blit the default +framebuffer's multi-sample depth into a single-sample texture (GL +handles the resolve). No separate occluder pass either — we use the +previous completed frame's depth buffer directly, which is what a +temporal-reprojection HiZ reduces to when the "occluder set" is +"everything visible last frame". + +##### The test + +```cpp +project 8 AABB corners through hiz_vp → NDC rect + min z +if any corner has w ≤ 0: return false // crosses near plane +if rect is outside [-1, 1]²: return false +pick mip level where rect ≤ 2×2 texels +hiz_max = max(pyramid[mip][covered texels]) +return aabb_near_depth > hiz_max +``` + +Comparing the AABB's *closest* point against the pyramid's *deepest* +value is the conservative direction — it only rejects when the AABB +is strictly beyond everything we already drew in that region. We pick +the mip at which the rect covers ≲ 2 texels on each axis so the lookup +is O(1) regardless of AABB size. + +##### BVH integration + +The same test runs on interior BVH node AABBs before leaf expansion, +so an occluded subtree skips all its instances in one shot. This is +where most of the per-frame cost savings show up on interior shots — +rejecting a 500-instance BVH subtree costs one 8-corner projection. + +##### Tuning knobs + +| Var | Default | Effect | +|-----|---------|--------| +| `IFC_NO_HIZ` | unset | `1` disables HiZ entirely (forces the Phase-3B-only path). | +| `IFC_HIZ_SIZE` | `256` | Base pyramid width in texels; height tracks viewport aspect. Raise for more accurate near-silhouette occlusion, lower to shrink readback. | + +The stats overlay gains one counter, `hiz_rej`, showing how many +instances per frame the HiZ test rejected. On outdoor overview shots +it hovers near zero; on indoor shots it climbs into the hundreds of +thousands and the frame time drops accordingly. + +##### Known caveats + +- **1 frame stale.** The pyramid is aligned to last frame's view, so + when you whip the camera across the scene we may draw one frame of + stuff that the new view would have occluded. Invisible in practice + at 60 fps. We tried a 3-deep PBO ring for async readback (2-frame + stale) and it produced visible flicker on fast orbits — reverted. +- **Readback syncs the GPU.** `glGetTextureImage` is blocking. + Measured cost is well under a millisecond at 256×128; not a + bottleneck on the machines tested. Phase 3D's compute-shader cull + removes it entirely. +- **Doesn't move the needle on overview shots.** Those scenes are + CPU-bound on the cull traversal itself, not GPU-bound on drawing, + so cutting the drawn-triangle count in half is invisible in the + frame time. `hiz_rej` still rises modestly on overviews (the frustum + hull contains everything behind visible walls) but saved GPU work + is masked by CPU cost. HiZ pays off on interior views, where the + GPU *was* the bottleneck. If a project never leaves overview, + `IFC_NO_HIZ=1` shaves the ~1 ms of HiZ cost. +- **Transparent geometry would need special handling**, but the + current renderer doesn't have any, so no-op for now. #### 3D. GPU-side culling via compute (longer-term) @@ -598,7 +703,7 @@ Scene size Bottleneck Fix 100k–500k CPU cull scan BVH (Phase 2) — done 500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull + Phase 3B LOD (done) -multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion +multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) ``` ## Roadmap @@ -615,10 +720,10 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`) +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`) - [x] Phase 3A — screen-space contribution culling - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) -- [ ] **Phase 3C — Hierarchical-Z occlusion culling** (next) -- [ ] Phase 3D — GPU-side compute-shader culling +- [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) +- [ ] **Phase 3D — GPU-side compute-shader culling** (next; replaces the readback) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2606ffd3f3..fdfff63997 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -343,6 +343,10 @@ ViewportWindow::~ViewportWindow() { 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_); + if (hiz_fbo_) gl_->glDeleteFramebuffers(1, &hiz_fbo_); + if (hiz_depth_tex_) gl_->glDeleteTextures(1, &hiz_depth_tex_); + if (hiz_resolve_fbo_) gl_->glDeleteFramebuffers(1, &hiz_resolve_fbo_); + if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_); } context_->doneCurrent(); } @@ -821,6 +825,240 @@ void ViewportWindow::removeModel(uint32_t model_id) { void ViewportWindow::setSelectedObjectId(uint32_t id) { selected_object_id_ = id; } +// --- HiZ occlusion culling (Phase 3C) ----------------------------------- + +// Baseline HiZ resolution. 256x128 is enough to cull big occluders +// (walls, slabs) reliably; finer detail doesn't help much because we're +// sampling the pyramid at the mip level where the AABB's rect is ~2 +// texels anyway. Readback cost is ~128 KB/frame ≈ negligible. +// IFC_HIZ_SIZE= overrides the width; height tracks aspect. +static int hizBaseWidth() { + static const int w = []{ + const char* e = std::getenv("IFC_HIZ_SIZE"); + return (e && *e) ? std::max(64, std::atoi(e)) : 256; + }(); + return w; +} + +static bool hizEnabled() { + static const bool disabled = []{ + const char* e = std::getenv("IFC_NO_HIZ"); + return e && e[0] == '1'; + }(); + return !disabled; +} + +void ViewportWindow::buildHizPyramid() { + if (!gl_initialized_) return; + + const int win_w = width() * devicePixelRatio(); + const int win_h = height() * devicePixelRatio(); + if (win_w <= 0 || win_h <= 0) return; + + const int base_w = hizBaseWidth(); + const int base_h = std::max(1, (base_w * win_h) / win_w); + + // Depth format must match the default FBO's depth format for the blit + // to succeed — GL spec requires identical internal formats for depth + // blits. Qt's default surface uses 24-bit depth (setDepthBufferSize(24) + // in initGL), so we match with DEPTH_COMPONENT24 on both textures. + // + // Resolve target (full window size, single sample). Needed because + // GL also forbids scale-blitting from an MSAA source: resolve at 1:1 + // first, then down-blit. + if (win_w != hiz_resolve_w_ || win_h != hiz_resolve_h_) { + if (hiz_resolve_fbo_) gl_->glDeleteFramebuffers(1, &hiz_resolve_fbo_); + if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_); + gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_resolve_depth_tex_); + gl_->glTextureStorage2D(hiz_resolve_depth_tex_, 1, + GL_DEPTH_COMPONENT24, win_w, win_h); + gl_->glCreateFramebuffers(1, &hiz_resolve_fbo_); + gl_->glNamedFramebufferTexture(hiz_resolve_fbo_, GL_DEPTH_ATTACHMENT, + hiz_resolve_depth_tex_, 0); + hiz_resolve_w_ = win_w; + hiz_resolve_h_ = win_h; + } + + if (base_w != hiz_base_w_ || base_h != hiz_base_h_) { + if (hiz_fbo_) gl_->glDeleteFramebuffers(1, &hiz_fbo_); + if (hiz_depth_tex_) gl_->glDeleteTextures(1, &hiz_depth_tex_); + gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_depth_tex_); + gl_->glTextureStorage2D(hiz_depth_tex_, 1, GL_DEPTH_COMPONENT24, + base_w, base_h); + gl_->glCreateFramebuffers(1, &hiz_fbo_); + gl_->glNamedFramebufferTexture(hiz_fbo_, GL_DEPTH_ATTACHMENT, + hiz_depth_tex_, 0); + + hiz_base_w_ = base_w; + hiz_base_h_ = base_h; + hiz_depth_readback_.assign(base_w * base_h, 1.0f); + + // Build the mip-offset table. Level 0 = base_w x base_h. + hiz_mip_offset_.clear(); + hiz_mip_w_.clear(); + hiz_mip_h_.clear(); + uint32_t off = 0; + int mw = base_w, mh = base_h; + while (mw >= 1 && mh >= 1) { + hiz_mip_offset_.push_back(off); + hiz_mip_w_.push_back(static_cast(mw)); + hiz_mip_h_.push_back(static_cast(mh)); + off += static_cast(mw) * static_cast(mh); + if (mw == 1 && mh == 1) break; + mw = std::max(1, mw / 2); + mh = std::max(1, mh / 2); + } + hiz_pyramid_.assign(off, 1.0f); + } + + // Two-step: MSAA default-fb → full-size SS resolve, then SS → down-scaled. + // GL forbids scaling a blit whose source is multisampled, and also + // requires matching depth internal formats — hence this dance. + gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_resolve_fbo_); + gl_->glBlitFramebuffer(0, 0, win_w, win_h, + 0, 0, win_w, win_h, + GL_DEPTH_BUFFER_BIT, GL_NEAREST); + + gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, hiz_resolve_fbo_); + gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_fbo_); + gl_->glBlitFramebuffer(0, 0, win_w, win_h, + 0, 0, hiz_base_w_, hiz_base_h_, + GL_DEPTH_BUFFER_BIT, GL_NEAREST); + gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + + // One-shot diagnostic so blit failures aren't silent. We only warn + // the first handful of times — GL errors can pile up and spam. + static int err_warn_budget = 3; + if (err_warn_budget > 0) { + GLenum e = gl_->glGetError(); + if (e != GL_NO_ERROR) { + qWarning("HiZ blit/readback GL error 0x%04x (win %dx%d → %dx%d → %dx%d)", + e, win_w, win_h, win_w, win_h, hiz_base_w_, hiz_base_h_); + --err_warn_budget; + } + } + + // Synchronous readback into level 0 of the pyramid. At 256x128 this + // is ~128 KB and the driver copy is fast enough not to matter in + // practice; PBO-ring async was tried and made orbiting flicker worse + // (2-frame-stale depth vs 1-frame). + gl_->glGetTextureImage(hiz_depth_tex_, 0, GL_DEPTH_COMPONENT, GL_FLOAT, + static_cast(hiz_depth_readback_.size() * sizeof(float)), + hiz_depth_readback_.data()); + + // Copy level 0 into the pyramid, then max-reduce subsequent levels. + std::memcpy(hiz_pyramid_.data() + hiz_mip_offset_[0], + hiz_depth_readback_.data(), + hiz_depth_readback_.size() * sizeof(float)); + for (size_t lvl = 1; lvl < hiz_mip_offset_.size(); ++lvl) { + const uint32_t pw = hiz_mip_w_[lvl - 1]; + const uint32_t ph = hiz_mip_h_[lvl - 1]; + const uint32_t cw = hiz_mip_w_[lvl]; + const uint32_t ch = hiz_mip_h_[lvl]; + const float* parent = hiz_pyramid_.data() + hiz_mip_offset_[lvl - 1]; + float* child = hiz_pyramid_.data() + hiz_mip_offset_[lvl]; + for (uint32_t y = 0; y < ch; ++y) { + const uint32_t py0 = std::min(2 * y, ph - 1); + const uint32_t py1 = std::min(2 * y + 1, ph - 1); + for (uint32_t x = 0; x < cw; ++x) { + const uint32_t px0 = std::min(2 * x, pw - 1); + const uint32_t px1 = std::min(2 * x + 1, pw - 1); + const float a = parent[py0 * pw + px0]; + const float b = parent[py0 * pw + px1]; + const float c = parent[py1 * pw + px0]; + const float d = parent[py1 * pw + px1]; + child[y * cw + x] = std::max(std::max(a, b), std::max(c, d)); + } + } + } + + hiz_vp_ = proj_matrix_ * view_matrix_; + hiz_vp_valid_ = true; +} + +bool ViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) const { + if (!hiz_vp_valid_ || hiz_pyramid_.empty()) return false; + + // Project all 8 corners through the HiZ frame's VP (stored last frame). + // Track NDC min/max over x, y, z. If any corner has w <= 0, the AABB + // straddles the near plane and we skip (behaves like "not occluded"). + float sx_min = std::numeric_limits::infinity(); + float sx_max = -std::numeric_limits::infinity(); + float sy_min = std::numeric_limits::infinity(); + float sy_max = -std::numeric_limits::infinity(); + float sz_min = std::numeric_limits::infinity(); + const float* vp = hiz_vp_.constData(); // column-major + for (int c = 0; c < 8; ++c) { + const float x = (c & 1) ? mx[0] : mn[0]; + const float y = (c & 2) ? mx[1] : mn[1]; + const float z = (c & 4) ? mx[2] : mn[2]; + const float cx = vp[0]*x + vp[4]*y + vp[8]*z + vp[12]; + const float cy = vp[1]*x + vp[5]*y + vp[9]*z + vp[13]; + const float cz = vp[2]*x + vp[6]*y + vp[10]*z + vp[14]; + const float cw = vp[3]*x + vp[7]*y + vp[11]*z + vp[15]; + if (cw <= 1e-4f) return false; // near-plane straddle + const float inv = 1.0f / cw; + const float nx = cx * inv; + const float ny = cy * inv; + const float nz = cz * inv; + if (nx < sx_min) sx_min = nx; if (nx > sx_max) sx_max = nx; + if (ny < sy_min) sy_min = ny; if (ny > sy_max) sy_max = ny; + if (nz < sz_min) sz_min = nz; + } + + if (sx_max < -1.0f || sx_min > 1.0f || + sy_max < -1.0f || sy_min > 1.0f) return false; + if (sz_min < -1.0f) return false; + + sx_min = std::max(sx_min, -1.0f); + sx_max = std::min(sx_max, 1.0f); + sy_min = std::max(sy_min, -1.0f); + sy_max = std::min(sy_max, 1.0f); + + const float u_min = 0.5f * (sx_min + 1.0f); + const float u_max = 0.5f * (sx_max + 1.0f); + const float v_min = 0.5f * (sy_min + 1.0f); + const float v_max = 0.5f * (sy_max + 1.0f); + const float aabb_near_depth = 0.5f * (sz_min + 1.0f); + + // Pick mip level where the projected rect covers at most 2 texels on + // each axis; sample the max over the covered texels there. + const float px_w = (u_max - u_min) * static_cast(hiz_base_w_); + const float px_h = (v_max - v_min) * static_cast(hiz_base_h_); + int mip = 0; + while ((int)hiz_mip_offset_.size() - 1 > mip && + ((px_w / (1 << mip)) > 2.0f || (px_h / (1 << mip)) > 2.0f)) { + ++mip; + } + + const uint32_t mw = hiz_mip_w_[mip]; + const uint32_t mh = hiz_mip_h_[mip]; + int x0 = static_cast(std::floor(u_min * mw)); + int x1 = static_cast(std::ceil (u_max * mw)); + int y0 = static_cast(std::floor(v_min * mh)); + int y1 = static_cast(std::ceil (v_max * mh)); + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 > (int)mw) x1 = mw; + if (y1 > (int)mh) y1 = mh; + if (x1 <= x0 || y1 <= y0) return false; + + const float* level = hiz_pyramid_.data() + hiz_mip_offset_[mip]; + float hiz_max = 0.0f; + for (int y = y0; y < y1; ++y) { + const float* row = level + static_cast(y) * mw; + for (int x = x0; x < x1; ++x) { + if (row[x] > hiz_max) hiz_max = row[x]; + } + } + + // AABB's closest point must be strictly farther than everything drawn + // in the region for it to be fully occluded. + return aabb_near_depth > hiz_max; +} + uint32_t ViewportWindow::pickObjectAt(int x, int y) { if (!gl_initialized_) return 0; context_->makeCurrent(this); @@ -936,10 +1174,19 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] : std::numeric_limits::infinity(); }; + // HiZ occlusion is skipped entirely when the pick pass runs + // (min_pixel_radius == 0 on that path), when the user disables it via + // env var, or before the first pyramid has been built. + const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_valid_; + auto test_and_push = [&](uint32_t inst_idx) { const InstanceCpu& inst = m.instances[inst_idx]; if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return; + if (hiz_on && aabbOccludedByHiz(inst.world_aabb_min, inst.world_aabb_max)) { + ++hiz_reject_count_; + return; + } if (inst.mesh_id >= m.meshes.size()) return; const MeshInfo& mesh = m.meshes[inst.mesh_id]; const bool want_lod1 = mesh.lod1_index_count > 0 && @@ -966,6 +1213,12 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // Contribution cull the whole subtree: if the node's enclosing // sphere is below threshold, every child is too. if (!contributionPasses(n.aabb_min, n.aabb_max)) continue; + // HiZ cull the whole subtree: if the node AABB is fully + // occluded, every leaf is too. The conservative test (AABB + // near-depth vs max pyramid depth) never rejects a visible + // parent wrongly even when some children could have peeked + // through. + if (hiz_on && aabbOccludedByHiz(n.aabb_min, n.aabb_max)) continue; if (n.count > 0) { for (uint32_t k = 0; k < n.count; ++k) { uint32_t item_idx = m.bvh.item_indices[n.right_or_first + k]; @@ -1108,6 +1361,7 @@ void ViewportWindow::render() { visible_objects_ = 0; gl_draw_calls_ = 0; indirect_sub_draws_ = 0; + hiz_reject_count_ = 0; // Start each frame with CCW-is-front; the two-pass draw below flips // back and forth. Harmless when culling is off. @@ -1178,6 +1432,13 @@ void ViewportWindow::render() { renderAxisGizmo(); + // Build HiZ from this frame's resolved depth for next frame's cull. + // Synchronous glReadPixels inside — cost ~0.5 ms at 256x128 on a + // mid-range dGPU. Skippable via IFC_NO_HIZ=1. + if (hizEnabled()) { + buildHizPyramid(); + } + context_->swapBuffers(this); float dt = frame_clock_.restart() / 1000.0f; @@ -1215,12 +1476,13 @@ void ViewportWindow::render() { emit frameStatsUpdated(stats); qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " - "meshes %u gl_draws %u sub_draws %u " + "meshes %u gl_draws %u sub_draws %u hiz_rej %u " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, visible_triangles_, total_tri, total_meshes, gl_draw_calls_, indirect_sub_draws_, + hiz_reject_count_, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index fe54cce921..fd584bb4cb 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -172,6 +172,20 @@ private: void buildShaders(); void buildAxisGizmo(); void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo); + + // Resolve the default framebuffer's MSAA depth into a single-sample + // texture, read it back, and max-reduce a mip pyramid on the CPU. The + // resulting pyramid is stored in hiz_pyramid_ along with the VP matrix + // used to draw it; next frame's cullAndUploadVisible can test AABBs + // against it. Synchronous readback — at 256×128 the cost is sub-ms + // and not a measured bottleneck; Phase 3D's compute-shader cull will + // eliminate the readback entirely. + void buildHizPyramid(); + + // True if the AABB is fully occluded by the previous frame's depth. + // Returns false when the HiZ is invalid, the AABB crosses the near + // plane, or the projection falls outside NDC. + bool aabbOccludedByHiz(const float mn[3], const float mx[3]) const; bool growModelVbo(ModelGpuData& m, size_t needed_total); bool growModelEbo(ModelGpuData& m, size_t needed_total); bool growModelSsbo(ModelGpuData& m, size_t needed_total); @@ -219,6 +233,32 @@ private: int pick_width_ = 0; int pick_height_ = 0; + // HiZ occlusion culling (Phase 3C). + // + // Each frame after the main draw we blit the MSAA depth buffer down + // into a single-sample depth texture (hiz_fbo_ / hiz_depth_tex_), then + // glReadPixels it into hiz_depth_readback_. We max-reduce that into a + // mip pyramid (hiz_pyramid_) and remember the VP matrix used + // (hiz_vp_ + hiz_vp_valid_) so next frame's cull can test AABBs + // against a slightly-stale depth. Skipped for the pick pass and when + // IFC_NO_HIZ=1. + GLuint hiz_fbo_ = 0; + GLuint hiz_depth_tex_ = 0; + GLuint hiz_resolve_fbo_ = 0; // full-size single-sample resolve + GLuint hiz_resolve_depth_tex_ = 0; + int hiz_resolve_w_ = 0; + int hiz_resolve_h_ = 0; + int hiz_base_w_ = 0; + int hiz_base_h_ = 0; + std::vector hiz_depth_readback_; // hiz_base_w_ * hiz_base_h_ floats + std::vector hiz_pyramid_; // concatenated mip levels + std::vector hiz_mip_offset_; // into hiz_pyramid_ + std::vector hiz_mip_w_; + std::vector hiz_mip_h_; + QMatrix4x4 hiz_vp_; + bool hiz_vp_valid_ = false; + uint32_t hiz_reject_count_ = 0; // per-frame stat + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; From c03a7fe117a237041c82461b927c8d68805360cb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 20:33:21 +1000 Subject: [PATCH 026/120] Cull: read AABBs from compact bvh_items in the hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cullAndUploadVisible was reading each instance's AABB through m.instances[idx] — a 104-byte InstanceCpu struct — for the frustum / contribution / HiZ tests. Only 24 of those bytes (the two float[3] AABBs) are actually used by the tests; the rest (4×4 transform + header) is pure cache-line waste, and with 569k instances the array is 59 MB, well past any cache. bvh_items[idx] already stores a 1:1 compact 28-byte record with the same AABB, built unconditionally in buildBvhForModel(). Switch the hot test path to read from it, and only touch InstanceCpu once an instance has passed all three tests (for mesh_id). Modest ~20 % drop in cull-traverse time on a 569k-object overview (26 ms → 21 ms). Also add four cull-phase timers (clr / trv / emt / upl) to the per-second stats line so future optimisation work has concrete numbers to chase. Confirmed via these timers that bucket clears, emit and GPU upload are all <1 ms combined; traversal is where the remaining CPU cost lives. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 43 +++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 10 ++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index fdfff63997..a85b33bae4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1097,6 +1097,9 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // the right thing for both. LOD split means instances that want the // decimated mesh go into a different bucket that emits against // mesh.lod1_ebo_byte_offset / lod1_index_count. + QElapsedTimer phase_timer; + phase_timer.start(); + auto resize_if = [&](std::vector>& v) { if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); }; @@ -1110,6 +1113,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] visible_by_mesh_rev_lod0_[i].clear(); visible_by_mesh_rev_lod1_[i].clear(); } + cull_clear_ns_ += phase_timer.nsecsElapsed(); + phase_timer.restart(); // LOD1 switches in when projected sphere radius (in pixels) drops below // this threshold. Overridable for tuning. Set to 0 to disable LOD1 @@ -1179,19 +1184,26 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // env var, or before the first pyramid has been built. const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_valid_; + // Hot path: read the AABB from the compact bvh_items array (28 B stride) + // rather than the wide InstanceCpu (104 B stride). Most instances fail + // frustum or contribution, so we want to avoid touching the wider struct + // until a survivor needs its mesh_id. This alone turns the cull from + // cache-miss-per-instance into stream-friendly linear reads. auto test_and_push = [&](uint32_t inst_idx) { - const InstanceCpu& inst = m.instances[inst_idx]; - if (!aabbInFrustum(inst.world_aabb_min, inst.world_aabb_max, planes)) return; - if (!contributionPasses(inst.world_aabb_min, inst.world_aabb_max)) return; - if (hiz_on && aabbOccludedByHiz(inst.world_aabb_min, inst.world_aabb_max)) { + const BvhItem& item = m.bvh_items[inst_idx]; + if (!aabbInFrustum(item.aabb_min, item.aabb_max, planes)) return; + if (!contributionPasses(item.aabb_min, item.aabb_max)) return; + if (hiz_on && aabbOccludedByHiz(item.aabb_min, item.aabb_max)) { ++hiz_reject_count_; return; } + // Survivor — now pay the wide-struct fetch for mesh_id. + const InstanceCpu& inst = m.instances[inst_idx]; if (inst.mesh_id >= m.meshes.size()) return; const MeshInfo& mesh = m.meshes[inst.mesh_id]; const bool want_lod1 = mesh.lod1_index_count > 0 && lod1_px_threshold > 0.0f && - pixelRadius(inst.world_aabb_min, inst.world_aabb_max) < lod1_px_threshold; + pixelRadius(item.aabb_min, item.aabb_max) < lod1_px_threshold; const bool reflected = inst_idx < m.instance_reflected.size() && m.instance_reflected[inst_idx] != 0; auto& bucket = @@ -1236,6 +1248,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] } else { for (uint32_t i = 0; i < m.instances.size(); ++i) test_and_push(i); } + cull_traverse_ns_ += phase_timer.nsecsElapsed(); + phase_timer.restart(); // Flatten fwd-slice first (LOD0 then LOD1), then rev-slice (ditto), into // visible_flat_. Commands for the fwd slice fill [0, indirect_forward_count), @@ -1274,6 +1288,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] emit_slice(visible_by_mesh_rev_lod0_, 0); emit_slice(visible_by_mesh_rev_lod1_, 1); m.indirect_command_count = static_cast(indirect_scratch_.size()); + cull_emit_ns_ += phase_timer.nsecsElapsed(); + phase_timer.restart(); // Upload visible list (keep binding alive even when empty). size_t vis_bytes = std::max(visible_flat_.size() * sizeof(uint32_t), @@ -1293,7 +1309,10 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // Upload indirect command buffer. size_t ind_bytes = indirect_scratch_.size() * sizeof(DrawElementsIndirectCommand); - if (ind_bytes == 0) return; + if (ind_bytes == 0) { + cull_upload_ns_ += phase_timer.nsecsElapsed(); + return; + } if (m.indirect_buffer == 0 || m.indirect_capacity < ind_bytes) { if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); size_t new_cap = m.indirect_capacity ? m.indirect_capacity : 4096; @@ -1303,6 +1322,7 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] m.indirect_capacity = new_cap; } gl_->glNamedBufferSubData(m.indirect_buffer, 0, ind_bytes, indirect_scratch_.data()); + cull_upload_ns_ += phase_timer.nsecsElapsed(); } void ViewportWindow::updateCamera() { @@ -1446,6 +1466,7 @@ void ViewportWindow::render() { frame_count_++; if (accumulated_time_ >= 1.0f) { last_fps_ = static_cast(frame_count_) / accumulated_time_; + const uint32_t frames_in_window = static_cast(frame_count_); frame_count_ = 0; accumulated_time_ = 0.0f; @@ -1475,14 +1496,24 @@ void ViewportWindow::render() { stats.indirect_sub_draws = indirect_sub_draws_; emit frameStatsUpdated(stats); + const double inv_frames = frames_in_window > 0 + ? 1.0 / static_cast(frames_in_window) : 0.0; + const double clr_ms = cull_clear_ns_ * 1e-6 * inv_frames; + const double trv_ms = cull_traverse_ns_ * 1e-6 * inv_frames; + const double emt_ms = cull_emit_ns_ * 1e-6 * inv_frames; + const double upl_ms = cull_upload_ns_ * 1e-6 * inv_frames; + cull_clear_ns_ = cull_traverse_ns_ = cull_emit_ns_ = cull_upload_ns_ = 0; + qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " + "cull[clr %.2f trv %.2f emt %.2f upl %.2f]ms " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, visible_triangles_, total_tri, total_meshes, gl_draw_calls_, indirect_sub_draws_, hiz_reject_count_, + clr_ms, trv_ms, emt_ms, upl_ms, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index fd584bb4cb..5d22f89288 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -259,6 +259,16 @@ private: bool hiz_vp_valid_ = false; uint32_t hiz_reject_count_ = 0; // per-frame stat + // Cull-phase timers. Accumulated across all frames in the current + // 1-second stats window; divided by frame_count_ at print time to + // give per-frame average ms. Reset each window. Lets us see where + // CPU time actually goes: bucket clears vs BVH traversal vs emit vs + // GPU upload. + uint64_t cull_clear_ns_ = 0; + uint64_t cull_traverse_ns_ = 0; + uint64_t cull_emit_ns_ = 0; + uint64_t cull_upload_ns_ = 0; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; From d0c5bd5e85c3d1ad1e4f39e1cc7f4e8d72802120 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 20:50:58 +1000 Subject: [PATCH 027/120] Cull: skip cullAndUploadVisible + HiZ on still frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render() was re-running the full cull every 16 ms timer tick even when nothing had changed — the camera matrices, scene state, and therefore visible set were all identical to the previous frame's. The GPU was still happy to redraw from the cached indirect buffer, but the CPU was burning 21 ms/frame rebuilding the same visible list. Detect the no-op case by comparing view/proj against last_cull_view_ / last_cull_proj_ and checking a scene-dirty flag (have_cached_cull_) that every mutator on models_gpu_ invalidates — finalizeModel, applyCachedModel, applyLodExtension, hide/show/remove/reset, and uploadInstanceChunk. When the check passes we skip both cullAndUploadVisible and buildHizPyramid (the depth buffer is bit-identical, so re-reading it produces the same pyramid). Per-model visible_objects / visible_triangles stats now live on ModelGpuData so the stats line reports correct numbers on skipped frames instead of reading from a stale indirect_scratch_. Measured on a 569k-object overview: still frames go 22 fps → 62 fps; orbiting goes 23 fps → ~30-50 fps depending on how hard you move the mouse (the cull only pays its full cost on the ~25 % of frames where the camera actually moved). The stats line gains a "skipped N/M" field so you can see the ratio live. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 72 +++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 16 +++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a85b33bae4..2070467362 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -612,6 +612,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { if (chunk.local_mesh_id < m.meshes.size()) { m.total_triangles += m.meshes[chunk.local_mesh_id].index_count / 3; } + have_cached_cull_ = false; } void ViewportWindow::finalizeModel(uint32_t model_id) { @@ -635,6 +636,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { buildBvhForModel(m, model_id); m.finalized = true; + have_cached_cull_ = false; const size_t ssbo_bytes = m.ssbo_instance_count * sizeof(InstanceGpu); qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram " @@ -743,6 +745,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); + have_cached_cull_ = false; qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances " "%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)", @@ -766,6 +769,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) // buildLods didn't add anything; just refresh the meshes vector in // case lod1_* fields were touched. m.meshes = sd.meshes; + have_cached_cull_ = false; return; } @@ -781,6 +785,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) // Replace mesh metadata so cullAndUploadVisible sees the new lod1_ fields. m.meshes = sd.meshes; + have_cached_cull_ = false; } void ViewportWindow::resetScene() { @@ -796,16 +801,23 @@ void ViewportWindow::resetScene() { } models_gpu_.clear(); selected_object_id_ = 0; + have_cached_cull_ = false; } void ViewportWindow::hideModel(uint32_t model_id) { auto it = models_gpu_.find(model_id); - if (it != models_gpu_.end()) it->second.hidden = true; + if (it != models_gpu_.end()) { + it->second.hidden = true; + have_cached_cull_ = false; + } } void ViewportWindow::showModel(uint32_t model_id) { auto it = models_gpu_.find(model_id); - if (it != models_gpu_.end()) it->second.hidden = false; + if (it != models_gpu_.end()) { + it->second.hidden = false; + have_cached_cull_ = false; + } } void ViewportWindow::removeModel(uint32_t model_id) { @@ -820,6 +832,7 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); models_gpu_.erase(it); + have_cached_cull_ = false; } } @@ -1288,6 +1301,17 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] emit_slice(visible_by_mesh_rev_lod0_, 0); emit_slice(visible_by_mesh_rev_lod1_, 1); m.indirect_command_count = static_cast(indirect_scratch_.size()); + + // Per-model stats snapshot — summed into the frame counters regardless + // of whether this frame ran a full cull or reused the cached one. + uint32_t model_vis_obj = 0, model_vis_tri = 0; + for (const auto& cmd : indirect_scratch_) { + model_vis_tri += (cmd.count / 3) * cmd.instanceCount; + model_vis_obj += cmd.instanceCount; + } + m.cached_visible_objects = model_vis_obj; + m.cached_visible_triangles = model_vis_tri; + cull_emit_ns_ += phase_timer.nsecsElapsed(); phase_timer.restart(); @@ -1381,7 +1405,23 @@ void ViewportWindow::render() { visible_objects_ = 0; gl_draw_calls_ = 0; indirect_sub_draws_ = 0; - hiz_reject_count_ = 0; + // Only reset hiz_reject_count_ on frames where we actually re-cull; + // otherwise we'd wipe the previous cull's number and print 0 every + // still frame. See the cull_this_frame branch below. + + // Decide whether this frame's view+scene is identical to the last + // successful cull. If so the per-model indirect buffers / visible + // SSBOs are still valid — we just re-issue the draws from them and + // skip the expensive cull traversal entirely. + const bool camera_unchanged = have_cached_cull_ + && last_cull_view_ == view_matrix_ + && last_cull_proj_ == proj_matrix_; + const bool cull_this_frame = !camera_unchanged; + if (cull_this_frame) { + hiz_reject_count_ = 0; + } else { + ++cull_skipped_frames_; + } // Start each frame with CCW-is-front; the two-pass draw below flips // back and forth. Harmless when culling is off. @@ -1390,7 +1430,9 @@ void ViewportWindow::render() { for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; - cullAndUploadVisible(m, planes, focal_px, min_pixel_radius); + if (cull_this_frame) { + cullAndUploadVisible(m, planes, focal_px, min_pixel_radius); + } if (m.indirect_command_count == 0) continue; gl_->glBindVertexArray(m.vao); @@ -1442,20 +1484,25 @@ void ViewportWindow::render() { gl_->glFrontFace(GL_CCW); } - for (const auto& cmd : indirect_scratch_) { - visible_triangles_ += (cmd.count / 3) * cmd.instanceCount; - visible_objects_ += cmd.instanceCount; - } + visible_triangles_ += m.cached_visible_triangles; + visible_objects_ += m.cached_visible_objects; indirect_sub_draws_ += m.indirect_command_count; } + if (cull_this_frame) { + last_cull_view_ = view_matrix_; + last_cull_proj_ = proj_matrix_; + have_cached_cull_ = true; + } gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); renderAxisGizmo(); // Build HiZ from this frame's resolved depth for next frame's cull. // Synchronous glReadPixels inside — cost ~0.5 ms at 256x128 on a - // mid-range dGPU. Skippable via IFC_NO_HIZ=1. - if (hizEnabled()) { + // mid-range dGPU. Skippable via IFC_NO_HIZ=1. Also skipped on + // still frames: if we didn't re-cull, the depth buffer is + // bit-identical to the one we already turned into a pyramid. + if (hizEnabled() && cull_this_frame) { buildHizPyramid(); } @@ -1503,10 +1550,12 @@ void ViewportWindow::render() { const double emt_ms = cull_emit_ns_ * 1e-6 * inv_frames; const double upl_ms = cull_upload_ns_ * 1e-6 * inv_frames; cull_clear_ns_ = cull_traverse_ns_ = cull_emit_ns_ = cull_upload_ns_ = 0; + const uint32_t skipped = cull_skipped_frames_; + cull_skipped_frames_ = 0; qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " - "cull[clr %.2f trv %.2f emt %.2f upl %.2f]ms " + "cull[clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, @@ -1514,6 +1563,7 @@ void ViewportWindow::render() { total_meshes, gl_draw_calls_, indirect_sub_draws_, hiz_reject_count_, clr_ms, trv_ms, emt_ms, upl_ms, + skipped, frames_in_window, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 5d22f89288..26c6d20b58 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -79,6 +79,13 @@ struct ModelGpuData { std::vector instance_reflected; uint32_t ssbo_instance_count = 0; + // Stats snapshot from the last cullAndUploadVisible call. Cached so we + // can report the same numbers on skipped-cull frames (see + // have_cached_cull_ on ViewportWindow) without iterating the per-model + // scratch array again. + uint32_t cached_visible_objects = 0; + uint32_t cached_visible_triangles = 0; + // Per-instance world AABB + BVH (built at finalize). The BVH is the // same ordering as `instances`; bvh_items[i] corresponds to instances[i]. std::vector bvh_items; @@ -268,6 +275,15 @@ private: uint64_t cull_traverse_ns_ = 0; uint64_t cull_emit_ns_ = 0; uint64_t cull_upload_ns_ = 0; + uint32_t cull_skipped_frames_ = 0; + + // Skip cullAndUploadVisible + buildHizPyramid when the camera and scene + // haven't changed since the last cull. The existing per-model + // indirect_buffer / visible_ssbo are still correct and just get + // redrawn. Invalidated by any function that mutates models_gpu_. + QMatrix4x4 last_cull_view_; + QMatrix4x4 last_cull_proj_; + bool have_cached_cull_ = false; // Per-frame stats uint32_t visible_triangles_ = 0; From 09ffdd202801fc19577fe48f49903268224ca50d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 21:05:27 +1000 Subject: [PATCH 028/120] ifcviewer: event-driven rendering, idle scenes cost zero CPU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the 16ms QTimer with QEvent::UpdateRequest delivered via requestUpdate(), posted from every state mutator (mouse/wheel, model lifecycle, selection, visibility, resize). A static BIM scene — the common case for a viewer — now does no work at all between user actions. FPS is now measured as time spent inside render() rather than wall-clock gap between frames, so idle gaps don't pollute the 1-second window and the headline number reflects real render throughput. Headline fps still caps at vsync; sub-vsync profiling lives in the cull[...] phase timers. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 53 +++++++++++++++++++++++++------- src/ifcviewer/ViewportWindow.h | 8 +++-- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2070467362..a48ef7f6d4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -317,10 +317,12 @@ ViewportWindow::ViewportWindow(QWindow* parent) fmt.setSamples(4); setFormat(fmt); - connect(&render_timer_, &QTimer::timeout, this, [this]() { - if (isExposed()) render(); - }); - render_timer_.setInterval(16); + // Redraw is driven by QEvent::UpdateRequest. We post one via + // requestUpdate() from every function that mutates visible state + // (mouse/wheel, model lifecycle, selection, resize). When nothing + // changes — the common case for a static BIM model — we don't burn + // CPU/GPU redrawing the same frame. Qt coalesces multiple + // requestUpdate() calls inside a single vblank. } ViewportWindow::~ViewportWindow() { @@ -381,11 +383,11 @@ void ViewportWindow::initGL() { context_->makeCurrent(this); if (on) gl_->glEnable(GL_CULL_FACE); else gl_->glDisable(GL_CULL_FACE); + requestUpdate(); }); gl_initialized_ = true; - frame_clock_.start(); - render_timer_.start(); + requestUpdate(); emit initialized(); } @@ -613,6 +615,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { m.total_triangles += m.meshes[chunk.local_mesh_id].index_count / 3; } have_cached_cull_ = false; + requestUpdate(); } void ViewportWindow::finalizeModel(uint32_t model_id) { @@ -637,6 +640,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { m.finalized = true; have_cached_cull_ = false; + requestUpdate(); const size_t ssbo_bytes = m.ssbo_instance_count * sizeof(InstanceGpu); qDebug("Model %u finalized: %zu verts, %zu meshes, %zu instances, %.1f MB vram " @@ -746,6 +750,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); have_cached_cull_ = false; + requestUpdate(); qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances " "%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)", @@ -770,6 +775,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) // case lod1_* fields were touched. m.meshes = sd.meshes; have_cached_cull_ = false; + requestUpdate(); return; } @@ -786,6 +792,7 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) // Replace mesh metadata so cullAndUploadVisible sees the new lod1_ fields. m.meshes = sd.meshes; have_cached_cull_ = false; + requestUpdate(); } void ViewportWindow::resetScene() { @@ -802,6 +809,7 @@ void ViewportWindow::resetScene() { models_gpu_.clear(); selected_object_id_ = 0; have_cached_cull_ = false; + requestUpdate(); } void ViewportWindow::hideModel(uint32_t model_id) { @@ -809,6 +817,7 @@ void ViewportWindow::hideModel(uint32_t model_id) { if (it != models_gpu_.end()) { it->second.hidden = true; have_cached_cull_ = false; + requestUpdate(); } } @@ -817,6 +826,7 @@ void ViewportWindow::showModel(uint32_t model_id) { if (it != models_gpu_.end()) { it->second.hidden = false; have_cached_cull_ = false; + requestUpdate(); } } @@ -833,10 +843,14 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); models_gpu_.erase(it); have_cached_cull_ = false; + requestUpdate(); } } -void ViewportWindow::setSelectedObjectId(uint32_t id) { selected_object_id_ = id; } +void ViewportWindow::setSelectedObjectId(uint32_t id) { + selected_object_id_ = id; + requestUpdate(); +} // --- HiZ occlusion culling (Phase 3C) ----------------------------------- @@ -1367,6 +1381,9 @@ void ViewportWindow::updateCamera() { void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; + QElapsedTimer frame_cost_clock; + frame_cost_clock.start(); + context_->makeCurrent(this); updateCamera(); @@ -1508,8 +1525,13 @@ void ViewportWindow::render() { context_->swapBuffers(this); - float dt = frame_clock_.restart() / 1000.0f; - accumulated_time_ += dt; + // Measure frame *cost* (time spent inside render()) rather than the + // wall-clock gap between frames. With event-driven rendering, idle gaps + // between requestUpdate() calls would otherwise pollute the FPS window. + // Reported fps = "if I rendered continuously, this is the rate I'd hit", + // which is what profiling actually wants. + const float frame_cost_s = frame_cost_clock.nsecsElapsed() * 1e-9f; + accumulated_time_ += frame_cost_s; frame_count_++; if (accumulated_time_ >= 1.0f) { last_fps_ = static_cast(frame_count_) / accumulated_time_; @@ -1649,13 +1671,19 @@ void ViewportWindow::renderAxisGizmo() { } void ViewportWindow::exposeEvent(QExposeEvent*) { - if (isExposed() && !gl_initialized_) initGL(); + if (isExposed()) { + if (!gl_initialized_) initGL(); + else requestUpdate(); + } } void ViewportWindow::resizeEvent(QResizeEvent*) { - if (gl_initialized_) render(); + if (gl_initialized_) requestUpdate(); } bool ViewportWindow::event(QEvent* e) { switch (e->type()) { + case QEvent::UpdateRequest: + if (isExposed() && gl_initialized_) render(); + return true; 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; @@ -1673,6 +1701,7 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { uint32_t id = pickObjectAt(e->pos().x(), e->pos().y()); selected_object_id_ = id; emit objectPicked(id); + requestUpdate(); // selection highlight changed } active_button_ = Qt::NoButton; } @@ -1695,10 +1724,12 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { camera_pitch_ += delta.y() * 0.3f; camera_pitch_ = qBound(-89.0f, camera_pitch_, 89.0f); } + requestUpdate(); } } 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_); + requestUpdate(); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 26c6d20b58..0a95ede077 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -23,7 +23,6 @@ #include #include #include -#include #include #include #include @@ -107,6 +106,11 @@ struct ModelGpuData { bool hidden = false; }; +// Rendering is event-driven: render() runs only when QEvent::UpdateRequest +// is delivered, posted via requestUpdate(). An idle scene costs zero CPU. +// INVARIANT: every public mutator that changes what should be on screen +// (camera, selection, model lifecycle, visibility) MUST call requestUpdate() +// before returning, or the viewport will go silently stale. class ViewportWindow : public QWindow { Q_OBJECT public: @@ -217,8 +221,6 @@ private: QOpenGLContext* context_ = nullptr; QOpenGLFunctions_4_5_Core* gl_ = nullptr; - QTimer render_timer_; - QElapsedTimer frame_clock_; bool gl_initialized_ = false; // Shaders From 574bcfa6c59ce64648ef4367d3bc278d6c165e16 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 21:54:15 +1000 Subject: [PATCH 029/120] ifcviewer: quantize VBO to 16 B/vertex (sidecar v6) Position now u16x3 normalized against each mesh's local AABB; normal oct-encoded to i16x2; RGBA8 colour unchanged. Per-mesh dequant basis lives in a new MeshGpu SSBO at binding 2; both main and pick shaders mix() against it before applying the instance transform. Drops VBO and sidecar size by ~43 % (28 -> 16 B/vert), which matters mostly for warm-load downloads of precomputed sidecars and steady-state VRAM. LodBuilder dequantizes positions into a scratch buffer before calling meshopt, since meshoptimizer needs float positions. Also fixes a streaming-time crash in cullAndUploadVisible: bvh_items was only populated at finalize, but the linear fallback indexes it during streaming. Mirror BvhItem appends in uploadInstanceChunk so the hot path stays valid before the BVH is built. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/InstancedGeometry.h | 39 ++++- src/ifcviewer/LodBuilder.cpp | 36 ++++- src/ifcviewer/LodBuilder.h | 7 +- src/ifcviewer/MainWindow.cpp | 2 +- src/ifcviewer/README.md | 20 ++- src/ifcviewer/SidecarCache.cpp | 11 +- src/ifcviewer/SidecarCache.h | 11 +- src/ifcviewer/ViewportWindow.cpp | 243 +++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 2 + 9 files changed, 299 insertions(+), 72 deletions(-) diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h index ef79751806..729e4df147 100644 --- a/src/ifcviewer/InstancedGeometry.h +++ b/src/ifcviewer/InstancedGeometry.h @@ -24,14 +24,36 @@ #include #include -// Per-vertex layout for instanced meshes, stored in local coordinates. -// 28 bytes per vertex: -// pos(3 float) -- 12 B -// normal(3 float) -- 12 B -// color(4 bytes RGBA8, read as GL_UNSIGNED_BYTE*4 normalized) -- 4 B -static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 28; +// Per-vertex layout for instanced meshes, stored in local coordinates, +// quantized against each mesh's local AABB. 16 bytes per vertex: +// offset 0 pos 3 x uint16 normalized -> [0,1]; dequant to +// mix(mesh.aabb_min, mesh.aabb_max, t) +// offset 6 _pad 2 bytes +// offset 8 normal 2 x int16 normalized -> [-1,1]; octahedral-decoded +// offset 12 color 4 x uint8 normalized -> [0,1] +// +// Quantization basis is per mesh, stored in the MeshGpu SSBO bound at +// binding=2. The vertex shader looks up its basis via the instance's mesh_id. +static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 16; + +// Streamer-side intermediate format: 7 floats per vertex (pos3 + normal3 + +// color-as-float). GeometryStreamer writes this into MeshChunk.vertices; +// ViewportWindow::uploadMeshChunk quantizes it down to STRIDE_BYTES on the +// way to the VBO. Not the GPU layout — purely a transfer convention. static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7; +static constexpr int INSTANCED_VERTEX_POS_OFFSET = 0; +static constexpr int INSTANCED_VERTEX_NORMAL_OFFSET = 8; +static constexpr int INSTANCED_VERTEX_COLOR_OFFSET = 12; + +// Per-mesh quantization basis, uploaded to a std430 SSBO. Two vec4s so +// std430 layout is trivial (no alignment surprises). w components unused. +struct alignas(16) MeshGpu { + float aabb_min[4]; // xyz = local AABB min; w = 0 + float aabb_max[4]; // xyz = local AABB max; w = 0 +}; +static_assert(sizeof(MeshGpu) == 32, "MeshGpu must be 32 bytes"); + // Per-mesh metadata on the CPU side. Meshes own a slice of the model's // VBO (shared across LODs) and one or more slices of the EBO, one per LOD. // @@ -61,12 +83,13 @@ static_assert(sizeof(MeshInfo) == 56, "MeshInfo must be 56 bytes"); // mat4 transform (64 B column-major) // uint object_id // uint color_override_rgba8 -- 0 = use baked vertex color, else override -// uint _pad0, _pad1 -- align to 16 for std430 +// uint mesh_id -- index into per-model MeshGpu[] +// uint _pad1 -- align to 16 for std430 struct alignas(16) InstanceGpu { float transform[16]; uint32_t object_id = 0; uint32_t color_override_rgba8 = 0; - uint32_t _pad0 = 0; + uint32_t mesh_id = 0; // index into per-model MeshGpu[] uint32_t _pad1 = 0; }; static_assert(sizeof(InstanceGpu) == 80, "InstanceGpu must be 80 bytes"); diff --git a/src/ifcviewer/LodBuilder.cpp b/src/ifcviewer/LodBuilder.cpp index 88b8c9f046..35b97df44a 100644 --- a/src/ifcviewer/LodBuilder.cpp +++ b/src/ifcviewer/LodBuilder.cpp @@ -33,9 +33,8 @@ void buildLods(SidecarData& sd, float target_error) { if (sd.meshes.empty() || sd.vertices.empty() || sd.indices.empty()) return; - const size_t vtx_stride_bytes = INSTANCED_VERTEX_STRIDE_BYTES; - const size_t vtx_stride_floats = INSTANCED_VERTEX_STRIDE_FLOATS; - const size_t total_vertex_count = sd.vertices.size() / vtx_stride_floats; + const size_t vtx_stride_bytes = INSTANCED_VERTEX_STRIDE_BYTES; + const size_t total_vertex_count = sd.vertices.size() / vtx_stride_bytes; // Env var knobs so we can tune without rebuilding. // IFC_LOD_LOCK_BORDER=1 re-enable LockBorder (off by default: BIM @@ -73,8 +72,10 @@ void buildLods(SidecarData& sd, // Scratch buffers reused across meshes so we only allocate once. std::vector simplified; std::vector shadow; + std::vector dequant_pos; // 3 floats/vertex, dequantized simplified.reserve(1024); shadow.reserve(1024); + dequant_pos.reserve(1024 * 3); int dbg_printed = 0; int dbg_rejected_savings = 0; @@ -101,8 +102,27 @@ void buildLods(SidecarData& sd, const uint32_t first_index = mesh.ebo_byte_offset / sizeof(uint32_t); if (first_index + mesh.index_count > sd.indices.size()) continue; - const float* positions = - sd.vertices.data() + base_vertex * vtx_stride_floats; + // Dequantize positions for this mesh into a temp float array. + // meshopt needs contiguous float3 positions with a known stride; + // quantized bytes aren't directly usable. + const uint8_t* quant_base = + sd.vertices.data() + base_vertex * vtx_stride_bytes; + dequant_pos.resize(static_cast(mesh.vertex_count) * 3); + const float extent[3] = { + mesh.local_aabb_max[0] - mesh.local_aabb_min[0], + mesh.local_aabb_max[1] - mesh.local_aabb_min[1], + mesh.local_aabb_max[2] - mesh.local_aabb_min[2], + }; + for (uint32_t v = 0; v < mesh.vertex_count; ++v) { + const uint16_t* p = reinterpret_cast( + quant_base + v * vtx_stride_bytes); + for (int a = 0; a < 3; ++a) { + float t = p[a] / 65535.0f; + dequant_pos[v * 3 + a] = mesh.local_aabb_min[a] + t * extent[a]; + } + } + const float* positions = dequant_pos.data(); + const size_t local_pos_stride = sizeof(float) * 3; const uint32_t* indices = sd.indices.data() + first_index; const size_t target_index_count = std::max( @@ -121,7 +141,7 @@ void buildLods(SidecarData& sd, indices, mesh.index_count, positions, mesh.vertex_count, sizeof(float) * 3, // compare only xyz - vtx_stride_bytes); + local_pos_stride); simplified.resize(mesh.index_count); float result_error = 0.0f; @@ -135,7 +155,7 @@ void buildLods(SidecarData& sd, new_index_count = meshopt_simplifySloppy( simplified.data(), indices, mesh.index_count, - positions, mesh.vertex_count, vtx_stride_bytes, + positions, mesh.vertex_count, local_pos_stride, target_index_count, target_error, &result_error); } else { @@ -144,7 +164,7 @@ void buildLods(SidecarData& sd, new_index_count = meshopt_simplify( simplified.data(), shadow.data(), mesh.index_count, - positions, mesh.vertex_count, vtx_stride_bytes, + positions, mesh.vertex_count, local_pos_stride, target_index_count, target_error, options, &result_error); } diff --git a/src/ifcviewer/LodBuilder.h b/src/ifcviewer/LodBuilder.h index a937ae4987..0147ba82f9 100644 --- a/src/ifcviewer/LodBuilder.h +++ b/src/ifcviewer/LodBuilder.h @@ -35,9 +35,10 @@ // target_ratio = 0.25 — aim for 25% of original tris // target_error = 0.05 — stop if relative error exceeds 5% // -// `sd.vertices` is read (position is the first 3 floats of each -// INSTANCED_VERTEX_STRIDE_FLOATS-wide vertex) but not modified — LOD1 -// reuses the same vertex buffer, just with a different index list. +// `sd.vertices` is raw bytes at the quantized layout; positions are +// dequantized per-mesh (using MeshInfo.local_aabb_min/max) into a temp +// float array before feeding meshoptimizer. Vertices are not modified — +// LOD1 reuses the same VBO, just with a different index list. void buildLods(SidecarData& sd, int min_triangles = 500, float target_ratio = 0.25f, diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 7dc5454700..0e8162f043 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -239,7 +239,7 @@ void MainWindow::applySidecarData(ModelId mid, SidecarData data) { qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", model.file_path.toStdString().c_str(), - data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS, + data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES, data.indices.size(), data.meshes.size(), data.instances.size(), diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index afa2042610..be4a69ec42 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -46,9 +46,12 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. - **Per-model GPU buffers**: each loaded model gets its own VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model growth copies. Removing a model frees its GPU memory immediately. -- **Local-coordinate vertex format (28 B):** position (3 floats) + normal - (3 floats) + packed RGBA8 colour (1 uint). The per-instance transform is - applied in the vertex shader via an SSBO lookup. No world-baked vertex data. +- **Quantized local-coordinate vertex format (16 B):** position as + `u16x3` normalised against each mesh's local AABB, octahedral-encoded + normal as `i16x2`, packed RGBA8 colour. Dequantisation basis is per + mesh, uploaded once in a `MeshGpu` SSBO at binding 2. The per-instance + transform is applied in the vertex shader. No world-baked vertex data. + ~43 % smaller VBO and sidecar than the previous 28 B float layout. - **Multi-draw indirect:** every frame the CPU builds a flat list of visible instance indices and one `DrawElementsIndirectCommand` per non-empty mesh, then issues a single `glMultiDrawElementsIndirect` per model. 50k visible @@ -93,7 +96,7 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | | `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v5) sidecar read/write | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v6) sidecar read/write | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `SettingsWindow.h/cpp` | Settings dialog | | `CMakeLists.txt` | Build configuration | @@ -270,7 +273,7 @@ while stack not empty: Depth 64 is enough for billions of items on any balanced tree. The stack is on the C++ stack, zero per-frame allocation. -#### Sidecar format (`.ifcview`, v5) +#### Sidecar format (`.ifcview`, v6) Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Stores everything needed to skip the `IfcGeom::Iterator` pass: @@ -278,7 +281,7 @@ Stores everything needed to skip the `IfcGeom::Iterator` pass: ``` SidecarHeader (magic "IFVW", version, endian, ...) uint64_t source_file_size -uint32_t + float[] vertex data (7 floats × N_verts, local coords) +uint32_t + uint8_t[] vertex data (16 B/vert quantized; per-mesh basis in MeshInfo) uint32_t + uint32_t[] index data (mesh-local) uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids) @@ -298,7 +301,8 @@ Per-model state on the GPU: | Buffer | Contents | Lifetime | |--------|----------|----------| -| `VBO` | Interleaved local-coord vertex data (28 B/vert). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | +| `VBO` | Quantized local-coord vertex data (16 B/vert: u16x3 pos, oct i16x2 normal, RGBA8). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | +| `MeshGpu SSBO` (binding 2) | Per-mesh dequant basis (`vec4 aabb_min`, `vec4 aabb_max`). | Grow-on-demand; static after finalize. | | `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. | | `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. | | `visible SSBO` (binding 1) | `uint32[]` — flat list of visible instance indices, ordered by mesh, uploaded each frame. | Rewritten every frame. | @@ -311,7 +315,7 @@ struct DrawElementsIndirectCommand { uint32_t count; // mesh.index_count uint32_t instanceCount; // visible-list length for this mesh uint32_t firstIndex; // mesh.ebo_byte_offset / 4 - uint32_t baseVertex; // mesh.vbo_byte_offset / 28 + uint32_t baseVertex; // mesh.vbo_byte_offset / 16 uint32_t baseInstance; // offset into the flat visible-index array }; ``` diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index da3943988d..171bf4bda6 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,17 +17,16 @@ * * ********************************************************************************/ -// v5 layout (all multi-byte fields native-endian; endianness marker in header). -// Same sequence as v4; the only change is that MeshInfo grew two uint32_ts -// (lod1_ebo_byte_offset + lod1_index_count) and `indices` may contain extra -// appended LOD1 slices pointed at by those offsets. +// v6 layout (all multi-byte fields native-endian; endianness marker in header). +// Same sequence as v5; the only change is that vertex data is now raw bytes +// at the 16 B/vertex quantized layout (see InstancedGeometry.h). // // // SidecarHeader (16 bytes) // uint64_t source_file_size // -// uint32_t num_vertices_floats -// float[] vertex data (28 B/vertex: pos3 + normal3 + color1_packed) +// uint32_t num_vertex_bytes +// uint8_t[] vertex data (16 B/vertex: pos u16x3 + pad2 + oct-normal i16x2 + rgba8) // uint32_t num_indices // uint32_t[] index data (mesh-local indices; base_vertex applied at draw time) // diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 332abdc802..e2e34373ab 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -37,7 +37,9 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // v5 = MeshInfo extended with lod1_ebo_byte_offset + lod1_index_count (56 B). // sd.indices may contain an appended LOD1 index slice for each mesh // where meshoptimizer decimation produced useful output. -static constexpr uint32_t SIDECAR_VERSION = 5; +// v6 = VBO vertices quantized to 16 B/vertex (pos u16x3 + normal oct i16x2 + +// color u8x4). Dequant basis is per-mesh MeshInfo.local_aabb_min/max. +static constexpr uint32_t SIDECAR_VERSION = 6; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs @@ -56,10 +58,11 @@ struct PackedElementInfo { }; // Everything needed to display an already-tessellated model without -// re-running the iterator. v4 schema: instanced geometry. +// re-running the iterator. v6 schema: instanced + quantized geometry. struct SidecarData { - // Per-model GPU geometry (local coords). 28 bytes/vertex. - std::vector vertices; + // Per-model GPU geometry (local coords). Raw VBO bytes at the + // INSTANCED_VERTEX_STRIDE_BYTES layout (16 B/vertex as of v6). + std::vector vertices; std::vector indices; // Mesh dictionary and per-instance data. diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a48ef7f6d4..4731a43186 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -44,16 +44,17 @@ static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 2 // Shaders // ----------------------------------------------------------------------------- // -// Vertex layout (GL side, 28 bytes): -// location 0: vec3 a_position (local coords) -// location 1: vec3 a_normal (local) -// location 2: vec4 a_color (GL_UNSIGNED_BYTE * 4 normalized) +// Vertex layout (GL side, 16 bytes — quantized; see InstancedGeometry.h): +// location 0: vec3 a_position_q (u16x3 normalized, per-mesh AABB basis) +// location 1: vec2 a_normal_oct (i16x2 normalized, octahedral) +// location 2: vec4 a_color (u8x4 normalized) // // Per-instance record in SSBO std430 (80 bytes): // mat4 transform // uint object_id // uint color_override_rgba8 -- 0 => use baked a_color -// uint _pad0, _pad1 +// uint mesh_id -- index into per-model MeshGpu[] +// uint _pad1 // // The draw calls pass `u_instance_offset = mesh.first_instance`; the shader // reads `instances[u_instance_offset + gl_InstanceID]`. @@ -61,15 +62,16 @@ static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 2 static const char* MAIN_VERTEX_SHADER = R"( #version 450 core #extension GL_ARB_shader_draw_parameters : require -layout(location = 0) in vec3 a_position; -layout(location = 1) in vec3 a_normal; +// Quantized vertex inputs — see InstancedGeometry.h for layout. +layout(location = 0) in vec3 a_position_q; // u16x3 normalized -> [0,1] +layout(location = 1) in vec2 a_normal_oct; // i16x2 normalized -> [-1,1] layout(location = 2) in vec4 a_color; struct InstanceRecord { mat4 transform; uint object_id; uint color_override; - uint _pad0; + uint mesh_id; uint _pad1; }; layout(std430, binding = 0) readonly buffer Instances { @@ -78,6 +80,10 @@ layout(std430, binding = 0) readonly buffer Instances { layout(std430, binding = 1) readonly buffer VisibleIndices { uint visible[]; }; +struct MeshQuant { vec4 aabb_min; vec4 aabb_max; }; +layout(std430, binding = 2) readonly buffer Meshes { + MeshQuant meshes[]; +}; uniform mat4 u_view_projection; uniform uint u_selected_id; @@ -87,11 +93,24 @@ out vec4 v_color; flat out uint v_object_id; flat out uint v_selected; +// Meyer et al. octahedral normal decode. Input is in [-1,1]^2. +vec3 octDecode(vec2 e) { + vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y)); + if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + return normalize(n); +} + void main() { uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); uint iid = visible[slot]; InstanceRecord inst = instances[iid]; - vec4 world = inst.transform * vec4(a_position, 1.0); + MeshQuant mq = meshes[inst.mesh_id]; + + // Dequantize local position against this mesh's AABB. + vec3 pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, a_position_q); + + vec4 world = inst.transform * vec4(pos_local, 1.0); gl_Position = u_view_projection * world; // Rotate the normal by the upper-3x3 of the transform. BIM placements @@ -101,8 +120,9 @@ void main() { // otherwise mirrored instances shade as if inside-out. The same // determinant sign is what GL_CULL_FACE uses to decide winding, so // keeping them in agreement means backface culling is safe to enable. + vec3 n_local = octDecode(a_normal_oct); mat3 rot = mat3(inst.transform); - vec3 n = rot * a_normal; + vec3 n = rot * n_local; if (determinant(rot) < 0.0) n = -n; v_normal = normalize(n); @@ -152,13 +172,13 @@ void main() { static const char* PICK_VERTEX_SHADER = R"( #version 450 core #extension GL_ARB_shader_draw_parameters : require -layout(location = 0) in vec3 a_position; +layout(location = 0) in vec3 a_position_q; struct InstanceRecord { mat4 transform; uint object_id; uint color_override; - uint _pad0; + uint mesh_id; uint _pad1; }; layout(std430, binding = 0) readonly buffer Instances { @@ -167,6 +187,10 @@ layout(std430, binding = 0) readonly buffer Instances { layout(std430, binding = 1) readonly buffer VisibleIndices { uint visible[]; }; +struct MeshQuant { vec4 aabb_min; vec4 aabb_max; }; +layout(std430, binding = 2) readonly buffer Meshes { + MeshQuant meshes[]; +}; uniform mat4 u_view_projection; @@ -176,7 +200,9 @@ void main() { uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); uint iid = visible[slot]; InstanceRecord inst = instances[iid]; - gl_Position = u_view_projection * inst.transform * vec4(a_position, 1.0); + MeshQuant mq = meshes[inst.mesh_id]; + vec3 pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, a_position_q); + gl_Position = u_view_projection * inst.transform * vec4(pos_local, 1.0); v_object_id = inst.object_id; } )"; @@ -240,6 +266,51 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra // ----------------------------------------------------------------------------- +// Meyer et al. octahedral normal encode. Input unit vector -> [-1,1]^2. +static void octEncode(const float n[3], float out[2]) { + float ax = std::fabs(n[0]), ay = std::fabs(n[1]), az = std::fabs(n[2]); + float denom = ax + ay + az; + if (denom < 1e-12f) { out[0] = 0.0f; out[1] = 0.0f; return; } + float px = n[0] / denom; + float py = n[1] / denom; + if (n[2] < 0.0f) { + float sx = px >= 0.0f ? 1.0f : -1.0f; + float sy = py >= 0.0f ? 1.0f : -1.0f; + float nx = (1.0f - std::fabs(py)) * sx; + float ny = (1.0f - std::fabs(px)) * sy; + px = nx; py = ny; + } + out[0] = px; + out[1] = py; +} + +// Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into +// the 16 B VBO record, given the mesh's tight local AABB. `extent_recip` +// is 1/(max-min) per axis, or 0 for degenerate axes (quantum becomes 0). +static void quantizeVertex(const float src[7], + const float aabb_min[3], + const float extent_recip[3], + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]) { + // Position -> u16 normalized. + uint16_t* p = reinterpret_cast(dst + INSTANCED_VERTEX_POS_OFFSET); + for (int a = 0; a < 3; ++a) { + float t = (src[a] - aabb_min[a]) * extent_recip[a]; + if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; + p[a] = static_cast(t * 65535.0f + 0.5f); + } + // Normal -> oct i16x2. + float oct[2]; + octEncode(src + 3, oct); + int16_t* n = reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); + for (int a = 0; a < 2; ++a) { + float v = oct[a]; + if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f; + n[a] = static_cast(std::lrintf(v * 32767.0f)); + } + // Color passes through — streamer packs 4 bytes into the 7th float slot. + std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4); +} + // Determinant of the upper-left 3x3 of a column-major mat4 stored as 16 floats. // Sign tells us whether the transform contains a reflection, which is what // decides which glFrontFace winding to draw the instance with. @@ -334,6 +405,7 @@ ViewportWindow::~ViewportWindow() { if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); } @@ -396,19 +468,22 @@ void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) { gl_->glVertexArrayVertexBuffer(vao, 0, vbo, 0, INSTANCED_VERTEX_STRIDE_BYTES); gl_->glVertexArrayElementBuffer(vao, ebo); - // position (3 float @ 0) + // position (3 x u16 normalized @ 0) gl_->glEnableVertexArrayAttrib(vao, 0); - gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_UNSIGNED_SHORT, GL_TRUE, + INSTANCED_VERTEX_POS_OFFSET); gl_->glVertexArrayAttribBinding(vao, 0, 0); - // normal (3 float @ 12) + // normal oct-encoded (2 x i16 normalized @ 8) gl_->glEnableVertexArrayAttrib(vao, 1); - gl_->glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 12); + gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_SHORT, GL_TRUE, + INSTANCED_VERTEX_NORMAL_OFFSET); gl_->glVertexArrayAttribBinding(vao, 1, 0); - // color (4 ubyte @ 24, normalized) + // color (4 x u8 normalized @ 12) gl_->glEnableVertexArrayAttrib(vao, 2); - gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, 24); + gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, + INSTANCED_VERTEX_COLOR_OFFSET); gl_->glVertexArrayAttribBinding(vao, 2, 0); } @@ -544,8 +619,44 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { ModelGpuData& m = getOrCreateModel(chunk.model_id); - const size_t vb_size = chunk.vertices.size() * sizeof(float); - const size_t ib_size = chunk.indices.size() * sizeof(uint32_t); + // Streamer format: 7 floats/vertex (pos3 + normal3 + color-as-float). + const size_t src_stride_floats = 7; + const size_t n_verts = chunk.vertices.size() / src_stride_floats; + + // Recompute a tight local AABB from the actual vertex positions — the + // chunk-provided AABB can be slightly loose, which wastes quantization + // precision. Also derives the dequant basis we'll ship to the GPU. + float bmin[3] = { std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity() }; + float bmax[3] = { -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity() }; + for (size_t i = 0; i < n_verts; ++i) { + const float* v = chunk.vertices.data() + i * src_stride_floats; + for (int a = 0; a < 3; ++a) { + if (v[a] < bmin[a]) bmin[a] = v[a]; + if (v[a] > bmax[a]) bmax[a] = v[a]; + } + } + // Degenerate / zero-extent axis: collapse to a single quantum. The + // dequant shader will output bmin[a] for every vertex, which is correct. + float extent_recip[3]; + for (int a = 0; a < 3; ++a) { + float ext = bmax[a] - bmin[a]; + extent_recip[a] = ext > 0.0f ? 1.0f / ext : 0.0f; + } + + // Quantize into a scratch buffer sized to the destination layout. + std::vector quant(n_verts * INSTANCED_VERTEX_STRIDE_BYTES); + for (size_t i = 0; i < n_verts; ++i) { + quantizeVertex(chunk.vertices.data() + i * src_stride_floats, + bmin, extent_recip, + quant.data() + i * INSTANCED_VERTEX_STRIDE_BYTES); + } + + const size_t vb_size = quant.size(); + const size_t ib_size = chunk.indices.size() * sizeof(uint32_t); if (m.vbo_used + vb_size > m.vbo_capacity) { if (!growModelVbo(m, m.vbo_used + vb_size)) return; @@ -556,18 +667,17 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { MeshInfo info; info.vbo_byte_offset = static_cast(m.vbo_used); - info.vertex_count = static_cast( - chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); + info.vertex_count = static_cast(n_verts); info.ebo_byte_offset = static_cast(m.ebo_used); info.index_count = static_cast(chunk.indices.size()); for (int a = 0; a < 3; ++a) { - info.local_aabb_min[a] = chunk.local_aabb_min[a]; - info.local_aabb_max[a] = chunk.local_aabb_max[a]; + info.local_aabb_min[a] = bmin[a]; + info.local_aabb_max[a] = bmax[a]; } info.first_instance = 0; info.instance_count = 0; - gl_->glNamedBufferSubData(m.vbo, m.vbo_used, vb_size, chunk.vertices.data()); + gl_->glNamedBufferSubData(m.vbo, m.vbo_used, vb_size, quant.data()); gl_->glNamedBufferSubData(m.ebo, m.ebo_used, ib_size, chunk.indices.data()); m.vbo_used += vb_size; m.ebo_used += ib_size; @@ -575,6 +685,33 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { if (m.meshes.size() <= chunk.local_mesh_id) m.meshes.resize(chunk.local_mesh_id + 1); m.meshes[chunk.local_mesh_id] = info; + + // Write the matching dequant basis into the MeshGpu SSBO. Grow on + // demand; geometrically doubling keeps this amortized O(1) over streaming. + MeshGpu mg{}; + for (int a = 0; a < 3; ++a) { + mg.aabb_min[a] = bmin[a]; + mg.aabb_max[a] = bmax[a]; + } + mg.aabb_min[3] = 0.0f; + mg.aabb_max[3] = 0.0f; + + const size_t mg_offset = chunk.local_mesh_id * sizeof(MeshGpu); + if (mg_offset + sizeof(MeshGpu) > m.mesh_info_capacity) { + size_t new_cap = m.mesh_info_capacity ? m.mesh_info_capacity : 32 * sizeof(MeshGpu); + while (new_cap < mg_offset + sizeof(MeshGpu)) new_cap *= 2; + GLuint new_ssbo = 0; + gl_->glCreateBuffers(1, &new_ssbo); + gl_->glNamedBufferStorage(new_ssbo, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); + if (m.mesh_info_ssbo && m.mesh_info_capacity > 0) { + gl_->glCopyNamedBufferSubData(m.mesh_info_ssbo, new_ssbo, 0, 0, + m.mesh_info_capacity); + gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); + } + m.mesh_info_ssbo = new_ssbo; + m.mesh_info_capacity = new_cap; + } + gl_->glNamedBufferSubData(m.mesh_info_ssbo, mg_offset, sizeof(MeshGpu), &mg); } void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { @@ -594,6 +731,15 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { m.instances.push_back(inst); m.instance_reflected.push_back(transformIsReflected(inst.transform) ? 1 : 0); + // Mirror into bvh_items so the hot cull path (which reads AABBs out of + // bvh_items even when no BVH has been built yet) stays correct during + // streaming. finalizeModel rebuilds the real BVH over these items. + BvhItem bi; + std::memcpy(bi.aabb_min, inst.world_aabb_min, sizeof(bi.aabb_min)); + std::memcpy(bi.aabb_max, inst.world_aabb_max, sizeof(bi.aabb_max)); + bi.model_id = inst.model_id; + m.bvh_items.push_back(bi); + // Append the GPU record to the instance SSBO so the model is drawable // immediately, without waiting for finalizeModel. The visible-list // architecture means SSBO order is irrelevant to correctness. @@ -601,7 +747,7 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { std::memcpy(gpu.transform, inst.transform, sizeof(gpu.transform)); gpu.object_id = inst.object_id; gpu.color_override_rgba8 = inst.color_override_rgba8; - gpu._pad0 = 0; + gpu.mesh_id = inst.mesh_id; gpu._pad1 = 0; const size_t offset = m.ssbo_instance_count * sizeof(InstanceGpu); @@ -659,9 +805,10 @@ bool ViewportWindow::snapshotModel(uint32_t model_id, SidecarData& out) const { const auto& m = it->second; if (!m.finalized) return false; - // GPU readback of the packed VBO/EBO ranges actually in use. + // GPU readback of the packed VBO/EBO ranges actually in use. VBO is + // raw bytes at the quantized layout. if (m.vbo_used > 0) { - out.vertices.resize(m.vbo_used / sizeof(float)); + out.vertices.resize(m.vbo_used); gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, out.vertices.data()); } if (m.ebo_used > 0) { @@ -685,6 +832,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.vbo) gl_->glDeleteBuffers(1, &existing->second.vbo); if (existing->second.ebo) gl_->glDeleteBuffers(1, &existing->second.ebo); if (existing->second.ssbo) gl_->glDeleteBuffers(1, &existing->second.ssbo); + if (existing->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &existing->second.mesh_info_ssbo); if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); models_gpu_.erase(existing); @@ -695,7 +843,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { gl_->glCreateBuffers(1, &m.vbo); gl_->glCreateBuffers(1, &m.ebo); - const size_t vb_bytes = data.vertices.size() * sizeof(float); + const size_t vb_bytes = data.vertices.size(); const size_t ib_bytes = data.indices.size() * sizeof(uint32_t); m.vbo_capacity = std::max(vb_bytes, 1); m.ebo_capacity = std::max(ib_bytes, 1); @@ -709,8 +857,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { m.vbo_used = vb_bytes; m.ebo_used = ib_bytes; - m.vertex_count = static_cast( - data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); + m.vertex_count = static_cast(vb_bytes / INSTANCED_VERTEX_STRIDE_BYTES); m.meshes = std::move(data.meshes); m.instances = std::move(data.instances); @@ -728,7 +875,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { std::memcpy(dst.transform, src.transform, sizeof(dst.transform)); dst.object_id = src.object_id; dst.color_override_rgba8 = src.color_override_rgba8; - dst._pad0 = 0; + dst.mesh_id = src.mesh_id; dst._pad1 = 0; } gl_->glCreateBuffers(1, &m.ssbo); @@ -738,6 +885,30 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } m.ssbo_instance_count = static_cast(gpu.size()); + // Build and upload the per-mesh quantization SSBO from cached meshes. + { + std::vector mesh_gpu(m.meshes.size()); + for (size_t i = 0; i < m.meshes.size(); ++i) { + for (int a = 0; a < 3; ++a) { + mesh_gpu[i].aabb_min[a] = m.meshes[i].local_aabb_min[a]; + mesh_gpu[i].aabb_max[a] = m.meshes[i].local_aabb_max[a]; + } + mesh_gpu[i].aabb_min[3] = 0.0f; + mesh_gpu[i].aabb_max[3] = 0.0f; + } + const size_t mg_bytes = mesh_gpu.size() * sizeof(MeshGpu); + gl_->glCreateBuffers(1, &m.mesh_info_ssbo); + if (mg_bytes > 0) { + gl_->glNamedBufferStorage(m.mesh_info_ssbo, mg_bytes, + mesh_gpu.data(), GL_DYNAMIC_STORAGE_BIT); + m.mesh_info_capacity = mg_bytes; + } else { + gl_->glNamedBufferStorage(m.mesh_info_ssbo, sizeof(MeshGpu), + nullptr, GL_DYNAMIC_STORAGE_BIT); + m.mesh_info_capacity = sizeof(MeshGpu); + } + } + // Recompute the reflection flag from each instance's transform — the // sidecar only caches InstanceCpu, not the parallel reflection flags. m.instance_reflected.resize(m.instances.size()); @@ -754,7 +925,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { qDebug("Sidecar apply: model %u %zu verts, %zu meshes, %zu instances " "%.1f MB vram (vbo %.1f + ebo %.1f + ssbo %.1f)", - model_id, data.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS, + model_id, vb_bytes / INSTANCED_VERTEX_STRIDE_BYTES, models_gpu_[model_id].meshes.size(), models_gpu_[model_id].instances.size(), (vb_bytes + ib_bytes + ssbo_bytes) / (1024.0*1024.0), @@ -803,6 +974,7 @@ void ViewportWindow::resetScene() { if (m.vbo) gl_->glDeleteBuffers(1, &m.vbo); if (m.ebo) gl_->glDeleteBuffers(1, &m.ebo); if (m.ssbo) gl_->glDeleteBuffers(1, &m.ssbo); + if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); } @@ -839,6 +1011,7 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.vbo) gl_->glDeleteBuffers(1, &it->second.vbo); if (it->second.ebo) gl_->glDeleteBuffers(1, &it->second.ebo); if (it->second.ssbo) gl_->glDeleteBuffers(1, &it->second.ssbo); + if (it->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &it->second.mesh_info_ssbo); if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); models_gpu_.erase(it); @@ -1455,6 +1628,7 @@ void ViewportWindow::render() { gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); uint32_t fwd = m.indirect_forward_count; @@ -1622,6 +1796,7 @@ void ViewportWindow::renderPickPass() { gl_->glBindVertexArray(m.vao); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); const uint32_t fwd = m.indirect_forward_count; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 0a95ede077..ed6668cc11 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -60,6 +60,8 @@ struct ModelGpuData { GLuint vbo = 0; GLuint ebo = 0; GLuint ssbo = 0; + GLuint mesh_info_ssbo = 0; // MeshGpu[] — per-mesh quantization basis + size_t mesh_info_capacity = 0; // bytes size_t vbo_capacity = 0; size_t ebo_capacity = 0; From 1ec273f508989971671169876838b0c982317134 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Apr 2026 21:55:14 +1000 Subject: [PATCH 030/120] =?UTF-8?q?ifcviewer:=20README=20=E2=80=94=20docum?= =?UTF-8?q?ent=20event-driven=20rendering=20and=20VBO=20quantization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the event-driven rendering bullet (zero idle cost, in-render frame timing) and roadmap entries for VBO quantization and event-driven rendering. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index be4a69ec42..01ceef7ecf 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -78,6 +78,14 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. through load. - **Non-blocking sidecar loading**: sidecars are read on a background thread; only the final GPU upload touches the main thread. +- **Event-driven rendering:** no continuous render timer. Frames are + scheduled via `QWindow::requestUpdate()` only when something changes + (camera move, streaming chunk, hover, settings). When the camera and + scene are idle the cull pass and HiZ readback are skipped entirely + and the main thread blocks in the Qt event loop — the viewer costs + zero CPU/GPU on a static scene. FPS is still reported accurately + because frame cost is measured *inside* `render()`, not as wall-clock + between frames. - **GPU object picking**: a second render pass writes object IDs into an R32UI framebuffer. Click reads back one pixel. No CPU-side raycasting. - **Multi-model support**: multiple IFCs can be loaded simultaneously. @@ -728,6 +736,8 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU - [x] Phase 3A — screen-space contribution culling - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) - [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) +- [x] Quantized VBO (16 B/vert, sidecar v6) +- [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) - [ ] **Phase 3D — GPU-side compute-shader culling** (next; replaces the readback) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console From 37fa4e90760221440fefacb6cc1e3613f10bc098 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 15 Apr 2026 15:18:51 +1000 Subject: [PATCH 031/120] ifcviewer: parallel per-model CPU cull Split cullAndUploadVisible into cullModelCpu (CPU-only, thread-safe) and uploadCullResults (GL-only, main thread). render() fans the per-model culls out via std::async and joins before the serial upload pass. The cull scratch (vis_fwd/rev_lod0/1, visible_flat, indirect_scratch) moved onto ModelGpuData so each worker owns its output buffers. Phase timers and hiz_reject_count_ are atomic since workers fetch_add into them. A new wall-clock timer around the dispatch block reports the actual frame-time contribution; the existing clr/trv/emt counters are now documented as per-thread sums. Measured on the 18-model / 569k-instance test scene: wall-clock cull dropped from ~25 ms to ~5 ms while the aggregate CPU work (trv) stayed ~30 ms. Frame time 34 ms -> 19 ms. IFC_CULL_THREADS=0 forces the single-threaded fallback. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 135 +++++++++++++++++++++---------- src/ifcviewer/ViewportWindow.h | 54 ++++++++----- 2 files changed, 127 insertions(+), 62 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 4731a43186..66525e28cf 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1292,6 +1292,12 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4], float focal_px, float min_pixel_radius) { + cullModelCpu(m, planes, focal_px, min_pixel_radius); + uploadCullResults(m); +} + +void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], + float focal_px, float min_pixel_radius) { // Per-mesh scratch, split by winding × LOD. Winding split lets the draw // pass toggle glFrontFace once between two MDI calls so GL_CULL_FACE does // the right thing for both. LOD split means instances that want the @@ -1303,15 +1309,15 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] auto resize_if = [&](std::vector>& v) { if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); }; - resize_if(visible_by_mesh_fwd_lod0_); - resize_if(visible_by_mesh_fwd_lod1_); - resize_if(visible_by_mesh_rev_lod0_); - resize_if(visible_by_mesh_rev_lod1_); + resize_if(m.vis_fwd_lod0); + resize_if(m.vis_fwd_lod1); + resize_if(m.vis_rev_lod0); + resize_if(m.vis_rev_lod1); for (size_t i = 0; i < m.meshes.size(); ++i) { - visible_by_mesh_fwd_lod0_[i].clear(); - visible_by_mesh_fwd_lod1_[i].clear(); - visible_by_mesh_rev_lod0_[i].clear(); - visible_by_mesh_rev_lod1_[i].clear(); + m.vis_fwd_lod0[i].clear(); + m.vis_fwd_lod1[i].clear(); + m.vis_rev_lod0[i].clear(); + m.vis_rev_lod1[i].clear(); } cull_clear_ns_ += phase_timer.nsecsElapsed(); phase_timer.restart(); @@ -1394,7 +1400,7 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] if (!aabbInFrustum(item.aabb_min, item.aabb_max, planes)) return; if (!contributionPasses(item.aabb_min, item.aabb_max)) return; if (hiz_on && aabbOccludedByHiz(item.aabb_min, item.aabb_max)) { - ++hiz_reject_count_; + hiz_reject_count_.fetch_add(1, std::memory_order_relaxed); return; } // Survivor — now pay the wide-struct fetch for mesh_id. @@ -1407,10 +1413,10 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] const bool reflected = inst_idx < m.instance_reflected.size() && m.instance_reflected[inst_idx] != 0; auto& bucket = - reflected ? (want_lod1 ? visible_by_mesh_rev_lod1_ - : visible_by_mesh_rev_lod0_) - : (want_lod1 ? visible_by_mesh_fwd_lod1_ - : visible_by_mesh_fwd_lod0_); + reflected ? (want_lod1 ? m.vis_rev_lod1 + : m.vis_rev_lod0) + : (want_lod1 ? m.vis_fwd_lod1 + : m.vis_fwd_lod0); bucket[inst.mesh_id].push_back(inst_idx); }; @@ -1456,8 +1462,8 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] // rev fills [indirect_forward_count, end). LOD0/LOD1 within a winding // slice are contiguous — winding is what requires glFrontFace to flip // between MDI calls, LOD is not. - visible_flat_.clear(); - indirect_scratch_.clear(); + m.visible_flat.clear(); + m.indirect_scratch.clear(); auto emit_slice = [&](std::vector>& by_mesh, int lod) { for (size_t mi = 0; mi < m.meshes.size(); ++mi) { @@ -1474,25 +1480,25 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] cmd.instanceCount = vis_count; cmd.firstIndex = ebo_off / sizeof(uint32_t); cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = static_cast(visible_flat_.size()); - indirect_scratch_.push_back(cmd); + cmd.baseInstance = static_cast(m.visible_flat.size()); + m.indirect_scratch.push_back(cmd); - visible_flat_.insert(visible_flat_.end(), - by_mesh[mi].begin(), by_mesh[mi].end()); + m.visible_flat.insert(m.visible_flat.end(), + by_mesh[mi].begin(), by_mesh[mi].end()); } }; - emit_slice(visible_by_mesh_fwd_lod0_, 0); - emit_slice(visible_by_mesh_fwd_lod1_, 1); - m.indirect_forward_count = static_cast(indirect_scratch_.size()); - emit_slice(visible_by_mesh_rev_lod0_, 0); - emit_slice(visible_by_mesh_rev_lod1_, 1); - m.indirect_command_count = static_cast(indirect_scratch_.size()); + emit_slice(m.vis_fwd_lod0, 0); + emit_slice(m.vis_fwd_lod1, 1); + m.indirect_forward_count = static_cast(m.indirect_scratch.size()); + emit_slice(m.vis_rev_lod0, 0); + emit_slice(m.vis_rev_lod1, 1); + m.indirect_command_count = static_cast(m.indirect_scratch.size()); // Per-model stats snapshot — summed into the frame counters regardless // of whether this frame ran a full cull or reused the cached one. uint32_t model_vis_obj = 0, model_vis_tri = 0; - for (const auto& cmd : indirect_scratch_) { + for (const auto& cmd : m.indirect_scratch) { model_vis_tri += (cmd.count / 3) * cmd.instanceCount; model_vis_obj += cmd.instanceCount; } @@ -1500,10 +1506,14 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] m.cached_visible_triangles = model_vis_tri; cull_emit_ns_ += phase_timer.nsecsElapsed(); - phase_timer.restart(); +} + +void ViewportWindow::uploadCullResults(ModelGpuData& m) { + QElapsedTimer phase_timer; + phase_timer.start(); // Upload visible list (keep binding alive even when empty). - size_t vis_bytes = std::max(visible_flat_.size() * sizeof(uint32_t), + size_t vis_bytes = std::max(m.visible_flat.size() * sizeof(uint32_t), sizeof(uint32_t)); if (m.visible_ssbo == 0 || m.visible_ssbo_capacity < vis_bytes) { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); @@ -1513,13 +1523,13 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] gl_->glNamedBufferStorage(m.visible_ssbo, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); m.visible_ssbo_capacity = new_cap; } - if (!visible_flat_.empty()) { + if (!m.visible_flat.empty()) { gl_->glNamedBufferSubData(m.visible_ssbo, 0, - visible_flat_.size() * sizeof(uint32_t), visible_flat_.data()); + m.visible_flat.size() * sizeof(uint32_t), m.visible_flat.data()); } // Upload indirect command buffer. - size_t ind_bytes = indirect_scratch_.size() * sizeof(DrawElementsIndirectCommand); + size_t ind_bytes = m.indirect_scratch.size() * sizeof(DrawElementsIndirectCommand); if (ind_bytes == 0) { cull_upload_ns_ += phase_timer.nsecsElapsed(); return; @@ -1532,7 +1542,7 @@ void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6] gl_->glNamedBufferStorage(m.indirect_buffer, new_cap, nullptr, GL_DYNAMIC_STORAGE_BIT); m.indirect_capacity = new_cap; } - gl_->glNamedBufferSubData(m.indirect_buffer, 0, ind_bytes, indirect_scratch_.data()); + gl_->glNamedBufferSubData(m.indirect_buffer, 0, ind_bytes, m.indirect_scratch.data()); cull_upload_ns_ += phase_timer.nsecsElapsed(); } @@ -1608,7 +1618,7 @@ void ViewportWindow::render() { && last_cull_proj_ == proj_matrix_; const bool cull_this_frame = !camera_unchanged; if (cull_this_frame) { - hiz_reject_count_ = 0; + hiz_reject_count_.store(0, std::memory_order_relaxed); } else { ++cull_skipped_frames_; } @@ -1617,11 +1627,47 @@ void ViewportWindow::render() { // back and forth. Harmless when culling is off. gl_->glFrontFace(GL_CCW); + // Parallel cull: each model's CPU cull is independent (no shared mutable + // state other than the atomic timing counters), so we fan them out to + // std::async and join before the (serial, GL-touching) upload pass. + // IFC_CULL_THREADS=0 forces the single-threaded fallback. + static const bool mt_cull_enabled = []{ + const char* e = std::getenv("IFC_CULL_THREADS"); + return !(e && e[0] == '0'); + }(); + QElapsedTimer cull_wall_timer; + if (cull_this_frame) { + cull_wall_timer.start(); + std::vector cull_targets; + cull_targets.reserve(models_gpu_.size()); + for (auto& [mid, m] : models_gpu_) { + if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; + cull_targets.push_back(&m); + } + if (mt_cull_enabled && cull_targets.size() > 1) { + std::vector> futs; + futs.reserve(cull_targets.size()); + for (ModelGpuData* mp : cull_targets) { + const float mpr = min_pixel_radius; + futs.emplace_back(std::async(std::launch::async, + [this, mp, &planes, focal_px, mpr]() { + cullModelCpu(*mp, planes, focal_px, mpr); + })); + } + for (auto& f : futs) f.get(); + } else { + for (ModelGpuData* mp : cull_targets) { + cullModelCpu(*mp, planes, focal_px, min_pixel_radius); + } + } + cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); + } + for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (cull_this_frame) { - cullAndUploadVisible(m, planes, focal_px, min_pixel_radius); + uploadCullResults(m); } if (m.indirect_command_count == 0) continue; @@ -1741,24 +1787,29 @@ void ViewportWindow::render() { const double inv_frames = frames_in_window > 0 ? 1.0 / static_cast(frames_in_window) : 0.0; - const double clr_ms = cull_clear_ns_ * 1e-6 * inv_frames; - const double trv_ms = cull_traverse_ns_ * 1e-6 * inv_frames; - const double emt_ms = cull_emit_ns_ * 1e-6 * inv_frames; - const double upl_ms = cull_upload_ns_ * 1e-6 * inv_frames; - cull_clear_ns_ = cull_traverse_ns_ = cull_emit_ns_ = cull_upload_ns_ = 0; + const double clr_ms = cull_clear_ns_.load() * 1e-6 * inv_frames; + const double trv_ms = cull_traverse_ns_.load() * 1e-6 * inv_frames; + const double emt_ms = cull_emit_ns_.load() * 1e-6 * inv_frames; + const double upl_ms = cull_upload_ns_.load() * 1e-6 * inv_frames; + const double wall_ms = cull_wall_ns_ * 1e-6 * inv_frames; + cull_clear_ns_.store(0); + cull_traverse_ns_.store(0); + cull_emit_ns_.store(0); + cull_upload_ns_.store(0); + cull_wall_ns_ = 0; const uint32_t skipped = cull_skipped_frames_; cull_skipped_frames_ = 0; qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " - "cull[clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " + "cull[wall %.2f | work: clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, visible_triangles_, total_tri, total_meshes, gl_draw_calls_, indirect_sub_draws_, - hiz_reject_count_, - clr_ms, trv_ms, emt_ms, upl_ms, + hiz_reject_count_.load(), + wall_ms, clr_ms, trv_ms, emt_ms, upl_ms, skipped, frames_in_window, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index ed6668cc11..30b9e8cfa1 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -32,6 +32,8 @@ #include #include #include +#include +#include #include "BvhAccel.h" #include "InstancedGeometry.h" @@ -104,6 +106,15 @@ struct ModelGpuData { uint32_t indirect_command_count = 0; // total valid commands this frame uint32_t indirect_forward_count = 0; // first N are CCW-winding draws + // Per-model cull scratch — owned by the model so each cull job runs + // without sharing mutable state. Four buckets = {fwd, rev} × {LOD0, LOD1}. + std::vector> vis_fwd_lod0; + std::vector> vis_fwd_lod1; + std::vector> vis_rev_lod0; + std::vector> vis_rev_lod1; + std::vector visible_flat; + std::vector indirect_scratch; + bool finalized = false; bool hidden = false; }; @@ -215,6 +226,18 @@ private: void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4], float focal_px, float min_pixel_radius); + // Thread-safe: CPU-only cull (frustum + contribution + HiZ + bucketing + + // emit). Writes survivors into m.vis_* / m.visible_flat / m.indirect_scratch + // and sets m.indirect_forward_count / m.indirect_command_count / + // m.cached_visible_*. Touches no GL state and no ViewportWindow mutable + // state other than the atomic counters below — safe to run on a worker. + void cullModelCpu(ModelGpuData& m, const float planes[6][4], + float focal_px, float min_pixel_radius); + + // Main-thread only: uploads m.visible_flat / m.indirect_scratch into the + // model's SSBO + indirect buffer, growing them if needed. + void uploadCullResults(ModelGpuData& m); + // Mouse interaction void handleMousePress(QMouseEvent* event); void handleMouseRelease(QMouseEvent* event); @@ -268,17 +291,23 @@ private: std::vector hiz_mip_h_; QMatrix4x4 hiz_vp_; bool hiz_vp_valid_ = false; - uint32_t hiz_reject_count_ = 0; // per-frame stat + std::atomic hiz_reject_count_{0}; // per-frame stat // Cull-phase timers. Accumulated across all frames in the current // 1-second stats window; divided by frame_count_ at print time to // give per-frame average ms. Reset each window. Lets us see where // CPU time actually goes: bucket clears vs BVH traversal vs emit vs // GPU upload. - uint64_t cull_clear_ns_ = 0; - uint64_t cull_traverse_ns_ = 0; - uint64_t cull_emit_ns_ = 0; - uint64_t cull_upload_ns_ = 0; + // Atomic so parallel cull workers can fetch_add into them without + // contending on a lock. clr/trv/emt are SUMS across all worker threads + // for the frame — they describe total CPU work, not wall-clock. The + // wall counter is measured once around the dispatch block in render() + // and is what actually determines frame time. + std::atomic cull_clear_ns_{0}; + std::atomic cull_traverse_ns_{0}; + std::atomic cull_emit_ns_{0}; + std::atomic cull_upload_ns_{0}; + uint64_t cull_wall_ns_ = 0; // main-thread only uint32_t cull_skipped_frames_ = 0; // Skip cullAndUploadVisible + buildHizPyramid when the camera and scene @@ -295,21 +324,6 @@ private: uint32_t gl_draw_calls_ = 0; uint32_t indirect_sub_draws_ = 0; - // Reused scratch: visible-instance index lists per mesh, flattened into - // `visible_flat_` for upload. Both live in the parent object to avoid - // per-frame allocation. indirect_scratch_ is the matching array of - // DrawElementsIndirectCommand records — forward-declared as bytes so - // the header doesn't need the struct definition. - // Four buckets = {fwd, rev} × {LOD0, LOD1}. LOD1 buckets are only - // populated when the mesh has lod1_index_count > 0 and the projected - // pixel radius is below the LOD switch threshold. - std::vector> visible_by_mesh_fwd_lod0_; - std::vector> visible_by_mesh_fwd_lod1_; - std::vector> visible_by_mesh_rev_lod0_; - std::vector> visible_by_mesh_rev_lod1_; - std::vector visible_flat_; - std::vector indirect_scratch_; - // Camera QVector3D camera_target_{0, 0, 0}; QVector3D camera_eye_{0, 0, 0}; // world-space eye, set in updateCamera From 0c9d3ea6d79ee458b90ba08daa94d2f33ff13f7b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 15 Apr 2026 15:29:21 +1000 Subject: [PATCH 032/120] =?UTF-8?q?ifcviewer:=20README=20=E2=80=94=20docum?= =?UTF-8?q?ent=20parallel=20per-model=20cull=20(Phase=203D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the parallel cull bullet to the feature list, a Phase 3D section explaining the fan-out / scratch-ownership design + measured 4x speedup, and renumber the planned GPU compute cull to Phase 3E so it can cite 3D as the CPU algorithm being ported. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 44 +++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 01ceef7ecf..8c09ed661f 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -60,6 +60,13 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. - **BVH frustum culling over instances**: per-model BVH trees cull whole subtrees of placements with one frustum test. Falls back to a linear scan during progressive upload and for very small models (< 32 instances). +- **Parallel per-model cull:** each model's CPU cull (frustum + contribution + + HiZ + bucketing + indirect-command emit) is independent, so `render()` + fans them out via `std::async` and joins before the serial GL-upload + pass. On an 18-model scene this took wall-clock cull from ~25 ms to + ~5 ms. The cull scratch buffers live on `ModelGpuData` so each worker + owns its output storage; phase-timer counters are atomic for the same + reason. `IFC_CULL_THREADS=0` forces single-threaded fallback. - **Reflection-aware two-pass draw:** IFC placements can have negative- determinant transforms (mirrored families). These flip the screen-space winding of their triangles, which would make them vanish under @@ -692,13 +699,35 @@ thousands and the frame time drops accordingly. - **Transparent geometry would need special handling**, but the current renderer doesn't have any, so no-op for now. -#### 3D. GPU-side culling via compute (longer-term) +#### 3D. Parallel per-model cull (CPU, done) + +A cheaper intermediate step before going full-GPU: each model's cull is +independent (no shared mutable state beyond atomic timing counters), so +`render()` fans the per-model culls out to a `std::async` pool and joins +before the serial GL-upload pass. On the 18-model / 569 k-instance test +scene this took the cull from ~25 ms wall-clock to ~5 ms — roughly a 4× +speedup on an 8-core machine, tracking `std::thread::hardware_concurrency()` +up to the model count. Load balancing is static (one job per model); a +single massive model still bottlenecks to single-threaded speed and would +need intra-model partitioning, but in practice BIM projects are +multi-discipline so the coarse partition lands well. + +The stats line now reports `cull[wall X | work: clr Y trv Z emt W upl U]`: +`wall` is frame-time impact, the `work` numbers are per-thread sums showing +where CPU cycles went. `IFC_CULL_THREADS=0` forces single-threaded mode +for comparison. + +#### 3E. GPU-side culling via compute (longer-term) Push the cull loop to a compute shader reading the per-instance SSBO + frustum planes + HiZ pyramid, emitting the visible list and indirect -commands with atomic counters. Eliminates all CPU→GPU per-frame bytes -and lets 3C scale to millions of instances. Worth doing once 3A–3C -have stabilised the CPU-side algorithm we'd be porting. +commands with atomic counters. Three compute dispatches per model: (1) +count survivors per `(mesh, winding, LOD)` bucket, (2) prefix-sum the +counts into `baseInstance` offsets and write the indirect command buffer, +(3) re-test and compact survivors into the dense visible list. HiZ moves +to a GPU depth texture sampled directly in the shader, eliminating the +Phase 3C readback. Lets culling scale to millions of instances and +single-model scenes where Phase 3D can't parallelise. ### Planned follow-ups (post-Phase-3) @@ -716,6 +745,8 @@ Scene size Bottleneck Fix 500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull + Phase 3B LOD (done) multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) +many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) +single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (planned) ``` ## Roadmap @@ -732,12 +763,13 @@ multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`) +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`) - [x] Phase 3A — screen-space contribution culling - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) - [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) +- [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) -- [ ] **Phase 3D — GPU-side compute-shader culling** (next; replaces the readback) +- [ ] **Phase 3E — GPU-side compute-shader culling** (next; replaces the HiZ readback) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console From 99f409280a8a39f58861fd2943af856e1ede0ca3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 15 Apr 2026 17:46:47 +1000 Subject: [PATCH 033/120] ifcviewer: disable HiZ cull when camera has moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HiZ from last frame encodes depth from last frame's viewpoint. When the camera moves, projecting a current-frame AABB through the stored VP answers 'was this occluded last frame?' rather than 'is it occluded now?' — a self-reinforcing feedback loop where objects culled in prior frames never appear in any depth buffer and stay permanently hidden at certain camera angles. Fix: require hiz_vp_ == current VP for the HiZ test to apply. HiZ still helps static views (kicks in one frame after camera stops) but no longer produces false occlusions during orbit. The correct fix for orbit coverage is a depth pre-pass feeding fresh HiZ — planned as part of Phase 3E GPU compute cull. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 66525e28cf..e96b45f909 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1388,7 +1388,21 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], // HiZ occlusion is skipped entirely when the pick pass runs // (min_pixel_radius == 0 on that path), when the user disables it via // env var, or before the first pyramid has been built. - const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_valid_; + // + // Crucially, HiZ is also skipped when the stored VP (hiz_vp_, captured at + // the end of the previous frame) differs from this frame's VP — i.e. + // whenever the camera has moved. The stored depth buffer encodes what + // was visible from hiz_vp_'s viewpoint; projecting a current-frame AABB + // through that VP answers "was this occluded LAST frame?", which is only + // a correct proxy for "is this occluded NOW?" when the camera is static. + // Orbiting past a wall would otherwise leave objects persistently culled + // because prior frames' depth buffers only ever contained the wall (the + // objects behind it were themselves HiZ-culled, never drawn, so never in + // the buffer — a self-reinforcing feedback loop). On static views HiZ + // kicks in after a single frame of lag. + const QMatrix4x4 current_vp = proj_matrix_ * view_matrix_; + const bool hiz_vp_matches = hiz_vp_valid_ && hiz_vp_ == current_vp; + const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_matches; // Hot path: read the AABB from the compact bvh_items array (28 B stride) // rather than the wide InstanceCpu (104 B stride). Most instances fail From 03662d201644759570c11bde02134918e866ac6e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 15 Apr 2026 18:30:03 +1000 Subject: [PATCH 034/120] ifcviewer: fix pick-pass cull corruption and cached-model ID collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two stability bugs: 1. Clicking an object left the scene with wrong shading until the camera moved. The pick pass re-culls every model with its own parameters (min_pixel_radius=0, no HiZ) and overwrites each model's visible_ssbo and indirect buffer. The next render() saw an unchanged camera, skipped the cull via the have_cached_cull_ shortcut, and drew the stale pick-pass buffers. Fix: invalidate have_cached_cull_ at the end of pickObjectAt(). 2. Loading two sidecar-cached models made the second model's picked properties resolve to the first model's elements. Sidecars store raw object_id / model_id values from the session that wrote them, and both files start at object_id=1, so element_map_ entries collided. Fix: on load, rebase every PackedElementInfo and InstanceCpu by (next_object_id_ - min_id_in_sidecar) and overwrite model_id with the freshly-assigned handle before the elements hit element_map_. Also document both in the README — the pick-pass note under 3A contribution culling, the sidecar rebase under the sidecar format section. --- src/ifcviewer/MainWindow.cpp | 27 ++++++++++++++++++++++----- src/ifcviewer/README.md | 15 +++++++++++++++ src/ifcviewer/ViewportWindow.cpp | 7 +++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index 0e8162f043..e75f7cf0dd 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -248,11 +248,28 @@ void MainWindow::applySidecarData(ModelId mid, SidecarData data) { QElapsedTimer t; t.start(); - // Update next_object_id_ past all objects in this model before the - // extracted `elements` is moved out of `data`. - for (const auto& elem : data.elements) { - if (elem.object_id >= next_object_id_) - next_object_id_ = elem.object_id + 1; + // Sidecars store raw object_ids and model_ids from the session that wrote + // them. On load we must rebase both onto the current session's ID space, + // or two cached models collide (both starting at object_id=1, both + // claiming the original model_id). Offset by (next_object_id_ - min_id) + // so the first cached object takes the next free slot. + uint32_t min_oid = UINT32_MAX; + for (const auto& pe : data.elements) { + if (pe.object_id < min_oid) min_oid = pe.object_id; + } + uint32_t oid_offset = 0; + if (!data.elements.empty() && min_oid < UINT32_MAX) { + oid_offset = next_object_id_ - min_oid; + } + for (auto& pe : data.elements) { + pe.object_id += oid_offset; + pe.model_id = mid; + if (pe.object_id >= next_object_id_) + next_object_id_ = pe.object_id + 1; + } + for (auto& inst : data.instances) { + inst.object_id += oid_offset; + inst.model_id = mid; } // Hand off geometry to GPU in a single call. diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 8c09ed661f..70540abfb1 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -307,6 +307,14 @@ uint32_t + char[] string table Staleness check: `source_file_size` vs actual file size. Mismatched → reject and rebuild. Endianness marker rejects cross-arch caches. +Sidecars store the raw `object_id` / `model_id` values from the session +that wrote them. On load they are rebased onto the current session's ID +space (`object_id += next_object_id_ - min_id_in_sidecar`, `model_id` +overwritten with the freshly-assigned handle) before the elements hit +`element_map_` or the viewport. Without this, two cached models loaded +back-to-back collide — both start at `object_id=1` and the second model's +property lookups return the first model's data. + ### GPU Instancing pipeline (the central pillar) Everything above plugs into a single data-flow, worth documenting on its @@ -451,6 +459,13 @@ and per-instance level. Short-circuits when the camera is inside the AABB so nothing-you're-standing-next-to is ever lost. Pick pass uses threshold 0 so sub-pixel objects remain clickable. +Because the pick pass re-runs the cull with its own parameters (no +contribution cull, no HiZ) and writes into each model's shared +`visible_ssbo` / indirect buffer, `pickObjectAt()` must invalidate +`have_cached_cull_` on exit. Otherwise the next `render()` sees an +unchanged camera, skips the cull, and draws the pick-pass buffers — +the user sees obviously-wrong shading until they nudge the camera. + Sphere-based (centre = AABB midpoint, radius = half-diagonal, r_px = focal_px · radius / distance). Loses a little precision on very elongated bounds vs. 8-corner projection, but costs ~5× less per diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index e96b45f909..a97714950a 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1282,6 +1282,13 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { renderPickPass(); + // The pick pass overwrote each model's visible_ssbo / indirect_buffer with + // pick-specific cull params (no contribution cull, no HiZ). Invalidate + // the cached cull so the next render() rebuilds them with main-render + // params; otherwise the viewport draws with stale pick-pass buffers and + // shading looks wrong until the camera moves. + have_cached_cull_ = false; + int px = x * devicePixelRatio(); int py = (height() - y) * devicePixelRatio(); uint32_t pixel = 0; From 0a752e09eb1a4aaefee968d9e343c916d29236d0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 15 Apr 2026 18:31:17 +1000 Subject: [PATCH 035/120] =?UTF-8?q?ifcviewer:=20README=20=E2=80=94=20docum?= =?UTF-8?q?ent=20HiZ=20disabled=20during=20camera=20motion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Known caveats' bullet still described the old 1-frame-stale behavior. Since 6b496d802 the cull compares hiz_vp_ to the current VP and drops HiZ rejection whenever they differ, so HiZ only helps on still frames — orbiting gets no benefit. Call out the tradeoff and the planned same-frame-depth-pre-pass fix slated for Phase 3E. --- src/ifcviewer/README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 70540abfb1..77ffefc40e 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -694,11 +694,19 @@ thousands and the frame time drops accordingly. ##### Known caveats -- **1 frame stale.** The pyramid is aligned to last frame's view, so - when you whip the camera across the scene we may draw one frame of - stuff that the new view would have occluded. Invisible in practice - at 60 fps. We tried a 3-deep PBO ring for async readback (2-frame - stale) and it produced visible flicker on fast orbits — reverted. +- **Disabled while the camera moves.** The pyramid is aligned to the + VP matrix of the frame that produced it. On a moving camera the + stored VP no longer matches the current one, and reusing it would + pop objects in and out as the stale depth falsely claims they're + occluded. The cull now compares `hiz_vp_ == current_vp` and drops + HiZ rejection entirely when they differ, so HiZ only contributes on + still frames. The honest cost: orbiting — the exact motion where + the frame rate tends to dip — gets no HiZ help. A proper fix needs + a same-frame depth pre-pass (draw cheap depth, build HiZ from *that* + frame's VP, then issue the colour pass against it); deferred to the + GPU-compute cull rewrite in Phase 3E where we're touching this code + anyway. We also tried a 3-deep PBO ring for async readback (2-frame + stale) which produced visible flicker on fast orbits — reverted. - **Readback syncs the GPU.** `glGetTextureImage` is blocking. Measured cost is well under a millisecond at 256×128; not a bottleneck on the machines tested. Phase 3D's compute-shader cull From 2f88778c9fa9b02cce11ecc4be0747cbc147d55e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 16 Apr 2026 20:51:26 +1000 Subject: [PATCH 036/120] ifcviewer: upload per-instance world AABBs to a GPU SSBO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolding for Phase 3E (GPU compute cull). After finalizeModel / applyCachedModel, pack each InstanceCpu's world AABB + mesh_id + reflection bit into a std430-friendly 32 B record and push it to a per-model aabb_ssbo. No consumer yet — the CPU cull still drives rendering — but the next commits will point a compute shader at this buffer and have it produce the visible list + indirect commands directly on the GPU. Cost: 32 B per instance, ~18 MB for the 569 k-instance test scene. One-shot upload at finalize time; streaming-time appends aren't mirrored (the CPU cull doesn't need the SSBO, and finalizeModel rebuilds the whole thing in one go). --- src/ifcviewer/ViewportWindow.cpp | 48 ++++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 14 ++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a97714950a..20068567b7 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -408,6 +408,7 @@ ViewportWindow::~ViewportWindow() { if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); + if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -764,6 +765,48 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { requestUpdate(); } +// Matches the std430 layout the GPU compute cull will consume. +struct InstanceAabbGpu { + float min[3]; + uint32_t mesh_id; + float max[3]; + uint32_t flags; // bit 0 = reflected +}; +static_assert(sizeof(InstanceAabbGpu) == 32, "InstanceAabbGpu must be 32 bytes"); + +void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { + const size_t n = m.instances.size(); + const size_t bytes = n * sizeof(InstanceAabbGpu); + + if (m.aabb_ssbo && m.aabb_ssbo_capacity < bytes) { + gl_->glDeleteBuffers(1, &m.aabb_ssbo); + m.aabb_ssbo = 0; + m.aabb_ssbo_capacity = 0; + } + if (!m.aabb_ssbo) { + gl_->glCreateBuffers(1, &m.aabb_ssbo); + const size_t cap = std::max(bytes, sizeof(InstanceAabbGpu)); + gl_->glNamedBufferStorage(m.aabb_ssbo, cap, nullptr, GL_DYNAMIC_STORAGE_BIT); + m.aabb_ssbo_capacity = cap; + } + if (n == 0) return; + + std::vector packed(n); + for (size_t i = 0; i < n; ++i) { + const InstanceCpu& src = m.instances[i]; + InstanceAabbGpu& dst = packed[i]; + dst.min[0] = src.world_aabb_min[0]; + dst.min[1] = src.world_aabb_min[1]; + dst.min[2] = src.world_aabb_min[2]; + dst.max[0] = src.world_aabb_max[0]; + dst.max[1] = src.world_aabb_max[1]; + dst.max[2] = src.world_aabb_max[2]; + dst.mesh_id = src.mesh_id; + dst.flags = (i < m.instance_reflected.size() && m.instance_reflected[i]) ? 1u : 0u; + } + gl_->glNamedBufferSubData(m.aabb_ssbo, 0, bytes, packed.data()); +} + void ViewportWindow::finalizeModel(uint32_t model_id) { if (!gl_initialized_) return; context_->makeCurrent(this); @@ -783,6 +826,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { } buildBvhForModel(m, model_id); + uploadInstanceAabbs(m); m.finalized = true; have_cached_cull_ = false; @@ -835,6 +879,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &existing->second.mesh_info_ssbo); if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); + if (existing->second.aabb_ssbo) gl_->glDeleteBuffers(1, &existing->second.aabb_ssbo); models_gpu_.erase(existing); } @@ -917,6 +962,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } buildBvhForModel(m, model_id); + uploadInstanceAabbs(m); m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); @@ -977,6 +1023,7 @@ void ViewportWindow::resetScene() { if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); + if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1014,6 +1061,7 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &it->second.mesh_info_ssbo); if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); + if (it->second.aabb_ssbo) gl_->glDeleteBuffers(1, &it->second.aabb_ssbo); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 30b9e8cfa1..e8fd89af9d 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -94,6 +94,14 @@ struct ModelGpuData { std::vector bvh_items; ModelBvh bvh; + // Per-instance world AABB on the GPU, 1:1 with `instances`. + // Populated at finalize / applyCachedModel. Consumed by the upcoming + // GPU-compute cull (Phase 3E); the CPU cull still reads from bvh_items. + // Layout: struct { vec3 min; uint mesh_id; vec3 max; uint flags; } = 32 B. + // `flags` bit 0 = reflected (for winding-bucket selection). + GLuint aabb_ssbo = 0; + size_t aabb_ssbo_capacity = 0; // bytes + // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; @@ -215,6 +223,12 @@ private: bool growModelSsbo(ModelGpuData& m, size_t needed_total); ModelGpuData& getOrCreateModel(uint32_t model_id); + // (Re)build the per-instance world AABB SSBO from m.instances + + // m.instance_reflected. One-shot upload called after finalizeModel / + // applyCachedModel once instances are settled. Consumed by the GPU + // compute cull (Phase 3E, in progress). + void uploadInstanceAabbs(ModelGpuData& m); + // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible // list, and upload both to m.indirect_buffer / m.visible_ssbo. From a0cc4b874bb5c98d2ef73e16854157f4afcb9e07 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 16 Apr 2026 20:57:47 +1000 Subject: [PATCH 037/120] ifcviewer: add GPU frustum-cull validation shader (IFC_GPU_CULL=1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Phase 3E milestone: a compute shader that reads the per-instance world-AABB SSBO added in the last commit, tests each instance against the 6 frustum planes, and atomicAdds a global counter. No visible list or indirect-buffer writes yet — the output is just a survivor count, cross-checked each frame against the CPU cull's numbers in the stats line (`gpu_cull[Xms in=A surv=B]`) so we can verify the plumbing end- to-end before we hand the GPU responsibility for the actual render data. Dispatched from render() after the CPU cull completes, only when IFC_GPU_CULL=1 and the camera moved (the skipped-cull still-frame path doesn't re-check either). The readback is synchronous — that's fine for a validation path; it'll go away once the GPU writes indirect commands directly. Expected invariant: gpu_cull.surv >= cpu_cull.visible_objects, since the GPU path does frustum-only and CPU adds contribution + HiZ cuts on top. A large mismatch (orders of magnitude, or surv < visible) means the SSBO upload or shader logic is wrong. No shader/buffer bindings overlap with the draw path (compute uses bindings 0/1, restored before drawing; draw programs rebind 0/1/2). --- src/ifcviewer/ViewportWindow.cpp | 100 +++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 9 +++ 2 files changed, 109 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 20068567b7..2778c173e7 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -247,6 +247,53 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch return shader; } +// Phase 3E compute cull (frustum-only, validation). Reads a model's +// per-instance AABB SSBO, tests against 6 planes, atomicAdds on a global +// counter. No visible list / indirect writeout yet; result is cross-checked +// against the CPU cull's visible_objects count to prove plumbing is correct +// before we hand the GPU the full emit responsibility. Gated by IFC_GPU_CULL=1. +static const char* CULL_COMPUTE_SHADER = R"( +#version 450 core +layout(local_size_x = 64) in; +// Each instance contributes two vec4 entries: (min.xyz, meshid_as_float), +// (max.xyz, flags_as_float). We ignore the w components here — they'll be +// needed once the shader also emits the per-mesh / fwd-rev buckets. +layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; +layout(std430, binding = 1) coherent buffer CountBuf { uint counter; }; +uniform vec4 u_planes[6]; +uniform uint u_count; +void main() { + uint gid = gl_GlobalInvocationID.x; + if (gid >= u_count) return; + vec3 mn = entries[gid * 2u].xyz; + vec3 mx = entries[gid * 2u + 1u].xyz; + for (int i = 0; i < 6; ++i) { + vec3 pv = vec3( + u_planes[i].x >= 0.0 ? mx.x : mn.x, + u_planes[i].y >= 0.0 ? mx.y : mn.y, + u_planes[i].z >= 0.0 ? mx.z : mn.z); + if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return; + } + atomicAdd(counter, 1u); +} +)"; + +static GLuint linkComputeProgram(QOpenGLFunctions_4_5_Core* gl, const char* src) { + GLuint cs = compileShader(gl, GL_COMPUTE_SHADER, src); + GLuint prog = gl->glCreateProgram(); + gl->glAttachShader(prog, cs); + gl->glLinkProgram(prog); + GLint ok = 0; + gl->glGetProgramiv(prog, GL_LINK_STATUS, &ok); + if (!ok) { + char log[2048]; + gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log); + qWarning("Compute program link error: %s", log); + } + gl->glDeleteShader(cs); + return prog; +} + static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint frag) { GLuint prog = gl->glCreateProgram(); gl->glAttachShader(prog, vert); @@ -415,6 +462,8 @@ ViewportWindow::~ViewportWindow() { if (main_program_) gl_->glDeleteProgram(main_program_); if (pick_program_) gl_->glDeleteProgram(pick_program_); if (axis_program_) gl_->glDeleteProgram(axis_program_); + if (cull_program_) gl_->glDeleteProgram(cull_program_); + if (gpu_cull_counter_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_counter_ssbo_); 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_); @@ -504,6 +553,10 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER); axis_program_ = linkProgram(gl_, vs, fs); } + cull_program_ = linkComputeProgram(gl_, CULL_COMPUTE_SHADER); + gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_); + gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr, + GL_DYNAMIC_STORAGE_BIT); } void ViewportWindow::buildAxisGizmo() { @@ -1732,6 +1785,49 @@ void ViewportWindow::render() { cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); } + // Phase 3E validation dispatch: frustum-only GPU cull, result compared + // against the CPU cull's visible_objects count. Gated, no draw-path + // effect. Synchronous readback is intentional — we want ground truth. + static const bool gpu_cull_enabled = []{ + const char* e = std::getenv("IFC_GPU_CULL"); + return e && e[0] == '1'; + }(); + if (gpu_cull_enabled && cull_this_frame && cull_program_) { + QElapsedTimer t; t.start(); + uint32_t zero = 0; + gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero); + + gl_->glUseProgram(cull_program_); + GLint u_planes = gl_->glGetUniformLocation(cull_program_, "u_planes"); + GLint u_count = gl_->glGetUniformLocation(cull_program_, "u_count"); + float planes_flat[24]; + for (int i = 0; i < 6; ++i) { + planes_flat[i*4+0] = planes[i][0]; + planes_flat[i*4+1] = planes[i][1]; + planes_flat[i*4+2] = planes[i][2]; + planes_flat[i*4+3] = planes[i][3]; + } + gl_->glUniform4fv(u_planes, 6, planes_flat); + + uint32_t total_in = 0; + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_); + for (const auto& [mid, m] : models_gpu_) { + if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; + const uint32_t n = static_cast(m.instances.size()); + total_in += n; + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); + gl_->glUniform1ui(u_count, n); + gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); + } + gl_->glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + uint32_t survivors = 0; + gl_->glGetNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(survivors), &survivors); + gpu_cull_last_survivors_ = survivors; + gpu_cull_last_input_ = total_in; + gpu_cull_ns_ += t.nsecsElapsed(); + gl_->glUseProgram(main_program_); + } + for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; @@ -1868,10 +1964,13 @@ void ViewportWindow::render() { cull_wall_ns_ = 0; const uint32_t skipped = cull_skipped_frames_; cull_skipped_frames_ = 0; + const double gpu_cull_ms = gpu_cull_ns_ * 1e-6 * inv_frames; + gpu_cull_ns_ = 0; qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " "cull[wall %.2f | work: clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " + "gpu_cull[%.2fms in=%u surv=%u] " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, @@ -1880,6 +1979,7 @@ void ViewportWindow::render() { hiz_reject_count_.load(), wall_ms, clr_ms, trv_ms, emt_ms, upl_ms, skipped, frames_in_window, + gpu_cull_ms, gpu_cull_last_input_, gpu_cull_last_survivors_, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index e8fd89af9d..45345c4b1b 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -267,6 +267,15 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; + // Phase 3E compute cull (frustum-only, validation). Runs alongside the + // CPU cull when IFC_GPU_CULL=1; result is cross-checked against CPU's + // visible_objects count. No draw-path side effects yet. + GLuint cull_program_ = 0; + GLuint gpu_cull_counter_ssbo_ = 0; + uint32_t gpu_cull_last_survivors_ = 0; + uint32_t gpu_cull_last_input_ = 0; + uint64_t gpu_cull_ns_ = 0; // per-window accumulator + // Axis gizmo GLuint axis_vao_ = 0; GLuint axis_vbo_ = 0; From 0b122ae1f5e3b7ee3bcd4908d24ad66fea819ac0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 07:26:31 +1000 Subject: [PATCH 038/120] ifcviewer: GPU cull drives rendering under IFC_GPU_CULL=1 Promote the compute cull from a validation shader to the actual draw driver. With the gate on, the CPU cull fan-out is skipped and MDI consumes gpu_indirect_buffer / gpu_visible_ssbo directly. - uploadGpuCullStaticBuffers() pre-fills per-mesh DrawElementsIndirect commands and a mesh_base prefix sum so the compact shader can scatter survivors into a fixed per-mesh range. Instance count for each command is zeroed by a tiny reset dispatch, then the compact shader atomically writes survivors and increments instanceCount. - Draw loop branches on the gate: single CCW MDI with all mesh commands. Fwd/rev winding split, LOD selection, and HiZ are still CPU-path-only; reflected instances render with wrong winding under this gate (step 3b). - Once-per-second readback of each model's indirect buffer populates the survivor / visible-object / visible-triangle stats so the [frame] line reflects what the GPU actually drew. Known regression: sub_draws is the full mesh count per model (~172k on the test dataset) vs the handful of non-empty commands the CPU path produces. Command-processor overhead from zero-instance sub-draws is what drives the FPS drop, not the cull itself (0.05 ms). Compacting non-empty commands requires glMultiDrawElementsIndirectCount, a GL 4.6 entrypoint not exposed by Qt's QOpenGLFunctions_4_5_Core; deferring to 3a-followup so we don't bolt a getProcAddress loader into the renderer mid-restructure. IFC_GPU_CULL is off by default, so this does not affect normal runs. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 12 +- src/ifcviewer/ViewportWindow.cpp | 348 +++++++++++++++++++++++++------ src/ifcviewer/ViewportWindow.h | 35 +++- 3 files changed, 322 insertions(+), 73 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 77ffefc40e..be7d7a1368 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -793,6 +793,16 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann - [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) -- [ ] **Phase 3E — GPU-side compute-shader culling** (next; replaces the HiZ readback) +- [~] **Phase 3E — GPU-side compute-shader culling** (in progress) + - [x] 3a: `IFC_GPU_CULL=1` drives rendering via compute cull (frustum + + contribution, single bucket per mesh). Correctness matches CPU + path; perf regressed — we submit one sub-draw per mesh even + when `instanceCount=0`. Fix is MDI compaction via + `glMultiDrawElementsIndirectCount`, deferred to 3a-followup so + we don't pull a GL 4.6 entrypoint loader into this commit. + - [ ] 3a-followup: compact non-empty commands, use count-buffer MDI + - [ ] 3b: fwd/rev reflection bucketing on GPU + - [ ] 3c: LOD0/LOD1 selection on GPU + - [ ] 3d: HiZ with same-frame depth pre-pass - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2778c173e7..2817123234 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -247,34 +247,84 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch return shader; } -// Phase 3E compute cull (frustum-only, validation). Reads a model's -// per-instance AABB SSBO, tests against 6 planes, atomicAdds on a global -// counter. No visible list / indirect writeout yet; result is cross-checked -// against the CPU cull's visible_objects count to prove plumbing is correct -// before we hand the GPU the full emit responsibility. Gated by IFC_GPU_CULL=1. -static const char* CULL_COMPUTE_SHADER = R"( +// Phase 3E compute cull. Two tiny shaders, dispatched per model per frame +// when IFC_GPU_CULL=1: +// +// RESET — zero the instanceCount field of each DrawElementsIndirectCommand +// in gpu_indirect_buffer. One thread per mesh command. +// +// COMPACT — for each instance, test frustum + contribution; if it survives, +// atomicAdd on ind[mesh_id].instanceCount to claim a local slot, then write +// the instance index into visible_ssbo[mesh_base[mesh_id] + local_slot]. +// The baseInstance / firstIndex / count fields are static — filled at +// finalize and left alone here. +// +// `ind[]` is addressed as uint[] because DrawElementsIndirectCommand is 5 +// uints (count, instanceCount, firstIndex, baseVertex, baseInstance) and +// we only need to touch index 1 per command. +static const char* CULL_RESET_COMPUTE_SHADER = R"( #version 450 core layout(local_size_x = 64) in; -// Each instance contributes two vec4 entries: (min.xyz, meshid_as_float), -// (max.xyz, flags_as_float). We ignore the w components here — they'll be -// needed once the shader also emits the per-mesh / fwd-rev buckets. -layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; -layout(std430, binding = 1) coherent buffer CountBuf { uint counter; }; -uniform vec4 u_planes[6]; -uniform uint u_count; +layout(std430, binding = 0) buffer IndirectBuf { uint ind[]; }; +uniform uint u_mesh_count; void main() { - uint gid = gl_GlobalInvocationID.x; - if (gid >= u_count) return; - vec3 mn = entries[gid * 2u].xyz; - vec3 mx = entries[gid * 2u + 1u].xyz; + uint mi = gl_GlobalInvocationID.x; + if (mi >= u_mesh_count) return; + ind[mi * 5u + 1u] = 0u; +} +)"; + +static const char* CULL_COMPACT_COMPUTE_SHADER = R"( +#version 450 core +layout(local_size_x = 64) in; +// Each instance contributes two vec4 entries: (min.xyz, mesh_id_as_float), +// (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint. +layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; +layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; +layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; +layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; + +uniform vec4 u_planes[6]; +uniform uint u_count; // num instances +uniform vec3 u_camera_eye; +uniform float u_focal_px; +uniform float u_min_pixel_radius; + +bool frustum(vec3 mn, vec3 mx) { for (int i = 0; i < 6; ++i) { vec3 pv = vec3( u_planes[i].x >= 0.0 ? mx.x : mn.x, u_planes[i].y >= 0.0 ? mx.y : mn.y, u_planes[i].z >= 0.0 ? mx.z : mn.z); - if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return; + if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return false; } - atomicAdd(counter, 1u); + return true; +} + +bool contribution(vec3 mn, vec3 mx) { + if (u_min_pixel_radius <= 0.0) return true; + // Camera inside the AABB -> always keep (matches CPU path). + if (all(greaterThanEqual(u_camera_eye, mn)) && + all(lessThanEqual (u_camera_eye, mx))) return true; + vec3 ctr = 0.5 * (mx + mn); + vec3 ext = 0.5 * (mx - mn); + float radius = length(ext); + float dist = distance(ctr, u_camera_eye); + return u_focal_px * radius >= u_min_pixel_radius * dist; +} + +void main() { + uint gid = gl_GlobalInvocationID.x; + if (gid >= u_count) return; + vec4 lo = entries[gid * 2u]; + vec4 hi = entries[gid * 2u + 1u]; + vec3 mn = lo.xyz; + vec3 mx = hi.xyz; + if (!frustum(mn, mx)) return; + if (!contribution(mn, mx)) return; + uint mesh_id = floatBitsToUint(lo.w); + uint local = atomicAdd(ind[mesh_id * 5u + 1u], 1u); + visible[mesh_base[mesh_id] + local] = gid; } )"; @@ -456,14 +506,17 @@ ViewportWindow::~ViewportWindow() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); + if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); } 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 (cull_program_) gl_->glDeleteProgram(cull_program_); - if (gpu_cull_counter_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_counter_ssbo_); + if (cull_reset_program_) gl_->glDeleteProgram(cull_reset_program_); + if (cull_compact_program_) gl_->glDeleteProgram(cull_compact_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_); @@ -553,10 +606,8 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER); axis_program_ = linkProgram(gl_, vs, fs); } - cull_program_ = linkComputeProgram(gl_, CULL_COMPUTE_SHADER); - gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_); - gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr, - GL_DYNAMIC_STORAGE_BIT); + cull_reset_program_ = linkComputeProgram(gl_, CULL_RESET_COMPUTE_SHADER); + cull_compact_program_ = linkComputeProgram(gl_, CULL_COMPACT_COMPUTE_SHADER); } void ViewportWindow::buildAxisGizmo() { @@ -860,6 +911,82 @@ void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { gl_->glNamedBufferSubData(m.aabb_ssbo, 0, bytes, packed.data()); } +void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { + const uint32_t M = static_cast(m.meshes.size()); + m.gpu_mesh_command_count = M; + + // Prefix-sum instance_count to get per-mesh base offsets. Also build a + // DrawElementsIndirectCommand template per mesh (count / firstIndex / + // baseVertex / baseInstance static; instanceCount starts at 0). + std::vector mesh_base(M, 0); + std::vector indir(M); + uint32_t running = 0; + for (uint32_t i = 0; i < M; ++i) { + const MeshInfo& mesh = m.meshes[i]; + mesh_base[i] = running; + DrawElementsIndirectCommand& cmd = indir[i]; + cmd.count = mesh.index_count; + cmd.instanceCount = 0; + cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = running; + running += mesh.instance_count; + } + const uint32_t total_instances = running; + + // Indirect buffer. + const size_t ind_bytes = std::max(M * sizeof(DrawElementsIndirectCommand), + sizeof(DrawElementsIndirectCommand)); + if (m.gpu_indirect_buffer && m.gpu_indirect_capacity < ind_bytes) { + gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + m.gpu_indirect_buffer = 0; + m.gpu_indirect_capacity = 0; + } + if (!m.gpu_indirect_buffer) { + gl_->glCreateBuffers(1, &m.gpu_indirect_buffer); + gl_->glNamedBufferStorage(m.gpu_indirect_buffer, ind_bytes, nullptr, + GL_DYNAMIC_STORAGE_BIT); + m.gpu_indirect_capacity = ind_bytes; + } + if (M > 0) { + gl_->glNamedBufferSubData(m.gpu_indirect_buffer, 0, + M * sizeof(DrawElementsIndirectCommand), indir.data()); + } + + // Visible list — sized to worst case (every instance survives). + const size_t vis_bytes = std::max(total_instances * sizeof(uint32_t), + sizeof(uint32_t)); + if (m.gpu_visible_ssbo && m.gpu_visible_capacity < vis_bytes) { + gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + m.gpu_visible_ssbo = 0; + m.gpu_visible_capacity = 0; + } + if (!m.gpu_visible_ssbo) { + gl_->glCreateBuffers(1, &m.gpu_visible_ssbo); + gl_->glNamedBufferStorage(m.gpu_visible_ssbo, vis_bytes, nullptr, + GL_DYNAMIC_STORAGE_BIT); + m.gpu_visible_capacity = vis_bytes; + } + + // Mesh-base SSBO. + const size_t mb_bytes = std::max(M * sizeof(uint32_t), sizeof(uint32_t)); + if (m.gpu_mesh_base_ssbo && m.gpu_mesh_base_capacity < mb_bytes) { + gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); + m.gpu_mesh_base_ssbo = 0; + m.gpu_mesh_base_capacity = 0; + } + if (!m.gpu_mesh_base_ssbo) { + gl_->glCreateBuffers(1, &m.gpu_mesh_base_ssbo); + gl_->glNamedBufferStorage(m.gpu_mesh_base_ssbo, mb_bytes, nullptr, + GL_DYNAMIC_STORAGE_BIT); + m.gpu_mesh_base_capacity = mb_bytes; + } + if (M > 0) { + gl_->glNamedBufferSubData(m.gpu_mesh_base_ssbo, 0, + M * sizeof(uint32_t), mesh_base.data()); + } +} + void ViewportWindow::finalizeModel(uint32_t model_id) { if (!gl_initialized_) return; context_->makeCurrent(this); @@ -880,6 +1007,7 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { buildBvhForModel(m, model_id); uploadInstanceAabbs(m); + uploadGpuCullStaticBuffers(m); m.finalized = true; have_cached_cull_ = false; @@ -933,6 +1061,9 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); if (existing->second.aabb_ssbo) gl_->glDeleteBuffers(1, &existing->second.aabb_ssbo); + if (existing->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_indirect_buffer); + if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); + if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); models_gpu_.erase(existing); } @@ -1016,6 +1147,7 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { buildBvhForModel(m, model_id); uploadInstanceAabbs(m); + uploadGpuCullStaticBuffers(m); m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); @@ -1077,6 +1209,9 @@ void ViewportWindow::resetScene() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); + if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1115,6 +1250,9 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); if (it->second.aabb_ssbo) gl_->glDeleteBuffers(1, &it->second.aabb_ssbo); + if (it->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_indirect_buffer); + if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); + if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -1757,8 +1895,15 @@ void ViewportWindow::render() { const char* e = std::getenv("IFC_CULL_THREADS"); return !(e && e[0] == '0'); }(); + // Phase 3E gate: when the GPU cull is driving rendering we skip the + // CPU cull entirely — its survivor list wouldn't be used. Declared + // here so the block below can branch on it. + static const bool gpu_cull_enabled = []{ + const char* e = std::getenv("IFC_GPU_CULL"); + return e && e[0] == '1'; + }(); QElapsedTimer cull_wall_timer; - if (cull_this_frame) { + if (cull_this_frame && !gpu_cull_enabled) { cull_wall_timer.start(); std::vector cull_targets; cull_targets.reserve(models_gpu_.size()); @@ -1785,21 +1930,14 @@ void ViewportWindow::render() { cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); } - // Phase 3E validation dispatch: frustum-only GPU cull, result compared - // against the CPU cull's visible_objects count. Gated, no draw-path - // effect. Synchronous readback is intentional — we want ground truth. - static const bool gpu_cull_enabled = []{ - const char* e = std::getenv("IFC_GPU_CULL"); - return e && e[0] == '1'; - }(); - if (gpu_cull_enabled && cull_this_frame && cull_program_) { + // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two + // tiny compute shaders per model (reset + compact), then let the draw + // loop below issue MDI from gpu_indirect_buffer. Single-bucket-per- + // mesh for now — LOD selection, reflection winding split, and HiZ + // still live only on the CPU path. Reflected instances therefore + // render with wrong winding under this gate; that's the next commit. + if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); - uint32_t zero = 0; - gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero); - - gl_->glUseProgram(cull_program_); - GLint u_planes = gl_->glGetUniformLocation(cull_program_, "u_planes"); - GLint u_count = gl_->glGetUniformLocation(cull_program_, "u_count"); float planes_flat[24]; for (int i = 0; i < 6; ++i) { planes_flat[i*4+0] = planes[i][0]; @@ -1807,30 +1945,94 @@ void ViewportWindow::render() { planes_flat[i*4+2] = planes[i][2]; planes_flat[i*4+3] = planes[i][3]; } - gl_->glUniform4fv(u_planes, 6, planes_flat); - uint32_t total_in = 0; - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_); - for (const auto& [mid, m] : models_gpu_) { + for (auto& [mid, m] : models_gpu_) { if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; + if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || + !m.gpu_mesh_base_ssbo) continue; const uint32_t n = static_cast(m.instances.size()); total_in += n; + + // Reset — zero instanceCount on all M commands. + gl_->glUseProgram(cull_reset_program_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), + m.gpu_mesh_command_count); + gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); + gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); + + // Compact — test + scatter. + gl_->glUseProgram(cull_compact_program_); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); - gl_->glUniform1ui(u_count, n); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); + gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), + 6, planes_flat); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); + gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), + camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), + focal_px); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), + min_pixel_radius); gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); } - gl_->glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); - uint32_t survivors = 0; - gl_->glGetNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(survivors), &survivors); - gpu_cull_last_survivors_ = survivors; - gpu_cull_last_input_ = total_in; - gpu_cull_ns_ += t.nsecsElapsed(); + gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); + gpu_cull_last_input_ = total_in; + gpu_cull_ns_ += t.nsecsElapsed(); gl_->glUseProgram(main_program_); } + // Perf diagnostics (confirmed 2026-04 on GTX 1650 @ 128M tris: + // draw-bound, not upload-bound — see README Phase 3): + // IFC_SKIP_MDI=1 skip the actual MDI draws (keeps cull + + // upload + binds). FPS jump == draw-bound. + // IFC_MAX_SUBDRAWS=N truncate drawcount to N per MDI. Lets + // you distinguish per-subdraw command- + // processor overhead from raw tri work. + static const bool skip_mdi = []{ + const char* e = std::getenv("IFC_SKIP_MDI"); + return e && e[0] == '1'; + }(); + static const uint32_t max_subdraws = []{ + const char* e = std::getenv("IFC_MAX_SUBDRAWS"); + return (e && *e) ? static_cast(std::atoi(e)) + : std::numeric_limits::max(); + }(); for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; + if (gpu_cull_enabled) { + // GPU path: compact shader already wrote visible indices into + // gpu_visible_ssbo at [mesh_base[i], mesh_base[i]+count) and + // set each command's instanceCount. One MDI per model, no + // fwd/rev split yet — reflected winding is wrong; step 3b. + if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || + m.gpu_mesh_command_count == 0) continue; + + gl_->glBindVertexArray(m.vao); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); + + uint32_t count = m.gpu_mesh_command_count; + if (max_subdraws < count) count = max_subdraws; + if (count > 0 && !skip_mdi) { + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(count), 0); + ++gl_draw_calls_; + } + // Stats: we don't have visible_objects / visible_triangles + // from the GPU yet (would need a readback). Report command + // count as a proxy for indirect_sub_draws_. + indirect_sub_draws_ += m.gpu_mesh_command_count; + continue; + } + if (cull_this_frame) { uploadCullResults(m); } @@ -1844,22 +2046,6 @@ void ViewportWindow::render() { uint32_t fwd = m.indirect_forward_count; uint32_t rev = m.indirect_command_count - fwd; - // Perf diagnostics (confirmed 2026-04 on GTX 1650 @ 128M tris: - // draw-bound, not upload-bound — see README Phase 3): - // IFC_SKIP_MDI=1 skip the actual MDI draws (keeps cull + - // upload + binds). FPS jump == draw-bound. - // IFC_MAX_SUBDRAWS=N truncate drawcount to N per MDI. Lets - // you distinguish per-subdraw command- - // processor overhead from raw tri work. - static const bool skip_mdi = []{ - const char* e = std::getenv("IFC_SKIP_MDI"); - return e && e[0] == '1'; - }(); - static const uint32_t max_subdraws = []{ - const char* e = std::getenv("IFC_MAX_SUBDRAWS"); - return (e && *e) ? static_cast(std::atoi(e)) - : std::numeric_limits::max(); - }(); if (max_subdraws < m.indirect_command_count) { // Keep the fwd/rev ratio so the workload mix is preserved. const uint32_t total = m.indirect_command_count; @@ -1938,6 +2124,34 @@ void ViewportWindow::render() { total_ssbo += mm.ssbo_instance_count * sizeof(InstanceGpu); } + // GPU-cull diagnostic readback: once per stats window, sum each + // model's indirect-buffer instanceCount fields so we can report + // survivors / visible objects / visible tris. Synchronous — it + // stalls the pipe — but only ~1 Hz so negligible. + if (gpu_cull_enabled) { + uint32_t gpu_surv = 0; + uint32_t gpu_obj = 0; + uint32_t gpu_tri = 0; + std::vector readback; + for (auto& [mid, mm] : models_gpu_) { + if (mm.hidden || !mm.gpu_indirect_buffer || + mm.gpu_mesh_command_count == 0) continue; + readback.resize(mm.gpu_mesh_command_count); + gl_->glGetNamedBufferSubData(mm.gpu_indirect_buffer, 0, + mm.gpu_mesh_command_count * sizeof(DrawElementsIndirectCommand), + readback.data()); + for (uint32_t i = 0; i < mm.gpu_mesh_command_count; ++i) { + const uint32_t ic = readback[i].instanceCount; + gpu_surv += ic; + gpu_obj += ic; + gpu_tri += ic * (mm.meshes[i].index_count / 3u); + } + } + gpu_cull_last_survivors_ = gpu_surv; + visible_objects_ = gpu_obj; + visible_triangles_ = gpu_tri; + } + FrameStats stats; stats.fps = last_fps_; stats.frame_time_ms = 1000.0f / last_fps_; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 45345c4b1b..3a07ae54ef 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -102,6 +102,20 @@ struct ModelGpuData { GLuint aabb_ssbo = 0; size_t aabb_ssbo_capacity = 0; // bytes + // Phase 3E GPU-cull draw buffers. Separate from the CPU path's + // visible_ssbo / indirect_buffer so the env-var gate can swap between + // them without reallocating. Built once at finalize; each frame only + // the instanceCount field of gpu_indirect_buffer is rewritten by the + // cull shader (zeroed by the reset shader, atomically incremented as + // survivors are appended into gpu_visible_ssbo at mesh_base[i] + local). + GLuint gpu_indirect_buffer = 0; + size_t gpu_indirect_capacity = 0; + GLuint gpu_visible_ssbo = 0; + size_t gpu_visible_capacity = 0; + GLuint gpu_mesh_base_ssbo = 0; + size_t gpu_mesh_base_capacity = 0; + uint32_t gpu_mesh_command_count = 0; + // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; @@ -229,6 +243,12 @@ private: // compute cull (Phase 3E, in progress). void uploadInstanceAabbs(ModelGpuData& m); + // Build the static GPU-cull draw buffers (gpu_indirect_buffer, + // gpu_visible_ssbo, gpu_mesh_base_ssbo) from m.meshes + m.instances. + // Called after uploadInstanceAabbs at finalize / applyCachedModel once + // m.meshes[].instance_count has been populated. + void uploadGpuCullStaticBuffers(ModelGpuData& m); + // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible // list, and upload both to m.indirect_buffer / m.visible_ssbo. @@ -267,11 +287,16 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Phase 3E compute cull (frustum-only, validation). Runs alongside the - // CPU cull when IFC_GPU_CULL=1; result is cross-checked against CPU's - // visible_objects count. No draw-path side effects yet. - GLuint cull_program_ = 0; - GLuint gpu_cull_counter_ssbo_ = 0; + // Phase 3E compute cull. When IFC_GPU_CULL=1, render() uses the GPU + // path exclusively: cull_reset_program_ zeros each mesh's instanceCount + // in gpu_indirect_buffer, then cull_compact_program_ runs frustum + + // contribution cull per instance and atomically appends survivors into + // gpu_visible_ssbo at mesh_base[mesh_id] + local_slot. No LOD / HiZ / + // reflection bucketing yet — reflected instances render with wrong + // winding under the gate, which is why this stays gated until the + // fwd/rev split lands (step 3b). + GLuint cull_reset_program_ = 0; + GLuint cull_compact_program_ = 0; uint32_t gpu_cull_last_survivors_ = 0; uint32_t gpu_cull_last_input_ = 0; uint64_t gpu_cull_ns_ = 0; // per-window accumulator From 069ef20c46302754c0078f680a46f61735db6bdb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 07:41:29 +1000 Subject: [PATCH 039/120] ifcviewer: GPU cull fwd/rev reflection bucketing (step 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the GPU-cull indirect buffer from M to 2M commands: the first M are the forward (non-reflected, CCW) bucket, the second M are the reverse (reflected, CW) bucket. The compact shader reads flags bit 0 from the AABB SSBO and routes each survivor to the appropriate bucket via bucket = reflected ? mesh_id + M : mesh_id. uploadGpuCullStaticBuffers() now precomputes exact per-mesh fwd/rev instance counts so each bucket reserves only the slots it needs (total visible_ssbo size unchanged — sum of fwd + rev = total). Draw loop issues two MDIs per model under IFC_GPU_CULL: first M commands CCW, next M commands CW. Sub-draws doubled (172k → 345k) which further regresses FPS due to command-processor overhead from zero-instance sub-draws — the same issue noted in 3a. MDI compaction remains the fix. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 115 ++++++++++++++++++++++--------- src/ifcviewer/ViewportWindow.h | 21 ++++-- 2 files changed, 95 insertions(+), 41 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2817123234..f5fe67ecdd 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -278,7 +278,8 @@ static const char* CULL_COMPACT_COMPUTE_SHADER = R"( #version 450 core layout(local_size_x = 64) in; // Each instance contributes two vec4 entries: (min.xyz, mesh_id_as_float), -// (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint. +// (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint; +// flags bit 0 = reflected (winding-bucket selector). layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; @@ -286,6 +287,7 @@ layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; uniform vec4 u_planes[6]; uniform uint u_count; // num instances +uniform uint u_fwd_mesh_count; // M; reflected bucket is mesh_id + M uniform vec3 u_camera_eye; uniform float u_focal_px; uniform float u_min_pixel_radius; @@ -322,9 +324,12 @@ void main() { vec3 mx = hi.xyz; if (!frustum(mn, mx)) return; if (!contribution(mn, mx)) return; - uint mesh_id = floatBitsToUint(lo.w); - uint local = atomicAdd(ind[mesh_id * 5u + 1u], 1u); - visible[mesh_base[mesh_id] + local] = gid; + uint mesh_id = floatBitsToUint(lo.w); + uint flags = floatBitsToUint(hi.w); + uint bucket = ((flags & 1u) != 0u) ? (mesh_id + u_fwd_mesh_count) + : mesh_id; + uint local = atomicAdd(ind[bucket * 5u + 1u], 1u); + visible[mesh_base[bucket] + local] = gid; } )"; @@ -913,13 +918,27 @@ void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { const uint32_t M = static_cast(m.meshes.size()); - m.gpu_mesh_command_count = M; + m.gpu_mesh_command_count = 2u * M; + m.gpu_forward_command_count = M; - // Prefix-sum instance_count to get per-mesh base offsets. Also build a - // DrawElementsIndirectCommand template per mesh (count / firstIndex / - // baseVertex / baseInstance static; instanceCount starts at 0). - std::vector mesh_base(M, 0); - std::vector indir(M); + // Count fwd / rev instances per mesh so each bucket gets a tight + // per-mesh slot range. (Sum of fwd + rev = total_instances, so the + // visible buffer is no bigger than the single-bucket version.) + std::vector fwd_n(M, 0), rev_n(M, 0); + for (size_t i = 0; i < m.instances.size(); ++i) { + const uint32_t mid = m.instances[i].mesh_id; + if (mid >= M) continue; + const bool reflected = i < m.instance_reflected.size() + && m.instance_reflected[i]; + (reflected ? rev_n[mid] : fwd_n[mid]) += 1u; + } + + // Prefix sums. mesh_base[0..M) for fwd, mesh_base[M..2M) for rev. + // Same layout for the indirect commands. baseInstance of each + // command points at its visible[] slot so the vertex shader's + // gl_BaseInstanceARB + gl_InstanceID indexes directly into it. + std::vector mesh_base(2u * M, 0); + std::vector indir(2u * M); uint32_t running = 0; for (uint32_t i = 0; i < M; ++i) { const MeshInfo& mesh = m.meshes[i]; @@ -930,12 +949,23 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; cmd.baseInstance = running; - running += mesh.instance_count; + running += fwd_n[i]; + } + for (uint32_t i = 0; i < M; ++i) { + const MeshInfo& mesh = m.meshes[i]; + mesh_base[M + i] = running; + DrawElementsIndirectCommand& cmd = indir[M + i]; + cmd.count = mesh.index_count; + cmd.instanceCount = 0; + cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = running; + running += rev_n[i]; } const uint32_t total_instances = running; - // Indirect buffer. - const size_t ind_bytes = std::max(M * sizeof(DrawElementsIndirectCommand), + // Indirect buffer — 2M commands (fwd bucket then rev bucket). + const size_t ind_bytes = std::max(2u * M * sizeof(DrawElementsIndirectCommand), sizeof(DrawElementsIndirectCommand)); if (m.gpu_indirect_buffer && m.gpu_indirect_capacity < ind_bytes) { gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); @@ -950,10 +980,10 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_indirect_buffer, 0, - M * sizeof(DrawElementsIndirectCommand), indir.data()); + 2u * M * sizeof(DrawElementsIndirectCommand), indir.data()); } - // Visible list — sized to worst case (every instance survives). + // Visible list — exact: fwd + rev per-mesh counts sum to total_instances. const size_t vis_bytes = std::max(total_instances * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_visible_ssbo && m.gpu_visible_capacity < vis_bytes) { @@ -968,8 +998,8 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { m.gpu_visible_capacity = vis_bytes; } - // Mesh-base SSBO. - const size_t mb_bytes = std::max(M * sizeof(uint32_t), sizeof(uint32_t)); + // Mesh-base SSBO — 2M entries (one per bucket). + const size_t mb_bytes = std::max(2u * M * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_mesh_base_ssbo && m.gpu_mesh_base_capacity < mb_bytes) { gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); m.gpu_mesh_base_ssbo = 0; @@ -983,7 +1013,7 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_mesh_base_ssbo, 0, - M * sizeof(uint32_t), mesh_base.data()); + 2u * M * sizeof(uint32_t), mesh_base.data()); } } @@ -1932,10 +1962,10 @@ void ViewportWindow::render() { // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two // tiny compute shaders per model (reset + compact), then let the draw - // loop below issue MDI from gpu_indirect_buffer. Single-bucket-per- - // mesh for now — LOD selection, reflection winding split, and HiZ - // still live only on the CPU path. Reflected instances therefore - // render with wrong winding under this gate; that's the next commit. + // loop below issue MDI from gpu_indirect_buffer. Commands are laid + // out as two buckets of M entries each — fwd (CCW) then rev (CW) — + // so reflected instances render with correct winding. LOD and HiZ + // still live only on the CPU path. if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); float planes_flat[24]; @@ -1970,6 +2000,8 @@ void ViewportWindow::render() { gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), 6, planes_flat); gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_fwd_mesh_count"), + m.gpu_forward_command_count); gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), @@ -2004,10 +2036,10 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (gpu_cull_enabled) { - // GPU path: compact shader already wrote visible indices into - // gpu_visible_ssbo at [mesh_base[i], mesh_base[i]+count) and - // set each command's instanceCount. One MDI per model, no - // fwd/rev split yet — reflected winding is wrong; step 3b. + // GPU path: compact shader routed survivors into fwd/rev + // buckets (commands [0..M) and [M..2M)). Two MDIs: CCW then + // CW. LOD and HiZ still CPU-only; reflected winding is now + // correct. if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || m.gpu_mesh_command_count == 0) continue; @@ -2017,18 +2049,29 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - uint32_t count = m.gpu_mesh_command_count; - if (max_subdraws < count) count = max_subdraws; - if (count > 0 && !skip_mdi) { + uint32_t fwd = m.gpu_forward_command_count; + uint32_t rev = m.gpu_mesh_command_count - fwd; + if (max_subdraws < m.gpu_mesh_command_count) { + const uint32_t total = m.gpu_mesh_command_count; + fwd = static_cast((uint64_t)fwd * max_subdraws / total); + rev = max_subdraws - fwd; + } + if (fwd > 0 && !skip_mdi) { gl_->glFrontFace(GL_CCW); gl_->glMultiDrawElementsIndirect( GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(count), 0); + static_cast(fwd), 0); ++gl_draw_calls_; } - // Stats: we don't have visible_objects / visible_triangles - // from the GPU yet (would need a readback). Report command - // count as a proxy for indirect_sub_draws_. + if (rev > 0 && !skip_mdi) { + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(m.gpu_forward_command_count * sizeof(DrawElementsIndirectCommand)), + static_cast(rev), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CCW); + } indirect_sub_draws_ += m.gpu_mesh_command_count; continue; } @@ -2140,11 +2183,15 @@ void ViewportWindow::render() { gl_->glGetNamedBufferSubData(mm.gpu_indirect_buffer, 0, mm.gpu_mesh_command_count * sizeof(DrawElementsIndirectCommand), readback.data()); + // Commands [0..M) are fwd, [M..2M) are rev for the same + // mesh — index meshes[] modulo forward_command_count. + const uint32_t M = mm.gpu_forward_command_count; for (uint32_t i = 0; i < mm.gpu_mesh_command_count; ++i) { const uint32_t ic = readback[i].instanceCount; + const uint32_t mesh_i = (M > 0) ? (i % M) : 0; gpu_surv += ic; gpu_obj += ic; - gpu_tri += ic * (mm.meshes[i].index_count / 3u); + gpu_tri += ic * (mm.meshes[mesh_i].index_count / 3u); } } gpu_cull_last_survivors_ = gpu_surv; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 3a07ae54ef..22a206971e 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -108,13 +108,20 @@ struct ModelGpuData { // the instanceCount field of gpu_indirect_buffer is rewritten by the // cull shader (zeroed by the reset shader, atomically incremented as // survivors are appended into gpu_visible_ssbo at mesh_base[i] + local). - GLuint gpu_indirect_buffer = 0; - size_t gpu_indirect_capacity = 0; - GLuint gpu_visible_ssbo = 0; - size_t gpu_visible_capacity = 0; - GLuint gpu_mesh_base_ssbo = 0; - size_t gpu_mesh_base_capacity = 0; - uint32_t gpu_mesh_command_count = 0; + // Layout per model: + // commands[0..M) fwd bucket (non-reflected, CCW winding) + // commands[M..2M) rev bucket (reflected, CW winding) + // gpu_mesh_command_count = 2M; gpu_forward_command_count = M. + // Each bucket gets its own mesh_base[] slot and its own visible[] + // range, sized to the exact per-mesh count of fwd / rev instances. + GLuint gpu_indirect_buffer = 0; + size_t gpu_indirect_capacity = 0; + GLuint gpu_visible_ssbo = 0; + size_t gpu_visible_capacity = 0; + GLuint gpu_mesh_base_ssbo = 0; + size_t gpu_mesh_base_capacity = 0; + uint32_t gpu_mesh_command_count = 0; + uint32_t gpu_forward_command_count = 0; // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. From e5ed7b53d4a601972564dedceccaf2527537e4df Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 07:58:30 +1000 Subject: [PATCH 040/120] ifcviewer: GPU LOD0/LOD1 selection in compute cull (step 3c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact shader now computes per-instance pixel radius and routes survivors to LOD1 buckets when the projected sphere falls below the LOD1 threshold (default 30 px, same as CPU path, tunable via IFC_LOD1_PX). Layout expanded from 2 to 4 buckets per mesh: [0..M) fwd_lod0 [M..2M) fwd_lod1 [2M..3M) rev_lod0 [3M..4M) rev_lod1 Two MDIs per model: CCW for [0..2M), CW for [2M..4M). Per-mesh has_lod1 flags live in a new gpu_mesh_flags_ssbo (binding 4). Contribution cull refactored: the compact shader now computes pixelRadius() once and uses it for both the min_pixel_radius rejection and LOD routing, matching the CPU path's logic. Visible-buffer worst case is 2 × total_instances (each LOD bucket reserves the full fwd/rev capacity per mesh, since LOD selection is dynamic). Tri count drops ~60% on the test dataset (53M → 22M) thanks to LOD1 decimated meshes. FPS recovers from 16 to 36 despite 690k sub_draws (4M layout). MDI compaction remains the final perf fix. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 20 +-- src/ifcviewer/ViewportWindow.cpp | 231 +++++++++++++++++++------------ src/ifcviewer/ViewportWindow.h | 15 +- 3 files changed, 166 insertions(+), 100 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index be7d7a1368..81c43b324c 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -795,14 +795,18 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) - [~] **Phase 3E — GPU-side compute-shader culling** (in progress) - [x] 3a: `IFC_GPU_CULL=1` drives rendering via compute cull (frustum + - contribution, single bucket per mesh). Correctness matches CPU - path; perf regressed — we submit one sub-draw per mesh even - when `instanceCount=0`. Fix is MDI compaction via - `glMultiDrawElementsIndirectCount`, deferred to 3a-followup so - we don't pull a GL 4.6 entrypoint loader into this commit. - - [ ] 3a-followup: compact non-empty commands, use count-buffer MDI - - [ ] 3b: fwd/rev reflection bucketing on GPU - - [ ] 3c: LOD0/LOD1 selection on GPU + contribution). Perf regressed — submits one sub-draw per mesh + even when `instanceCount=0` (CP overhead from empty commands). + - [x] 3b: fwd/rev reflection bucketing — compact shader routes by + reflected flag into CCW and CW MDI buckets. + - [x] 3c: LOD0/LOD1 selection — compact shader computes per-instance + pixel radius and routes to LOD1 bucket when below threshold. + Per-mesh `has_lod1` flags SSBO. 4 buckets per mesh (fwd/rev × + LOD0/LOD1), 4M commands total, 2 MDIs per model. - [ ] 3d: HiZ with same-frame depth pre-pass + - [ ] MDI compaction — compact non-empty commands into contiguous + buffer, use `glMultiDrawElementsIndirectCount` (GL 4.6 / + `ARB_indirect_parameters`). Deferred until all feature buckets + land so we can introduce GL 4.6 loading once, cleanly. - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index f5fe67ecdd..5688a8e810 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -280,17 +280,19 @@ layout(local_size_x = 64) in; // Each instance contributes two vec4 entries: (min.xyz, mesh_id_as_float), // (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint; // flags bit 0 = reflected (winding-bucket selector). -layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; -layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; -layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; -layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; +layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; +layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; +layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; +layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; +layout(std430, binding = 4) readonly buffer MeshFlagsBuf { uint mesh_flags[]; }; uniform vec4 u_planes[6]; -uniform uint u_count; // num instances -uniform uint u_fwd_mesh_count; // M; reflected bucket is mesh_id + M +uniform uint u_count; // num instances +uniform uint u_M; // unique meshes per model uniform vec3 u_camera_eye; uniform float u_focal_px; uniform float u_min_pixel_radius; +uniform float u_lod1_px_threshold; bool frustum(vec3 mn, vec3 mx) { for (int i = 0; i < 6; ++i) { @@ -303,16 +305,14 @@ bool frustum(vec3 mn, vec3 mx) { return true; } -bool contribution(vec3 mn, vec3 mx) { - if (u_min_pixel_radius <= 0.0) return true; - // Camera inside the AABB -> always keep (matches CPU path). +float pixelRadius(vec3 mn, vec3 mx) { if (all(greaterThanEqual(u_camera_eye, mn)) && - all(lessThanEqual (u_camera_eye, mx))) return true; + all(lessThanEqual (u_camera_eye, mx))) return 1e30; vec3 ctr = 0.5 * (mx + mn); vec3 ext = 0.5 * (mx - mn); float radius = length(ext); float dist = distance(ctr, u_camera_eye); - return u_focal_px * radius >= u_min_pixel_radius * dist; + return u_focal_px * radius / max(dist, 0.001); } void main() { @@ -322,13 +322,24 @@ void main() { vec4 hi = entries[gid * 2u + 1u]; vec3 mn = lo.xyz; vec3 mx = hi.xyz; - if (!frustum(mn, mx)) return; - if (!contribution(mn, mx)) return; + if (!frustum(mn, mx)) return; + float px_rad = pixelRadius(mn, mx); + if (px_rad < u_min_pixel_radius) return; + uint mesh_id = floatBitsToUint(lo.w); uint flags = floatBitsToUint(hi.w); - uint bucket = ((flags & 1u) != 0u) ? (mesh_id + u_fwd_mesh_count) - : mesh_id; - uint local = atomicAdd(ind[bucket * 5u + 1u], 1u); + bool reflected = (flags & 1u) != 0u; + bool want_lod1 = (mesh_flags[mesh_id] & 1u) != 0u + && u_lod1_px_threshold > 0.0 + && px_rad < u_lod1_px_threshold; + + // Bucket layout: [0..M) fwd_lod0, [M..2M) fwd_lod1, + // [2M..3M) rev_lod0, [3M..4M) rev_lod1. + uint bucket = mesh_id; + if (want_lod1) bucket += u_M; + if (reflected) bucket += 2u * u_M; + + uint local = atomicAdd(ind[bucket * 5u + 1u], 1u); visible[mesh_base[bucket] + local] = gid; } )"; @@ -511,9 +522,10 @@ ViewportWindow::~ViewportWindow() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); - if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); + if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); + if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -918,12 +930,13 @@ void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { const uint32_t M = static_cast(m.meshes.size()); - m.gpu_mesh_command_count = 2u * M; + m.gpu_mesh_command_count = 4u * M; m.gpu_forward_command_count = M; - // Count fwd / rev instances per mesh so each bucket gets a tight - // per-mesh slot range. (Sum of fwd + rev = total_instances, so the - // visible buffer is no bigger than the single-bucket version.) + // Count fwd / rev instances per mesh. LOD is dynamic (depends on + // camera distance), so each LOD bucket reserves worst-case capacity + // = the full fwd or rev count for that mesh. Total visible slots = + // 2 × total_instances (each instance only fills one bucket per frame). std::vector fwd_n(M, 0), rev_n(M, 0); for (size_t i = 0; i < m.instances.size(); ++i) { const uint32_t mid = m.instances[i].mesh_id; @@ -933,39 +946,52 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { (reflected ? rev_n[mid] : fwd_n[mid]) += 1u; } - // Prefix sums. mesh_base[0..M) for fwd, mesh_base[M..2M) for rev. - // Same layout for the indirect commands. baseInstance of each - // command points at its visible[] slot so the vertex shader's - // gl_BaseInstanceARB + gl_InstanceID indexes directly into it. - std::vector mesh_base(2u * M, 0); - std::vector indir(2u * M); - uint32_t running = 0; + // Per-mesh flags SSBO: bit 0 = has_lod1. Read by the compact shader + // to decide whether LOD1 routing is possible for a given mesh_id. + std::vector mesh_flags(M, 0); for (uint32_t i = 0; i < M; ++i) { - const MeshInfo& mesh = m.meshes[i]; - mesh_base[i] = running; - DrawElementsIndirectCommand& cmd = indir[i]; - cmd.count = mesh.index_count; - cmd.instanceCount = 0; - cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = running; - running += fwd_n[i]; + if (m.meshes[i].lod1_index_count > 0) mesh_flags[i] |= 1u; } - for (uint32_t i = 0; i < M; ++i) { - const MeshInfo& mesh = m.meshes[i]; - mesh_base[M + i] = running; - DrawElementsIndirectCommand& cmd = indir[M + i]; - cmd.count = mesh.index_count; - cmd.instanceCount = 0; - cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = running; - running += rev_n[i]; - } - const uint32_t total_instances = running; - // Indirect buffer — 2M commands (fwd bucket then rev bucket). - const size_t ind_bytes = std::max(2u * M * sizeof(DrawElementsIndirectCommand), + // Build 4M commands and 4M mesh_base entries. + // [0..M) fwd_lod0 [M..2M) fwd_lod1 + // [2M..3M) rev_lod0 [3M..4M) rev_lod1 + // Each LOD0 command uses mesh.index_count / ebo_byte_offset; + // each LOD1 command uses mesh.lod1_index_count / lod1_ebo_byte_offset + // (count=0 if mesh has no LOD1 → MDI skips automatically). + std::vector mesh_base(4u * M, 0); + std::vector indir(4u * M); + + auto fill_bucket = [&](uint32_t bucket_offset, bool use_lod1, + const std::vector& capacity, + uint32_t& running) { + for (uint32_t i = 0; i < M; ++i) { + const MeshInfo& mesh = m.meshes[i]; + const uint32_t slot = bucket_offset + i; + mesh_base[slot] = running; + DrawElementsIndirectCommand& cmd = indir[slot]; + cmd.count = use_lod1 ? mesh.lod1_index_count : mesh.index_count; + cmd.instanceCount = 0; + cmd.firstIndex = use_lod1 + ? (mesh.lod1_ebo_byte_offset / sizeof(uint32_t)) + : (mesh.ebo_byte_offset / sizeof(uint32_t)); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = running; + running += capacity[i]; + } + }; + + uint32_t running = 0; + fill_bucket(0, false, fwd_n, running); // fwd_lod0 + fill_bucket(M, true, fwd_n, running); // fwd_lod1 + fill_bucket(2u * M, false, rev_n, running); // rev_lod0 + fill_bucket(3u * M, true, rev_n, running); // rev_lod1 + const uint32_t total_slots = running; // = 2 × total_instances + + // --- GPU buffer uploads --- + + // Indirect buffer — 4M commands. + const size_t ind_bytes = std::max(4u * M * sizeof(DrawElementsIndirectCommand), sizeof(DrawElementsIndirectCommand)); if (m.gpu_indirect_buffer && m.gpu_indirect_capacity < ind_bytes) { gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); @@ -980,11 +1006,11 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_indirect_buffer, 0, - 2u * M * sizeof(DrawElementsIndirectCommand), indir.data()); + 4u * M * sizeof(DrawElementsIndirectCommand), indir.data()); } - // Visible list — exact: fwd + rev per-mesh counts sum to total_instances. - const size_t vis_bytes = std::max(total_instances * sizeof(uint32_t), + // Visible list — worst-case 2 × total_instances. + const size_t vis_bytes = std::max(total_slots * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_visible_ssbo && m.gpu_visible_capacity < vis_bytes) { gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); @@ -998,8 +1024,8 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { m.gpu_visible_capacity = vis_bytes; } - // Mesh-base SSBO — 2M entries (one per bucket). - const size_t mb_bytes = std::max(2u * M * sizeof(uint32_t), sizeof(uint32_t)); + // Mesh-base SSBO — 4M entries. + const size_t mb_bytes = std::max(4u * M * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_mesh_base_ssbo && m.gpu_mesh_base_capacity < mb_bytes) { gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); m.gpu_mesh_base_ssbo = 0; @@ -1013,7 +1039,25 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_mesh_base_ssbo, 0, - 2u * M * sizeof(uint32_t), mesh_base.data()); + 4u * M * sizeof(uint32_t), mesh_base.data()); + } + + // Mesh-flags SSBO — M entries; bit 0 = has_lod1. + const size_t mf_bytes = std::max(M * sizeof(uint32_t), sizeof(uint32_t)); + if (m.gpu_mesh_flags_ssbo && m.gpu_mesh_flags_capacity < mf_bytes) { + gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); + m.gpu_mesh_flags_ssbo = 0; + m.gpu_mesh_flags_capacity = 0; + } + if (!m.gpu_mesh_flags_ssbo) { + gl_->glCreateBuffers(1, &m.gpu_mesh_flags_ssbo); + gl_->glNamedBufferStorage(m.gpu_mesh_flags_ssbo, mf_bytes, nullptr, + GL_DYNAMIC_STORAGE_BIT); + m.gpu_mesh_flags_capacity = mf_bytes; + } + if (M > 0) { + gl_->glNamedBufferSubData(m.gpu_mesh_flags_ssbo, 0, + M * sizeof(uint32_t), mesh_flags.data()); } } @@ -1091,9 +1135,10 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); if (existing->second.aabb_ssbo) gl_->glDeleteBuffers(1, &existing->second.aabb_ssbo); - if (existing->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_indirect_buffer); - if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); - if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); + if (existing->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_indirect_buffer); + if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); + if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); + if (existing->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_flags_ssbo); models_gpu_.erase(existing); } @@ -1239,9 +1284,10 @@ void ViewportWindow::resetScene() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); - if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); + if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); + if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1280,9 +1326,10 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); if (it->second.aabb_ssbo) gl_->glDeleteBuffers(1, &it->second.aabb_ssbo); - if (it->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_indirect_buffer); - if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); - if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); + if (it->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_indirect_buffer); + if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); + if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); + if (it->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_flags_ssbo); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -1962,10 +2009,13 @@ void ViewportWindow::render() { // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two // tiny compute shaders per model (reset + compact), then let the draw - // loop below issue MDI from gpu_indirect_buffer. Commands are laid - // out as two buckets of M entries each — fwd (CCW) then rev (CW) — - // so reflected instances render with correct winding. LOD and HiZ - // still live only on the CPU path. + // loop below issue MDI from gpu_indirect_buffer. 4M commands per model: + // fwd_lod0, fwd_lod1, rev_lod0, rev_lod1. Two MDIs: CCW for [0..2M), + // CW for [2M..4M). HiZ still CPU-only. + static const float gpu_lod1_px_threshold = []{ + const char* e = std::getenv("IFC_LOD1_PX"); + return (e && *e) ? static_cast(std::atof(e)) : 30.0f; + }(); if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); float planes_flat[24]; @@ -1979,11 +2029,11 @@ void ViewportWindow::render() { for (auto& [mid, m] : models_gpu_) { if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || - !m.gpu_mesh_base_ssbo) continue; + !m.gpu_mesh_base_ssbo || !m.gpu_mesh_flags_ssbo) continue; const uint32_t n = static_cast(m.instances.size()); total_in += n; - // Reset — zero instanceCount on all M commands. + // Reset — zero instanceCount on all 4M commands. gl_->glUseProgram(cull_reset_program_); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), @@ -1991,16 +2041,17 @@ void ViewportWindow::render() { gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); - // Compact — test + scatter. + // Compact — frustum + contribution cull, LOD select, scatter. gl_->glUseProgram(cull_compact_program_); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m.gpu_mesh_flags_ssbo); gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), 6, planes_flat); gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_fwd_mesh_count"), + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_M"), m.gpu_forward_command_count); gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); @@ -2008,6 +2059,8 @@ void ViewportWindow::render() { focal_px); gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), min_pixel_radius); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_lod1_px_threshold"), + gpu_lod1_px_threshold); gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); } gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); @@ -2036,10 +2089,9 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (gpu_cull_enabled) { - // GPU path: compact shader routed survivors into fwd/rev - // buckets (commands [0..M) and [M..2M)). Two MDIs: CCW then - // CW. LOD and HiZ still CPU-only; reflected winding is now - // correct. + // GPU path: compact shader routed survivors into 4 buckets + // (fwd_lod0, fwd_lod1, rev_lod0, rev_lod1), each with M + // commands. CCW MDI for [0..2M), CW MDI for [2M..4M). if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || m.gpu_mesh_command_count == 0) continue; @@ -2049,8 +2101,9 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - uint32_t fwd = m.gpu_forward_command_count; - uint32_t rev = m.gpu_mesh_command_count - fwd; + const uint32_t M = m.gpu_forward_command_count; + uint32_t fwd = 2u * M; // fwd_lod0 + fwd_lod1 + uint32_t rev = 2u * M; // rev_lod0 + rev_lod1 if (max_subdraws < m.gpu_mesh_command_count) { const uint32_t total = m.gpu_mesh_command_count; fwd = static_cast((uint64_t)fwd * max_subdraws / total); @@ -2067,7 +2120,7 @@ void ViewportWindow::render() { gl_->glFrontFace(GL_CW); gl_->glMultiDrawElementsIndirect( GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(m.gpu_forward_command_count * sizeof(DrawElementsIndirectCommand)), + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), static_cast(rev), 0); ++gl_draw_calls_; gl_->glFrontFace(GL_CCW); @@ -2183,15 +2236,21 @@ void ViewportWindow::render() { gl_->glGetNamedBufferSubData(mm.gpu_indirect_buffer, 0, mm.gpu_mesh_command_count * sizeof(DrawElementsIndirectCommand), readback.data()); - // Commands [0..M) are fwd, [M..2M) are rev for the same - // mesh — index meshes[] modulo forward_command_count. + // Buckets: [0..M) fwd_lod0, [M..2M) fwd_lod1, + // [2M..3M) rev_lod0, [3M..4M) rev_lod1. const uint32_t M = mm.gpu_forward_command_count; for (uint32_t i = 0; i < mm.gpu_mesh_command_count; ++i) { const uint32_t ic = readback[i].instanceCount; + if (ic == 0) continue; const uint32_t mesh_i = (M > 0) ? (i % M) : 0; + const bool is_lod1 = M > 0 + && ((i / M) == 1 || (i / M) == 3); + const uint32_t idx_count = is_lod1 + ? mm.meshes[mesh_i].lod1_index_count + : mm.meshes[mesh_i].index_count; gpu_surv += ic; gpu_obj += ic; - gpu_tri += ic * (mm.meshes[mesh_i].index_count / 3u); + gpu_tri += ic * (idx_count / 3u); } } gpu_cull_last_survivors_ = gpu_surv; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 22a206971e..12338596b9 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -108,18 +108,21 @@ struct ModelGpuData { // the instanceCount field of gpu_indirect_buffer is rewritten by the // cull shader (zeroed by the reset shader, atomically incremented as // survivors are appended into gpu_visible_ssbo at mesh_base[i] + local). - // Layout per model: - // commands[0..M) fwd bucket (non-reflected, CCW winding) - // commands[M..2M) rev bucket (reflected, CW winding) - // gpu_mesh_command_count = 2M; gpu_forward_command_count = M. - // Each bucket gets its own mesh_base[] slot and its own visible[] - // range, sized to the exact per-mesh count of fwd / rev instances. + // Layout per model — 4 buckets of M commands each: + // [0..M) fwd_lod0 (non-reflected, LOD0, CCW winding) + // [M..2M) fwd_lod1 (non-reflected, LOD1, CCW winding) + // [2M..3M) rev_lod0 (reflected, LOD0, CW winding) + // [3M..4M) rev_lod1 (reflected, LOD1, CW winding) + // gpu_mesh_command_count = 4M; gpu_forward_command_count = M. + // Two MDIs: CCW for [0..2M), CW for [2M..4M). GLuint gpu_indirect_buffer = 0; size_t gpu_indirect_capacity = 0; GLuint gpu_visible_ssbo = 0; size_t gpu_visible_capacity = 0; GLuint gpu_mesh_base_ssbo = 0; size_t gpu_mesh_base_capacity = 0; + GLuint gpu_mesh_flags_ssbo = 0; + size_t gpu_mesh_flags_capacity = 0; uint32_t gpu_mesh_command_count = 0; uint32_t gpu_forward_command_count = 0; From a34c36d22e8357c126ad5a20a02f5afce5314ced Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 10:12:56 +1000 Subject: [PATCH 041/120] ifcviewer: same-frame HiZ occlusion cull on GPU (step 3d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-phase compute-cull dispatch when IFC_GPU_CULL=1: Phase 1 frustum + contribution + LOD, no HiZ → survivors Depth render survivors depth-only into half-viewport FBO Build GPU compute max-reduce depth → R32F mip pyramid Phase 2 same cull + HiZ test → final survivors Color render final survivors The compact shader's new hizOccluded() projects 8 AABB corners to screen space, picks the mip level where the covered rect fits in ≤2×2 texels, and rejects when the AABB's near-depth exceeds the pyramid's max depth. New GPU resources (per-window): hiz_gpu_fbo_ / hiz_gpu_depth_tex_ — depth-only FBO at half viewport hiz_gpu_pyramid_tex_ — R32F mipmapped pyramid hiz_gpu_copy_prog_ — compute: depth → pyramid L0 hiz_gpu_reduce_prog_ — compute: max-reduce L(n-1)→L(n) hiz_gpu_depth_prog_ — vertex + trivial fragment On a dense 18-model BIM dataset: survivors: 140k → 65k (HiZ rejects ~50%) triangles: 22M → 13M gpu_cull: 0.06ms → 22.5ms (depth pre-pass CP overhead) The depth pre-pass suffers the same empty-sub-draws CP overhead as the color pass (690k commands, most with instanceCount=0). Once MDI compaction lands, both passes will be fast. For now, net FPS is flat (savings on color ≈ cost of depth pre-pass). Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 7 +- src/ifcviewer/ViewportWindow.cpp | 313 +++++++++++++++++++++++++++---- src/ifcviewer/ViewportWindow.h | 29 ++- 3 files changed, 304 insertions(+), 45 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 81c43b324c..77d64b214d 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -803,7 +803,12 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann pixel radius and routes to LOD1 bucket when below threshold. Per-mesh `has_lod1` flags SSBO. 4 buckets per mesh (fwd/rev × LOD0/LOD1), 4M commands total, 2 MDIs per model. - - [ ] 3d: HiZ with same-frame depth pre-pass + - [x] 3d: same-frame HiZ — two-phase dispatch: phase 1 (no HiZ) + drives a depth-only pre-pass, GPU max-reduce builds HiZ + pyramid, phase 2 (with HiZ) produces final survivors. + Occlusion halves survivors on dense interiors. Depth + pre-pass has same CP overhead as color pass (690k empty + sub-draws); cost dominated by MDI command processing. - [ ] MDI compaction — compact non-empty commands into contiguous buffer, use `glMultiDrawElementsIndirectCount` (GL 4.6 / `ARB_indirect_parameters`). Deferred until all feature buckets diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 5688a8e810..1f893b0a56 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -233,6 +233,49 @@ out vec4 frag_color; void main() { frag_color = vec4(v_color, 1.0); } )"; +// Depth-only fragment shader — paired with MAIN_VERTEX_SHADER for the HiZ +// depth pre-pass. The vertex shader does the full transform; the fragment +// shader is a no-op (early-Z writes depth, we discard color via glColorMask). +static const char* DEPTH_ONLY_FRAGMENT_SHADER = R"( +#version 450 core +void main() {} +)"; + +// Copy the depth texture into pyramid level 0 (R32F). One thread per texel. +static const char* HIZ_COPY_COMPUTE_SHADER = R"( +#version 450 core +layout(local_size_x = 16, local_size_y = 16) in; +uniform sampler2D u_depth; +layout(r32f, binding = 0) writeonly uniform image2D u_dst; +uniform ivec2 u_size; +void main() { + ivec2 pos = ivec2(gl_GlobalInvocationID.xy); + if (pos.x >= u_size.x || pos.y >= u_size.y) return; + float d = texelFetch(u_depth, pos, 0).r; + imageStore(u_dst, pos, vec4(d)); +} +)"; + +// Max-reduce one mip level. Reads 2×2 texels from level N−1, writes +// max to level N. u_src is bound to level N−1 via glBindImageTexture. +static const char* HIZ_REDUCE_COMPUTE_SHADER = R"( +#version 450 core +layout(local_size_x = 16, local_size_y = 16) in; +layout(r32f, binding = 0) readonly uniform image2D u_src; +layout(r32f, binding = 1) writeonly uniform image2D u_dst; +uniform ivec2 u_dst_size; +void main() { + ivec2 dp = ivec2(gl_GlobalInvocationID.xy); + if (dp.x >= u_dst_size.x || dp.y >= u_dst_size.y) return; + ivec2 sp = dp * 2; + float d0 = imageLoad(u_src, sp).r; + float d1 = imageLoad(u_src, sp + ivec2(1,0)).r; + float d2 = imageLoad(u_src, sp + ivec2(0,1)).r; + float d3 = imageLoad(u_src, sp + ivec2(1,1)).r; + imageStore(u_dst, dp, vec4(max(max(d0,d1), max(d2,d3)))); +} +)"; + static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) { GLuint shader = gl->glCreateShader(type); gl->glShaderSource(shader, 1, &source, nullptr); @@ -294,6 +337,12 @@ uniform float u_focal_px; uniform float u_min_pixel_radius; uniform float u_lod1_px_threshold; +// HiZ occlusion — enabled in phase 2 of the two-phase dispatch. +uniform uint u_hiz_enabled; +uniform mat4 u_hiz_vp; +uniform vec2 u_hiz_size; // base pyramid dimensions +uniform sampler2D u_hiz_pyramid; + bool frustum(vec3 mn, vec3 mx) { for (int i = 0; i < 6; ++i) { vec3 pv = vec3( @@ -315,6 +364,48 @@ float pixelRadius(vec3 mn, vec3 mx) { return u_focal_px * radius / max(dist, 0.001); } +bool hizOccluded(vec3 mn, vec3 mx) { + if (u_hiz_enabled == 0u) return false; + // Project 8 AABB corners to clip space. + float near_depth = 1.0; + float sx_min = 1.0, sx_max = -1.0; + float sy_min = 1.0, sy_max = -1.0; + for (int i = 0; i < 8; ++i) { + vec3 c = vec3( + ((i & 1) != 0) ? mx.x : mn.x, + ((i & 2) != 0) ? mx.y : mn.y, + ((i & 4) != 0) ? mx.z : mn.z); + vec4 clip = u_hiz_vp * vec4(c, 1.0); + if (clip.w <= 1e-4) return false; + vec3 ndc = clip.xyz / clip.w; + near_depth = min(near_depth, ndc.z * 0.5 + 0.5); + sx_min = min(sx_min, ndc.x); + sx_max = max(sx_max, ndc.x); + sy_min = min(sy_min, ndc.y); + sy_max = max(sy_max, ndc.y); + } + sx_min = clamp(sx_min, -1.0, 1.0); + sx_max = clamp(sx_max, -1.0, 1.0); + sy_min = clamp(sy_min, -1.0, 1.0); + sy_max = clamp(sy_max, -1.0, 1.0); + vec2 uv_min = vec2(sx_min, sy_min) * 0.5 + 0.5; + vec2 uv_max = vec2(sx_max, sy_max) * 0.5 + 0.5; + // Pick mip where covered rect fits in ≤2×2 texels. + vec2 extent = (uv_max - uv_min) * u_hiz_size; + float level = ceil(log2(max(max(extent.x, extent.y), 1.0))); + int ilevel = clamp(int(level), 0, textureQueryLevels(u_hiz_pyramid) - 1); + // Sample 4 corners at chosen mip — texelFetch for exact max-reduction. + ivec2 mip_size = textureSize(u_hiz_pyramid, ilevel); + ivec2 tmin = clamp(ivec2(uv_min * vec2(mip_size)), ivec2(0), mip_size - 1); + ivec2 tmax = clamp(ivec2(uv_max * vec2(mip_size)), ivec2(0), mip_size - 1); + float hiz_max = 0.0; + hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmin.x, tmin.y), ilevel).r); + hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmax.x, tmin.y), ilevel).r); + hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmin.x, tmax.y), ilevel).r); + hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmax.x, tmax.y), ilevel).r); + return near_depth > hiz_max; +} + void main() { uint gid = gl_GlobalInvocationID.x; if (gid >= u_count) return; @@ -325,6 +416,7 @@ void main() { if (!frustum(mn, mx)) return; float px_rad = pixelRadius(mn, mx); if (px_rad < u_min_pixel_radius) return; + if (hizOccluded(mn, mx)) return; uint mesh_id = floatBitsToUint(lo.w); uint flags = floatBitsToUint(hi.w); @@ -534,6 +626,12 @@ ViewportWindow::~ViewportWindow() { if (axis_program_) gl_->glDeleteProgram(axis_program_); if (cull_reset_program_) gl_->glDeleteProgram(cull_reset_program_); if (cull_compact_program_) gl_->glDeleteProgram(cull_compact_program_); + if (hiz_gpu_depth_prog_) gl_->glDeleteProgram(hiz_gpu_depth_prog_); + if (hiz_gpu_copy_prog_) gl_->glDeleteProgram(hiz_gpu_copy_prog_); + if (hiz_gpu_reduce_prog_) gl_->glDeleteProgram(hiz_gpu_reduce_prog_); + if (hiz_gpu_fbo_) gl_->glDeleteFramebuffers(1, &hiz_gpu_fbo_); + if (hiz_gpu_depth_tex_) gl_->glDeleteTextures(1, &hiz_gpu_depth_tex_); + if (hiz_gpu_pyramid_tex_) gl_->glDeleteTextures(1, &hiz_gpu_pyramid_tex_); 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_); @@ -625,6 +723,13 @@ void ViewportWindow::buildShaders() { } cull_reset_program_ = linkComputeProgram(gl_, CULL_RESET_COMPUTE_SHADER); cull_compact_program_ = linkComputeProgram(gl_, CULL_COMPACT_COMPUTE_SHADER); + { + GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, MAIN_VERTEX_SHADER); + GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, DEPTH_ONLY_FRAGMENT_SHADER); + hiz_gpu_depth_prog_ = linkProgram(gl_, vs, fs); + } + hiz_gpu_copy_prog_ = linkComputeProgram(gl_, HIZ_COPY_COMPUTE_SHADER); + hiz_gpu_reduce_prog_ = linkComputeProgram(gl_, HIZ_REDUCE_COMPUTE_SHADER); } void ViewportWindow::buildAxisGizmo() { @@ -1898,6 +2003,42 @@ void ViewportWindow::updateCamera() { proj_matrix_.perspective(camera_fov_y_deg_, aspect, 0.1f, camera_distance_ * 10.0f); } +void ViewportWindow::ensureHizGpuResources(int vp_w, int vp_h) { + // Target: half-viewport resolution for the HiZ pyramid. + const int w = std::max(vp_w / 2, 1); + const int h = std::max(vp_h / 2, 1); + if (w == hiz_gpu_w_ && h == hiz_gpu_h_ && hiz_gpu_fbo_) return; + hiz_gpu_w_ = w; + hiz_gpu_h_ = h; + hiz_gpu_levels_ = 1 + static_cast(std::floor(std::log2( + static_cast(std::max(w, h))))); + + if (hiz_gpu_fbo_) gl_->glDeleteFramebuffers(1, &hiz_gpu_fbo_); + if (hiz_gpu_depth_tex_) gl_->glDeleteTextures(1, &hiz_gpu_depth_tex_); + if (hiz_gpu_pyramid_tex_) gl_->glDeleteTextures(1, &hiz_gpu_pyramid_tex_); + + // Depth-only FBO for the pre-pass. + gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_gpu_depth_tex_); + gl_->glTextureStorage2D(hiz_gpu_depth_tex_, 1, GL_DEPTH_COMPONENT32F, w, h); + gl_->glTextureParameteri(hiz_gpu_depth_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl_->glTextureParameteri(hiz_gpu_depth_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + + gl_->glCreateFramebuffers(1, &hiz_gpu_fbo_); + gl_->glNamedFramebufferTexture(hiz_gpu_fbo_, GL_DEPTH_ATTACHMENT, + hiz_gpu_depth_tex_, 0); + gl_->glNamedFramebufferDrawBuffer(hiz_gpu_fbo_, GL_NONE); + gl_->glNamedFramebufferReadBuffer(hiz_gpu_fbo_, GL_NONE); + + // Pyramid texture (R32F, full mip chain). + gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_gpu_pyramid_tex_); + gl_->glTextureStorage2D(hiz_gpu_pyramid_tex_, hiz_gpu_levels_, + GL_R32F, w, h); + gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); + gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); +} + void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; @@ -2007,17 +2148,22 @@ void ViewportWindow::render() { cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); } - // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two - // tiny compute shaders per model (reset + compact), then let the draw - // loop below issue MDI from gpu_indirect_buffer. 4M commands per model: - // fwd_lod0, fwd_lod1, rev_lod0, rev_lod1. Two MDIs: CCW for [0..2M), - // CW for [2M..4M). HiZ still CPU-only. + // Phase 3E: two-phase GPU cull + same-frame HiZ. + // Phase 1: frustum + contribution + LOD, no HiZ → survivors for depth pre-pass + // Depth pre-pass: render survivors depth-only into HiZ FBO + // HiZ build: max-reduce depth into pyramid mip chain + // Phase 2: same cull + HiZ test → final survivors for color pass static const float gpu_lod1_px_threshold = []{ const char* e = std::getenv("IFC_LOD1_PX"); return (e && *e) ? static_cast(std::atof(e)) : 30.0f; }(); if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); + const int dpr = devicePixelRatio(); + const int vp_w = width() * dpr; + const int vp_h = height() * dpr; + ensureHizGpuResources(vp_w, vp_h); + float planes_flat[24]; for (int i = 0; i < 6; ++i) { planes_flat[i*4+0] = planes[i][0]; @@ -2025,6 +2171,11 @@ void ViewportWindow::render() { planes_flat[i*4+2] = planes[i][2]; planes_flat[i*4+3] = planes[i][3]; } + QMatrix4x4 vp_hiz = proj_matrix_ * view_matrix_; + + // Collect models eligible for GPU cull. + struct CullTarget { uint32_t mid; ModelGpuData* m; uint32_t n; }; + std::vector targets; uint32_t total_in = 0; for (auto& [mid, m] : models_gpu_) { if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; @@ -2032,38 +2183,128 @@ void ViewportWindow::render() { !m.gpu_mesh_base_ssbo || !m.gpu_mesh_flags_ssbo) continue; const uint32_t n = static_cast(m.instances.size()); total_in += n; - - // Reset — zero instanceCount on all 4M commands. - gl_->glUseProgram(cull_reset_program_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), - m.gpu_mesh_command_count); - gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); - gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); - - // Compact — frustum + contribution cull, LOD select, scatter. - gl_->glUseProgram(cull_compact_program_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m.gpu_mesh_flags_ssbo); - gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), - 6, planes_flat); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_M"), - m.gpu_forward_command_count); - gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), - camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), - focal_px); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), - min_pixel_radius); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_lod1_px_threshold"), - gpu_lod1_px_threshold); - gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); + targets.push_back({mid, &m, n}); } - gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); + + // Helper: dispatch reset + compact for all targets. + auto dispatchCull = [&](bool hiz_enabled) { + for (auto& tgt : targets) { + ModelGpuData& m = *tgt.m; + gl_->glUseProgram(cull_reset_program_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), + m.gpu_mesh_command_count); + gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); + gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); + + gl_->glUseProgram(cull_compact_program_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m.gpu_mesh_flags_ssbo); + gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), + 6, planes_flat); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), tgt.n); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_M"), + m.gpu_forward_command_count); + gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), + camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), + focal_px); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), + min_pixel_radius); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_lod1_px_threshold"), + gpu_lod1_px_threshold); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_enabled"), + hiz_enabled ? 1u : 0u); + if (hiz_enabled) { + gl_->glUniformMatrix4fv( + gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_vp"), + 1, GL_FALSE, vp_hiz.constData()); + gl_->glUniform2f( + gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_size"), + static_cast(hiz_gpu_w_), static_cast(hiz_gpu_h_)); + gl_->glActiveTexture(GL_TEXTURE0); + gl_->glBindTexture(GL_TEXTURE_2D, hiz_gpu_pyramid_tex_); + gl_->glUniform1i( + gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_pyramid"), 0); + } + gl_->glDispatchCompute((tgt.n + 63u) / 64u, 1, 1); + } + gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); + }; + + // Phase 1: cull without HiZ. + dispatchCull(false); + + // Depth pre-pass: render phase 1 survivors into hiz_gpu_fbo_. + gl_->glBindFramebuffer(GL_FRAMEBUFFER, hiz_gpu_fbo_); + gl_->glViewport(0, 0, hiz_gpu_w_, hiz_gpu_h_); + gl_->glClear(GL_DEPTH_BUFFER_BIT); + gl_->glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + gl_->glUseProgram(hiz_gpu_depth_prog_); + GLint u_vp_depth = gl_->glGetUniformLocation(hiz_gpu_depth_prog_, "u_view_projection"); + gl_->glUniformMatrix4fv(u_vp_depth, 1, GL_FALSE, vp_hiz.constData()); + for (auto& tgt : targets) { + ModelGpuData& m = *tgt.m; + gl_->glBindVertexArray(m.vao); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); + const uint32_t M = m.gpu_forward_command_count; + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(2u * M), 0); + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + static_cast(2u * M), 0); + } + gl_->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + + // Build HiZ pyramid: copy depth → pyramid L0, then max-reduce. + gl_->glMemoryBarrier(GL_FRAMEBUFFER_BARRIER_BIT); + gl_->glUseProgram(hiz_gpu_copy_prog_); + gl_->glActiveTexture(GL_TEXTURE0); + gl_->glBindTexture(GL_TEXTURE_2D, hiz_gpu_depth_tex_); + gl_->glUniform1i(gl_->glGetUniformLocation(hiz_gpu_copy_prog_, "u_depth"), 0); + gl_->glUniform2i(gl_->glGetUniformLocation(hiz_gpu_copy_prog_, "u_size"), + hiz_gpu_w_, hiz_gpu_h_); + gl_->glBindImageTexture(0, hiz_gpu_pyramid_tex_, 0, GL_FALSE, 0, + GL_WRITE_ONLY, GL_R32F); + gl_->glDispatchCompute((hiz_gpu_w_ + 15) / 16, (hiz_gpu_h_ + 15) / 16, 1); + gl_->glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + + gl_->glUseProgram(hiz_gpu_reduce_prog_); + int sw = hiz_gpu_w_, sh = hiz_gpu_h_; + for (int lev = 1; lev < hiz_gpu_levels_; ++lev) { + int dw = std::max(sw / 2, 1); + int dh = std::max(sh / 2, 1); + gl_->glBindImageTexture(0, hiz_gpu_pyramid_tex_, lev - 1, GL_FALSE, 0, + GL_READ_ONLY, GL_R32F); + gl_->glBindImageTexture(1, hiz_gpu_pyramid_tex_, lev, GL_FALSE, 0, + GL_WRITE_ONLY, GL_R32F); + gl_->glUniform2i(gl_->glGetUniformLocation(hiz_gpu_reduce_prog_, "u_dst_size"), + dw, dh); + gl_->glDispatchCompute((dw + 15) / 16, (dh + 15) / 16, 1); + gl_->glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); + sw = dw; + sh = dh; + } + + // Restore main FBO + viewport for phase 2 and the color pass. + gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); + gl_->glViewport(0, 0, vp_w, vp_h); + + // Phase 2: cull with HiZ — overwrites indirect + visible with + // the tighter set. + gl_->glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT); + dispatchCull(true); + gpu_cull_last_input_ = total_in; gpu_cull_ns_ += t.nsecsElapsed(); gl_->glUseProgram(main_program_); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 12338596b9..4345553da5 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -258,6 +258,7 @@ private: // Called after uploadInstanceAabbs at finalize / applyCachedModel once // m.meshes[].instance_count has been populated. void uploadGpuCullStaticBuffers(ModelGpuData& m); + void ensureHizGpuResources(int vp_w, int vp_h); // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible @@ -297,16 +298,28 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Phase 3E compute cull. When IFC_GPU_CULL=1, render() uses the GPU - // path exclusively: cull_reset_program_ zeros each mesh's instanceCount - // in gpu_indirect_buffer, then cull_compact_program_ runs frustum + - // contribution cull per instance and atomically appends survivors into - // gpu_visible_ssbo at mesh_base[mesh_id] + local_slot. No LOD / HiZ / - // reflection bucketing yet — reflected instances render with wrong - // winding under the gate, which is why this stays gated until the - // fwd/rev split lands (step 3b). + // Phase 3E compute cull. Two-phase dispatch when IFC_GPU_CULL=1: + // Phase 1: frustum + contribution + LOD, no HiZ → depth pre-pass + // Phase 2: same + HiZ test → final survivors for color pass GLuint cull_reset_program_ = 0; GLuint cull_compact_program_ = 0; + + // Depth-only program for the HiZ depth pre-pass — same vertex shader + // as main_program_, trivial fragment shader. + GLuint hiz_gpu_depth_prog_ = 0; + + // GPU HiZ pyramid: depth pre-pass renders into hiz_gpu_fbo_ at + // hiz_gpu_w_ × hiz_gpu_h_; copy+reduce compute shaders build a + // max-reduction mip chain in hiz_gpu_pyramid_tex_ (R32F). + GLuint hiz_gpu_fbo_ = 0; + GLuint hiz_gpu_depth_tex_ = 0; + GLuint hiz_gpu_pyramid_tex_ = 0; + GLuint hiz_gpu_copy_prog_ = 0; + GLuint hiz_gpu_reduce_prog_ = 0; + int hiz_gpu_w_ = 0; + int hiz_gpu_h_ = 0; + int hiz_gpu_levels_ = 0; + uint32_t gpu_cull_last_survivors_ = 0; uint32_t gpu_cull_last_input_ = 0; uint64_t gpu_cull_ns_ = 0; // per-window accumulator From 4bedb40d8a6e386d0d71b339e7ce1a4a08a5ac2b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 17:44:52 +1000 Subject: [PATCH 042/120] ifcviewer: MDI compaction via glMultiDrawElementsIndirectCount Pack compute shader compacts non-empty indirect commands into contiguous fwd/rev ranges, eliminating ~690k empty sub-draws that dominated command-processor overhead. GL 4.6 entrypoint loaded via getProcAddress with ARB fallback; graceful degradation to uncompacted MDI when unavailable. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 11 +- src/ifcviewer/ViewportWindow.cpp | 222 ++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 9 ++ 3 files changed, 202 insertions(+), 40 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 77d64b214d..61d708590d 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -809,9 +809,12 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann Occlusion halves survivors on dense interiors. Depth pre-pass has same CP overhead as color pass (690k empty sub-draws); cost dominated by MDI command processing. - - [ ] MDI compaction — compact non-empty commands into contiguous - buffer, use `glMultiDrawElementsIndirectCount` (GL 4.6 / - `ARB_indirect_parameters`). Deferred until all feature buckets - land so we can introduce GL 4.6 loading once, cleanly. + - [x] MDI compaction — pack compute shader compacts non-empty + commands into contiguous fwd/rev ranges; drawn via + `glMultiDrawElementsIndirectCount` (GL 4.6 / + `ARB_indirect_parameters`, loaded via `getProcAddress`). + Eliminates ~690k empty sub-draws from both depth pre-pass + and color pass. Falls back to uncompacted MDI when the + entrypoint is unavailable. - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 1f893b0a56..07290a8f84 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -436,6 +436,42 @@ void main() { } )"; +// Pack non-empty indirect commands from the source (4M) into a compact +// buffer. Forward commands [0..2M) pack to dst[0..], reverse [2M..4M) pack +// to dst[2M..]. Two atomic counters in counts[]: [0]=fwd, [1]=rev. +static const char* CULL_PACK_COMPUTE_SHADER = R"( +#version 450 core +layout(local_size_x = 64) in; +layout(std430, binding = 0) readonly buffer SrcBuf { uint src[]; }; +layout(std430, binding = 1) writeonly buffer DstBuf { uint dst[]; }; +layout(std430, binding = 2) coherent buffer CountBuf { uint counts[]; }; +uniform uint u_total_cmds; +uniform uint u_fwd_cmds; +void main() { + uint i = gl_GlobalInvocationID.x; + if (i >= u_total_cmds) return; + uint ic = src[i * 5u + 1u]; + if (ic == 0u) return; + bool is_fwd = (i < u_fwd_cmds); + uint slot = is_fwd ? atomicAdd(counts[0], 1u) + : atomicAdd(counts[1], 1u) + u_fwd_cmds; + dst[slot * 5u + 0u] = src[i * 5u + 0u]; + dst[slot * 5u + 1u] = ic; + dst[slot * 5u + 2u] = src[i * 5u + 2u]; + dst[slot * 5u + 3u] = src[i * 5u + 3u]; + dst[slot * 5u + 4u] = src[i * 5u + 4u]; +} +)"; + +#ifndef GL_PARAMETER_BUFFER_ARB +#define GL_PARAMETER_BUFFER_ARB 0x80EE +#endif + +using PFN_glMultiDrawElementsIndirectCount = void (*)( + GLenum mode, GLenum type, const void* indirect, + GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride); +static PFN_glMultiDrawElementsIndirectCount glMultiDrawElementsIndirectCount_ = nullptr; + static GLuint linkComputeProgram(QOpenGLFunctions_4_5_Core* gl, const char* src) { GLuint cs = compileShader(gl, GL_COMPUTE_SHADER, src); GLuint prog = gl->glCreateProgram(); @@ -618,6 +654,8 @@ ViewportWindow::~ViewportWindow() { if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); + if (m.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &m.gpu_compacted_buffer); + if (m.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &m.gpu_draw_count_buffer); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -626,6 +664,7 @@ ViewportWindow::~ViewportWindow() { if (axis_program_) gl_->glDeleteProgram(axis_program_); if (cull_reset_program_) gl_->glDeleteProgram(cull_reset_program_); if (cull_compact_program_) gl_->glDeleteProgram(cull_compact_program_); + if (cull_pack_program_) gl_->glDeleteProgram(cull_pack_program_); if (hiz_gpu_depth_prog_) gl_->glDeleteProgram(hiz_gpu_depth_prog_); if (hiz_gpu_copy_prog_) gl_->glDeleteProgram(hiz_gpu_copy_prog_); if (hiz_gpu_reduce_prog_) gl_->glDeleteProgram(hiz_gpu_reduce_prog_); @@ -730,6 +769,20 @@ void ViewportWindow::buildShaders() { } hiz_gpu_copy_prog_ = linkComputeProgram(gl_, HIZ_COPY_COMPUTE_SHADER); hiz_gpu_reduce_prog_ = linkComputeProgram(gl_, HIZ_REDUCE_COMPUTE_SHADER); + cull_pack_program_ = linkComputeProgram(gl_, CULL_PACK_COMPUTE_SHADER); + + if (!glMultiDrawElementsIndirectCount_) { + glMultiDrawElementsIndirectCount_ = + reinterpret_cast( + context_->getProcAddress("glMultiDrawElementsIndirectCount")); + if (!glMultiDrawElementsIndirectCount_) { + glMultiDrawElementsIndirectCount_ = + reinterpret_cast( + context_->getProcAddress("glMultiDrawElementsIndirectCountARB")); + } + if (!glMultiDrawElementsIndirectCount_) + qWarning("glMultiDrawElementsIndirectCount not available — MDI compaction disabled"); + } } void ViewportWindow::buildAxisGizmo() { @@ -1164,6 +1217,26 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { gl_->glNamedBufferSubData(m.gpu_mesh_flags_ssbo, 0, M * sizeof(uint32_t), mesh_flags.data()); } + + // Compacted indirect buffer — same capacity as the source buffer. + if (m.gpu_compacted_buffer && m.gpu_compacted_capacity < ind_bytes) { + gl_->glDeleteBuffers(1, &m.gpu_compacted_buffer); + m.gpu_compacted_buffer = 0; + m.gpu_compacted_capacity = 0; + } + if (!m.gpu_compacted_buffer) { + gl_->glCreateBuffers(1, &m.gpu_compacted_buffer); + gl_->glNamedBufferStorage(m.gpu_compacted_buffer, ind_bytes, nullptr, + GL_DYNAMIC_STORAGE_BIT); + m.gpu_compacted_capacity = ind_bytes; + } + + // Draw-count buffer — 2 × uint32: [fwd_count, rev_count]. + if (!m.gpu_draw_count_buffer) { + gl_->glCreateBuffers(1, &m.gpu_draw_count_buffer); + gl_->glNamedBufferStorage(m.gpu_draw_count_buffer, 2 * sizeof(uint32_t), + nullptr, GL_DYNAMIC_STORAGE_BIT); + } } void ViewportWindow::finalizeModel(uint32_t model_id) { @@ -1244,6 +1317,8 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); if (existing->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_flags_ssbo); + if (existing->second.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_compacted_buffer); + if (existing->second.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_draw_count_buffer); models_gpu_.erase(existing); } @@ -1393,6 +1468,8 @@ void ViewportWindow::resetScene() { if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); + if (m.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &m.gpu_compacted_buffer); + if (m.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &m.gpu_draw_count_buffer); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1435,6 +1512,8 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); if (it->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_flags_ssbo); + if (it->second.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_compacted_buffer); + if (it->second.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_draw_count_buffer); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -2235,8 +2314,35 @@ void ViewportWindow::render() { gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); }; + const bool mdi_count_available = + glMultiDrawElementsIndirectCount_ && cull_pack_program_; + + // Pack non-empty commands into contiguous fwd / rev ranges. + auto dispatchPack = [&]() { + if (!mdi_count_available) return; + for (auto& tgt : targets) { + ModelGpuData& m = *tgt.m; + const uint32_t total_cmds = m.gpu_mesh_command_count; + const uint32_t fwd_cmds = 2u * m.gpu_forward_command_count; + const uint32_t zero[2] = {0, 0}; + gl_->glNamedBufferSubData(m.gpu_draw_count_buffer, 0, + sizeof(zero), zero); + gl_->glUseProgram(cull_pack_program_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_compacted_buffer); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_draw_count_buffer); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_pack_program_, "u_total_cmds"), + total_cmds); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_pack_program_, "u_fwd_cmds"), + fwd_cmds); + gl_->glDispatchCompute((total_cmds + 63u) / 64u, 1, 1); + } + gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); + }; + // Phase 1: cull without HiZ. dispatchCull(false); + dispatchPack(); // Depth pre-pass: render phase 1 survivors into hiz_gpu_fbo_. gl_->glBindFramebuffer(GL_FRAMEBUFFER, hiz_gpu_fbo_); @@ -2252,17 +2358,31 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); const uint32_t M = m.gpu_forward_command_count; - gl_->glFrontFace(GL_CCW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(2u * M), 0); - gl_->glFrontFace(GL_CW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - static_cast(2u * M), 0); + if (mdi_count_available && m.gpu_compacted_buffer && m.gpu_draw_count_buffer) { + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_compacted_buffer); + gl_->glBindBuffer(GL_PARAMETER_BUFFER_ARB, m.gpu_draw_count_buffer); + gl_->glFrontFace(GL_CCW); + glMultiDrawElementsIndirectCount_( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + 0, static_cast(2u * M), 0); + gl_->glFrontFace(GL_CW); + glMultiDrawElementsIndirectCount_( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + sizeof(uint32_t), static_cast(2u * M), 0); + } else { + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(2u * M), 0); + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + static_cast(2u * M), 0); + } } gl_->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); @@ -2304,6 +2424,7 @@ void ViewportWindow::render() { // the tighter set. gl_->glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT); dispatchCull(true); + dispatchPack(); gpu_cull_last_input_ = total_in; gpu_cull_ns_ += t.nsecsElapsed(); @@ -2330,9 +2451,6 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (gpu_cull_enabled) { - // GPU path: compact shader routed survivors into 4 buckets - // (fwd_lod0, fwd_lod1, rev_lod0, rev_lod1), each with M - // commands. CCW MDI for [0..2M), CW MDI for [2M..4M). if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || m.gpu_mesh_command_count == 0) continue; @@ -2340,31 +2458,51 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); const uint32_t M = m.gpu_forward_command_count; - uint32_t fwd = 2u * M; // fwd_lod0 + fwd_lod1 - uint32_t rev = 2u * M; // rev_lod0 + rev_lod1 - if (max_subdraws < m.gpu_mesh_command_count) { - const uint32_t total = m.gpu_mesh_command_count; - fwd = static_cast((uint64_t)fwd * max_subdraws / total); - rev = max_subdraws - fwd; - } - if (fwd > 0 && !skip_mdi) { - gl_->glFrontFace(GL_CCW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(fwd), 0); - ++gl_draw_calls_; - } - if (rev > 0 && !skip_mdi) { - gl_->glFrontFace(GL_CW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - static_cast(rev), 0); - ++gl_draw_calls_; - gl_->glFrontFace(GL_CCW); + if (glMultiDrawElementsIndirectCount_ && + m.gpu_compacted_buffer && m.gpu_draw_count_buffer) { + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_compacted_buffer); + gl_->glBindBuffer(GL_PARAMETER_BUFFER_ARB, m.gpu_draw_count_buffer); + if (!skip_mdi) { + gl_->glFrontFace(GL_CCW); + glMultiDrawElementsIndirectCount_( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + 0, static_cast(2u * M), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CW); + glMultiDrawElementsIndirectCount_( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + sizeof(uint32_t), static_cast(2u * M), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CCW); + } + } else { + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); + uint32_t fwd = 2u * M; + uint32_t rev = 2u * M; + if (max_subdraws < m.gpu_mesh_command_count) { + const uint32_t total = m.gpu_mesh_command_count; + fwd = static_cast((uint64_t)fwd * max_subdraws / total); + rev = max_subdraws - fwd; + } + if (fwd > 0 && !skip_mdi) { + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(fwd), 0); + ++gl_draw_calls_; + } + if (rev > 0 && !skip_mdi) { + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + static_cast(rev), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CCW); + } } indirect_sub_draws_ += m.gpu_mesh_command_count; continue; @@ -2497,6 +2635,18 @@ void ViewportWindow::render() { gpu_cull_last_survivors_ = gpu_surv; visible_objects_ = gpu_obj; visible_triangles_ = gpu_tri; + + if (glMultiDrawElementsIndirectCount_) { + uint32_t compacted_sub_draws = 0; + uint32_t counts[2]; + for (auto& [mid2, mm2] : models_gpu_) { + if (mm2.hidden || !mm2.gpu_draw_count_buffer) continue; + gl_->glGetNamedBufferSubData(mm2.gpu_draw_count_buffer, 0, + sizeof(counts), counts); + compacted_sub_draws += counts[0] + counts[1]; + } + indirect_sub_draws_ = compacted_sub_draws; + } } FrameStats stats; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 4345553da5..8dbfb616ff 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -126,6 +126,14 @@ struct ModelGpuData { uint32_t gpu_mesh_command_count = 0; uint32_t gpu_forward_command_count = 0; + // MDI compaction: non-empty commands are packed here by the pack shader. + // Same capacity as gpu_indirect_buffer; fwd at [0..2M), rev at [2M..4M). + // gpu_draw_count_buffer holds 2 × uint32: [fwd_count, rev_count], read + // by glMultiDrawElementsIndirectCount as GL_PARAMETER_BUFFER. + GLuint gpu_compacted_buffer = 0; + size_t gpu_compacted_capacity = 0; + GLuint gpu_draw_count_buffer = 0; + // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; @@ -303,6 +311,7 @@ private: // Phase 2: same + HiZ test → final survivors for color pass GLuint cull_reset_program_ = 0; GLuint cull_compact_program_ = 0; + GLuint cull_pack_program_ = 0; // Depth-only program for the HiZ depth pre-pass — same vertex shader // as main_program_, trivial fragment shader. From fc89ffeb199acd76fb584be43580a042ba61d486 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 18:34:01 +1000 Subject: [PATCH 043/120] Revert "ifcviewer: MDI compaction via glMultiDrawElementsIndirectCount" This reverts commit d5b7b87ba17c90008cf0673c838ce8431ad85e36. --- src/ifcviewer/README.md | 11 +- src/ifcviewer/ViewportWindow.cpp | 222 +++++-------------------------- src/ifcviewer/ViewportWindow.h | 9 -- 3 files changed, 40 insertions(+), 202 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 61d708590d..77d64b214d 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -809,12 +809,9 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann Occlusion halves survivors on dense interiors. Depth pre-pass has same CP overhead as color pass (690k empty sub-draws); cost dominated by MDI command processing. - - [x] MDI compaction — pack compute shader compacts non-empty - commands into contiguous fwd/rev ranges; drawn via - `glMultiDrawElementsIndirectCount` (GL 4.6 / - `ARB_indirect_parameters`, loaded via `getProcAddress`). - Eliminates ~690k empty sub-draws from both depth pre-pass - and color pass. Falls back to uncompacted MDI when the - entrypoint is unavailable. + - [ ] MDI compaction — compact non-empty commands into contiguous + buffer, use `glMultiDrawElementsIndirectCount` (GL 4.6 / + `ARB_indirect_parameters`). Deferred until all feature buckets + land so we can introduce GL 4.6 loading once, cleanly. - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 07290a8f84..1f893b0a56 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -436,42 +436,6 @@ void main() { } )"; -// Pack non-empty indirect commands from the source (4M) into a compact -// buffer. Forward commands [0..2M) pack to dst[0..], reverse [2M..4M) pack -// to dst[2M..]. Two atomic counters in counts[]: [0]=fwd, [1]=rev. -static const char* CULL_PACK_COMPUTE_SHADER = R"( -#version 450 core -layout(local_size_x = 64) in; -layout(std430, binding = 0) readonly buffer SrcBuf { uint src[]; }; -layout(std430, binding = 1) writeonly buffer DstBuf { uint dst[]; }; -layout(std430, binding = 2) coherent buffer CountBuf { uint counts[]; }; -uniform uint u_total_cmds; -uniform uint u_fwd_cmds; -void main() { - uint i = gl_GlobalInvocationID.x; - if (i >= u_total_cmds) return; - uint ic = src[i * 5u + 1u]; - if (ic == 0u) return; - bool is_fwd = (i < u_fwd_cmds); - uint slot = is_fwd ? atomicAdd(counts[0], 1u) - : atomicAdd(counts[1], 1u) + u_fwd_cmds; - dst[slot * 5u + 0u] = src[i * 5u + 0u]; - dst[slot * 5u + 1u] = ic; - dst[slot * 5u + 2u] = src[i * 5u + 2u]; - dst[slot * 5u + 3u] = src[i * 5u + 3u]; - dst[slot * 5u + 4u] = src[i * 5u + 4u]; -} -)"; - -#ifndef GL_PARAMETER_BUFFER_ARB -#define GL_PARAMETER_BUFFER_ARB 0x80EE -#endif - -using PFN_glMultiDrawElementsIndirectCount = void (*)( - GLenum mode, GLenum type, const void* indirect, - GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride); -static PFN_glMultiDrawElementsIndirectCount glMultiDrawElementsIndirectCount_ = nullptr; - static GLuint linkComputeProgram(QOpenGLFunctions_4_5_Core* gl, const char* src) { GLuint cs = compileShader(gl, GL_COMPUTE_SHADER, src); GLuint prog = gl->glCreateProgram(); @@ -654,8 +618,6 @@ ViewportWindow::~ViewportWindow() { if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); - if (m.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &m.gpu_compacted_buffer); - if (m.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &m.gpu_draw_count_buffer); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -664,7 +626,6 @@ ViewportWindow::~ViewportWindow() { if (axis_program_) gl_->glDeleteProgram(axis_program_); if (cull_reset_program_) gl_->glDeleteProgram(cull_reset_program_); if (cull_compact_program_) gl_->glDeleteProgram(cull_compact_program_); - if (cull_pack_program_) gl_->glDeleteProgram(cull_pack_program_); if (hiz_gpu_depth_prog_) gl_->glDeleteProgram(hiz_gpu_depth_prog_); if (hiz_gpu_copy_prog_) gl_->glDeleteProgram(hiz_gpu_copy_prog_); if (hiz_gpu_reduce_prog_) gl_->glDeleteProgram(hiz_gpu_reduce_prog_); @@ -769,20 +730,6 @@ void ViewportWindow::buildShaders() { } hiz_gpu_copy_prog_ = linkComputeProgram(gl_, HIZ_COPY_COMPUTE_SHADER); hiz_gpu_reduce_prog_ = linkComputeProgram(gl_, HIZ_REDUCE_COMPUTE_SHADER); - cull_pack_program_ = linkComputeProgram(gl_, CULL_PACK_COMPUTE_SHADER); - - if (!glMultiDrawElementsIndirectCount_) { - glMultiDrawElementsIndirectCount_ = - reinterpret_cast( - context_->getProcAddress("glMultiDrawElementsIndirectCount")); - if (!glMultiDrawElementsIndirectCount_) { - glMultiDrawElementsIndirectCount_ = - reinterpret_cast( - context_->getProcAddress("glMultiDrawElementsIndirectCountARB")); - } - if (!glMultiDrawElementsIndirectCount_) - qWarning("glMultiDrawElementsIndirectCount not available — MDI compaction disabled"); - } } void ViewportWindow::buildAxisGizmo() { @@ -1217,26 +1164,6 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { gl_->glNamedBufferSubData(m.gpu_mesh_flags_ssbo, 0, M * sizeof(uint32_t), mesh_flags.data()); } - - // Compacted indirect buffer — same capacity as the source buffer. - if (m.gpu_compacted_buffer && m.gpu_compacted_capacity < ind_bytes) { - gl_->glDeleteBuffers(1, &m.gpu_compacted_buffer); - m.gpu_compacted_buffer = 0; - m.gpu_compacted_capacity = 0; - } - if (!m.gpu_compacted_buffer) { - gl_->glCreateBuffers(1, &m.gpu_compacted_buffer); - gl_->glNamedBufferStorage(m.gpu_compacted_buffer, ind_bytes, nullptr, - GL_DYNAMIC_STORAGE_BIT); - m.gpu_compacted_capacity = ind_bytes; - } - - // Draw-count buffer — 2 × uint32: [fwd_count, rev_count]. - if (!m.gpu_draw_count_buffer) { - gl_->glCreateBuffers(1, &m.gpu_draw_count_buffer); - gl_->glNamedBufferStorage(m.gpu_draw_count_buffer, 2 * sizeof(uint32_t), - nullptr, GL_DYNAMIC_STORAGE_BIT); - } } void ViewportWindow::finalizeModel(uint32_t model_id) { @@ -1317,8 +1244,6 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); if (existing->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_flags_ssbo); - if (existing->second.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_compacted_buffer); - if (existing->second.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_draw_count_buffer); models_gpu_.erase(existing); } @@ -1468,8 +1393,6 @@ void ViewportWindow::resetScene() { if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); - if (m.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &m.gpu_compacted_buffer); - if (m.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &m.gpu_draw_count_buffer); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1512,8 +1435,6 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); if (it->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_flags_ssbo); - if (it->second.gpu_compacted_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_compacted_buffer); - if (it->second.gpu_draw_count_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_draw_count_buffer); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -2314,35 +2235,8 @@ void ViewportWindow::render() { gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); }; - const bool mdi_count_available = - glMultiDrawElementsIndirectCount_ && cull_pack_program_; - - // Pack non-empty commands into contiguous fwd / rev ranges. - auto dispatchPack = [&]() { - if (!mdi_count_available) return; - for (auto& tgt : targets) { - ModelGpuData& m = *tgt.m; - const uint32_t total_cmds = m.gpu_mesh_command_count; - const uint32_t fwd_cmds = 2u * m.gpu_forward_command_count; - const uint32_t zero[2] = {0, 0}; - gl_->glNamedBufferSubData(m.gpu_draw_count_buffer, 0, - sizeof(zero), zero); - gl_->glUseProgram(cull_pack_program_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_compacted_buffer); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_draw_count_buffer); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_pack_program_, "u_total_cmds"), - total_cmds); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_pack_program_, "u_fwd_cmds"), - fwd_cmds); - gl_->glDispatchCompute((total_cmds + 63u) / 64u, 1, 1); - } - gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); - }; - // Phase 1: cull without HiZ. dispatchCull(false); - dispatchPack(); // Depth pre-pass: render phase 1 survivors into hiz_gpu_fbo_. gl_->glBindFramebuffer(GL_FRAMEBUFFER, hiz_gpu_fbo_); @@ -2358,31 +2252,17 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); const uint32_t M = m.gpu_forward_command_count; - if (mdi_count_available && m.gpu_compacted_buffer && m.gpu_draw_count_buffer) { - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_compacted_buffer); - gl_->glBindBuffer(GL_PARAMETER_BUFFER_ARB, m.gpu_draw_count_buffer); - gl_->glFrontFace(GL_CCW); - glMultiDrawElementsIndirectCount_( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - 0, static_cast(2u * M), 0); - gl_->glFrontFace(GL_CW); - glMultiDrawElementsIndirectCount_( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - sizeof(uint32_t), static_cast(2u * M), 0); - } else { - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - gl_->glFrontFace(GL_CCW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(2u * M), 0); - gl_->glFrontFace(GL_CW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - static_cast(2u * M), 0); - } + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(2u * M), 0); + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + static_cast(2u * M), 0); } gl_->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); @@ -2424,7 +2304,6 @@ void ViewportWindow::render() { // the tighter set. gl_->glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT); dispatchCull(true); - dispatchPack(); gpu_cull_last_input_ = total_in; gpu_cull_ns_ += t.nsecsElapsed(); @@ -2451,6 +2330,9 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (gpu_cull_enabled) { + // GPU path: compact shader routed survivors into 4 buckets + // (fwd_lod0, fwd_lod1, rev_lod0, rev_lod1), each with M + // commands. CCW MDI for [0..2M), CW MDI for [2M..4M). if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || m.gpu_mesh_command_count == 0) continue; @@ -2458,51 +2340,31 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); + gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); const uint32_t M = m.gpu_forward_command_count; - if (glMultiDrawElementsIndirectCount_ && - m.gpu_compacted_buffer && m.gpu_draw_count_buffer) { - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_compacted_buffer); - gl_->glBindBuffer(GL_PARAMETER_BUFFER_ARB, m.gpu_draw_count_buffer); - if (!skip_mdi) { - gl_->glFrontFace(GL_CCW); - glMultiDrawElementsIndirectCount_( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - 0, static_cast(2u * M), 0); - ++gl_draw_calls_; - gl_->glFrontFace(GL_CW); - glMultiDrawElementsIndirectCount_( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - sizeof(uint32_t), static_cast(2u * M), 0); - ++gl_draw_calls_; - gl_->glFrontFace(GL_CCW); - } - } else { - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - uint32_t fwd = 2u * M; - uint32_t rev = 2u * M; - if (max_subdraws < m.gpu_mesh_command_count) { - const uint32_t total = m.gpu_mesh_command_count; - fwd = static_cast((uint64_t)fwd * max_subdraws / total); - rev = max_subdraws - fwd; - } - if (fwd > 0 && !skip_mdi) { - gl_->glFrontFace(GL_CCW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(fwd), 0); - ++gl_draw_calls_; - } - if (rev > 0 && !skip_mdi) { - gl_->glFrontFace(GL_CW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - static_cast(rev), 0); - ++gl_draw_calls_; - gl_->glFrontFace(GL_CCW); - } + uint32_t fwd = 2u * M; // fwd_lod0 + fwd_lod1 + uint32_t rev = 2u * M; // rev_lod0 + rev_lod1 + if (max_subdraws < m.gpu_mesh_command_count) { + const uint32_t total = m.gpu_mesh_command_count; + fwd = static_cast((uint64_t)fwd * max_subdraws / total); + rev = max_subdraws - fwd; + } + if (fwd > 0 && !skip_mdi) { + gl_->glFrontFace(GL_CCW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, + static_cast(fwd), 0); + ++gl_draw_calls_; + } + if (rev > 0 && !skip_mdi) { + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + static_cast(rev), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CCW); } indirect_sub_draws_ += m.gpu_mesh_command_count; continue; @@ -2635,18 +2497,6 @@ void ViewportWindow::render() { gpu_cull_last_survivors_ = gpu_surv; visible_objects_ = gpu_obj; visible_triangles_ = gpu_tri; - - if (glMultiDrawElementsIndirectCount_) { - uint32_t compacted_sub_draws = 0; - uint32_t counts[2]; - for (auto& [mid2, mm2] : models_gpu_) { - if (mm2.hidden || !mm2.gpu_draw_count_buffer) continue; - gl_->glGetNamedBufferSubData(mm2.gpu_draw_count_buffer, 0, - sizeof(counts), counts); - compacted_sub_draws += counts[0] + counts[1]; - } - indirect_sub_draws_ = compacted_sub_draws; - } } FrameStats stats; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 8dbfb616ff..4345553da5 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -126,14 +126,6 @@ struct ModelGpuData { uint32_t gpu_mesh_command_count = 0; uint32_t gpu_forward_command_count = 0; - // MDI compaction: non-empty commands are packed here by the pack shader. - // Same capacity as gpu_indirect_buffer; fwd at [0..2M), rev at [2M..4M). - // gpu_draw_count_buffer holds 2 × uint32: [fwd_count, rev_count], read - // by glMultiDrawElementsIndirectCount as GL_PARAMETER_BUFFER. - GLuint gpu_compacted_buffer = 0; - size_t gpu_compacted_capacity = 0; - GLuint gpu_draw_count_buffer = 0; - // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; @@ -311,7 +303,6 @@ private: // Phase 2: same + HiZ test → final survivors for color pass GLuint cull_reset_program_ = 0; GLuint cull_compact_program_ = 0; - GLuint cull_pack_program_ = 0; // Depth-only program for the HiZ depth pre-pass — same vertex shader // as main_program_, trivial fragment shader. From 3c5c8e44cbe9717eff49f2ceed7f19c2f60cd6cd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 18:34:01 +1000 Subject: [PATCH 044/120] Revert "ifcviewer: same-frame HiZ occlusion cull on GPU (step 3d)" This reverts commit 9a7a48944f4b62f9ca431149139eb846229f6114. --- src/ifcviewer/README.md | 7 +- src/ifcviewer/ViewportWindow.cpp | 313 ++++--------------------------- src/ifcviewer/ViewportWindow.h | 29 +-- 3 files changed, 45 insertions(+), 304 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 77d64b214d..81c43b324c 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -803,12 +803,7 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann pixel radius and routes to LOD1 bucket when below threshold. Per-mesh `has_lod1` flags SSBO. 4 buckets per mesh (fwd/rev × LOD0/LOD1), 4M commands total, 2 MDIs per model. - - [x] 3d: same-frame HiZ — two-phase dispatch: phase 1 (no HiZ) - drives a depth-only pre-pass, GPU max-reduce builds HiZ - pyramid, phase 2 (with HiZ) produces final survivors. - Occlusion halves survivors on dense interiors. Depth - pre-pass has same CP overhead as color pass (690k empty - sub-draws); cost dominated by MDI command processing. + - [ ] 3d: HiZ with same-frame depth pre-pass - [ ] MDI compaction — compact non-empty commands into contiguous buffer, use `glMultiDrawElementsIndirectCount` (GL 4.6 / `ARB_indirect_parameters`). Deferred until all feature buckets diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 1f893b0a56..5688a8e810 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -233,49 +233,6 @@ out vec4 frag_color; void main() { frag_color = vec4(v_color, 1.0); } )"; -// Depth-only fragment shader — paired with MAIN_VERTEX_SHADER for the HiZ -// depth pre-pass. The vertex shader does the full transform; the fragment -// shader is a no-op (early-Z writes depth, we discard color via glColorMask). -static const char* DEPTH_ONLY_FRAGMENT_SHADER = R"( -#version 450 core -void main() {} -)"; - -// Copy the depth texture into pyramid level 0 (R32F). One thread per texel. -static const char* HIZ_COPY_COMPUTE_SHADER = R"( -#version 450 core -layout(local_size_x = 16, local_size_y = 16) in; -uniform sampler2D u_depth; -layout(r32f, binding = 0) writeonly uniform image2D u_dst; -uniform ivec2 u_size; -void main() { - ivec2 pos = ivec2(gl_GlobalInvocationID.xy); - if (pos.x >= u_size.x || pos.y >= u_size.y) return; - float d = texelFetch(u_depth, pos, 0).r; - imageStore(u_dst, pos, vec4(d)); -} -)"; - -// Max-reduce one mip level. Reads 2×2 texels from level N−1, writes -// max to level N. u_src is bound to level N−1 via glBindImageTexture. -static const char* HIZ_REDUCE_COMPUTE_SHADER = R"( -#version 450 core -layout(local_size_x = 16, local_size_y = 16) in; -layout(r32f, binding = 0) readonly uniform image2D u_src; -layout(r32f, binding = 1) writeonly uniform image2D u_dst; -uniform ivec2 u_dst_size; -void main() { - ivec2 dp = ivec2(gl_GlobalInvocationID.xy); - if (dp.x >= u_dst_size.x || dp.y >= u_dst_size.y) return; - ivec2 sp = dp * 2; - float d0 = imageLoad(u_src, sp).r; - float d1 = imageLoad(u_src, sp + ivec2(1,0)).r; - float d2 = imageLoad(u_src, sp + ivec2(0,1)).r; - float d3 = imageLoad(u_src, sp + ivec2(1,1)).r; - imageStore(u_dst, dp, vec4(max(max(d0,d1), max(d2,d3)))); -} -)"; - static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) { GLuint shader = gl->glCreateShader(type); gl->glShaderSource(shader, 1, &source, nullptr); @@ -337,12 +294,6 @@ uniform float u_focal_px; uniform float u_min_pixel_radius; uniform float u_lod1_px_threshold; -// HiZ occlusion — enabled in phase 2 of the two-phase dispatch. -uniform uint u_hiz_enabled; -uniform mat4 u_hiz_vp; -uniform vec2 u_hiz_size; // base pyramid dimensions -uniform sampler2D u_hiz_pyramid; - bool frustum(vec3 mn, vec3 mx) { for (int i = 0; i < 6; ++i) { vec3 pv = vec3( @@ -364,48 +315,6 @@ float pixelRadius(vec3 mn, vec3 mx) { return u_focal_px * radius / max(dist, 0.001); } -bool hizOccluded(vec3 mn, vec3 mx) { - if (u_hiz_enabled == 0u) return false; - // Project 8 AABB corners to clip space. - float near_depth = 1.0; - float sx_min = 1.0, sx_max = -1.0; - float sy_min = 1.0, sy_max = -1.0; - for (int i = 0; i < 8; ++i) { - vec3 c = vec3( - ((i & 1) != 0) ? mx.x : mn.x, - ((i & 2) != 0) ? mx.y : mn.y, - ((i & 4) != 0) ? mx.z : mn.z); - vec4 clip = u_hiz_vp * vec4(c, 1.0); - if (clip.w <= 1e-4) return false; - vec3 ndc = clip.xyz / clip.w; - near_depth = min(near_depth, ndc.z * 0.5 + 0.5); - sx_min = min(sx_min, ndc.x); - sx_max = max(sx_max, ndc.x); - sy_min = min(sy_min, ndc.y); - sy_max = max(sy_max, ndc.y); - } - sx_min = clamp(sx_min, -1.0, 1.0); - sx_max = clamp(sx_max, -1.0, 1.0); - sy_min = clamp(sy_min, -1.0, 1.0); - sy_max = clamp(sy_max, -1.0, 1.0); - vec2 uv_min = vec2(sx_min, sy_min) * 0.5 + 0.5; - vec2 uv_max = vec2(sx_max, sy_max) * 0.5 + 0.5; - // Pick mip where covered rect fits in ≤2×2 texels. - vec2 extent = (uv_max - uv_min) * u_hiz_size; - float level = ceil(log2(max(max(extent.x, extent.y), 1.0))); - int ilevel = clamp(int(level), 0, textureQueryLevels(u_hiz_pyramid) - 1); - // Sample 4 corners at chosen mip — texelFetch for exact max-reduction. - ivec2 mip_size = textureSize(u_hiz_pyramid, ilevel); - ivec2 tmin = clamp(ivec2(uv_min * vec2(mip_size)), ivec2(0), mip_size - 1); - ivec2 tmax = clamp(ivec2(uv_max * vec2(mip_size)), ivec2(0), mip_size - 1); - float hiz_max = 0.0; - hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmin.x, tmin.y), ilevel).r); - hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmax.x, tmin.y), ilevel).r); - hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmin.x, tmax.y), ilevel).r); - hiz_max = max(hiz_max, texelFetch(u_hiz_pyramid, ivec2(tmax.x, tmax.y), ilevel).r); - return near_depth > hiz_max; -} - void main() { uint gid = gl_GlobalInvocationID.x; if (gid >= u_count) return; @@ -416,7 +325,6 @@ void main() { if (!frustum(mn, mx)) return; float px_rad = pixelRadius(mn, mx); if (px_rad < u_min_pixel_radius) return; - if (hizOccluded(mn, mx)) return; uint mesh_id = floatBitsToUint(lo.w); uint flags = floatBitsToUint(hi.w); @@ -626,12 +534,6 @@ ViewportWindow::~ViewportWindow() { if (axis_program_) gl_->glDeleteProgram(axis_program_); if (cull_reset_program_) gl_->glDeleteProgram(cull_reset_program_); if (cull_compact_program_) gl_->glDeleteProgram(cull_compact_program_); - if (hiz_gpu_depth_prog_) gl_->glDeleteProgram(hiz_gpu_depth_prog_); - if (hiz_gpu_copy_prog_) gl_->glDeleteProgram(hiz_gpu_copy_prog_); - if (hiz_gpu_reduce_prog_) gl_->glDeleteProgram(hiz_gpu_reduce_prog_); - if (hiz_gpu_fbo_) gl_->glDeleteFramebuffers(1, &hiz_gpu_fbo_); - if (hiz_gpu_depth_tex_) gl_->glDeleteTextures(1, &hiz_gpu_depth_tex_); - if (hiz_gpu_pyramid_tex_) gl_->glDeleteTextures(1, &hiz_gpu_pyramid_tex_); 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_); @@ -723,13 +625,6 @@ void ViewportWindow::buildShaders() { } cull_reset_program_ = linkComputeProgram(gl_, CULL_RESET_COMPUTE_SHADER); cull_compact_program_ = linkComputeProgram(gl_, CULL_COMPACT_COMPUTE_SHADER); - { - GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, MAIN_VERTEX_SHADER); - GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, DEPTH_ONLY_FRAGMENT_SHADER); - hiz_gpu_depth_prog_ = linkProgram(gl_, vs, fs); - } - hiz_gpu_copy_prog_ = linkComputeProgram(gl_, HIZ_COPY_COMPUTE_SHADER); - hiz_gpu_reduce_prog_ = linkComputeProgram(gl_, HIZ_REDUCE_COMPUTE_SHADER); } void ViewportWindow::buildAxisGizmo() { @@ -2003,42 +1898,6 @@ void ViewportWindow::updateCamera() { proj_matrix_.perspective(camera_fov_y_deg_, aspect, 0.1f, camera_distance_ * 10.0f); } -void ViewportWindow::ensureHizGpuResources(int vp_w, int vp_h) { - // Target: half-viewport resolution for the HiZ pyramid. - const int w = std::max(vp_w / 2, 1); - const int h = std::max(vp_h / 2, 1); - if (w == hiz_gpu_w_ && h == hiz_gpu_h_ && hiz_gpu_fbo_) return; - hiz_gpu_w_ = w; - hiz_gpu_h_ = h; - hiz_gpu_levels_ = 1 + static_cast(std::floor(std::log2( - static_cast(std::max(w, h))))); - - if (hiz_gpu_fbo_) gl_->glDeleteFramebuffers(1, &hiz_gpu_fbo_); - if (hiz_gpu_depth_tex_) gl_->glDeleteTextures(1, &hiz_gpu_depth_tex_); - if (hiz_gpu_pyramid_tex_) gl_->glDeleteTextures(1, &hiz_gpu_pyramid_tex_); - - // Depth-only FBO for the pre-pass. - gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_gpu_depth_tex_); - gl_->glTextureStorage2D(hiz_gpu_depth_tex_, 1, GL_DEPTH_COMPONENT32F, w, h); - gl_->glTextureParameteri(hiz_gpu_depth_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - gl_->glTextureParameteri(hiz_gpu_depth_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - - gl_->glCreateFramebuffers(1, &hiz_gpu_fbo_); - gl_->glNamedFramebufferTexture(hiz_gpu_fbo_, GL_DEPTH_ATTACHMENT, - hiz_gpu_depth_tex_, 0); - gl_->glNamedFramebufferDrawBuffer(hiz_gpu_fbo_, GL_NONE); - gl_->glNamedFramebufferReadBuffer(hiz_gpu_fbo_, GL_NONE); - - // Pyramid texture (R32F, full mip chain). - gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_gpu_pyramid_tex_); - gl_->glTextureStorage2D(hiz_gpu_pyramid_tex_, hiz_gpu_levels_, - GL_R32F, w, h); - gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); - gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - gl_->glTextureParameteri(hiz_gpu_pyramid_tex_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); -} - void ViewportWindow::render() { if (!gl_initialized_ || !isExposed()) return; @@ -2148,22 +2007,17 @@ void ViewportWindow::render() { cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); } - // Phase 3E: two-phase GPU cull + same-frame HiZ. - // Phase 1: frustum + contribution + LOD, no HiZ → survivors for depth pre-pass - // Depth pre-pass: render survivors depth-only into HiZ FBO - // HiZ build: max-reduce depth into pyramid mip chain - // Phase 2: same cull + HiZ test → final survivors for color pass + // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two + // tiny compute shaders per model (reset + compact), then let the draw + // loop below issue MDI from gpu_indirect_buffer. 4M commands per model: + // fwd_lod0, fwd_lod1, rev_lod0, rev_lod1. Two MDIs: CCW for [0..2M), + // CW for [2M..4M). HiZ still CPU-only. static const float gpu_lod1_px_threshold = []{ const char* e = std::getenv("IFC_LOD1_PX"); return (e && *e) ? static_cast(std::atof(e)) : 30.0f; }(); if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); - const int dpr = devicePixelRatio(); - const int vp_w = width() * dpr; - const int vp_h = height() * dpr; - ensureHizGpuResources(vp_w, vp_h); - float planes_flat[24]; for (int i = 0; i < 6; ++i) { planes_flat[i*4+0] = planes[i][0]; @@ -2171,11 +2025,6 @@ void ViewportWindow::render() { planes_flat[i*4+2] = planes[i][2]; planes_flat[i*4+3] = planes[i][3]; } - QMatrix4x4 vp_hiz = proj_matrix_ * view_matrix_; - - // Collect models eligible for GPU cull. - struct CullTarget { uint32_t mid; ModelGpuData* m; uint32_t n; }; - std::vector targets; uint32_t total_in = 0; for (auto& [mid, m] : models_gpu_) { if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; @@ -2183,128 +2032,38 @@ void ViewportWindow::render() { !m.gpu_mesh_base_ssbo || !m.gpu_mesh_flags_ssbo) continue; const uint32_t n = static_cast(m.instances.size()); total_in += n; - targets.push_back({mid, &m, n}); + + // Reset — zero instanceCount on all 4M commands. + gl_->glUseProgram(cull_reset_program_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), + m.gpu_mesh_command_count); + gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); + gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); + + // Compact — frustum + contribution cull, LOD select, scatter. + gl_->glUseProgram(cull_compact_program_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m.gpu_mesh_flags_ssbo); + gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), + 6, planes_flat); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_M"), + m.gpu_forward_command_count); + gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), + camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), + focal_px); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), + min_pixel_radius); + gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_lod1_px_threshold"), + gpu_lod1_px_threshold); + gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); } - - // Helper: dispatch reset + compact for all targets. - auto dispatchCull = [&](bool hiz_enabled) { - for (auto& tgt : targets) { - ModelGpuData& m = *tgt.m; - gl_->glUseProgram(cull_reset_program_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), - m.gpu_mesh_command_count); - gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); - gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); - - gl_->glUseProgram(cull_compact_program_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m.gpu_mesh_flags_ssbo); - gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), - 6, planes_flat); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), tgt.n); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_M"), - m.gpu_forward_command_count); - gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), - camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), - focal_px); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), - min_pixel_radius); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_lod1_px_threshold"), - gpu_lod1_px_threshold); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_enabled"), - hiz_enabled ? 1u : 0u); - if (hiz_enabled) { - gl_->glUniformMatrix4fv( - gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_vp"), - 1, GL_FALSE, vp_hiz.constData()); - gl_->glUniform2f( - gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_size"), - static_cast(hiz_gpu_w_), static_cast(hiz_gpu_h_)); - gl_->glActiveTexture(GL_TEXTURE0); - gl_->glBindTexture(GL_TEXTURE_2D, hiz_gpu_pyramid_tex_); - gl_->glUniform1i( - gl_->glGetUniformLocation(cull_compact_program_, "u_hiz_pyramid"), 0); - } - gl_->glDispatchCompute((tgt.n + 63u) / 64u, 1, 1); - } - gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); - }; - - // Phase 1: cull without HiZ. - dispatchCull(false); - - // Depth pre-pass: render phase 1 survivors into hiz_gpu_fbo_. - gl_->glBindFramebuffer(GL_FRAMEBUFFER, hiz_gpu_fbo_); - gl_->glViewport(0, 0, hiz_gpu_w_, hiz_gpu_h_); - gl_->glClear(GL_DEPTH_BUFFER_BIT); - gl_->glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - gl_->glUseProgram(hiz_gpu_depth_prog_); - GLint u_vp_depth = gl_->glGetUniformLocation(hiz_gpu_depth_prog_, "u_view_projection"); - gl_->glUniformMatrix4fv(u_vp_depth, 1, GL_FALSE, vp_hiz.constData()); - for (auto& tgt : targets) { - ModelGpuData& m = *tgt.m; - gl_->glBindVertexArray(m.vao); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - const uint32_t M = m.gpu_forward_command_count; - gl_->glFrontFace(GL_CCW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(2u * M), 0); - gl_->glFrontFace(GL_CW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), - static_cast(2u * M), 0); - } - gl_->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - - // Build HiZ pyramid: copy depth → pyramid L0, then max-reduce. - gl_->glMemoryBarrier(GL_FRAMEBUFFER_BARRIER_BIT); - gl_->glUseProgram(hiz_gpu_copy_prog_); - gl_->glActiveTexture(GL_TEXTURE0); - gl_->glBindTexture(GL_TEXTURE_2D, hiz_gpu_depth_tex_); - gl_->glUniform1i(gl_->glGetUniformLocation(hiz_gpu_copy_prog_, "u_depth"), 0); - gl_->glUniform2i(gl_->glGetUniformLocation(hiz_gpu_copy_prog_, "u_size"), - hiz_gpu_w_, hiz_gpu_h_); - gl_->glBindImageTexture(0, hiz_gpu_pyramid_tex_, 0, GL_FALSE, 0, - GL_WRITE_ONLY, GL_R32F); - gl_->glDispatchCompute((hiz_gpu_w_ + 15) / 16, (hiz_gpu_h_ + 15) / 16, 1); - gl_->glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); - - gl_->glUseProgram(hiz_gpu_reduce_prog_); - int sw = hiz_gpu_w_, sh = hiz_gpu_h_; - for (int lev = 1; lev < hiz_gpu_levels_; ++lev) { - int dw = std::max(sw / 2, 1); - int dh = std::max(sh / 2, 1); - gl_->glBindImageTexture(0, hiz_gpu_pyramid_tex_, lev - 1, GL_FALSE, 0, - GL_READ_ONLY, GL_R32F); - gl_->glBindImageTexture(1, hiz_gpu_pyramid_tex_, lev, GL_FALSE, 0, - GL_WRITE_ONLY, GL_R32F); - gl_->glUniform2i(gl_->glGetUniformLocation(hiz_gpu_reduce_prog_, "u_dst_size"), - dw, dh); - gl_->glDispatchCompute((dw + 15) / 16, (dh + 15) / 16, 1); - gl_->glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); - sw = dw; - sh = dh; - } - - // Restore main FBO + viewport for phase 2 and the color pass. - gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); - gl_->glViewport(0, 0, vp_w, vp_h); - - // Phase 2: cull with HiZ — overwrites indirect + visible with - // the tighter set. - gl_->glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT); - dispatchCull(true); - + gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); gpu_cull_last_input_ = total_in; gpu_cull_ns_ += t.nsecsElapsed(); gl_->glUseProgram(main_program_); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 4345553da5..12338596b9 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -258,7 +258,6 @@ private: // Called after uploadInstanceAabbs at finalize / applyCachedModel once // m.meshes[].instance_count has been populated. void uploadGpuCullStaticBuffers(ModelGpuData& m); - void ensureHizGpuResources(int vp_w, int vp_h); // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible @@ -298,28 +297,16 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Phase 3E compute cull. Two-phase dispatch when IFC_GPU_CULL=1: - // Phase 1: frustum + contribution + LOD, no HiZ → depth pre-pass - // Phase 2: same + HiZ test → final survivors for color pass + // Phase 3E compute cull. When IFC_GPU_CULL=1, render() uses the GPU + // path exclusively: cull_reset_program_ zeros each mesh's instanceCount + // in gpu_indirect_buffer, then cull_compact_program_ runs frustum + + // contribution cull per instance and atomically appends survivors into + // gpu_visible_ssbo at mesh_base[mesh_id] + local_slot. No LOD / HiZ / + // reflection bucketing yet — reflected instances render with wrong + // winding under the gate, which is why this stays gated until the + // fwd/rev split lands (step 3b). GLuint cull_reset_program_ = 0; GLuint cull_compact_program_ = 0; - - // Depth-only program for the HiZ depth pre-pass — same vertex shader - // as main_program_, trivial fragment shader. - GLuint hiz_gpu_depth_prog_ = 0; - - // GPU HiZ pyramid: depth pre-pass renders into hiz_gpu_fbo_ at - // hiz_gpu_w_ × hiz_gpu_h_; copy+reduce compute shaders build a - // max-reduction mip chain in hiz_gpu_pyramid_tex_ (R32F). - GLuint hiz_gpu_fbo_ = 0; - GLuint hiz_gpu_depth_tex_ = 0; - GLuint hiz_gpu_pyramid_tex_ = 0; - GLuint hiz_gpu_copy_prog_ = 0; - GLuint hiz_gpu_reduce_prog_ = 0; - int hiz_gpu_w_ = 0; - int hiz_gpu_h_ = 0; - int hiz_gpu_levels_ = 0; - uint32_t gpu_cull_last_survivors_ = 0; uint32_t gpu_cull_last_input_ = 0; uint64_t gpu_cull_ns_ = 0; // per-window accumulator From 643a2e1c1f32819b796c44db10bae491a4ecf7f1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 18:34:01 +1000 Subject: [PATCH 045/120] Revert "ifcviewer: GPU LOD0/LOD1 selection in compute cull (step 3c)" This reverts commit 77cac3ec170b622db6977829f66b62603266a047. --- src/ifcviewer/README.md | 20 ++- src/ifcviewer/ViewportWindow.cpp | 233 ++++++++++++------------------- src/ifcviewer/ViewportWindow.h | 15 +- 3 files changed, 101 insertions(+), 167 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 81c43b324c..be7d7a1368 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -795,18 +795,14 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) - [~] **Phase 3E — GPU-side compute-shader culling** (in progress) - [x] 3a: `IFC_GPU_CULL=1` drives rendering via compute cull (frustum + - contribution). Perf regressed — submits one sub-draw per mesh - even when `instanceCount=0` (CP overhead from empty commands). - - [x] 3b: fwd/rev reflection bucketing — compact shader routes by - reflected flag into CCW and CW MDI buckets. - - [x] 3c: LOD0/LOD1 selection — compact shader computes per-instance - pixel radius and routes to LOD1 bucket when below threshold. - Per-mesh `has_lod1` flags SSBO. 4 buckets per mesh (fwd/rev × - LOD0/LOD1), 4M commands total, 2 MDIs per model. + contribution, single bucket per mesh). Correctness matches CPU + path; perf regressed — we submit one sub-draw per mesh even + when `instanceCount=0`. Fix is MDI compaction via + `glMultiDrawElementsIndirectCount`, deferred to 3a-followup so + we don't pull a GL 4.6 entrypoint loader into this commit. + - [ ] 3a-followup: compact non-empty commands, use count-buffer MDI + - [ ] 3b: fwd/rev reflection bucketing on GPU + - [ ] 3c: LOD0/LOD1 selection on GPU - [ ] 3d: HiZ with same-frame depth pre-pass - - [ ] MDI compaction — compact non-empty commands into contiguous - buffer, use `glMultiDrawElementsIndirectCount` (GL 4.6 / - `ARB_indirect_parameters`). Deferred until all feature buckets - land so we can introduce GL 4.6 loading once, cleanly. - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 5688a8e810..f5fe67ecdd 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -280,19 +280,17 @@ layout(local_size_x = 64) in; // Each instance contributes two vec4 entries: (min.xyz, mesh_id_as_float), // (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint; // flags bit 0 = reflected (winding-bucket selector). -layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; -layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; -layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; -layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; -layout(std430, binding = 4) readonly buffer MeshFlagsBuf { uint mesh_flags[]; }; +layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; +layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; +layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; +layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; uniform vec4 u_planes[6]; -uniform uint u_count; // num instances -uniform uint u_M; // unique meshes per model +uniform uint u_count; // num instances +uniform uint u_fwd_mesh_count; // M; reflected bucket is mesh_id + M uniform vec3 u_camera_eye; uniform float u_focal_px; uniform float u_min_pixel_radius; -uniform float u_lod1_px_threshold; bool frustum(vec3 mn, vec3 mx) { for (int i = 0; i < 6; ++i) { @@ -305,14 +303,16 @@ bool frustum(vec3 mn, vec3 mx) { return true; } -float pixelRadius(vec3 mn, vec3 mx) { +bool contribution(vec3 mn, vec3 mx) { + if (u_min_pixel_radius <= 0.0) return true; + // Camera inside the AABB -> always keep (matches CPU path). if (all(greaterThanEqual(u_camera_eye, mn)) && - all(lessThanEqual (u_camera_eye, mx))) return 1e30; + all(lessThanEqual (u_camera_eye, mx))) return true; vec3 ctr = 0.5 * (mx + mn); vec3 ext = 0.5 * (mx - mn); float radius = length(ext); float dist = distance(ctr, u_camera_eye); - return u_focal_px * radius / max(dist, 0.001); + return u_focal_px * radius >= u_min_pixel_radius * dist; } void main() { @@ -322,24 +322,13 @@ void main() { vec4 hi = entries[gid * 2u + 1u]; vec3 mn = lo.xyz; vec3 mx = hi.xyz; - if (!frustum(mn, mx)) return; - float px_rad = pixelRadius(mn, mx); - if (px_rad < u_min_pixel_radius) return; - + if (!frustum(mn, mx)) return; + if (!contribution(mn, mx)) return; uint mesh_id = floatBitsToUint(lo.w); uint flags = floatBitsToUint(hi.w); - bool reflected = (flags & 1u) != 0u; - bool want_lod1 = (mesh_flags[mesh_id] & 1u) != 0u - && u_lod1_px_threshold > 0.0 - && px_rad < u_lod1_px_threshold; - - // Bucket layout: [0..M) fwd_lod0, [M..2M) fwd_lod1, - // [2M..3M) rev_lod0, [3M..4M) rev_lod1. - uint bucket = mesh_id; - if (want_lod1) bucket += u_M; - if (reflected) bucket += 2u * u_M; - - uint local = atomicAdd(ind[bucket * 5u + 1u], 1u); + uint bucket = ((flags & 1u) != 0u) ? (mesh_id + u_fwd_mesh_count) + : mesh_id; + uint local = atomicAdd(ind[bucket * 5u + 1u], 1u); visible[mesh_base[bucket] + local] = gid; } )"; @@ -522,10 +511,9 @@ ViewportWindow::~ViewportWindow() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); - if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); - if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); + if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); @@ -930,13 +918,12 @@ void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { const uint32_t M = static_cast(m.meshes.size()); - m.gpu_mesh_command_count = 4u * M; + m.gpu_mesh_command_count = 2u * M; m.gpu_forward_command_count = M; - // Count fwd / rev instances per mesh. LOD is dynamic (depends on - // camera distance), so each LOD bucket reserves worst-case capacity - // = the full fwd or rev count for that mesh. Total visible slots = - // 2 × total_instances (each instance only fills one bucket per frame). + // Count fwd / rev instances per mesh so each bucket gets a tight + // per-mesh slot range. (Sum of fwd + rev = total_instances, so the + // visible buffer is no bigger than the single-bucket version.) std::vector fwd_n(M, 0), rev_n(M, 0); for (size_t i = 0; i < m.instances.size(); ++i) { const uint32_t mid = m.instances[i].mesh_id; @@ -946,52 +933,39 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { (reflected ? rev_n[mid] : fwd_n[mid]) += 1u; } - // Per-mesh flags SSBO: bit 0 = has_lod1. Read by the compact shader - // to decide whether LOD1 routing is possible for a given mesh_id. - std::vector mesh_flags(M, 0); - for (uint32_t i = 0; i < M; ++i) { - if (m.meshes[i].lod1_index_count > 0) mesh_flags[i] |= 1u; - } - - // Build 4M commands and 4M mesh_base entries. - // [0..M) fwd_lod0 [M..2M) fwd_lod1 - // [2M..3M) rev_lod0 [3M..4M) rev_lod1 - // Each LOD0 command uses mesh.index_count / ebo_byte_offset; - // each LOD1 command uses mesh.lod1_index_count / lod1_ebo_byte_offset - // (count=0 if mesh has no LOD1 → MDI skips automatically). - std::vector mesh_base(4u * M, 0); - std::vector indir(4u * M); - - auto fill_bucket = [&](uint32_t bucket_offset, bool use_lod1, - const std::vector& capacity, - uint32_t& running) { - for (uint32_t i = 0; i < M; ++i) { - const MeshInfo& mesh = m.meshes[i]; - const uint32_t slot = bucket_offset + i; - mesh_base[slot] = running; - DrawElementsIndirectCommand& cmd = indir[slot]; - cmd.count = use_lod1 ? mesh.lod1_index_count : mesh.index_count; - cmd.instanceCount = 0; - cmd.firstIndex = use_lod1 - ? (mesh.lod1_ebo_byte_offset / sizeof(uint32_t)) - : (mesh.ebo_byte_offset / sizeof(uint32_t)); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = running; - running += capacity[i]; - } - }; - + // Prefix sums. mesh_base[0..M) for fwd, mesh_base[M..2M) for rev. + // Same layout for the indirect commands. baseInstance of each + // command points at its visible[] slot so the vertex shader's + // gl_BaseInstanceARB + gl_InstanceID indexes directly into it. + std::vector mesh_base(2u * M, 0); + std::vector indir(2u * M); uint32_t running = 0; - fill_bucket(0, false, fwd_n, running); // fwd_lod0 - fill_bucket(M, true, fwd_n, running); // fwd_lod1 - fill_bucket(2u * M, false, rev_n, running); // rev_lod0 - fill_bucket(3u * M, true, rev_n, running); // rev_lod1 - const uint32_t total_slots = running; // = 2 × total_instances + for (uint32_t i = 0; i < M; ++i) { + const MeshInfo& mesh = m.meshes[i]; + mesh_base[i] = running; + DrawElementsIndirectCommand& cmd = indir[i]; + cmd.count = mesh.index_count; + cmd.instanceCount = 0; + cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = running; + running += fwd_n[i]; + } + for (uint32_t i = 0; i < M; ++i) { + const MeshInfo& mesh = m.meshes[i]; + mesh_base[M + i] = running; + DrawElementsIndirectCommand& cmd = indir[M + i]; + cmd.count = mesh.index_count; + cmd.instanceCount = 0; + cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = running; + running += rev_n[i]; + } + const uint32_t total_instances = running; - // --- GPU buffer uploads --- - - // Indirect buffer — 4M commands. - const size_t ind_bytes = std::max(4u * M * sizeof(DrawElementsIndirectCommand), + // Indirect buffer — 2M commands (fwd bucket then rev bucket). + const size_t ind_bytes = std::max(2u * M * sizeof(DrawElementsIndirectCommand), sizeof(DrawElementsIndirectCommand)); if (m.gpu_indirect_buffer && m.gpu_indirect_capacity < ind_bytes) { gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); @@ -1006,11 +980,11 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_indirect_buffer, 0, - 4u * M * sizeof(DrawElementsIndirectCommand), indir.data()); + 2u * M * sizeof(DrawElementsIndirectCommand), indir.data()); } - // Visible list — worst-case 2 × total_instances. - const size_t vis_bytes = std::max(total_slots * sizeof(uint32_t), + // Visible list — exact: fwd + rev per-mesh counts sum to total_instances. + const size_t vis_bytes = std::max(total_instances * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_visible_ssbo && m.gpu_visible_capacity < vis_bytes) { gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); @@ -1024,8 +998,8 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { m.gpu_visible_capacity = vis_bytes; } - // Mesh-base SSBO — 4M entries. - const size_t mb_bytes = std::max(4u * M * sizeof(uint32_t), sizeof(uint32_t)); + // Mesh-base SSBO — 2M entries (one per bucket). + const size_t mb_bytes = std::max(2u * M * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_mesh_base_ssbo && m.gpu_mesh_base_capacity < mb_bytes) { gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); m.gpu_mesh_base_ssbo = 0; @@ -1039,25 +1013,7 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_mesh_base_ssbo, 0, - 4u * M * sizeof(uint32_t), mesh_base.data()); - } - - // Mesh-flags SSBO — M entries; bit 0 = has_lod1. - const size_t mf_bytes = std::max(M * sizeof(uint32_t), sizeof(uint32_t)); - if (m.gpu_mesh_flags_ssbo && m.gpu_mesh_flags_capacity < mf_bytes) { - gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); - m.gpu_mesh_flags_ssbo = 0; - m.gpu_mesh_flags_capacity = 0; - } - if (!m.gpu_mesh_flags_ssbo) { - gl_->glCreateBuffers(1, &m.gpu_mesh_flags_ssbo); - gl_->glNamedBufferStorage(m.gpu_mesh_flags_ssbo, mf_bytes, nullptr, - GL_DYNAMIC_STORAGE_BIT); - m.gpu_mesh_flags_capacity = mf_bytes; - } - if (M > 0) { - gl_->glNamedBufferSubData(m.gpu_mesh_flags_ssbo, 0, - M * sizeof(uint32_t), mesh_flags.data()); + 2u * M * sizeof(uint32_t), mesh_base.data()); } } @@ -1135,10 +1091,9 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); if (existing->second.aabb_ssbo) gl_->glDeleteBuffers(1, &existing->second.aabb_ssbo); - if (existing->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_indirect_buffer); - if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); - if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); - if (existing->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_flags_ssbo); + if (existing->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_indirect_buffer); + if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); + if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); models_gpu_.erase(existing); } @@ -1284,10 +1239,9 @@ void ViewportWindow::resetScene() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); - if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); - if (m.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_flags_ssbo); + if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); + if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); + if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1326,10 +1280,9 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); if (it->second.aabb_ssbo) gl_->glDeleteBuffers(1, &it->second.aabb_ssbo); - if (it->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_indirect_buffer); - if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); - if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); - if (it->second.gpu_mesh_flags_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_flags_ssbo); + if (it->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_indirect_buffer); + if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); + if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -2009,13 +1962,10 @@ void ViewportWindow::render() { // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two // tiny compute shaders per model (reset + compact), then let the draw - // loop below issue MDI from gpu_indirect_buffer. 4M commands per model: - // fwd_lod0, fwd_lod1, rev_lod0, rev_lod1. Two MDIs: CCW for [0..2M), - // CW for [2M..4M). HiZ still CPU-only. - static const float gpu_lod1_px_threshold = []{ - const char* e = std::getenv("IFC_LOD1_PX"); - return (e && *e) ? static_cast(std::atof(e)) : 30.0f; - }(); + // loop below issue MDI from gpu_indirect_buffer. Commands are laid + // out as two buckets of M entries each — fwd (CCW) then rev (CW) — + // so reflected instances render with correct winding. LOD and HiZ + // still live only on the CPU path. if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); float planes_flat[24]; @@ -2029,11 +1979,11 @@ void ViewportWindow::render() { for (auto& [mid, m] : models_gpu_) { if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || - !m.gpu_mesh_base_ssbo || !m.gpu_mesh_flags_ssbo) continue; + !m.gpu_mesh_base_ssbo) continue; const uint32_t n = static_cast(m.instances.size()); total_in += n; - // Reset — zero instanceCount on all 4M commands. + // Reset — zero instanceCount on all M commands. gl_->glUseProgram(cull_reset_program_); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), @@ -2041,17 +1991,16 @@ void ViewportWindow::render() { gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); - // Compact — frustum + contribution cull, LOD select, scatter. + // Compact — test + scatter. gl_->glUseProgram(cull_compact_program_); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 4, m.gpu_mesh_flags_ssbo); gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), 6, planes_flat); gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_M"), + gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_fwd_mesh_count"), m.gpu_forward_command_count); gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); @@ -2059,8 +2008,6 @@ void ViewportWindow::render() { focal_px); gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), min_pixel_radius); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_lod1_px_threshold"), - gpu_lod1_px_threshold); gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); } gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); @@ -2089,9 +2036,10 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (gpu_cull_enabled) { - // GPU path: compact shader routed survivors into 4 buckets - // (fwd_lod0, fwd_lod1, rev_lod0, rev_lod1), each with M - // commands. CCW MDI for [0..2M), CW MDI for [2M..4M). + // GPU path: compact shader routed survivors into fwd/rev + // buckets (commands [0..M) and [M..2M)). Two MDIs: CCW then + // CW. LOD and HiZ still CPU-only; reflected winding is now + // correct. if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || m.gpu_mesh_command_count == 0) continue; @@ -2101,9 +2049,8 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - const uint32_t M = m.gpu_forward_command_count; - uint32_t fwd = 2u * M; // fwd_lod0 + fwd_lod1 - uint32_t rev = 2u * M; // rev_lod0 + rev_lod1 + uint32_t fwd = m.gpu_forward_command_count; + uint32_t rev = m.gpu_mesh_command_count - fwd; if (max_subdraws < m.gpu_mesh_command_count) { const uint32_t total = m.gpu_mesh_command_count; fwd = static_cast((uint64_t)fwd * max_subdraws / total); @@ -2120,7 +2067,7 @@ void ViewportWindow::render() { gl_->glFrontFace(GL_CW); gl_->glMultiDrawElementsIndirect( GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(2u * M * sizeof(DrawElementsIndirectCommand)), + reinterpret_cast(m.gpu_forward_command_count * sizeof(DrawElementsIndirectCommand)), static_cast(rev), 0); ++gl_draw_calls_; gl_->glFrontFace(GL_CCW); @@ -2236,21 +2183,15 @@ void ViewportWindow::render() { gl_->glGetNamedBufferSubData(mm.gpu_indirect_buffer, 0, mm.gpu_mesh_command_count * sizeof(DrawElementsIndirectCommand), readback.data()); - // Buckets: [0..M) fwd_lod0, [M..2M) fwd_lod1, - // [2M..3M) rev_lod0, [3M..4M) rev_lod1. + // Commands [0..M) are fwd, [M..2M) are rev for the same + // mesh — index meshes[] modulo forward_command_count. const uint32_t M = mm.gpu_forward_command_count; for (uint32_t i = 0; i < mm.gpu_mesh_command_count; ++i) { const uint32_t ic = readback[i].instanceCount; - if (ic == 0) continue; const uint32_t mesh_i = (M > 0) ? (i % M) : 0; - const bool is_lod1 = M > 0 - && ((i / M) == 1 || (i / M) == 3); - const uint32_t idx_count = is_lod1 - ? mm.meshes[mesh_i].lod1_index_count - : mm.meshes[mesh_i].index_count; gpu_surv += ic; gpu_obj += ic; - gpu_tri += ic * (idx_count / 3u); + gpu_tri += ic * (mm.meshes[mesh_i].index_count / 3u); } } gpu_cull_last_survivors_ = gpu_surv; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 12338596b9..22a206971e 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -108,21 +108,18 @@ struct ModelGpuData { // the instanceCount field of gpu_indirect_buffer is rewritten by the // cull shader (zeroed by the reset shader, atomically incremented as // survivors are appended into gpu_visible_ssbo at mesh_base[i] + local). - // Layout per model — 4 buckets of M commands each: - // [0..M) fwd_lod0 (non-reflected, LOD0, CCW winding) - // [M..2M) fwd_lod1 (non-reflected, LOD1, CCW winding) - // [2M..3M) rev_lod0 (reflected, LOD0, CW winding) - // [3M..4M) rev_lod1 (reflected, LOD1, CW winding) - // gpu_mesh_command_count = 4M; gpu_forward_command_count = M. - // Two MDIs: CCW for [0..2M), CW for [2M..4M). + // Layout per model: + // commands[0..M) fwd bucket (non-reflected, CCW winding) + // commands[M..2M) rev bucket (reflected, CW winding) + // gpu_mesh_command_count = 2M; gpu_forward_command_count = M. + // Each bucket gets its own mesh_base[] slot and its own visible[] + // range, sized to the exact per-mesh count of fwd / rev instances. GLuint gpu_indirect_buffer = 0; size_t gpu_indirect_capacity = 0; GLuint gpu_visible_ssbo = 0; size_t gpu_visible_capacity = 0; GLuint gpu_mesh_base_ssbo = 0; size_t gpu_mesh_base_capacity = 0; - GLuint gpu_mesh_flags_ssbo = 0; - size_t gpu_mesh_flags_capacity = 0; uint32_t gpu_mesh_command_count = 0; uint32_t gpu_forward_command_count = 0; From 175efcfffe165bdb00c790d479af7d5d54e69088 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 18:34:01 +1000 Subject: [PATCH 046/120] Revert "ifcviewer: GPU cull fwd/rev reflection bucketing (step 3b)" This reverts commit 7defbe982464536e34e80aa85d2cd7eaafbb62ee. --- src/ifcviewer/ViewportWindow.cpp | 115 +++++++++---------------------- src/ifcviewer/ViewportWindow.h | 21 ++---- 2 files changed, 41 insertions(+), 95 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index f5fe67ecdd..2817123234 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -278,8 +278,7 @@ static const char* CULL_COMPACT_COMPUTE_SHADER = R"( #version 450 core layout(local_size_x = 64) in; // Each instance contributes two vec4 entries: (min.xyz, mesh_id_as_float), -// (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint; -// flags bit 0 = reflected (winding-bucket selector). +// (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint. layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; @@ -287,7 +286,6 @@ layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; uniform vec4 u_planes[6]; uniform uint u_count; // num instances -uniform uint u_fwd_mesh_count; // M; reflected bucket is mesh_id + M uniform vec3 u_camera_eye; uniform float u_focal_px; uniform float u_min_pixel_radius; @@ -324,12 +322,9 @@ void main() { vec3 mx = hi.xyz; if (!frustum(mn, mx)) return; if (!contribution(mn, mx)) return; - uint mesh_id = floatBitsToUint(lo.w); - uint flags = floatBitsToUint(hi.w); - uint bucket = ((flags & 1u) != 0u) ? (mesh_id + u_fwd_mesh_count) - : mesh_id; - uint local = atomicAdd(ind[bucket * 5u + 1u], 1u); - visible[mesh_base[bucket] + local] = gid; + uint mesh_id = floatBitsToUint(lo.w); + uint local = atomicAdd(ind[mesh_id * 5u + 1u], 1u); + visible[mesh_base[mesh_id] + local] = gid; } )"; @@ -918,27 +913,13 @@ void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { const uint32_t M = static_cast(m.meshes.size()); - m.gpu_mesh_command_count = 2u * M; - m.gpu_forward_command_count = M; + m.gpu_mesh_command_count = M; - // Count fwd / rev instances per mesh so each bucket gets a tight - // per-mesh slot range. (Sum of fwd + rev = total_instances, so the - // visible buffer is no bigger than the single-bucket version.) - std::vector fwd_n(M, 0), rev_n(M, 0); - for (size_t i = 0; i < m.instances.size(); ++i) { - const uint32_t mid = m.instances[i].mesh_id; - if (mid >= M) continue; - const bool reflected = i < m.instance_reflected.size() - && m.instance_reflected[i]; - (reflected ? rev_n[mid] : fwd_n[mid]) += 1u; - } - - // Prefix sums. mesh_base[0..M) for fwd, mesh_base[M..2M) for rev. - // Same layout for the indirect commands. baseInstance of each - // command points at its visible[] slot so the vertex shader's - // gl_BaseInstanceARB + gl_InstanceID indexes directly into it. - std::vector mesh_base(2u * M, 0); - std::vector indir(2u * M); + // Prefix-sum instance_count to get per-mesh base offsets. Also build a + // DrawElementsIndirectCommand template per mesh (count / firstIndex / + // baseVertex / baseInstance static; instanceCount starts at 0). + std::vector mesh_base(M, 0); + std::vector indir(M); uint32_t running = 0; for (uint32_t i = 0; i < M; ++i) { const MeshInfo& mesh = m.meshes[i]; @@ -949,23 +930,12 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; cmd.baseInstance = running; - running += fwd_n[i]; - } - for (uint32_t i = 0; i < M; ++i) { - const MeshInfo& mesh = m.meshes[i]; - mesh_base[M + i] = running; - DrawElementsIndirectCommand& cmd = indir[M + i]; - cmd.count = mesh.index_count; - cmd.instanceCount = 0; - cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = running; - running += rev_n[i]; + running += mesh.instance_count; } const uint32_t total_instances = running; - // Indirect buffer — 2M commands (fwd bucket then rev bucket). - const size_t ind_bytes = std::max(2u * M * sizeof(DrawElementsIndirectCommand), + // Indirect buffer. + const size_t ind_bytes = std::max(M * sizeof(DrawElementsIndirectCommand), sizeof(DrawElementsIndirectCommand)); if (m.gpu_indirect_buffer && m.gpu_indirect_capacity < ind_bytes) { gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); @@ -980,10 +950,10 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_indirect_buffer, 0, - 2u * M * sizeof(DrawElementsIndirectCommand), indir.data()); + M * sizeof(DrawElementsIndirectCommand), indir.data()); } - // Visible list — exact: fwd + rev per-mesh counts sum to total_instances. + // Visible list — sized to worst case (every instance survives). const size_t vis_bytes = std::max(total_instances * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_visible_ssbo && m.gpu_visible_capacity < vis_bytes) { @@ -998,8 +968,8 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { m.gpu_visible_capacity = vis_bytes; } - // Mesh-base SSBO — 2M entries (one per bucket). - const size_t mb_bytes = std::max(2u * M * sizeof(uint32_t), sizeof(uint32_t)); + // Mesh-base SSBO. + const size_t mb_bytes = std::max(M * sizeof(uint32_t), sizeof(uint32_t)); if (m.gpu_mesh_base_ssbo && m.gpu_mesh_base_capacity < mb_bytes) { gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); m.gpu_mesh_base_ssbo = 0; @@ -1013,7 +983,7 @@ void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { } if (M > 0) { gl_->glNamedBufferSubData(m.gpu_mesh_base_ssbo, 0, - 2u * M * sizeof(uint32_t), mesh_base.data()); + M * sizeof(uint32_t), mesh_base.data()); } } @@ -1962,10 +1932,10 @@ void ViewportWindow::render() { // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two // tiny compute shaders per model (reset + compact), then let the draw - // loop below issue MDI from gpu_indirect_buffer. Commands are laid - // out as two buckets of M entries each — fwd (CCW) then rev (CW) — - // so reflected instances render with correct winding. LOD and HiZ - // still live only on the CPU path. + // loop below issue MDI from gpu_indirect_buffer. Single-bucket-per- + // mesh for now — LOD selection, reflection winding split, and HiZ + // still live only on the CPU path. Reflected instances therefore + // render with wrong winding under this gate; that's the next commit. if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { QElapsedTimer t; t.start(); float planes_flat[24]; @@ -2000,8 +1970,6 @@ void ViewportWindow::render() { gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), 6, planes_flat); gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_fwd_mesh_count"), - m.gpu_forward_command_count); gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), @@ -2036,10 +2004,10 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; if (gpu_cull_enabled) { - // GPU path: compact shader routed survivors into fwd/rev - // buckets (commands [0..M) and [M..2M)). Two MDIs: CCW then - // CW. LOD and HiZ still CPU-only; reflected winding is now - // correct. + // GPU path: compact shader already wrote visible indices into + // gpu_visible_ssbo at [mesh_base[i], mesh_base[i]+count) and + // set each command's instanceCount. One MDI per model, no + // fwd/rev split yet — reflected winding is wrong; step 3b. if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || m.gpu_mesh_command_count == 0) continue; @@ -2049,29 +2017,18 @@ void ViewportWindow::render() { gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - uint32_t fwd = m.gpu_forward_command_count; - uint32_t rev = m.gpu_mesh_command_count - fwd; - if (max_subdraws < m.gpu_mesh_command_count) { - const uint32_t total = m.gpu_mesh_command_count; - fwd = static_cast((uint64_t)fwd * max_subdraws / total); - rev = max_subdraws - fwd; - } - if (fwd > 0 && !skip_mdi) { + uint32_t count = m.gpu_mesh_command_count; + if (max_subdraws < count) count = max_subdraws; + if (count > 0 && !skip_mdi) { gl_->glFrontFace(GL_CCW); gl_->glMultiDrawElementsIndirect( GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(fwd), 0); + static_cast(count), 0); ++gl_draw_calls_; } - if (rev > 0 && !skip_mdi) { - gl_->glFrontFace(GL_CW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, - reinterpret_cast(m.gpu_forward_command_count * sizeof(DrawElementsIndirectCommand)), - static_cast(rev), 0); - ++gl_draw_calls_; - gl_->glFrontFace(GL_CCW); - } + // Stats: we don't have visible_objects / visible_triangles + // from the GPU yet (would need a readback). Report command + // count as a proxy for indirect_sub_draws_. indirect_sub_draws_ += m.gpu_mesh_command_count; continue; } @@ -2183,15 +2140,11 @@ void ViewportWindow::render() { gl_->glGetNamedBufferSubData(mm.gpu_indirect_buffer, 0, mm.gpu_mesh_command_count * sizeof(DrawElementsIndirectCommand), readback.data()); - // Commands [0..M) are fwd, [M..2M) are rev for the same - // mesh — index meshes[] modulo forward_command_count. - const uint32_t M = mm.gpu_forward_command_count; for (uint32_t i = 0; i < mm.gpu_mesh_command_count; ++i) { const uint32_t ic = readback[i].instanceCount; - const uint32_t mesh_i = (M > 0) ? (i % M) : 0; gpu_surv += ic; gpu_obj += ic; - gpu_tri += ic * (mm.meshes[mesh_i].index_count / 3u); + gpu_tri += ic * (mm.meshes[i].index_count / 3u); } } gpu_cull_last_survivors_ = gpu_surv; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 22a206971e..3a07ae54ef 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -108,20 +108,13 @@ struct ModelGpuData { // the instanceCount field of gpu_indirect_buffer is rewritten by the // cull shader (zeroed by the reset shader, atomically incremented as // survivors are appended into gpu_visible_ssbo at mesh_base[i] + local). - // Layout per model: - // commands[0..M) fwd bucket (non-reflected, CCW winding) - // commands[M..2M) rev bucket (reflected, CW winding) - // gpu_mesh_command_count = 2M; gpu_forward_command_count = M. - // Each bucket gets its own mesh_base[] slot and its own visible[] - // range, sized to the exact per-mesh count of fwd / rev instances. - GLuint gpu_indirect_buffer = 0; - size_t gpu_indirect_capacity = 0; - GLuint gpu_visible_ssbo = 0; - size_t gpu_visible_capacity = 0; - GLuint gpu_mesh_base_ssbo = 0; - size_t gpu_mesh_base_capacity = 0; - uint32_t gpu_mesh_command_count = 0; - uint32_t gpu_forward_command_count = 0; + GLuint gpu_indirect_buffer = 0; + size_t gpu_indirect_capacity = 0; + GLuint gpu_visible_ssbo = 0; + size_t gpu_visible_capacity = 0; + GLuint gpu_mesh_base_ssbo = 0; + size_t gpu_mesh_base_capacity = 0; + uint32_t gpu_mesh_command_count = 0; // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. From 9aae8f0329f043de768fc247da19de726dfaa94c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 18:34:01 +1000 Subject: [PATCH 047/120] Revert "ifcviewer: GPU cull drives rendering under IFC_GPU_CULL=1" This reverts commit 4fe32b54105ca2c5c00290603db17164837211e1. --- src/ifcviewer/README.md | 12 +- src/ifcviewer/ViewportWindow.cpp | 348 ++++++------------------------- src/ifcviewer/ViewportWindow.h | 35 +--- 3 files changed, 73 insertions(+), 322 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index be7d7a1368..77ffefc40e 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -793,16 +793,6 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann - [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) -- [~] **Phase 3E — GPU-side compute-shader culling** (in progress) - - [x] 3a: `IFC_GPU_CULL=1` drives rendering via compute cull (frustum + - contribution, single bucket per mesh). Correctness matches CPU - path; perf regressed — we submit one sub-draw per mesh even - when `instanceCount=0`. Fix is MDI compaction via - `glMultiDrawElementsIndirectCount`, deferred to 3a-followup so - we don't pull a GL 4.6 entrypoint loader into this commit. - - [ ] 3a-followup: compact non-empty commands, use count-buffer MDI - - [ ] 3b: fwd/rev reflection bucketing on GPU - - [ ] 3c: LOD0/LOD1 selection on GPU - - [ ] 3d: HiZ with same-frame depth pre-pass +- [ ] **Phase 3E — GPU-side compute-shader culling** (next; replaces the HiZ readback) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2817123234..2778c173e7 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -247,84 +247,34 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch return shader; } -// Phase 3E compute cull. Two tiny shaders, dispatched per model per frame -// when IFC_GPU_CULL=1: -// -// RESET — zero the instanceCount field of each DrawElementsIndirectCommand -// in gpu_indirect_buffer. One thread per mesh command. -// -// COMPACT — for each instance, test frustum + contribution; if it survives, -// atomicAdd on ind[mesh_id].instanceCount to claim a local slot, then write -// the instance index into visible_ssbo[mesh_base[mesh_id] + local_slot]. -// The baseInstance / firstIndex / count fields are static — filled at -// finalize and left alone here. -// -// `ind[]` is addressed as uint[] because DrawElementsIndirectCommand is 5 -// uints (count, instanceCount, firstIndex, baseVertex, baseInstance) and -// we only need to touch index 1 per command. -static const char* CULL_RESET_COMPUTE_SHADER = R"( +// Phase 3E compute cull (frustum-only, validation). Reads a model's +// per-instance AABB SSBO, tests against 6 planes, atomicAdds on a global +// counter. No visible list / indirect writeout yet; result is cross-checked +// against the CPU cull's visible_objects count to prove plumbing is correct +// before we hand the GPU the full emit responsibility. Gated by IFC_GPU_CULL=1. +static const char* CULL_COMPUTE_SHADER = R"( #version 450 core layout(local_size_x = 64) in; -layout(std430, binding = 0) buffer IndirectBuf { uint ind[]; }; -uniform uint u_mesh_count; +// Each instance contributes two vec4 entries: (min.xyz, meshid_as_float), +// (max.xyz, flags_as_float). We ignore the w components here — they'll be +// needed once the shader also emits the per-mesh / fwd-rev buckets. +layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; +layout(std430, binding = 1) coherent buffer CountBuf { uint counter; }; +uniform vec4 u_planes[6]; +uniform uint u_count; void main() { - uint mi = gl_GlobalInvocationID.x; - if (mi >= u_mesh_count) return; - ind[mi * 5u + 1u] = 0u; -} -)"; - -static const char* CULL_COMPACT_COMPUTE_SHADER = R"( -#version 450 core -layout(local_size_x = 64) in; -// Each instance contributes two vec4 entries: (min.xyz, mesh_id_as_float), -// (max.xyz, flags_as_float). mesh_id is packed via floatBitsToUint. -layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; -layout(std430, binding = 1) coherent buffer IndirectBuf { uint ind[]; }; -layout(std430, binding = 2) writeonly buffer VisibleBuf { uint visible[]; }; -layout(std430, binding = 3) readonly buffer MeshBaseBuf { uint mesh_base[]; }; - -uniform vec4 u_planes[6]; -uniform uint u_count; // num instances -uniform vec3 u_camera_eye; -uniform float u_focal_px; -uniform float u_min_pixel_radius; - -bool frustum(vec3 mn, vec3 mx) { + uint gid = gl_GlobalInvocationID.x; + if (gid >= u_count) return; + vec3 mn = entries[gid * 2u].xyz; + vec3 mx = entries[gid * 2u + 1u].xyz; for (int i = 0; i < 6; ++i) { vec3 pv = vec3( u_planes[i].x >= 0.0 ? mx.x : mn.x, u_planes[i].y >= 0.0 ? mx.y : mn.y, u_planes[i].z >= 0.0 ? mx.z : mn.z); - if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return false; + if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return; } - return true; -} - -bool contribution(vec3 mn, vec3 mx) { - if (u_min_pixel_radius <= 0.0) return true; - // Camera inside the AABB -> always keep (matches CPU path). - if (all(greaterThanEqual(u_camera_eye, mn)) && - all(lessThanEqual (u_camera_eye, mx))) return true; - vec3 ctr = 0.5 * (mx + mn); - vec3 ext = 0.5 * (mx - mn); - float radius = length(ext); - float dist = distance(ctr, u_camera_eye); - return u_focal_px * radius >= u_min_pixel_radius * dist; -} - -void main() { - uint gid = gl_GlobalInvocationID.x; - if (gid >= u_count) return; - vec4 lo = entries[gid * 2u]; - vec4 hi = entries[gid * 2u + 1u]; - vec3 mn = lo.xyz; - vec3 mx = hi.xyz; - if (!frustum(mn, mx)) return; - if (!contribution(mn, mx)) return; - uint mesh_id = floatBitsToUint(lo.w); - uint local = atomicAdd(ind[mesh_id * 5u + 1u], 1u); - visible[mesh_base[mesh_id] + local] = gid; + atomicAdd(counter, 1u); } )"; @@ -506,17 +456,14 @@ ViewportWindow::~ViewportWindow() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); - if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); } 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 (cull_reset_program_) gl_->glDeleteProgram(cull_reset_program_); - if (cull_compact_program_) gl_->glDeleteProgram(cull_compact_program_); + if (cull_program_) gl_->glDeleteProgram(cull_program_); + if (gpu_cull_counter_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_counter_ssbo_); 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_); @@ -606,8 +553,10 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER); axis_program_ = linkProgram(gl_, vs, fs); } - cull_reset_program_ = linkComputeProgram(gl_, CULL_RESET_COMPUTE_SHADER); - cull_compact_program_ = linkComputeProgram(gl_, CULL_COMPACT_COMPUTE_SHADER); + cull_program_ = linkComputeProgram(gl_, CULL_COMPUTE_SHADER); + gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_); + gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr, + GL_DYNAMIC_STORAGE_BIT); } void ViewportWindow::buildAxisGizmo() { @@ -911,82 +860,6 @@ void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { gl_->glNamedBufferSubData(m.aabb_ssbo, 0, bytes, packed.data()); } -void ViewportWindow::uploadGpuCullStaticBuffers(ModelGpuData& m) { - const uint32_t M = static_cast(m.meshes.size()); - m.gpu_mesh_command_count = M; - - // Prefix-sum instance_count to get per-mesh base offsets. Also build a - // DrawElementsIndirectCommand template per mesh (count / firstIndex / - // baseVertex / baseInstance static; instanceCount starts at 0). - std::vector mesh_base(M, 0); - std::vector indir(M); - uint32_t running = 0; - for (uint32_t i = 0; i < M; ++i) { - const MeshInfo& mesh = m.meshes[i]; - mesh_base[i] = running; - DrawElementsIndirectCommand& cmd = indir[i]; - cmd.count = mesh.index_count; - cmd.instanceCount = 0; - cmd.firstIndex = mesh.ebo_byte_offset / sizeof(uint32_t); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = running; - running += mesh.instance_count; - } - const uint32_t total_instances = running; - - // Indirect buffer. - const size_t ind_bytes = std::max(M * sizeof(DrawElementsIndirectCommand), - sizeof(DrawElementsIndirectCommand)); - if (m.gpu_indirect_buffer && m.gpu_indirect_capacity < ind_bytes) { - gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - m.gpu_indirect_buffer = 0; - m.gpu_indirect_capacity = 0; - } - if (!m.gpu_indirect_buffer) { - gl_->glCreateBuffers(1, &m.gpu_indirect_buffer); - gl_->glNamedBufferStorage(m.gpu_indirect_buffer, ind_bytes, nullptr, - GL_DYNAMIC_STORAGE_BIT); - m.gpu_indirect_capacity = ind_bytes; - } - if (M > 0) { - gl_->glNamedBufferSubData(m.gpu_indirect_buffer, 0, - M * sizeof(DrawElementsIndirectCommand), indir.data()); - } - - // Visible list — sized to worst case (every instance survives). - const size_t vis_bytes = std::max(total_instances * sizeof(uint32_t), - sizeof(uint32_t)); - if (m.gpu_visible_ssbo && m.gpu_visible_capacity < vis_bytes) { - gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - m.gpu_visible_ssbo = 0; - m.gpu_visible_capacity = 0; - } - if (!m.gpu_visible_ssbo) { - gl_->glCreateBuffers(1, &m.gpu_visible_ssbo); - gl_->glNamedBufferStorage(m.gpu_visible_ssbo, vis_bytes, nullptr, - GL_DYNAMIC_STORAGE_BIT); - m.gpu_visible_capacity = vis_bytes; - } - - // Mesh-base SSBO. - const size_t mb_bytes = std::max(M * sizeof(uint32_t), sizeof(uint32_t)); - if (m.gpu_mesh_base_ssbo && m.gpu_mesh_base_capacity < mb_bytes) { - gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); - m.gpu_mesh_base_ssbo = 0; - m.gpu_mesh_base_capacity = 0; - } - if (!m.gpu_mesh_base_ssbo) { - gl_->glCreateBuffers(1, &m.gpu_mesh_base_ssbo); - gl_->glNamedBufferStorage(m.gpu_mesh_base_ssbo, mb_bytes, nullptr, - GL_DYNAMIC_STORAGE_BIT); - m.gpu_mesh_base_capacity = mb_bytes; - } - if (M > 0) { - gl_->glNamedBufferSubData(m.gpu_mesh_base_ssbo, 0, - M * sizeof(uint32_t), mesh_base.data()); - } -} - void ViewportWindow::finalizeModel(uint32_t model_id) { if (!gl_initialized_) return; context_->makeCurrent(this); @@ -1007,7 +880,6 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { buildBvhForModel(m, model_id); uploadInstanceAabbs(m); - uploadGpuCullStaticBuffers(m); m.finalized = true; have_cached_cull_ = false; @@ -1061,9 +933,6 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); if (existing->second.aabb_ssbo) gl_->glDeleteBuffers(1, &existing->second.aabb_ssbo); - if (existing->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.gpu_indirect_buffer); - if (existing->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_visible_ssbo); - if (existing->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &existing->second.gpu_mesh_base_ssbo); models_gpu_.erase(existing); } @@ -1147,7 +1016,6 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { buildBvhForModel(m, model_id); uploadInstanceAabbs(m); - uploadGpuCullStaticBuffers(m); m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); @@ -1209,9 +1077,6 @@ void ViewportWindow::resetScene() { if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); - if (m.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &m.gpu_indirect_buffer); - if (m.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &m.gpu_visible_ssbo); - if (m.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &m.gpu_mesh_base_ssbo); } models_gpu_.clear(); selected_object_id_ = 0; @@ -1250,9 +1115,6 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); if (it->second.aabb_ssbo) gl_->glDeleteBuffers(1, &it->second.aabb_ssbo); - if (it->second.gpu_indirect_buffer) gl_->glDeleteBuffers(1, &it->second.gpu_indirect_buffer); - if (it->second.gpu_visible_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_visible_ssbo); - if (it->second.gpu_mesh_base_ssbo) gl_->glDeleteBuffers(1, &it->second.gpu_mesh_base_ssbo); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -1895,15 +1757,8 @@ void ViewportWindow::render() { const char* e = std::getenv("IFC_CULL_THREADS"); return !(e && e[0] == '0'); }(); - // Phase 3E gate: when the GPU cull is driving rendering we skip the - // CPU cull entirely — its survivor list wouldn't be used. Declared - // here so the block below can branch on it. - static const bool gpu_cull_enabled = []{ - const char* e = std::getenv("IFC_GPU_CULL"); - return e && e[0] == '1'; - }(); QElapsedTimer cull_wall_timer; - if (cull_this_frame && !gpu_cull_enabled) { + if (cull_this_frame) { cull_wall_timer.start(); std::vector cull_targets; cull_targets.reserve(models_gpu_.size()); @@ -1930,14 +1785,21 @@ void ViewportWindow::render() { cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); } - // Phase 3E: the GPU-cull path. When IFC_GPU_CULL=1 we dispatch two - // tiny compute shaders per model (reset + compact), then let the draw - // loop below issue MDI from gpu_indirect_buffer. Single-bucket-per- - // mesh for now — LOD selection, reflection winding split, and HiZ - // still live only on the CPU path. Reflected instances therefore - // render with wrong winding under this gate; that's the next commit. - if (gpu_cull_enabled && cull_this_frame && cull_compact_program_) { + // Phase 3E validation dispatch: frustum-only GPU cull, result compared + // against the CPU cull's visible_objects count. Gated, no draw-path + // effect. Synchronous readback is intentional — we want ground truth. + static const bool gpu_cull_enabled = []{ + const char* e = std::getenv("IFC_GPU_CULL"); + return e && e[0] == '1'; + }(); + if (gpu_cull_enabled && cull_this_frame && cull_program_) { QElapsedTimer t; t.start(); + uint32_t zero = 0; + gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero); + + gl_->glUseProgram(cull_program_); + GLint u_planes = gl_->glGetUniformLocation(cull_program_, "u_planes"); + GLint u_count = gl_->glGetUniformLocation(cull_program_, "u_count"); float planes_flat[24]; for (int i = 0; i < 6; ++i) { planes_flat[i*4+0] = planes[i][0]; @@ -1945,94 +1807,30 @@ void ViewportWindow::render() { planes_flat[i*4+2] = planes[i][2]; planes_flat[i*4+3] = planes[i][3]; } + gl_->glUniform4fv(u_planes, 6, planes_flat); + uint32_t total_in = 0; - for (auto& [mid, m] : models_gpu_) { + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_); + for (const auto& [mid, m] : models_gpu_) { if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; - if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || - !m.gpu_mesh_base_ssbo) continue; const uint32_t n = static_cast(m.instances.size()); total_in += n; - - // Reset — zero instanceCount on all M commands. - gl_->glUseProgram(cull_reset_program_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.gpu_indirect_buffer); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_reset_program_, "u_mesh_count"), - m.gpu_mesh_command_count); - gl_->glDispatchCompute((m.gpu_mesh_command_count + 63u) / 64u, 1, 1); - gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); - - // Compact — test + scatter. - gl_->glUseProgram(cull_compact_program_); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_indirect_buffer); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.gpu_visible_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 3, m.gpu_mesh_base_ssbo); - gl_->glUniform4fv(gl_->glGetUniformLocation(cull_compact_program_, "u_planes"), - 6, planes_flat); - gl_->glUniform1ui(gl_->glGetUniformLocation(cull_compact_program_, "u_count"), n); - gl_->glUniform3f (gl_->glGetUniformLocation(cull_compact_program_, "u_camera_eye"), - camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_focal_px"), - focal_px); - gl_->glUniform1f (gl_->glGetUniformLocation(cull_compact_program_, "u_min_pixel_radius"), - min_pixel_radius); + gl_->glUniform1ui(u_count, n); gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); } - gl_->glMemoryBarrier(GL_COMMAND_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT); - gpu_cull_last_input_ = total_in; - gpu_cull_ns_ += t.nsecsElapsed(); + gl_->glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); + uint32_t survivors = 0; + gl_->glGetNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(survivors), &survivors); + gpu_cull_last_survivors_ = survivors; + gpu_cull_last_input_ = total_in; + gpu_cull_ns_ += t.nsecsElapsed(); gl_->glUseProgram(main_program_); } - // Perf diagnostics (confirmed 2026-04 on GTX 1650 @ 128M tris: - // draw-bound, not upload-bound — see README Phase 3): - // IFC_SKIP_MDI=1 skip the actual MDI draws (keeps cull + - // upload + binds). FPS jump == draw-bound. - // IFC_MAX_SUBDRAWS=N truncate drawcount to N per MDI. Lets - // you distinguish per-subdraw command- - // processor overhead from raw tri work. - static const bool skip_mdi = []{ - const char* e = std::getenv("IFC_SKIP_MDI"); - return e && e[0] == '1'; - }(); - static const uint32_t max_subdraws = []{ - const char* e = std::getenv("IFC_MAX_SUBDRAWS"); - return (e && *e) ? static_cast(std::atoi(e)) - : std::numeric_limits::max(); - }(); for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; - if (gpu_cull_enabled) { - // GPU path: compact shader already wrote visible indices into - // gpu_visible_ssbo at [mesh_base[i], mesh_base[i]+count) and - // set each command's instanceCount. One MDI per model, no - // fwd/rev split yet — reflected winding is wrong; step 3b. - if (!m.gpu_indirect_buffer || !m.gpu_visible_ssbo || - m.gpu_mesh_command_count == 0) continue; - - gl_->glBindVertexArray(m.vao); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.gpu_visible_ssbo); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, m.mesh_info_ssbo); - gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.gpu_indirect_buffer); - - uint32_t count = m.gpu_mesh_command_count; - if (max_subdraws < count) count = max_subdraws; - if (count > 0 && !skip_mdi) { - gl_->glFrontFace(GL_CCW); - gl_->glMultiDrawElementsIndirect( - GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, - static_cast(count), 0); - ++gl_draw_calls_; - } - // Stats: we don't have visible_objects / visible_triangles - // from the GPU yet (would need a readback). Report command - // count as a proxy for indirect_sub_draws_. - indirect_sub_draws_ += m.gpu_mesh_command_count; - continue; - } - if (cull_this_frame) { uploadCullResults(m); } @@ -2046,6 +1844,22 @@ void ViewportWindow::render() { uint32_t fwd = m.indirect_forward_count; uint32_t rev = m.indirect_command_count - fwd; + // Perf diagnostics (confirmed 2026-04 on GTX 1650 @ 128M tris: + // draw-bound, not upload-bound — see README Phase 3): + // IFC_SKIP_MDI=1 skip the actual MDI draws (keeps cull + + // upload + binds). FPS jump == draw-bound. + // IFC_MAX_SUBDRAWS=N truncate drawcount to N per MDI. Lets + // you distinguish per-subdraw command- + // processor overhead from raw tri work. + static const bool skip_mdi = []{ + const char* e = std::getenv("IFC_SKIP_MDI"); + return e && e[0] == '1'; + }(); + static const uint32_t max_subdraws = []{ + const char* e = std::getenv("IFC_MAX_SUBDRAWS"); + return (e && *e) ? static_cast(std::atoi(e)) + : std::numeric_limits::max(); + }(); if (max_subdraws < m.indirect_command_count) { // Keep the fwd/rev ratio so the workload mix is preserved. const uint32_t total = m.indirect_command_count; @@ -2124,34 +1938,6 @@ void ViewportWindow::render() { total_ssbo += mm.ssbo_instance_count * sizeof(InstanceGpu); } - // GPU-cull diagnostic readback: once per stats window, sum each - // model's indirect-buffer instanceCount fields so we can report - // survivors / visible objects / visible tris. Synchronous — it - // stalls the pipe — but only ~1 Hz so negligible. - if (gpu_cull_enabled) { - uint32_t gpu_surv = 0; - uint32_t gpu_obj = 0; - uint32_t gpu_tri = 0; - std::vector readback; - for (auto& [mid, mm] : models_gpu_) { - if (mm.hidden || !mm.gpu_indirect_buffer || - mm.gpu_mesh_command_count == 0) continue; - readback.resize(mm.gpu_mesh_command_count); - gl_->glGetNamedBufferSubData(mm.gpu_indirect_buffer, 0, - mm.gpu_mesh_command_count * sizeof(DrawElementsIndirectCommand), - readback.data()); - for (uint32_t i = 0; i < mm.gpu_mesh_command_count; ++i) { - const uint32_t ic = readback[i].instanceCount; - gpu_surv += ic; - gpu_obj += ic; - gpu_tri += ic * (mm.meshes[i].index_count / 3u); - } - } - gpu_cull_last_survivors_ = gpu_surv; - visible_objects_ = gpu_obj; - visible_triangles_ = gpu_tri; - } - FrameStats stats; stats.fps = last_fps_; stats.frame_time_ms = 1000.0f / last_fps_; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 3a07ae54ef..45345c4b1b 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -102,20 +102,6 @@ struct ModelGpuData { GLuint aabb_ssbo = 0; size_t aabb_ssbo_capacity = 0; // bytes - // Phase 3E GPU-cull draw buffers. Separate from the CPU path's - // visible_ssbo / indirect_buffer so the env-var gate can swap between - // them without reallocating. Built once at finalize; each frame only - // the instanceCount field of gpu_indirect_buffer is rewritten by the - // cull shader (zeroed by the reset shader, atomically incremented as - // survivors are appended into gpu_visible_ssbo at mesh_base[i] + local). - GLuint gpu_indirect_buffer = 0; - size_t gpu_indirect_capacity = 0; - GLuint gpu_visible_ssbo = 0; - size_t gpu_visible_capacity = 0; - GLuint gpu_mesh_base_ssbo = 0; - size_t gpu_mesh_base_capacity = 0; - uint32_t gpu_mesh_command_count = 0; - // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; @@ -243,12 +229,6 @@ private: // compute cull (Phase 3E, in progress). void uploadInstanceAabbs(ModelGpuData& m); - // Build the static GPU-cull draw buffers (gpu_indirect_buffer, - // gpu_visible_ssbo, gpu_mesh_base_ssbo) from m.meshes + m.instances. - // Called after uploadInstanceAabbs at finalize / applyCachedModel once - // m.meshes[].instance_count has been populated. - void uploadGpuCullStaticBuffers(ModelGpuData& m); - // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible // list, and upload both to m.indirect_buffer / m.visible_ssbo. @@ -287,16 +267,11 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Phase 3E compute cull. When IFC_GPU_CULL=1, render() uses the GPU - // path exclusively: cull_reset_program_ zeros each mesh's instanceCount - // in gpu_indirect_buffer, then cull_compact_program_ runs frustum + - // contribution cull per instance and atomically appends survivors into - // gpu_visible_ssbo at mesh_base[mesh_id] + local_slot. No LOD / HiZ / - // reflection bucketing yet — reflected instances render with wrong - // winding under the gate, which is why this stays gated until the - // fwd/rev split lands (step 3b). - GLuint cull_reset_program_ = 0; - GLuint cull_compact_program_ = 0; + // Phase 3E compute cull (frustum-only, validation). Runs alongside the + // CPU cull when IFC_GPU_CULL=1; result is cross-checked against CPU's + // visible_objects count. No draw-path side effects yet. + GLuint cull_program_ = 0; + GLuint gpu_cull_counter_ssbo_ = 0; uint32_t gpu_cull_last_survivors_ = 0; uint32_t gpu_cull_last_input_ = 0; uint64_t gpu_cull_ns_ = 0; // per-window accumulator From 71612e07801461a1e148e1c7e4101d682cf9e3e7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 19:31:08 +1000 Subject: [PATCH 048/120] ifcviewer: hybrid GPU frustum+contribution cull with async readback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the CPU BVH traversal + frustum + contribution stages with a GPU compute path (IFC_GPU_CULL=1). A single scene-wide dispatch tests all instances against frustum planes and screen-space contribution threshold, compacting survivors into a flat uint32 buffer via atomicAdd. Uses one-frame-late async readback: frame N dispatches and fences, frame N+1 polls the fence (non-blocking) and reads the persistent- mapped result buffer with zero GPU sync cost. CPU still handles HiZ, LOD selection, winding bucketing, and indirect command generation from the compact survivor list; draw path is unchanged. On a 1M-instance / 111-model scene (GTX 1650): GPU dispatch: 0.70 ms (frustum + contribution, brute-force) Readback: 0.00 ms (fence already signaled, persistent map) CPU consume: 5.7–6.7 ms (parallel emit across models) Cull wall: 5.8–6.9 ms (vs 9.6–15.2 ms CPU-only path) Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 476 ++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 39 ++- 2 files changed, 437 insertions(+), 78 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2778c173e7..6aee36f329 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -252,21 +252,31 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch // counter. No visible list / indirect writeout yet; result is cross-checked // against the CPU cull's visible_objects count to prove plumbing is correct // before we hand the GPU the full emit responsibility. Gated by IFC_GPU_CULL=1. +// GPU frustum + contribution cull. Per-model AABB SSBO at binding 0; shared +// counter at binding 1; shared survivor-index output at binding 2. Each +// survivor is written as (u_model_tag | local_instance_index) so the CPU can +// unpack model + local index from one uint. static const char* CULL_COMPUTE_SHADER = R"( #version 450 core layout(local_size_x = 64) in; -// Each instance contributes two vec4 entries: (min.xyz, meshid_as_float), -// (max.xyz, flags_as_float). We ignore the w components here — they'll be -// needed once the shader also emits the per-mesh / fwd-rev buckets. -layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; + +layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; layout(std430, binding = 1) coherent buffer CountBuf { uint counter; }; -uniform vec4 u_planes[6]; -uniform uint u_count; +layout(std430, binding = 2) writeonly buffer OutBuf { uint survivors[]; }; + +uniform vec4 u_planes[6]; +uniform uint u_count; +uniform vec3 u_camera_eye; +uniform float u_focal_px; +uniform float u_min_pixel_radius; +uniform uint u_model_tag; + void main() { uint gid = gl_GlobalInvocationID.x; if (gid >= u_count) return; vec3 mn = entries[gid * 2u].xyz; vec3 mx = entries[gid * 2u + 1u].xyz; + for (int i = 0; i < 6; ++i) { vec3 pv = vec3( u_planes[i].x >= 0.0 ? mx.x : mn.x, @@ -274,7 +284,21 @@ void main() { u_planes[i].z >= 0.0 ? mx.z : mn.z); if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return; } - atomicAdd(counter, 1u); + + if (u_min_pixel_radius > 0.0) { + bool inside = all(greaterThanEqual(u_camera_eye, mn)) + && all(lessThanEqual(u_camera_eye, mx)); + if (!inside) { + vec3 ext = 0.5 * (mx - mn); + float radius = length(ext); + vec3 center = 0.5 * (mn + mx); + float dist = length(center - u_camera_eye); + if (u_focal_px * radius < u_min_pixel_radius * dist) return; + } + } + + uint slot = atomicAdd(counter, 1u); + survivors[slot] = u_model_tag | gid; } )"; @@ -464,6 +488,13 @@ ViewportWindow::~ViewportWindow() { if (axis_program_) gl_->glDeleteProgram(axis_program_); if (cull_program_) gl_->glDeleteProgram(cull_program_); if (gpu_cull_counter_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_counter_ssbo_); + if (gpu_cull_survivor_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_survivor_ssbo_); + if (gpu_cull_readback_buf_) { + gl_->glUnmapNamedBuffer(gpu_cull_readback_buf_); + gl_->glDeleteBuffers(1, &gpu_cull_readback_buf_); + } + if (gpu_cull_fence_) gl_->glDeleteSync(gpu_cull_fence_); + if (gpu_cull_ts_[0]) gl_->glDeleteQueries(2, gpu_cull_ts_); 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_); @@ -557,6 +588,7 @@ void ViewportWindow::buildShaders() { gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_); gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr, GL_DYNAMIC_STORAGE_BIT); + gl_->glGenQueries(2, gpu_cull_ts_); } void ViewportWindow::buildAxisGizmo() { @@ -1079,6 +1111,24 @@ void ViewportWindow::resetScene() { if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); } models_gpu_.clear(); + if (gpu_cull_survivor_ssbo_) { + gl_->glDeleteBuffers(1, &gpu_cull_survivor_ssbo_); + gpu_cull_survivor_ssbo_ = 0; + gpu_cull_survivor_capacity_ = 0; + } + if (gpu_cull_readback_buf_) { + gl_->glUnmapNamedBuffer(gpu_cull_readback_buf_); + gl_->glDeleteBuffers(1, &gpu_cull_readback_buf_); + gpu_cull_readback_buf_ = 0; + gpu_cull_readback_ptr_ = nullptr; + gpu_cull_readback_capacity_ = 0; + } + if (gpu_cull_fence_) { + gl_->glDeleteSync(gpu_cull_fence_); + gpu_cull_fence_ = nullptr; + } + gpu_cull_pending_.model_targets.clear(); + gpu_cull_pending_.total_in = 0; selected_object_id_ = 0; have_cached_cull_ = false; requestUpdate(); @@ -1630,6 +1680,121 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], cull_emit_ns_ += phase_timer.nsecsElapsed(); } +void ViewportWindow::emitFromGpuSurvivors( + ModelGpuData& m, + const uint32_t* survivor_indices, uint32_t count, + float focal_px, float min_pixel_radius) { + + auto resize_if = [&](std::vector>& v) { + if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); + }; + resize_if(m.vis_fwd_lod0); + resize_if(m.vis_fwd_lod1); + resize_if(m.vis_rev_lod0); + resize_if(m.vis_rev_lod1); + for (size_t i = 0; i < m.meshes.size(); ++i) { + m.vis_fwd_lod0[i].clear(); + m.vis_fwd_lod1[i].clear(); + m.vis_rev_lod0[i].clear(); + m.vis_rev_lod1[i].clear(); + } + + static const float lod1_px_threshold = []{ + const char* e = std::getenv("IFC_LOD1_PX"); + return (e && *e) ? static_cast(std::atof(e)) : 30.0f; + }(); + + const float cx = camera_eye_.x(); + const float cy = camera_eye_.y(); + const float cz = camera_eye_.z(); + auto pixelRadius = [&](const float mn[3], const float mx[3]) -> float { + if (cx >= mn[0] && cx <= mx[0] && + cy >= mn[1] && cy <= mx[1] && + cz >= mn[2] && cz <= mx[2]) { + return std::numeric_limits::infinity(); + } + float ex = 0.5f * (mx[0] - mn[0]); + float ey = 0.5f * (mx[1] - mn[1]); + float ez = 0.5f * (mx[2] - mn[2]); + float radius = std::sqrt(ex*ex + ey*ey + ez*ez); + float dx = 0.5f * (mx[0] + mn[0]) - cx; + float dy = 0.5f * (mx[1] + mn[1]) - cy; + float dz = 0.5f * (mx[2] + mn[2]) - cz; + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + return dist > 0.0f ? focal_px * radius / dist + : std::numeric_limits::infinity(); + }; + + const QMatrix4x4 current_vp = proj_matrix_ * view_matrix_; + const bool hiz_vp_matches = hiz_vp_valid_ && hiz_vp_ == current_vp; + const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_matches; + + for (uint32_t si = 0; si < count; ++si) { + uint32_t inst_idx = survivor_indices[si]; + if (inst_idx >= m.bvh_items.size()) continue; + const BvhItem& item = m.bvh_items[inst_idx]; + if (hiz_on && aabbOccludedByHiz(item.aabb_min, item.aabb_max)) { + hiz_reject_count_.fetch_add(1, std::memory_order_relaxed); + continue; + } + const InstanceCpu& inst = m.instances[inst_idx]; + if (inst.mesh_id >= m.meshes.size()) continue; + const MeshInfo& mesh = m.meshes[inst.mesh_id]; + const bool want_lod1 = mesh.lod1_index_count > 0 && + lod1_px_threshold > 0.0f && + pixelRadius(item.aabb_min, item.aabb_max) < lod1_px_threshold; + const bool reflected = inst_idx < m.instance_reflected.size() + && m.instance_reflected[inst_idx] != 0; + auto& bucket = + reflected ? (want_lod1 ? m.vis_rev_lod1 + : m.vis_rev_lod0) + : (want_lod1 ? m.vis_fwd_lod1 + : m.vis_fwd_lod0); + bucket[inst.mesh_id].push_back(inst_idx); + } + + m.visible_flat.clear(); + m.indirect_scratch.clear(); + + auto emit_slice = [&](std::vector>& by_mesh, int lod) { + for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + const auto& mesh = m.meshes[mi]; + const uint32_t vis_count = static_cast(by_mesh[mi].size()); + const uint32_t idx_count = + (lod == 1) ? mesh.lod1_index_count : mesh.index_count; + const uint32_t ebo_off = + (lod == 1) ? mesh.lod1_ebo_byte_offset : mesh.ebo_byte_offset; + if (vis_count == 0 || idx_count == 0) continue; + + DrawElementsIndirectCommand cmd; + cmd.count = idx_count; + cmd.instanceCount = vis_count; + cmd.firstIndex = ebo_off / sizeof(uint32_t); + cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; + cmd.baseInstance = static_cast(m.visible_flat.size()); + m.indirect_scratch.push_back(cmd); + + m.visible_flat.insert(m.visible_flat.end(), + by_mesh[mi].begin(), by_mesh[mi].end()); + } + }; + + emit_slice(m.vis_fwd_lod0, 0); + emit_slice(m.vis_fwd_lod1, 1); + m.indirect_forward_count = static_cast(m.indirect_scratch.size()); + emit_slice(m.vis_rev_lod0, 0); + emit_slice(m.vis_rev_lod1, 1); + m.indirect_command_count = static_cast(m.indirect_scratch.size()); + + uint32_t model_vis_obj = 0, model_vis_tri = 0; + for (const auto& cmd : m.indirect_scratch) { + model_vis_tri += (cmd.count / 3) * cmd.instanceCount; + model_vis_obj += cmd.instanceCount; + } + m.cached_visible_objects = model_vis_obj; + m.cached_visible_triangles = model_vis_tri; +} + void ViewportWindow::uploadCullResults(ModelGpuData& m) { QElapsedTimer phase_timer; phase_timer.start(); @@ -1749,14 +1914,15 @@ void ViewportWindow::render() { // back and forth. Harmless when culling is off. gl_->glFrontFace(GL_CCW); - // Parallel cull: each model's CPU cull is independent (no shared mutable - // state other than the atomic timing counters), so we fan them out to - // std::async and join before the (serial, GL-touching) upload pass. - // IFC_CULL_THREADS=0 forces the single-threaded fallback. + static const bool gpu_cull_enabled = []{ + const char* e = std::getenv("IFC_GPU_CULL"); + return e && e[0] == '1'; + }(); static const bool mt_cull_enabled = []{ const char* e = std::getenv("IFC_CULL_THREADS"); return !(e && e[0] == '0'); }(); + QElapsedTimer cull_wall_timer; if (cull_this_frame) { cull_wall_timer.start(); @@ -1766,68 +1932,229 @@ void ViewportWindow::render() { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; cull_targets.push_back(&m); } - if (mt_cull_enabled && cull_targets.size() > 1) { - std::vector> futs; - futs.reserve(cull_targets.size()); - for (ModelGpuData* mp : cull_targets) { - const float mpr = min_pixel_radius; - futs.emplace_back(std::async(std::launch::async, - [this, mp, &planes, focal_px, mpr]() { - cullModelCpu(*mp, planes, focal_px, mpr); - })); - } - for (auto& f : futs) f.get(); - } else { - for (ModelGpuData* mp : cull_targets) { - cullModelCpu(*mp, planes, focal_px, min_pixel_radius); + + // --- Try to consume last frame's GPU cull results (one-frame-late) --- + bool gpu_consumed = false; + if (gpu_cull_enabled && gpu_cull_fence_) { + GLenum sync_status = gl_->glClientWaitSync( + gpu_cull_fence_, 0, 0); + if (sync_status == GL_ALREADY_SIGNALED || + sync_status == GL_CONDITION_SATISFIED) { + gl_->glDeleteSync(gpu_cull_fence_); + gpu_cull_fence_ = nullptr; + + // Read GPU timestamp delta. + uint64_t ts0 = 0, ts1 = 0; + gl_->glGetQueryObjectui64v(gpu_cull_ts_[0], GL_QUERY_RESULT, &ts0); + gl_->glGetQueryObjectui64v(gpu_cull_ts_[1], GL_QUERY_RESULT, &ts1); + gpu_cull_dispatch_ns_ += (ts1 > ts0) ? (ts1 - ts0) : 0; + + QElapsedTimer readback_timer; readback_timer.start(); + + // Read counter from persistent-mapped readback buffer. + // Counter is at offset 0, survivor indices follow at offset 4. + uint32_t survivor_count = gpu_cull_readback_ptr_[0]; + const uint32_t* surv_data = gpu_cull_readback_ptr_ + 1; + + gpu_cull_last_survivors_ = survivor_count; + gpu_cull_last_input_ = gpu_cull_pending_.total_in; + + // Validate the models from the pending dispatch still match + // the current scene. If models were added/removed between + // frames, the tags are stale — fall through to CPU. + bool targets_match = true; + if (gpu_cull_pending_.model_targets.size() != cull_targets.size()) { + targets_match = false; + } else { + for (size_t ti = 0; ti < cull_targets.size(); ++ti) { + if (gpu_cull_pending_.model_targets[ti].second != cull_targets[ti]) { + targets_match = false; + break; + } + } + } + + gpu_cull_readback_ns_ += readback_timer.nsecsElapsed(); + + if (targets_match && survivor_count <= gpu_cull_pending_.total_in) { + QElapsedTimer consume_timer; consume_timer.start(); + + // Bin survivors by model tag. + const size_t n_models = cull_targets.size(); + std::vector> per_model_survivors(n_models); + for (uint32_t si = 0; si < survivor_count; ++si) { + uint32_t packed = surv_data[si]; + uint32_t model_idx = packed >> 20u; + uint32_t local_idx = packed & 0xFFFFFu; + if (model_idx < n_models) { + per_model_survivors[model_idx].push_back(local_idx); + } + } + + // Parallel emit across models. + if (mt_cull_enabled && n_models > 1) { + std::vector> futs; + futs.reserve(n_models); + const float fp = focal_px; + const float mpr = min_pixel_radius; + for (size_t ti = 0; ti < n_models; ++ti) { + futs.emplace_back(std::async(std::launch::async, + [this, ti, &cull_targets, &per_model_survivors, fp, mpr]() { + emitFromGpuSurvivors( + *cull_targets[ti], + per_model_survivors[ti].data(), + static_cast(per_model_survivors[ti].size()), + fp, mpr); + })); + } + for (auto& f : futs) f.get(); + } else { + for (size_t ti = 0; ti < n_models; ++ti) { + emitFromGpuSurvivors( + *cull_targets[ti], + per_model_survivors[ti].data(), + static_cast(per_model_survivors[ti].size()), + focal_px, min_pixel_radius); + } + } + + gpu_cull_consume_ns_ += consume_timer.nsecsElapsed(); + gpu_consumed = true; + } + } else { + // Fence not ready — GPU is still working. Fall through to CPU. } } + + // --- CPU fallback if GPU results weren't available --- + if (!gpu_consumed) { + if (mt_cull_enabled && cull_targets.size() > 1) { + std::vector> futs; + futs.reserve(cull_targets.size()); + for (ModelGpuData* mp : cull_targets) { + const float mpr = min_pixel_radius; + futs.emplace_back(std::async(std::launch::async, + [this, mp, &planes, focal_px, mpr]() { + cullModelCpu(*mp, planes, focal_px, mpr); + })); + } + for (auto& f : futs) f.get(); + } else { + for (ModelGpuData* mp : cull_targets) { + cullModelCpu(*mp, planes, focal_px, min_pixel_radius); + } + } + } + + // --- Dispatch this frame's GPU cull (results consumed next frame) --- + if (gpu_cull_enabled && cull_program_) { + // Clean up any lingering fence (shouldn't happen — consumed above). + if (gpu_cull_fence_) { + gl_->glDeleteSync(gpu_cull_fence_); + gpu_cull_fence_ = nullptr; + } + + uint32_t total_in = 0; + for (ModelGpuData* mp : cull_targets) { + total_in += static_cast(mp->instances.size()); + } + + // Ensure survivor SSBO + readback buffer are large enough. + // Layout of readback: [uint32 counter][uint32 survivors[total_in]] + const size_t buf_bytes = (1 + total_in) * sizeof(uint32_t); + const size_t needed = std::max(buf_bytes, sizeof(uint32_t)); + if (!gpu_cull_survivor_ssbo_ || gpu_cull_survivor_capacity_ < needed) { + if (gpu_cull_survivor_ssbo_) + gl_->glDeleteBuffers(1, &gpu_cull_survivor_ssbo_); + size_t cap = gpu_cull_survivor_capacity_ ? gpu_cull_survivor_capacity_ : 4096; + while (cap < needed) cap *= 2; + gl_->glCreateBuffers(1, &gpu_cull_survivor_ssbo_); + gl_->glNamedBufferStorage(gpu_cull_survivor_ssbo_, cap, nullptr, + GL_DYNAMIC_STORAGE_BIT); + gpu_cull_survivor_capacity_ = cap; + } + if (!gpu_cull_readback_buf_ || gpu_cull_readback_capacity_ < needed) { + if (gpu_cull_readback_buf_) { + gl_->glUnmapNamedBuffer(gpu_cull_readback_buf_); + gl_->glDeleteBuffers(1, &gpu_cull_readback_buf_); + gpu_cull_readback_ptr_ = nullptr; + } + size_t cap = gpu_cull_readback_capacity_ ? gpu_cull_readback_capacity_ : 4096; + while (cap < needed) cap *= 2; + gl_->glCreateBuffers(1, &gpu_cull_readback_buf_); + gl_->glNamedBufferStorage(gpu_cull_readback_buf_, cap, nullptr, + GL_MAP_READ_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT); + gpu_cull_readback_ptr_ = static_cast( + gl_->glMapNamedBufferRange(gpu_cull_readback_buf_, 0, cap, + GL_MAP_READ_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT)); + gpu_cull_readback_capacity_ = cap; + } + + // Reset counter. + uint32_t zero = 0; + gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero); + + gl_->glUseProgram(cull_program_); + GLint u_planes_loc = gl_->glGetUniformLocation(cull_program_, "u_planes"); + GLint u_count_loc = gl_->glGetUniformLocation(cull_program_, "u_count"); + GLint u_eye_loc = gl_->glGetUniformLocation(cull_program_, "u_camera_eye"); + GLint u_focal_loc = gl_->glGetUniformLocation(cull_program_, "u_focal_px"); + GLint u_minpx_loc = gl_->glGetUniformLocation(cull_program_, "u_min_pixel_radius"); + GLint u_tag_loc = gl_->glGetUniformLocation(cull_program_, "u_model_tag"); + + float planes_flat[24]; + for (int i = 0; i < 6; ++i) { + planes_flat[i*4+0] = planes[i][0]; + planes_flat[i*4+1] = planes[i][1]; + planes_flat[i*4+2] = planes[i][2]; + planes_flat[i*4+3] = planes[i][3]; + } + gl_->glUniform4fv(u_planes_loc, 6, planes_flat); + gl_->glUniform3f(u_eye_loc, camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); + gl_->glUniform1f(u_focal_loc, focal_px); + gl_->glUniform1f(u_minpx_loc, min_pixel_radius); + + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, gpu_cull_survivor_ssbo_); + + gl_->glQueryCounter(gpu_cull_ts_[0], GL_TIMESTAMP); + for (size_t ti = 0; ti < cull_targets.size(); ++ti) { + ModelGpuData* mp = cull_targets[ti]; + const uint32_t n = static_cast(mp->instances.size()); + gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, mp->aabb_ssbo); + gl_->glUniform1ui(u_count_loc, n); + gl_->glUniform1ui(u_tag_loc, static_cast(ti) << 20u); + gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); + } + gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); + gl_->glQueryCounter(gpu_cull_ts_[1], GL_TIMESTAMP); + + // Copy counter + survivors into the readback buffer. + gl_->glCopyNamedBufferSubData(gpu_cull_counter_ssbo_, gpu_cull_readback_buf_, + 0, 0, sizeof(uint32_t)); + if (total_in > 0) { + gl_->glCopyNamedBufferSubData(gpu_cull_survivor_ssbo_, gpu_cull_readback_buf_, + 0, sizeof(uint32_t), total_in * sizeof(uint32_t)); + } + + gpu_cull_fence_ = gl_->glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + + // Stash model targets for next frame's consumption. + gpu_cull_pending_.model_targets.clear(); + gpu_cull_pending_.model_targets.reserve(cull_targets.size()); + for (size_t ti = 0; ti < cull_targets.size(); ++ti) { + gpu_cull_pending_.model_targets.emplace_back( + static_cast(cull_targets[ti]->instances.size()), + cull_targets[ti]); + } + gpu_cull_pending_.total_in = total_in; + + gl_->glUseProgram(main_program_); + } + cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); } - // Phase 3E validation dispatch: frustum-only GPU cull, result compared - // against the CPU cull's visible_objects count. Gated, no draw-path - // effect. Synchronous readback is intentional — we want ground truth. - static const bool gpu_cull_enabled = []{ - const char* e = std::getenv("IFC_GPU_CULL"); - return e && e[0] == '1'; - }(); - if (gpu_cull_enabled && cull_this_frame && cull_program_) { - QElapsedTimer t; t.start(); - uint32_t zero = 0; - gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero); - - gl_->glUseProgram(cull_program_); - GLint u_planes = gl_->glGetUniformLocation(cull_program_, "u_planes"); - GLint u_count = gl_->glGetUniformLocation(cull_program_, "u_count"); - float planes_flat[24]; - for (int i = 0; i < 6; ++i) { - planes_flat[i*4+0] = planes[i][0]; - planes_flat[i*4+1] = planes[i][1]; - planes_flat[i*4+2] = planes[i][2]; - planes_flat[i*4+3] = planes[i][3]; - } - gl_->glUniform4fv(u_planes, 6, planes_flat); - - uint32_t total_in = 0; - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_); - for (const auto& [mid, m] : models_gpu_) { - if (m.hidden || !m.aabb_ssbo || m.instances.empty()) continue; - const uint32_t n = static_cast(m.instances.size()); - total_in += n; - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m.aabb_ssbo); - gl_->glUniform1ui(u_count, n); - gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); - } - gl_->glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT); - uint32_t survivors = 0; - gl_->glGetNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(survivors), &survivors); - gpu_cull_last_survivors_ = survivors; - gpu_cull_last_input_ = total_in; - gpu_cull_ns_ += t.nsecsElapsed(); - gl_->glUseProgram(main_program_); - } - for (auto& [model_id, m] : models_gpu_) { if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; @@ -1964,13 +2291,17 @@ void ViewportWindow::render() { cull_wall_ns_ = 0; const uint32_t skipped = cull_skipped_frames_; cull_skipped_frames_ = 0; - const double gpu_cull_ms = gpu_cull_ns_ * 1e-6 * inv_frames; - gpu_cull_ns_ = 0; + const double gpu_dispatch_ms = gpu_cull_dispatch_ns_ * 1e-6 * inv_frames; + const double gpu_readback_ms = gpu_cull_readback_ns_ * 1e-6 * inv_frames; + const double gpu_consume_ms = gpu_cull_consume_ns_ * 1e-6 * inv_frames; + gpu_cull_dispatch_ns_ = 0; + gpu_cull_readback_ns_ = 0; + gpu_cull_consume_ns_ = 0; qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " "cull[wall %.2f | work: clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " - "gpu_cull[%.2fms in=%u surv=%u] " + "gpu_cull[disp %.2f rdback %.2f consume %.2fms in=%u surv=%u] " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, @@ -1979,7 +2310,8 @@ void ViewportWindow::render() { hiz_reject_count_.load(), wall_ms, clr_ms, trv_ms, emt_ms, upl_ms, skipped, frames_in_window, - gpu_cull_ms, gpu_cull_last_input_, gpu_cull_last_survivors_, + gpu_dispatch_ms, gpu_readback_ms, gpu_consume_ms, + gpu_cull_last_input_, gpu_cull_last_survivors_, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 45345c4b1b..ec463a4cf7 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -248,6 +248,13 @@ private: void cullModelCpu(ModelGpuData& m, const float planes[6][4], float focal_px, float min_pixel_radius); + // Emit pass for GPU cull path: given a flat list of surviving instance + // indices (already frustum+contribution filtered by GPU), perform HiZ, + // LOD selection, winding bucketing, and build indirect commands. + void emitFromGpuSurvivors(ModelGpuData& m, + const uint32_t* survivor_indices, uint32_t count, + float focal_px, float min_pixel_radius); + // Main-thread only: uploads m.visible_flat / m.indirect_scratch into the // model's SSBO + indirect buffer, growing them if needed. void uploadCullResults(ModelGpuData& m); @@ -267,14 +274,34 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Phase 3E compute cull (frustum-only, validation). Runs alongside the - // CPU cull when IFC_GPU_CULL=1; result is cross-checked against CPU's - // visible_objects count. No draw-path side effects yet. - GLuint cull_program_ = 0; - GLuint gpu_cull_counter_ssbo_ = 0; + // Phase 3E GPU frustum+contribution cull. When IFC_GPU_CULL=1, replaces + // the CPU BVH walk + frustum + contribution stages. Produces a scene-wide + // compact survivor-index list; CPU still handles LOD, winding, HiZ, emit. + // + // Uses one-frame-late async readback: frame N dispatches and fences, frame + // N+1 reads the results via a persistent-mapped buffer. The first frame + // (or any frame where the previous dispatch hasn't completed) falls back + // to the CPU path. + GLuint cull_program_ = 0; + GLuint gpu_cull_counter_ssbo_ = 0; // single uint32 atomic counter + GLuint gpu_cull_survivor_ssbo_ = 0; // uint32[] packed survivors (GPU write) + size_t gpu_cull_survivor_capacity_= 0; // bytes + GLuint gpu_cull_readback_buf_ = 0; // persistent-mapped readback buffer + size_t gpu_cull_readback_capacity_= 0; + uint32_t* gpu_cull_readback_ptr_ = nullptr; // persistent map pointer + GLsync gpu_cull_fence_ = nullptr; + GLuint gpu_cull_ts_[2] = {}; // GPU timestamp queries uint32_t gpu_cull_last_survivors_ = 0; uint32_t gpu_cull_last_input_ = 0; - uint64_t gpu_cull_ns_ = 0; // per-window accumulator + uint64_t gpu_cull_dispatch_ns_ = 0; // GPU-side dispatch time + uint64_t gpu_cull_readback_ns_ = 0; // CPU-side readback time + uint64_t gpu_cull_consume_ns_ = 0; // CPU-side consume (emit) time + // Stashed per-frame dispatch metadata for one-frame-late consumption. + struct GpuCullPending { + std::vector> model_targets; + uint32_t total_in = 0; + }; + GpuCullPending gpu_cull_pending_; // Axis gizmo GLuint axis_vao_ = 0; From 7b64dd338ba4413ffbc2e912d4b1527ba18248ad Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 17 Apr 2026 20:00:02 +1000 Subject: [PATCH 049/120] ifcviewer: dirty-mesh tracking + consume sub-phase profiling for GPU cull Only clear and emit mesh buckets that received survivors in the previous frame, converting both phases from O(total_meshes) to O(active_meshes). Adds per-sub-phase timing (bin/clr/class/emit) to the stats line. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 44 +++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 6 +++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 6aee36f329..86d7e91e03 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1685,6 +1685,8 @@ void ViewportWindow::emitFromGpuSurvivors( const uint32_t* survivor_indices, uint32_t count, float focal_px, float min_pixel_radius) { + QElapsedTimer pt; pt.start(); + auto resize_if = [&](std::vector>& v) { if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); }; @@ -1692,12 +1694,16 @@ void ViewportWindow::emitFromGpuSurvivors( resize_if(m.vis_fwd_lod1); resize_if(m.vis_rev_lod0); resize_if(m.vis_rev_lod1); - for (size_t i = 0; i < m.meshes.size(); ++i) { - m.vis_fwd_lod0[i].clear(); - m.vis_fwd_lod1[i].clear(); - m.vis_rev_lod0[i].clear(); - m.vis_rev_lod1[i].clear(); + for (uint32_t mi : m.dirty_meshes) { + m.vis_fwd_lod0[mi].clear(); + m.vis_fwd_lod1[mi].clear(); + m.vis_rev_lod0[mi].clear(); + m.vis_rev_lod1[mi].clear(); } + m.dirty_meshes.clear(); + + gpu_consume_clear_ns_.fetch_add(pt.nsecsElapsed(), std::memory_order_relaxed); + pt.restart(); static const float lod1_px_threshold = []{ const char* e = std::getenv("IFC_LOD1_PX"); @@ -1729,6 +1735,9 @@ void ViewportWindow::emitFromGpuSurvivors( const bool hiz_vp_matches = hiz_vp_valid_ && hiz_vp_ == current_vp; const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_matches; + thread_local std::vector mesh_seen; + mesh_seen.assign(m.meshes.size(), false); + for (uint32_t si = 0; si < count; ++si) { uint32_t inst_idx = survivor_indices[si]; if (inst_idx >= m.bvh_items.size()) continue; @@ -1751,13 +1760,20 @@ void ViewportWindow::emitFromGpuSurvivors( : (want_lod1 ? m.vis_fwd_lod1 : m.vis_fwd_lod0); bucket[inst.mesh_id].push_back(inst_idx); + if (!mesh_seen[inst.mesh_id]) { + mesh_seen[inst.mesh_id] = true; + m.dirty_meshes.push_back(inst.mesh_id); + } } + gpu_consume_class_ns_.fetch_add(pt.nsecsElapsed(), std::memory_order_relaxed); + pt.restart(); + m.visible_flat.clear(); m.indirect_scratch.clear(); auto emit_slice = [&](std::vector>& by_mesh, int lod) { - for (size_t mi = 0; mi < m.meshes.size(); ++mi) { + for (uint32_t mi : m.dirty_meshes) { const auto& mesh = m.meshes[mi]; const uint32_t vis_count = static_cast(by_mesh[mi].size()); const uint32_t idx_count = @@ -1793,6 +1809,8 @@ void ViewportWindow::emitFromGpuSurvivors( } m.cached_visible_objects = model_vis_obj; m.cached_visible_triangles = model_vis_tri; + + gpu_consume_emit_ns_.fetch_add(pt.nsecsElapsed(), std::memory_order_relaxed); } void ViewportWindow::uploadCullResults(ModelGpuData& m) { @@ -1990,6 +2008,8 @@ void ViewportWindow::render() { per_model_survivors[model_idx].push_back(local_idx); } } + gpu_consume_bin_ns_.fetch_add(consume_timer.nsecsElapsed(), + std::memory_order_relaxed); // Parallel emit across models. if (mt_cull_enabled && n_models > 1) { @@ -2294,14 +2314,23 @@ void ViewportWindow::render() { const double gpu_dispatch_ms = gpu_cull_dispatch_ns_ * 1e-6 * inv_frames; const double gpu_readback_ms = gpu_cull_readback_ns_ * 1e-6 * inv_frames; const double gpu_consume_ms = gpu_cull_consume_ns_ * 1e-6 * inv_frames; + const double gc_bin_ms = gpu_consume_bin_ns_.load() * 1e-6 * inv_frames; + const double gc_clear_ms = gpu_consume_clear_ns_.load() * 1e-6 * inv_frames; + const double gc_class_ms = gpu_consume_class_ns_.load() * 1e-6 * inv_frames; + const double gc_emit_ms = gpu_consume_emit_ns_.load() * 1e-6 * inv_frames; gpu_cull_dispatch_ns_ = 0; gpu_cull_readback_ns_ = 0; gpu_cull_consume_ns_ = 0; + gpu_consume_bin_ns_.store(0); + gpu_consume_clear_ns_.store(0); + gpu_consume_class_ns_.store(0); + gpu_consume_emit_ns_.store(0); qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " "cull[wall %.2f | work: clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " - "gpu_cull[disp %.2f rdback %.2f consume %.2fms in=%u surv=%u] " + "gpu_cull[disp %.2f rdback %.2f consume %.2f " + "(bin %.2f clr %.2f class %.2f emit %.2f)ms in=%u surv=%u] " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, @@ -2311,6 +2340,7 @@ void ViewportWindow::render() { wall_ms, clr_ms, trv_ms, emt_ms, upl_ms, skipped, frames_in_window, gpu_dispatch_ms, gpu_readback_ms, gpu_consume_ms, + gc_bin_ms, gc_clear_ms, gc_class_ms, gc_emit_ms, gpu_cull_last_input_, gpu_cull_last_survivors_, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index ec463a4cf7..8475f987c3 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -122,6 +122,7 @@ struct ModelGpuData { std::vector> vis_rev_lod1; std::vector visible_flat; std::vector indirect_scratch; + std::vector dirty_meshes; bool finalized = false; bool hidden = false; @@ -296,6 +297,11 @@ private: uint64_t gpu_cull_dispatch_ns_ = 0; // GPU-side dispatch time uint64_t gpu_cull_readback_ns_ = 0; // CPU-side readback time uint64_t gpu_cull_consume_ns_ = 0; // CPU-side consume (emit) time + // Consume sub-phase profiling (atomics — safe from worker threads). + std::atomic gpu_consume_bin_ns_{0}; // survivor binning by model + std::atomic gpu_consume_clear_ns_{0}; // per-model bucket clearing + std::atomic gpu_consume_class_ns_{0}; // LOD + winding classification + std::atomic gpu_consume_emit_ns_{0}; // indirect command building // Stashed per-frame dispatch metadata for one-frame-late consumption. struct GpuCullPending { std::vector> model_targets; From 4e3cc63de15ca031ea8a881d2e932396c26e049d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 18 Apr 2026 20:25:12 +1000 Subject: [PATCH 050/120] ifcviewer: fix HiZ depth blit and make occlusion test conservative The HiZ pipeline had two bugs causing false occlusions: 1. The scaling depth blit (glBlitFramebuffer from window-size to HiZ-size) produced GL_INVALID_VALUE on some drivers. Replace with a fullscreen- triangle shader that samples the resolved depth and writes gl_FragDepth. 2. The resolve texture used GL_DEPTH_COMPONENT24 but Qt's default FBO uses D24S8 (depth+stencil). Mismatched formats cause the MSAA resolve blit to fail. Fix by using GL_DEPTH24_STENCIL8 for the resolve texture. Additionally, the occlusion test was too aggressive for scenes with compressed depth ranges (entire scene in 0.99-1.0). Change from "max over coarse mip texels" to "reject only if ALL fine-mip texels agree the AABB is behind them", with early-out on first non-occluding texel and a 64-sample cap. Also fix IFC_HIZ_MOTION=0 being treated as enabled (checked env var existence, not value). Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 133 +++++++++++++++++++------------ src/ifcviewer/ViewportWindow.h | 2 + 2 files changed, 84 insertions(+), 51 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 86d7e91e03..a8beb1704f 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -256,6 +256,25 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch // counter at binding 1; shared survivor-index output at binding 2. Each // survivor is written as (u_model_tag | local_instance_index) so the CPU can // unpack model + local index from one uint. +static const char* HIZ_DOWNSAMPLE_VS = R"( +#version 450 core +void main() { + vec2 pos = vec2((gl_VertexID & 1) * 4.0 - 1.0, + (gl_VertexID & 2) * 2.0 - 1.0); + gl_Position = vec4(pos, 0.0, 1.0); +} +)"; + +static const char* HIZ_DOWNSAMPLE_FS = R"( +#version 450 core +uniform sampler2D u_depth; +uniform vec2 u_inv_dest_size; +void main() { + vec2 uv = gl_FragCoord.xy * u_inv_dest_size; + gl_FragDepth = texture(u_depth, uv).r; +} +)"; + static const char* CULL_COMPUTE_SHADER = R"( #version 450 core layout(local_size_x = 64) in; @@ -502,6 +521,8 @@ ViewportWindow::~ViewportWindow() { if (hiz_depth_tex_) gl_->glDeleteTextures(1, &hiz_depth_tex_); if (hiz_resolve_fbo_) gl_->glDeleteFramebuffers(1, &hiz_resolve_fbo_); if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_); + if (hiz_downsample_program_) gl_->glDeleteProgram(hiz_downsample_program_); + if (hiz_downsample_vao_) gl_->glDeleteVertexArrays(1, &hiz_downsample_vao_); } context_->doneCurrent(); } @@ -584,6 +605,12 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER); axis_program_ = linkProgram(gl_, vs, fs); } + { + GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, HIZ_DOWNSAMPLE_VS); + GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, HIZ_DOWNSAMPLE_FS); + hiz_downsample_program_ = linkProgram(gl_, vs, fs); + gl_->glCreateVertexArrays(1, &hiz_downsample_vao_); + } cull_program_ = linkComputeProgram(gl_, CULL_COMPUTE_SHADER); gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_); gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr, @@ -1209,22 +1236,16 @@ void ViewportWindow::buildHizPyramid() { const int base_w = hizBaseWidth(); const int base_h = std::max(1, (base_w * win_h) / win_w); - // Depth format must match the default FBO's depth format for the blit - // to succeed — GL spec requires identical internal formats for depth - // blits. Qt's default surface uses 24-bit depth (setDepthBufferSize(24) - // in initGL), so we match with DEPTH_COMPONENT24 on both textures. - // - // Resolve target (full window size, single sample). Needed because - // GL also forbids scale-blitting from an MSAA source: resolve at 1:1 - // first, then down-blit. + // Resolve target (full window size, single sample, D24S8 to match Qt's + // default FBO which uses depth+stencil even when only depth is requested). if (win_w != hiz_resolve_w_ || win_h != hiz_resolve_h_) { if (hiz_resolve_fbo_) gl_->glDeleteFramebuffers(1, &hiz_resolve_fbo_); if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_); gl_->glCreateTextures(GL_TEXTURE_2D, 1, &hiz_resolve_depth_tex_); gl_->glTextureStorage2D(hiz_resolve_depth_tex_, 1, - GL_DEPTH_COMPONENT24, win_w, win_h); + GL_DEPTH24_STENCIL8, win_w, win_h); gl_->glCreateFramebuffers(1, &hiz_resolve_fbo_); - gl_->glNamedFramebufferTexture(hiz_resolve_fbo_, GL_DEPTH_ATTACHMENT, + gl_->glNamedFramebufferTexture(hiz_resolve_fbo_, GL_DEPTH_STENCIL_ATTACHMENT, hiz_resolve_depth_tex_, 0); hiz_resolve_w_ = win_w; hiz_resolve_h_ = win_h; @@ -1239,6 +1260,8 @@ void ViewportWindow::buildHizPyramid() { gl_->glCreateFramebuffers(1, &hiz_fbo_); gl_->glNamedFramebufferTexture(hiz_fbo_, GL_DEPTH_ATTACHMENT, hiz_depth_tex_, 0); + gl_->glNamedFramebufferDrawBuffer(hiz_fbo_, GL_NONE); + gl_->glNamedFramebufferReadBuffer(hiz_fbo_, GL_NONE); hiz_base_w_ = base_w; hiz_base_h_ = base_h; @@ -1262,34 +1285,39 @@ void ViewportWindow::buildHizPyramid() { hiz_pyramid_.assign(off, 1.0f); } - // Two-step: MSAA default-fb → full-size SS resolve, then SS → down-scaled. - // GL forbids scaling a blit whose source is multisampled, and also - // requires matching depth internal formats — hence this dance. + // Step 1: MSAA default-fb → full-size single-sample resolve (same-size). + while (gl_->glGetError() != GL_NO_ERROR) {} gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_resolve_fbo_); gl_->glBlitFramebuffer(0, 0, win_w, win_h, 0, 0, win_w, win_h, - GL_DEPTH_BUFFER_BIT, GL_NEAREST); + GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST); - gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, hiz_resolve_fbo_); - gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_fbo_); - gl_->glBlitFramebuffer(0, 0, win_w, win_h, - 0, 0, hiz_base_w_, hiz_base_h_, - GL_DEPTH_BUFFER_BIT, GL_NEAREST); - gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); - gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); - - // One-shot diagnostic so blit failures aren't silent. We only warn - // the first handful of times — GL errors can pile up and spam. - static int err_warn_budget = 3; - if (err_warn_budget > 0) { - GLenum e = gl_->glGetError(); - if (e != GL_NO_ERROR) { - qWarning("HiZ blit/readback GL error 0x%04x (win %dx%d → %dx%d → %dx%d)", - e, win_w, win_h, win_w, win_h, hiz_base_w_, hiz_base_h_); - --err_warn_budget; - } - } + // Step 2: downsample resolved depth to HiZ base via fullscreen-triangle. + // glBlitFramebuffer with depth + scaling produces GL_INVALID_VALUE on + // some drivers, so we sample the resolve texture and write gl_FragDepth. + gl_->glBindFramebuffer(GL_FRAMEBUFFER, hiz_fbo_); + gl_->glViewport(0, 0, hiz_base_w_, hiz_base_h_); + gl_->glEnable(GL_DEPTH_TEST); + gl_->glDepthFunc(GL_ALWAYS); + gl_->glDepthMask(GL_TRUE); + gl_->glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + gl_->glUseProgram(hiz_downsample_program_); + gl_->glTextureParameteri(hiz_resolve_depth_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl_->glTextureParameteri(hiz_resolve_depth_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl_->glTextureParameteri(hiz_resolve_depth_tex_, GL_TEXTURE_COMPARE_MODE, GL_NONE); + gl_->glBindTextureUnit(0, hiz_resolve_depth_tex_); + gl_->glUniform1i(gl_->glGetUniformLocation(hiz_downsample_program_, "u_depth"), 0); + gl_->glUniform2f(gl_->glGetUniformLocation(hiz_downsample_program_, "u_inv_dest_size"), + 1.0f / static_cast(hiz_base_w_), + 1.0f / static_cast(hiz_base_h_)); + gl_->glBindVertexArray(hiz_downsample_vao_); + gl_->glDrawArrays(GL_TRIANGLES, 0, 3); + gl_->glBindVertexArray(0); + gl_->glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + gl_->glDepthFunc(GL_LESS); + gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); + gl_->glViewport(0, 0, win_w, win_h); // Synchronous readback into level 0 of the pyramid. At 256x128 this // is ~128 KB and the driver copy is fast enough not to matter in @@ -1374,16 +1402,10 @@ bool ViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) con const float v_max = 0.5f * (sy_max + 1.0f); const float aabb_near_depth = 0.5f * (sz_min + 1.0f); - // Pick mip level where the projected rect covers at most 2 texels on - // each axis; sample the max over the covered texels there. - const float px_w = (u_max - u_min) * static_cast(hiz_base_w_); - const float px_h = (v_max - v_min) * static_cast(hiz_base_h_); - int mip = 0; - while ((int)hiz_mip_offset_.size() - 1 > mip && - ((px_w / (1 << mip)) > 2.0f || (px_h / (1 << mip)) > 2.0f)) { - ++mip; - } - + // Sample at a fine mip and reject ONLY if every texel agrees the AABB + // is behind it. One non-occluding texel → visible, early-out. Cap + // iteration to avoid slow queries on large projected rects. + const int mip = std::min(1, (int)hiz_mip_offset_.size() - 1); const uint32_t mw = hiz_mip_w_[mip]; const uint32_t mh = hiz_mip_h_[mip]; int x0 = static_cast(std::floor(u_min * mw)); @@ -1396,18 +1418,17 @@ bool ViewportWindow::aabbOccludedByHiz(const float mn[3], const float mx[3]) con if (y1 > (int)mh) y1 = mh; if (x1 <= x0 || y1 <= y0) return false; + static constexpr int MAX_HIZ_SAMPLES = 64; + if ((x1 - x0) * (y1 - y0) > MAX_HIZ_SAMPLES) return false; + const float* level = hiz_pyramid_.data() + hiz_mip_offset_[mip]; - float hiz_max = 0.0f; for (int y = y0; y < y1; ++y) { const float* row = level + static_cast(y) * mw; for (int x = x0; x < x1; ++x) { - if (row[x] > hiz_max) hiz_max = row[x]; + if (aabb_near_depth <= row[x]) return false; } } - - // AABB's closest point must be strictly farther than everything drawn - // in the region for it to be fully occluded. - return aabb_near_depth > hiz_max; + return true; } uint32_t ViewportWindow::pickObjectAt(int x, int y) { @@ -1559,7 +1580,12 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], // the buffer — a self-reinforcing feedback loop). On static views HiZ // kicks in after a single frame of lag. const QMatrix4x4 current_vp = proj_matrix_ * view_matrix_; - const bool hiz_vp_matches = hiz_vp_valid_ && hiz_vp_ == current_vp; + static const bool hiz_force_motion = []{ + const char* e = std::getenv("IFC_HIZ_MOTION"); + return e && *e && std::atoi(e) != 0; + }(); + const bool hiz_vp_matches = hiz_vp_valid_ + && (hiz_force_motion || hiz_vp_ == current_vp); const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_matches; // Hot path: read the AABB from the compact bvh_items array (28 B stride) @@ -1732,7 +1758,12 @@ void ViewportWindow::emitFromGpuSurvivors( }; const QMatrix4x4 current_vp = proj_matrix_ * view_matrix_; - const bool hiz_vp_matches = hiz_vp_valid_ && hiz_vp_ == current_vp; + static const bool hiz_force_motion = []{ + const char* e = std::getenv("IFC_HIZ_MOTION"); + return e && *e && std::atoi(e) != 0; + }(); + const bool hiz_vp_matches = hiz_vp_valid_ + && (hiz_force_motion || hiz_vp_ == current_vp); const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_matches; thread_local std::vector mesh_seen; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 8475f987c3..3a0b7e4049 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -332,6 +332,8 @@ private: // (hiz_vp_ + hiz_vp_valid_) so next frame's cull can test AABBs // against a slightly-stale depth. Skipped for the pick pass and when // IFC_NO_HIZ=1. + GLuint hiz_downsample_program_ = 0; + GLuint hiz_downsample_vao_ = 0; GLuint hiz_fbo_ = 0; GLuint hiz_depth_tex_ = 0; GLuint hiz_resolve_fbo_ = 0; // full-size single-sample resolve From 930678e3d26a65aa0c8bb29307f0772c3d6e9276 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 18 Apr 2026 20:46:30 +1000 Subject: [PATCH 051/120] ifcviewer: motion-adaptive contribution culling + sub-draw diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During camera motion, use a larger pixel-radius threshold (IFC_MIN_PX_MOTION) to aggressively cull small objects, dramatically reducing sub_draws and improving orbit fps (e.g. 29→67 fps on 1M-instance scene). When the camera stops, automatically re-cull at the base threshold to restore full detail. Key behaviors: - IFC_MIN_PX_MOTION=N sets the motion threshold (0 = disabled) - Settle recull fires on the first still frame after motion - HiZ pyramid invalidated on settle (stale from sparse motion frame) - GPU cull results skipped on settle (dispatched at motion threshold) - requestUpdate() ensures the settle frame actually runs Also adds IFC_SUBDRAW_DIAG=1 diagnostic for sub-draw composition analysis and documents Phase 3E/3F experiment results in README. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/README.md | 235 +++++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.cpp | 214 ++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 5 + 3 files changed, 434 insertions(+), 20 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 77ffefc40e..81b128bdf8 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -740,17 +740,226 @@ The stats line now reports `cull[wall X | work: clr Y trv Z emt W upl U]`: where CPU cycles went. `IFC_CULL_THREADS=0` forces single-threaded mode for comparison. -#### 3E. GPU-side culling via compute (longer-term) +#### 3E. GPU compute culling — experiments, results, and current state -Push the cull loop to a compute shader reading the per-instance SSBO + -frustum planes + HiZ pyramid, emitting the visible list and indirect -commands with atomic counters. Three compute dispatches per model: (1) -count survivors per `(mesh, winding, LOD)` bucket, (2) prefix-sum the -counts into `baseInstance` offsets and write the indirect command buffer, -(3) re-test and compact survivors into the dense visible list. HiZ moves -to a GPU depth texture sampled directly in the shader, eliminating the -Phase 3C readback. Lets culling scale to millions of instances and -single-model scenes where Phase 3D can't parallelise. +##### What we tried + +**Attempt 1: Full GPU-driven rendering (reverted).** Five commits +(`4fe32b54`..`d5b7b87b`) moved the entire cull-to-draw pipeline onto +the GPU: a compute shader performed frustum + contribution + HiZ +culling, selected LOD0/LOD1, handled fwd/rev winding bucketing, wrote +indirect draw commands via `glMultiDrawElementsIndirectCount`, and +drove rendering without CPU readback. This was architecturally clean +but complex — the GPU built per-model indirect command buffers with +atomic counters, prefix sums, and per-bucket compaction. It worked +correctly but introduced code smells (extension loaders for +`glMultiDrawElementsIndirectCount` not exposed by Qt6's +`QOpenGLFunctions_4_5_Core`, ad-hoc GPU readbacks for validation). +All five commits were reverted as a single block to keep the codebase +clean while preserving the AABB SSBO upload (`b2044737`) and the +frustum-only validation shader (`b17860fc`). + +**Attempt 2: GPU frustum-only validation shader.** A minimal compute +shader (64 threads/workgroup) testing each instance's AABB against 6 +frustum planes. Used as a measurement baseline — no contribution, +HiZ, LOD, or winding. Results on a 1.06 M-instance / 111-model scene +(GTX 1650): + +| Metric | GPU frustum-only | CPU BVH (parallel) | +|--------|------------------|--------------------| +| Cull time | **0.82 ms** (GPU timestamp) | 9.6–15.2 ms wall | +| Survivors | 279 k (frustum only) | 130 k (frustum + contribution + HiZ) | + +The GPU brute-force scan of 1.06 M instances in 0.82 ms was 12–18× +faster than the CPU BVH walk despite testing every instance. + +**Attempt 3: Hybrid GPU cull with synchronous readback.** Added +contribution culling to the GPU shader (bounding-sphere screen-space +radius test), then read back the compact survivor list to the CPU with +`glGetNamedBufferSubData`. CPU retains HiZ, LOD selection, winding +bucketing, indirect command building, and all GL draw calls. + +| Phase | Time | +|-------|------| +| GPU dispatch (frustum + contribution) | 0.92 ms | +| Synchronous readback (`glGetNamedBufferSubData`) | **4.2–7.4 ms** | +| CPU consume (HiZ + LOD + winding + emit) | 6.4–9.8 ms | +| **Total wall** | **~15 ms** | + +The synchronous readback pipeline-stalled the GPU, adding 4–7 ms of +idle wait. Total wall time was roughly equal to the CPU-only path, +negating the GPU cull's speed advantage. + +**Attempt 4: Async one-frame-late readback (committed, `30e43ffe`).** +Replaced synchronous readback with a persistent-mapped buffer +(`GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT`) and a `glFenceSync` / +`glClientWaitSync` fence. The GPU writes survivors this frame; the +CPU reads them next frame. One frame of latency, but zero stalls. + +| Phase | Time | +|-------|------| +| GPU dispatch | 0.69–0.78 ms | +| Async readback (fence poll) | **0.00 ms** | +| CPU consume | 5.0–6.2 ms | +| **Total wall** | **~5.5 ms** | + +vs the CPU-only path at 5.2–6.4 ms wall on the same scene. The GPU +cull + async readback matches or slightly beats the parallel CPU BVH +path, with headroom for scenes where the CPU path can't parallelise +(single large model). + +**Attempt 5: Dirty-mesh tracking (committed, `01dd8d57`).** Profiling +the CPU consume phase revealed that `clr` (clearing per-mesh visibility +buckets) and `emit` (building indirect commands) were O(total_meshes) += O(462 k), not O(survivors). Added a dirty-mesh list so only mesh +buckets that received survivors are cleared and iterated. + +Consume sub-phase breakdown (summed across parallel threads, +~128 k survivors): + +| Sub-phase | Before | After | Scales with | +|-----------|--------|-------|-------------| +| bin (model binning) | 0.11 ms | 0.18 ms | O(survivors) | +| clr (bucket clear) | 2.0 ms | **1.6 ms** | O(dirty meshes) | +| class (HiZ + LOD + winding) | 5.1 ms | 5.3 ms | O(survivors) | +| emit (indirect cmd build) | 4.2 ms | **2.2 ms** | O(dirty meshes) | + +Emit improved ~48%, clr ~20%. The dominant cost shifted to `class` +(per-survivor HiZ + LOD + winding classification). + +##### What we learned + +1. **GPU brute-force beats CPU BVH for frustum + contribution.** + 0.82 ms for 1.06 M instances vs 10–15 ms for the CPU BVH walk. + The BVH's hierarchical skip advantage is overwhelmed by the GPU's + raw parallelism — 1 M independent AABB-vs-frustum tests is a + perfect compute workload. + +2. **Synchronous readback kills the advantage.** The 4–7 ms stall from + `glGetNamedBufferSubData` on ~1 MB of data negated all GPU savings. + A pipeline stall is worse than just doing the work on the CPU. + +3. **Async one-frame-late readback works well.** Persistent mapping + + fence polling adds zero measurable overhead. The one-frame latency + is imperceptible for culling — worst case, a few objects at the + frustum edge pop in one frame late during fast camera motion. + +4. **CPU consume is now the bottleneck.** With GPU dispatch at <1 ms + and readback at 0 ms, the 5–6 ms consume phase (HiZ test, LOD + selection, winding classification, indirect command building) + dominates. The `class` sub-phase alone is 5+ ms, scaling linearly + with survivor count. + +5. **Dirty-mesh tracking helps but doesn't transform performance.** + The 462 k total meshes → ~104 k active meshes reduction cut emit + in half, but the per-survivor classification work is the true + bottleneck. + +##### What remains + +The hybrid path (`IFC_GPU_CULL=1`) is functional and committed. It +matches the CPU path's performance today and provides the foundation +for further GPU offload. Remaining opportunities: + +- Move HiZ + LOD + winding classification to the GPU (eliminates the + 5 ms `class` sub-phase entirely — the GPU already has the AABBs and + can sample the HiZ pyramid directly). +- GPU BVH traversal to reduce dispatch from O(total) to O(visible + + tree overhead) — matters when survivor ratio is low. +- GPU-driven indirect command building (eliminates CPU emit entirely). + +Each of these would chip away at the consume phase, but the sub_draw +analysis below reveals a more fundamental bottleneck. + +#### 3F. Sub-draw fragmentation analysis + +##### The problem + +With GPU cull solving the *culling* bottleneck, the dominant cost +shifts to the *drawing* side. On the 1.06 M-instance / 111-model +scene, frame times are 48–63 ms despite only 24–47 M visible +triangles — well within the GTX 1650's throughput. The culprit is +the number of indirect sub-draws (individual `DrawElementsIndirectCommand` +entries inside each `glMultiDrawElementsIndirect` call). + +##### Measurement + +Diagnostic instrumentation (`IFC_SUBDRAW_DIAG=1`) revealed: + +**Mixed scene (111 models, 1.06 M instances):** + +| instanceCount | sub_draws | % of total | instances | triangles | +|---------------|-----------|------------|-----------|-----------| +| 1 | 114,624 | **95.7%** | 114,624 | 16.9 M | +| 2 | 2,269 | 1.9% | 4,538 | 1.3 M | +| 3–4 | 1,127 | 0.9% | 3,873 | 1.6 M | +| 5–8 | 1,106 | 0.9% | 6,407 | 1.9 M | +| 9–16 | 376 | 0.3% | 4,315 | 0.8 M | +| 17–64 | 264 | 0.2% | 7,766 | 8.0 M | +| 65–256 | 29 | <0.1% | 3,331 | 2.0 M | +| 257+ | 8 | <0.1% | 9,732 | 0.4 M | + +**Steel-only scene (18 models, 570 k instances):** + +| instanceCount | sub_draws | % of total | instances | triangles | +|---------------|-----------|------------|-----------|-----------| +| 1 | 68,616 | **85.9%** | 68,616 | 12.5 M | +| 2 | 5,385 | 6.7% | 10,770 | 2.7 M | +| 3–4 | 2,581 | 3.2% | 9,100 | 1.3 M | +| 5+ | 3,324 | 4.2% | 66,407 | 7.0 M | + +##### Consolidation potential + +The mesh-level consolidation analysis found: + +- **119,803 unique visible mesh IDs = 119,803 sub_draws** (perfect 1:1) +- **0 meshes split by winding or LOD buckets** — no mesh_id appears in + more than one (fwd/rev × lod0/lod1) bucket +- **0% reduction** available from merging across winding/LOD +- **114,624 meshes (95.7%)** are genuinely unique geometry placed + exactly once — instancing provides zero benefit for these + +This is a fundamental property of the IFC data, not a pipeline +inefficiency. BIM models contain thousands of unique parametric +shapes (custom brackets, unique beam profiles, one-off fittings) each +placed at a single location. Only a minority of elements (standard +doors, windows, pipe fittings) share geometry across placements. + +##### Conclusions + +1. **Instancing is maxed out.** The pipeline already groups all + instances of each mesh into a single sub_draw. With 96% of meshes + having exactly one visible instance, there is nothing more to + group. + +2. **Per-draw overhead dominates frame time.** 95–120 k sub_draws at + ~20 fps = 48–50 ms/frame, but only 24–33 M triangles. A GTX 1650 + can shade 1+ billion triangles/sec; the GPU is starving on + per-command overhead (command fetch, baseInstance lookup, draw + setup), not vertex/fragment throughput. + +3. **The path forward is static batching.** Merge the vertex and + index data of multiple distinct single-instance meshes into + combined VBO/EBO ranges, each issued as one sub_draw. Batches of + 256–1024 spatially-coherent meshes would collapse 91–115 k + sub_draws into 100–450, a 200–1000× reduction. + +4. **Trade-offs of static batching:** + - Culling granularity degrades from per-mesh to per-batch. Batches + must be spatially coherent (e.g., BVH subtree leaves) or invisible + geometry gets drawn. + - Per-instance attributes (object_id, colour_override) must move + into the vertex stream or a per-vertex SSBO lookup, since + instancing no longer applies to merged meshes. + - The VBO/EBO layout changes at finalize time; existing instancing + stays for multi-instance meshes (the 4% that benefit from it). + - The sidecar format needs a version bump to cache batch membership. + +5. **The steel scene validates the hypothesis.** It has better + instancing reuse (86% single-instance vs 96%) and correspondingly + better fps (49 vs 20). The ~2.5× fps ratio tracks the sub_draw + ratio (~80 k vs ~120 k), confirming per-draw overhead as the + dominant cost. ### Planned follow-ups (post-Phase-3) @@ -769,7 +978,8 @@ Scene size Bottleneck Fix + Phase 3B LOD (done) multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) -single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (planned) +single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybrid, done) +90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next) ``` ## Roadmap @@ -793,6 +1003,7 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (plann - [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) -- [ ] **Phase 3E — GPU-side compute-shader culling** (next; replaces the HiZ readback) +- [x] Phase 3E — GPU compute-shader culling (hybrid: GPU frustum+contribution, async readback, CPU HiZ+LOD+emit) +- [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a8beb1704f..e67a378fd3 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1262,6 +1262,11 @@ void ViewportWindow::buildHizPyramid() { hiz_depth_tex_, 0); gl_->glNamedFramebufferDrawBuffer(hiz_fbo_, GL_NONE); gl_->glNamedFramebufferReadBuffer(hiz_fbo_, GL_NONE); + { + GLenum s = gl_->glCheckNamedFramebufferStatus(hiz_fbo_, GL_FRAMEBUFFER); + if (s != GL_FRAMEBUFFER_COMPLETE) + qWarning("HiZ FBO incomplete: 0x%04x", s); + } hiz_base_w_ = base_w; hiz_base_h_ = base_h; @@ -1285,8 +1290,10 @@ void ViewportWindow::buildHizPyramid() { hiz_pyramid_.assign(off, 1.0f); } - // Step 1: MSAA default-fb → full-size single-sample resolve (same-size). + // Drain stale GL errors before HiZ pipeline. while (gl_->glGetError() != GL_NO_ERROR) {} + + // Step 1: MSAA default-fb → full-size SS resolve (same-size blit). gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, hiz_resolve_fbo_); gl_->glBlitFramebuffer(0, 0, win_w, win_h, @@ -1327,6 +1334,28 @@ void ViewportWindow::buildHizPyramid() { static_cast(hiz_depth_readback_.size() * sizeof(float)), hiz_depth_readback_.data()); + { + static int diag = 5; + static int skip = 60; + if (skip > 0) { --skip; } + else if (diag > 0) { + --diag; + float mn = 1.0f, mx = 0.0f; + int zeros = 0, ones = 0; + for (size_t i = 0; i < hiz_depth_readback_.size(); ++i) { + float v = hiz_depth_readback_[i]; + if (v < mn) mn = v; + if (v > mx) mx = v; + if (v == 0.0f) ++zeros; + if (v == 1.0f) ++ones; + } + int geom = (int)hiz_depth_readback_.size() - zeros - ones; + qWarning("HiZ readback %dx%d: min=%.6f max=%.6f zeros=%d ones=%d geom=%d total=%d", + hiz_base_w_, hiz_base_h_, mn, mx, zeros, ones, geom, + (int)hiz_depth_readback_.size()); + } + } + // Copy level 0 into the pyramid, then max-reduce subsequent levels. std::memcpy(hiz_pyramid_.data() + hiz_mip_offset_[0], hiz_depth_readback_.data(), @@ -1920,14 +1949,15 @@ void ViewportWindow::render() { // culling below. const float focal_px = 0.5f * static_cast(h) / std::tan(qDegreesToRadians(0.5f * camera_fov_y_deg_)); - // Drop frustum-visible objects smaller than this many pixels. Override - // with IFC_MIN_PX (0 = disabled). 2 px radius = ~4x4 pixels, well below - // what's meaningful at normal viewing distances and eliminates the long - // tail of distant MEP/fixings that dominate BIM triangle counts. - static const float min_pixel_radius = []{ + static const float base_min_pixel_radius = []{ const char* e = std::getenv("IFC_MIN_PX"); return (e && *e) ? static_cast(std::atof(e)) : 2.0f; }(); + static const float motion_min_pixel_radius = []{ + const char* e = std::getenv("IFC_MIN_PX_MOTION"); + return (e && *e) ? static_cast(std::atof(e)) + : 0.0f; // 0 = disabled (no motion boost) + }(); gl_->glUseProgram(main_program_); GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); @@ -1952,9 +1982,25 @@ void ViewportWindow::render() { const bool camera_unchanged = have_cached_cull_ && last_cull_view_ == view_matrix_ && last_cull_proj_ == proj_matrix_; - const bool cull_this_frame = !camera_unchanged; + const bool camera_moving = !camera_unchanged; + // Force a re-cull on the first still frame after motion so we + // restore the base (tighter) contribution threshold. + const bool needs_settle_recull = !camera_moving + && last_cull_was_motion_ + && motion_min_pixel_radius > base_min_pixel_radius; + const bool cull_this_frame = camera_moving || needs_settle_recull; + // Invalidate HiZ on the settle frame: the pyramid was built from the + // motion frame's sparse depth (aggressive threshold hid objects whose + // depth would normally populate the pyramid), causing false occlusion. + if (needs_settle_recull) + hiz_vp_valid_ = false; + const bool use_motion_threshold = camera_moving + && motion_min_pixel_radius > base_min_pixel_radius; + const float min_pixel_radius = use_motion_threshold + ? motion_min_pixel_radius : base_min_pixel_radius; if (cull_this_frame) { hiz_reject_count_.store(0, std::memory_order_relaxed); + last_cull_was_motion_ = use_motion_threshold; } else { ++cull_skipped_frames_; } @@ -1983,8 +2029,11 @@ void ViewportWindow::render() { } // --- Try to consume last frame's GPU cull results (one-frame-late) --- + // Skip GPU consume on the settle re-cull: the pending results were + // dispatched at the motion threshold and would be too aggressively + // culled. Fall through to CPU which culls at the base threshold. bool gpu_consumed = false; - if (gpu_cull_enabled && gpu_cull_fence_) { + if (gpu_cull_enabled && gpu_cull_fence_ && !needs_settle_recull) { GLenum sync_status = gl_->glClientWaitSync( gpu_cull_fence_, 0, 0); if (sync_status == GL_ALREADY_SIGNALED || @@ -2273,6 +2322,10 @@ void ViewportWindow::render() { last_cull_proj_ = proj_matrix_; have_cached_cull_ = true; } + if (needs_settle_recull) + qDebug("[motion-cull] settle result: obj=%u sub_draws=%u hiz_rej=%u", + visible_objects_, indirect_sub_draws_, + hiz_reject_count_.load()); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); renderAxisGizmo(); @@ -2288,6 +2341,12 @@ void ViewportWindow::render() { context_->swapBuffers(this); + // Ensure one more frame runs after the last motion frame so the + // settle recull can detect the camera has stopped and restore the + // base contribution threshold. + if (last_cull_was_motion_) + requestUpdate(); + // Measure frame *cost* (time spent inside render()) rather than the // wall-clock gap between frames. With event-driven rendering, idle gaps // between requestUpdate() calls would otherwise pollute the FPS window. @@ -2378,6 +2437,145 @@ void ViewportWindow::render() { total_ebo / (1024.0*1024.0), total_ssbo / (1024.0*1024.0), num_models, num_hidden); + + // One-shot sub_draw composition diagnostic. + static const bool subdraw_diag = std::getenv("IFC_SUBDRAW_DIAG") != nullptr; + if (subdraw_diag) { + uint32_t total_subdraws = 0; + uint32_t hist[8] = {}; + uint32_t instances_in_bucket[8] = {}; + uint32_t tris_in_bucket[8] = {}; + + struct ModelStats { + uint32_t model_id; + uint32_t subdraws; + uint32_t single_instance; + uint32_t total_meshes; + uint32_t total_instances; + }; + std::vector per_model; + + auto bucket_idx = [](uint32_t ic) -> int { + if (ic <= 1) return 0; + if (ic <= 2) return 1; + if (ic <= 4) return 2; + if (ic <= 8) return 3; + if (ic <= 16) return 4; + if (ic <= 64) return 5; + if (ic <= 256) return 6; + return 7; + }; + + // --- Mesh-level consolidation analysis --- + // Per mesh_id, count visible instances across all 4 buckets. + // Also count how many buckets each mesh_id appears in. + uint32_t unique_visible_meshes = 0; + uint32_t meshes_truly_single = 0; // 1 instance total, 1 bucket + uint32_t meshes_split_by_state = 0; // >1 bucket but each has 1 instance + uint32_t subdraws_if_merged_buckets = 0; // sub_draws if winding+LOD ignored + uint32_t mesh_vis_hist[8] = {}; // histogram of per-mesh visible instance counts + + for (const auto& [mid, mm] : models_gpu_) { + if (mm.hidden) continue; + ModelStats ms{mid, mm.indirect_command_count, 0, + static_cast(mm.meshes.size()), + static_cast(mm.instances.size())}; + for (const auto& cmd : mm.indirect_scratch) { + int b = bucket_idx(cmd.instanceCount); + hist[b]++; + instances_in_bucket[b] += cmd.instanceCount; + tris_in_bucket[b] += (cmd.count / 3) * cmd.instanceCount; + if (cmd.instanceCount == 1) ms.single_instance++; + total_subdraws++; + } + per_model.push_back(ms); + + const size_t nm = mm.meshes.size(); + for (size_t mi = 0; mi < nm; ++mi) { + uint32_t total_vis = 0; + uint32_t buckets_present = 0; + auto count_bucket = [&](const std::vector>& v) { + if (mi < v.size() && !v[mi].empty()) { + total_vis += static_cast(v[mi].size()); + buckets_present++; + } + }; + count_bucket(mm.vis_fwd_lod0); + count_bucket(mm.vis_fwd_lod1); + count_bucket(mm.vis_rev_lod0); + count_bucket(mm.vis_rev_lod1); + if (total_vis == 0) continue; + + unique_visible_meshes++; + mesh_vis_hist[bucket_idx(total_vis)]++; + if (total_vis > 0) subdraws_if_merged_buckets++; + + if (total_vis == 1 && buckets_present == 1) + meshes_truly_single++; + else if (buckets_present > 1) { + bool all_single = true; + auto check = [&](const std::vector>& v) { + if (mi < v.size() && v[mi].size() > 1) all_single = false; + }; + check(mm.vis_fwd_lod0); check(mm.vis_fwd_lod1); + check(mm.vis_rev_lod0); check(mm.vis_rev_lod1); + if (all_single) meshes_split_by_state++; + } + } + } + + qDebug("\n=== SUB_DRAW COMPOSITION (this frame) ==="); + qDebug("Total sub_draws: %u", total_subdraws); + const char* labels[] = {" 1", " 2", " 3-4", " 5-8", + " 9-16", "17-64", "65-256", " 257+"}; + qDebug("instanceCount histogram:"); + qDebug(" range | sub_draws | instances | triangles"); + for (int i = 0; i < 8; ++i) { + if (hist[i] == 0) continue; + qDebug(" %s | %7u | %9u | %10u", + labels[i], hist[i], instances_in_bucket[i], tris_in_bucket[i]); + } + + uint32_t single = hist[0], small = hist[0] + hist[1] + hist[2]; + qDebug("Single-instance sub_draws: %u (%.1f%%)", + single, total_subdraws ? 100.0 * single / total_subdraws : 0.0); + qDebug("Small (<=4) sub_draws: %u (%.1f%%)", + small, total_subdraws ? 100.0 * small / total_subdraws : 0.0); + + qDebug("\n--- MESH-LEVEL CONSOLIDATION ---"); + qDebug("Unique visible mesh IDs: %u", unique_visible_meshes); + qDebug("Visible instance count per mesh_id:"); + qDebug(" range | mesh_ids"); + for (int i = 0; i < 8; ++i) { + if (mesh_vis_hist[i] == 0) continue; + qDebug(" %s | %7u", labels[i], mesh_vis_hist[i]); + } + + qDebug("\nAmong single-instance sub_draws (%u):", single); + qDebug(" Truly unique (1 inst, 1 bucket): %u", meshes_truly_single); + qDebug(" Split by state (>1 bucket, each =1): %u (saves %u sub_draws if merged)", + meshes_split_by_state, meshes_split_by_state); + + qDebug("\nEstimated sub_draws by grouping strategy:"); + qDebug(" Current (mesh_id x winding x LOD): %u", total_subdraws); + qDebug(" Merged buckets (mesh_id only): %u (%.0f%% reduction)", + subdraws_if_merged_buckets, + total_subdraws ? 100.0 * (1.0 - (double)subdraws_if_merged_buckets / total_subdraws) : 0.0); + + std::sort(per_model.begin(), per_model.end(), + [](const ModelStats& a, const ModelStats& b) { + return a.subdraws > b.subdraws; + }); + qDebug("\nTop 15 models by sub_draw count:"); + qDebug(" model_id | sub_draws | single_inst | meshes | instances"); + for (size_t i = 0; i < std::min(15, per_model.size()); ++i) { + const auto& ms = per_model[i]; + qDebug(" %7u | %7u | %7u | %7u | %7u", + ms.model_id, ms.subdraws, ms.single_instance, + ms.total_meshes, ms.total_instances); + } + qDebug("=== END SUB_DRAW COMPOSITION ===\n"); + } } } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 3a0b7e4049..a442802176 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -376,6 +376,11 @@ private: QMatrix4x4 last_cull_proj_; bool have_cached_cull_ = false; + // Motion-adaptive contribution culling. During camera motion, use a + // larger pixel-radius threshold to aggressively cull small objects. + // When the camera stops, re-cull once at the base threshold. + bool last_cull_was_motion_ = false; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; From ed6e8d831e60350bdad7ba8c9c942c3c3bf1d2e7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 18 Apr 2026 21:27:28 +1000 Subject: [PATCH 052/120] ifcviewer: benchmark CLI, settle recull fix, and Phase 3G documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --camera tx,ty,tz,dist,yaw,pitch and --benchmark N CLI args for reproducible performance measurement. The benchmark orbits the camera (0.5°/frame yaw) for N frames after a 5-frame warmup, prints avg/median/p1/p99 frame times, then exits. Press C during interactive use to print the current camera as a --camera argument. Fix settle recull to fire after ANY camera motion (not just when IFC_MIN_PX_MOTION is set), ensuring HiZ artifacts from motion frames are always cleared when the camera stops. Document Phase 3G (motion-adaptive culling + HiZ during motion) in README with benchmark results from 1.06M-instance scene: - Baseline: 16.3 fps - IFC_MIN_PX_MOTION=10: 26.5 fps (1.6x) - IFC_HIZ_MOTION=1: 46.6 fps (2.9x) - Both combined: 51.0 fps (3.1x) - + GPU_CULL: 52.0 fps (3.2x, negligible gain) Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/MainWindow.cpp | 32 ++++++++ src/ifcviewer/MainWindow.h | 7 ++ src/ifcviewer/README.md | 121 +++++++++++++++++++++++++------ src/ifcviewer/ViewportWindow.cpp | 89 +++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 13 ++++ src/ifcviewer/main.cpp | 11 +++ 6 files changed, 242 insertions(+), 31 deletions(-) diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer/MainWindow.cpp index e75f7cf0dd..865b464170 100644 --- a/src/ifcviewer/MainWindow.cpp +++ b/src/ifcviewer/MainWindow.cpp @@ -190,6 +190,7 @@ void MainWindow::connectStreamer(GeometryStreamer* streamer) { void MainWindow::startNextLoad() { if (load_queue_.empty()) { loading_model_id_ = 0; + applyPendingBenchmark(); return; } @@ -554,3 +555,34 @@ void MainWindow::populateProperties(uint32_t object_id) { } } } + +void MainWindow::setPendingCamera(const QString& params) { + pending_camera_ = params; +} + +void MainWindow::setPendingBenchmark(int frames) { + pending_benchmark_ = frames; +} + +void MainWindow::applyPendingBenchmark() { + if (pending_camera_.isEmpty() && pending_benchmark_ <= 0) return; + + if (!pending_camera_.isEmpty()) { + QStringList parts = pending_camera_.split(','); + if (parts.size() == 6) { + viewport_->setCamera( + parts[0].toFloat(), parts[1].toFloat(), parts[2].toFloat(), + parts[3].toFloat(), parts[4].toFloat(), parts[5].toFloat()); + qDebug("Camera set: %s", qPrintable(pending_camera_)); + } else { + qWarning("--camera expects 6 comma-separated values: tx,ty,tz,dist,yaw,pitch"); + } + pending_camera_.clear(); + } + + if (pending_benchmark_ > 0) { + qDebug("Starting benchmark: %d frames", pending_benchmark_); + viewport_->setBenchmarkFrames(pending_benchmark_); + pending_benchmark_ = 0; + } +} diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer/MainWindow.h index 5270676af5..4f5a946998 100644 --- a/src/ifcviewer/MainWindow.h +++ b/src/ifcviewer/MainWindow.h @@ -57,6 +57,8 @@ public: ~MainWindow(); void addFiles(const QStringList& paths); + void setPendingCamera(const QString& params); + void setPendingBenchmark(int frames); private slots: void onFileOpen(); @@ -109,6 +111,11 @@ private: static uint64_t scopedKey(uint32_t model_id, int ifc_id) { return (static_cast(model_id) << 32) | static_cast(ifc_id); } + + QString pending_camera_; + int pending_benchmark_ = 0; + + void applyPendingBenchmark(); }; #endif // MAINWINDOW_H diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 81b128bdf8..17cc4b7f8f 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -694,31 +694,33 @@ thousands and the frame time drops accordingly. ##### Known caveats -- **Disabled while the camera moves.** The pyramid is aligned to the - VP matrix of the frame that produced it. On a moving camera the - stored VP no longer matches the current one, and reusing it would - pop objects in and out as the stale depth falsely claims they're - occluded. The cull now compares `hiz_vp_ == current_vp` and drops - HiZ rejection entirely when they differ, so HiZ only contributes on - still frames. The honest cost: orbiting — the exact motion where - the frame rate tends to dip — gets no HiZ help. A proper fix needs - a same-frame depth pre-pass (draw cheap depth, build HiZ from *that* - frame's VP, then issue the colour pass against it); deferred to the - GPU-compute cull rewrite in Phase 3E where we're touching this code - anyway. We also tried a 3-deep PBO ring for async readback (2-frame - stale) which produced visible flicker on fast orbits — reverted. +- **Optional during camera motion (`IFC_HIZ_MOTION=1`).** The pyramid + is aligned to the previous frame's VP. On a moving camera the stale + depth can falsely occlude objects, particularly thin geometry (pipes, + railings) at oblique angles. By default HiZ is disabled during motion + (`hiz_vp_ == current_vp` check). Setting `IFC_HIZ_MOTION=1` forces + HiZ on during motion — benchmarks show this is the single biggest + perf lever (2.9× speedup), and the artifacts are transient and minor + during active orbiting. When the camera stops, a settle recull fires + with `hiz_vp_valid_ = false`, disabling HiZ for that one frame and + re-culling the full scene. This guarantees the stationary view is + artifact-free. See Phase 3G for benchmark data. +- **Conservative occlusion test.** The original "max over coarse mip" + test was too aggressive for BIM scenes where the entire depth range + compresses into 0.99–1.00. Replaced with "all fine-mip texels must + agree" — sample at mip 1, reject only if every texel has depth less + than the AABB's nearest point, early-out on the first non-occluding + texel. Queries covering >64 texels skip HiZ entirely. Eliminates + most false occlusions at the cost of fewer true rejections. +- **Depth blit replaced with shader downsample.** The original + `glBlitFramebuffer` for scaling the resolved depth to HiZ size + produced `GL_INVALID_VALUE` on some drivers. Replaced with a + fullscreen-triangle shader writing `gl_FragDepth`. The resolve + texture uses `GL_DEPTH24_STENCIL8` to match Qt's default FBO format + (which uses D24S8 even when only depth is requested). - **Readback syncs the GPU.** `glGetTextureImage` is blocking. Measured cost is well under a millisecond at 256×128; not a - bottleneck on the machines tested. Phase 3D's compute-shader cull - removes it entirely. -- **Doesn't move the needle on overview shots.** Those scenes are - CPU-bound on the cull traversal itself, not GPU-bound on drawing, - so cutting the drawn-triangle count in half is invisible in the - frame time. `hiz_rej` still rises modestly on overviews (the frustum - hull contains everything behind visible walls) but saved GPU work - is masked by CPU cost. HiZ pays off on interior views, where the - GPU *was* the bottleneck. If a project never leaves overview, - `IFC_NO_HIZ=1` shaves the ~1 ms of HiZ cost. + bottleneck on the machines tested. - **Transparent geometry would need special handling**, but the current renderer doesn't have any, so no-op for now. @@ -961,6 +963,74 @@ doors, windows, pipe fittings) share geometry across placements. ratio (~80 k vs ~120 k), confirming per-draw overhead as the dominant cost. +#### 3G. Motion-adaptive culling + HiZ during motion — ✅ done + +The bottleneck during camera orbit is the sheer number of visible +objects and sub_draws. Two complementary strategies address this: + +##### Motion-adaptive contribution culling (`IFC_MIN_PX_MOTION`) + +During camera motion, use a larger pixel-radius threshold to hide +small objects that contribute little at interactive rates. When the +camera stops, a settle recull restores the base threshold and full +detail within one frame. No visual artifacts — objects below the +motion threshold are genuinely tiny on screen. + +##### HiZ during motion (`IFC_HIZ_MOTION=1`) + +Force the one-frame-stale HiZ pyramid to remain active during camera +motion. The stale depth causes minor false occlusions on thin +geometry at oblique angles, but these are transient during active +orbit. When the camera stops, the settle recull invalidates the HiZ +pyramid (`hiz_vp_valid_ = false`) and re-culls without HiZ, +guaranteeing the stationary view is artifact-free. + +##### Benchmark results + +Benchmarked on 1.06 M-instance / 111-model scene, 200-frame orbit +(103° arc, 0.5°/frame), GTX 1650: + +| Configuration | avg ms | fps | speedup | obj | sub_draws | hiz_rej | +|----------------------------------|--------|------|---------|-------|-----------|---------| +| Baseline (no opts) | 61.25 | 16.3 | 1.0× | 254k | 155k | 0 | +| MIN_PX_MOTION=10 | 37.67 | 26.5 | 1.6× | 70k | 56k | 0 | +| HIZ_MOTION=1 | 21.44 | 46.6 | 2.9× | 33k | 17.5k | 28k | +| HIZ_MOTION=1 + MIN_PX_MOTION=10 | 19.62 | 51.0 | 3.1× | 11.4k | 8.7k | 11.5k | +| GPU_CULL + HIZ + MIN_PX | 19.22 | 52.0 | 3.2× | 11.3k | 8.6k | 59k | + +##### Conclusions + +1. **HiZ during motion is the biggest single lever** — 2.9× alone. + Artifacts are minor and transient during orbit; the stationary view + is guaranteed correct by the settle recull. + +2. **Motion pixel culling is clean and effective** — 1.6× with zero + artifacts. + +3. **Combining both gives diminishing returns** — 3.1× vs 2.9× (HiZ + alone) or 1.6× (MIN_PX alone). They compete over the same objects. + +4. **GPU cull adds nothing** on top of these — 52.0 vs 51.0 fps. The + CPU BVH path handles the reduced visible set in ~2 ms. + +5. **The ~19 ms floor is GPU rendering**, not culling. At 8.6k + sub_draws the bottleneck shifts to draw dispatch + triangle + rasterization. Further improvement requires reducing sub_draws + (static batching) or moving to a more efficient draw model. + +##### Benchmark CLI + +Press **C** during interactive use to print the current camera as a +`--camera` argument. Then benchmark reproducibly: + +```bash +./IfcViewer --camera tx,ty,tz,dist,yaw,pitch --benchmark 200 files... +``` + +The benchmark orbits the camera (0.5°/frame yaw), measures N frames +after a 5-frame warmup, prints avg/median/p1/p99 frame times, then +exits. Env vars control the test configuration. + ### Planned follow-ups (post-Phase-3) - **Mesh shaders / meshlets.** Ceiling-raising, but overkill until the @@ -979,6 +1049,7 @@ Scene size Bottleneck Fix multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybrid, done) +orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling + HiZ (done, 3.1×) 90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next) ``` @@ -996,7 +1067,7 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybri - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`) +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`, `IFC_MIN_PX_MOTION`, `IFC_HIZ_MOTION`, `IFC_GPU_CULL`, `IFC_SUBDRAW_DIAG`) - [x] Phase 3A — screen-space contribution culling - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) - [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) @@ -1004,6 +1075,8 @@ single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybri - [x] Quantized VBO (16 B/vert, sidecar v6) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) - [x] Phase 3E — GPU compute-shader culling (hybrid: GPU frustum+contribution, async readback, CPU HiZ+LOD+emit) +- [x] Phase 3G — Motion-adaptive culling + HiZ during motion (3.1× orbit speedup on 1M-instance scene) +- [x] Benchmark CLI (`--camera`, `--benchmark`, press C to capture camera) - [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds) - [ ] Vulkan/MoltenVK backend for macOS - [ ] Embedded Python scripting console diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index e67a378fd3..eb49854a91 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -22,8 +22,10 @@ #include "AppSettings.h" #include +#include #include #include +#include #include #include @@ -1203,6 +1205,44 @@ void ViewportWindow::setSelectedObjectId(uint32_t id) { requestUpdate(); } +void ViewportWindow::setCamera(float tx, float ty, float tz, + float dist, float yaw, float pitch) { + camera_target_ = QVector3D(tx, ty, tz); + camera_distance_ = dist; + camera_yaw_ = yaw; + camera_pitch_ = pitch; + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::setBenchmarkFrames(int n) { + benchmark_total_ = n; + benchmark_count_ = 0; + benchmark_warmup_ = 5; + benchmark_yaw_start_ = camera_yaw_; + benchmark_frame_times_.clear(); + benchmark_frame_times_.reserve(n); + requestUpdate(); +} + +QString ViewportWindow::cameraString() const { + return QString("%1,%2,%3,%4,%5,%6") + .arg(camera_target_.x(), 0, 'f', 4) + .arg(camera_target_.y(), 0, 'f', 4) + .arg(camera_target_.z(), 0, 'f', 4) + .arg(camera_distance_, 0, 'f', 4) + .arg(camera_yaw_, 0, 'f', 2) + .arg(camera_pitch_, 0, 'f', 2); +} + +void ViewportWindow::keyPressEvent(QKeyEvent* event) { + if (event->key() == Qt::Key_C && !(event->modifiers() & Qt::ControlModifier)) { + qDebug("--camera %s", qPrintable(cameraString())); + return; + } + QWindow::keyPressEvent(event); +} + // --- HiZ occlusion culling (Phase 3C) ----------------------------------- // Baseline HiZ resolution. 256x128 is enough to cull big occluders @@ -1983,24 +2023,23 @@ void ViewportWindow::render() { && last_cull_view_ == view_matrix_ && last_cull_proj_ == proj_matrix_; const bool camera_moving = !camera_unchanged; - // Force a re-cull on the first still frame after motion so we - // restore the base (tighter) contribution threshold. - const bool needs_settle_recull = !camera_moving - && last_cull_was_motion_ + const bool use_motion_threshold = camera_moving && motion_min_pixel_radius > base_min_pixel_radius; + // Force a re-cull on the first still frame after motion so we + // restore the base contribution threshold and clear stale HiZ. + const bool needs_settle_recull = !camera_moving + && last_cull_was_motion_; const bool cull_this_frame = camera_moving || needs_settle_recull; // Invalidate HiZ on the settle frame: the pyramid was built from the // motion frame's sparse depth (aggressive threshold hid objects whose // depth would normally populate the pyramid), causing false occlusion. if (needs_settle_recull) hiz_vp_valid_ = false; - const bool use_motion_threshold = camera_moving - && motion_min_pixel_radius > base_min_pixel_radius; const float min_pixel_radius = use_motion_threshold ? motion_min_pixel_radius : base_min_pixel_radius; if (cull_this_frame) { hiz_reject_count_.store(0, std::memory_order_relaxed); - last_cull_was_motion_ = use_motion_threshold; + last_cull_was_motion_ = camera_moving; } else { ++cull_skipped_frames_; } @@ -2353,6 +2392,42 @@ void ViewportWindow::render() { // Reported fps = "if I rendered continuously, this is the rate I'd hit", // which is what profiling actually wants. const float frame_cost_s = frame_cost_clock.nsecsElapsed() * 1e-9f; + + if (benchmark_total_ > 0) { + camera_yaw_ += benchmark_yaw_speed_; + have_cached_cull_ = false; + + if (benchmark_warmup_ > 0) { + --benchmark_warmup_; + } else { + benchmark_frame_times_.push_back(frame_cost_s * 1000.0f); + ++benchmark_count_; + } + if (benchmark_count_ >= benchmark_total_) { + std::sort(benchmark_frame_times_.begin(), benchmark_frame_times_.end()); + float sum = 0.0f; + for (float t : benchmark_frame_times_) sum += t; + float avg = sum / benchmark_frame_times_.size(); + float median = benchmark_frame_times_[benchmark_frame_times_.size() / 2]; + float p1 = benchmark_frame_times_[(size_t)(benchmark_frame_times_.size() * 0.01f)]; + float p99 = benchmark_frame_times_[(size_t)(benchmark_frame_times_.size() * 0.99f)]; + float total_arc = benchmark_yaw_speed_ * (benchmark_total_ + 5); + qDebug("\n=== BENCHMARK (%d frames, orbit %.0f° at %.1f°/frame) ===", + benchmark_total_, total_arc, benchmark_yaw_speed_); + qDebug(" avg: %.2f ms (%.1f fps)", avg, 1000.0f / avg); + qDebug(" median: %.2f ms (%.1f fps)", median, 1000.0f / median); + qDebug(" p1: %.2f ms p99: %.2f ms", p1, p99); + qDebug(" last frame: obj %u tri %u sub_draws %u hiz_rej %u", + visible_objects_, visible_triangles_, + indirect_sub_draws_, + hiz_reject_count_.load()); + qDebug("=== END BENCHMARK ===\n"); + QCoreApplication::quit(); + return; + } + requestUpdate(); + } + accumulated_time_ += frame_cost_s; frame_count_++; if (accumulated_time_ >= 1.0f) { diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index a442802176..261cef6e0f 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -174,6 +174,10 @@ public: void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); + void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch); + void setBenchmarkFrames(int n); + QString cameraString() const; + struct FrameStats { float fps; float frame_time_ms; @@ -194,6 +198,7 @@ signals: protected: void exposeEvent(QExposeEvent* event) override; void resizeEvent(QResizeEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; bool event(QEvent* event) override; private: @@ -381,6 +386,14 @@ private: // When the camera stops, re-cull once at the base threshold. bool last_cull_was_motion_ = false; + // Benchmark mode: render N frames, collect stats, then exit. + int benchmark_total_ = 0; + int benchmark_count_ = 0; + int benchmark_warmup_ = 5; + float benchmark_yaw_start_ = 0.0f; + float benchmark_yaw_speed_ = 0.5f; // degrees per frame + std::vector benchmark_frame_times_; + // Per-frame stats uint32_t visible_triangles_ = 0; uint32_t visible_objects_ = 0; diff --git a/src/ifcviewer/main.cpp b/src/ifcviewer/main.cpp index a5bb487db8..d15a525c53 100644 --- a/src/ifcviewer/main.cpp +++ b/src/ifcviewer/main.cpp @@ -41,6 +41,10 @@ int main(int argc, char* argv[]) { parser.setApplicationDescription("IfcOpenShell IFC Viewer"); parser.addHelpOption(); parser.addPositionalArgument("files", "IFC file(s) to open", "[files...]"); + parser.addOption({{"c", "camera"}, + "Set camera: tx,ty,tz,dist,yaw,pitch", "params"}); + parser.addOption({{"b", "benchmark"}, + "Run N frames then print stats and exit", "frames"}); parser.process(app); MainWindow window; @@ -51,5 +55,12 @@ int main(int argc, char* argv[]) { window.addFiles(args); } + if (parser.isSet("camera")) { + window.setPendingCamera(parser.value("camera")); + } + if (parser.isSet("benchmark")) { + window.setPendingBenchmark(parser.value("benchmark").toInt()); + } + return app.exec(); } From 094d96c7356e7abf342e120b66a9f52cc1a21092 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 19 Apr 2026 18:59:08 +1000 Subject: [PATCH 053/120] ifcviewer: remove GPU compute cull (IFC_GPU_CULL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmarks showed negligible gain (52 vs 51 fps) — the CPU BVH path already culls efficiently, and the GPU path still read back to CPU for LOD/winding/HiZ. Removes ~570 lines of dead weight: compute shader, async readback, one-frame-late consume, per-model AABB SSBOs, and profiling counters. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/ViewportWindow.cpp | 542 +------------------------------ src/ifcviewer/ViewportWindow.h | 55 ---- 2 files changed, 12 insertions(+), 585 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index eb49854a91..dd0b1a4462 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -249,15 +249,6 @@ static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const ch return shader; } -// Phase 3E compute cull (frustum-only, validation). Reads a model's -// per-instance AABB SSBO, tests against 6 planes, atomicAdds on a global -// counter. No visible list / indirect writeout yet; result is cross-checked -// against the CPU cull's visible_objects count to prove plumbing is correct -// before we hand the GPU the full emit responsibility. Gated by IFC_GPU_CULL=1. -// GPU frustum + contribution cull. Per-model AABB SSBO at binding 0; shared -// counter at binding 1; shared survivor-index output at binding 2. Each -// survivor is written as (u_model_tag | local_instance_index) so the CPU can -// unpack model + local index from one uint. static const char* HIZ_DOWNSAMPLE_VS = R"( #version 450 core void main() { @@ -277,67 +268,6 @@ void main() { } )"; -static const char* CULL_COMPUTE_SHADER = R"( -#version 450 core -layout(local_size_x = 64) in; - -layout(std430, binding = 0) readonly buffer AabbBuf { vec4 entries[]; }; -layout(std430, binding = 1) coherent buffer CountBuf { uint counter; }; -layout(std430, binding = 2) writeonly buffer OutBuf { uint survivors[]; }; - -uniform vec4 u_planes[6]; -uniform uint u_count; -uniform vec3 u_camera_eye; -uniform float u_focal_px; -uniform float u_min_pixel_radius; -uniform uint u_model_tag; - -void main() { - uint gid = gl_GlobalInvocationID.x; - if (gid >= u_count) return; - vec3 mn = entries[gid * 2u].xyz; - vec3 mx = entries[gid * 2u + 1u].xyz; - - for (int i = 0; i < 6; ++i) { - vec3 pv = vec3( - u_planes[i].x >= 0.0 ? mx.x : mn.x, - u_planes[i].y >= 0.0 ? mx.y : mn.y, - u_planes[i].z >= 0.0 ? mx.z : mn.z); - if (dot(u_planes[i].xyz, pv) + u_planes[i].w < 0.0) return; - } - - if (u_min_pixel_radius > 0.0) { - bool inside = all(greaterThanEqual(u_camera_eye, mn)) - && all(lessThanEqual(u_camera_eye, mx)); - if (!inside) { - vec3 ext = 0.5 * (mx - mn); - float radius = length(ext); - vec3 center = 0.5 * (mn + mx); - float dist = length(center - u_camera_eye); - if (u_focal_px * radius < u_min_pixel_radius * dist) return; - } - } - - uint slot = atomicAdd(counter, 1u); - survivors[slot] = u_model_tag | gid; -} -)"; - -static GLuint linkComputeProgram(QOpenGLFunctions_4_5_Core* gl, const char* src) { - GLuint cs = compileShader(gl, GL_COMPUTE_SHADER, src); - GLuint prog = gl->glCreateProgram(); - gl->glAttachShader(prog, cs); - gl->glLinkProgram(prog); - GLint ok = 0; - gl->glGetProgramiv(prog, GL_LINK_STATUS, &ok); - if (!ok) { - char log[2048]; - gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log); - qWarning("Compute program link error: %s", log); - } - gl->glDeleteShader(cs); - return prog; -} static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint frag) { GLuint prog = gl->glCreateProgram(); @@ -500,22 +430,12 @@ ViewportWindow::~ViewportWindow() { if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); - if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); } 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 (cull_program_) gl_->glDeleteProgram(cull_program_); - if (gpu_cull_counter_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_counter_ssbo_); - if (gpu_cull_survivor_ssbo_) gl_->glDeleteBuffers(1, &gpu_cull_survivor_ssbo_); - if (gpu_cull_readback_buf_) { - gl_->glUnmapNamedBuffer(gpu_cull_readback_buf_); - gl_->glDeleteBuffers(1, &gpu_cull_readback_buf_); - } - if (gpu_cull_fence_) gl_->glDeleteSync(gpu_cull_fence_); - if (gpu_cull_ts_[0]) gl_->glDeleteQueries(2, gpu_cull_ts_); 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_); @@ -613,11 +533,6 @@ void ViewportWindow::buildShaders() { hiz_downsample_program_ = linkProgram(gl_, vs, fs); gl_->glCreateVertexArrays(1, &hiz_downsample_vao_); } - cull_program_ = linkComputeProgram(gl_, CULL_COMPUTE_SHADER); - gl_->glCreateBuffers(1, &gpu_cull_counter_ssbo_); - gl_->glNamedBufferStorage(gpu_cull_counter_ssbo_, sizeof(uint32_t), nullptr, - GL_DYNAMIC_STORAGE_BIT); - gl_->glGenQueries(2, gpu_cull_ts_); } void ViewportWindow::buildAxisGizmo() { @@ -879,48 +794,6 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { requestUpdate(); } -// Matches the std430 layout the GPU compute cull will consume. -struct InstanceAabbGpu { - float min[3]; - uint32_t mesh_id; - float max[3]; - uint32_t flags; // bit 0 = reflected -}; -static_assert(sizeof(InstanceAabbGpu) == 32, "InstanceAabbGpu must be 32 bytes"); - -void ViewportWindow::uploadInstanceAabbs(ModelGpuData& m) { - const size_t n = m.instances.size(); - const size_t bytes = n * sizeof(InstanceAabbGpu); - - if (m.aabb_ssbo && m.aabb_ssbo_capacity < bytes) { - gl_->glDeleteBuffers(1, &m.aabb_ssbo); - m.aabb_ssbo = 0; - m.aabb_ssbo_capacity = 0; - } - if (!m.aabb_ssbo) { - gl_->glCreateBuffers(1, &m.aabb_ssbo); - const size_t cap = std::max(bytes, sizeof(InstanceAabbGpu)); - gl_->glNamedBufferStorage(m.aabb_ssbo, cap, nullptr, GL_DYNAMIC_STORAGE_BIT); - m.aabb_ssbo_capacity = cap; - } - if (n == 0) return; - - std::vector packed(n); - for (size_t i = 0; i < n; ++i) { - const InstanceCpu& src = m.instances[i]; - InstanceAabbGpu& dst = packed[i]; - dst.min[0] = src.world_aabb_min[0]; - dst.min[1] = src.world_aabb_min[1]; - dst.min[2] = src.world_aabb_min[2]; - dst.max[0] = src.world_aabb_max[0]; - dst.max[1] = src.world_aabb_max[1]; - dst.max[2] = src.world_aabb_max[2]; - dst.mesh_id = src.mesh_id; - dst.flags = (i < m.instance_reflected.size() && m.instance_reflected[i]) ? 1u : 0u; - } - gl_->glNamedBufferSubData(m.aabb_ssbo, 0, bytes, packed.data()); -} - void ViewportWindow::finalizeModel(uint32_t model_id) { if (!gl_initialized_) return; context_->makeCurrent(this); @@ -940,7 +813,6 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { } buildBvhForModel(m, model_id); - uploadInstanceAabbs(m); m.finalized = true; have_cached_cull_ = false; @@ -993,7 +865,6 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { if (existing->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &existing->second.mesh_info_ssbo); if (existing->second.visible_ssbo) gl_->glDeleteBuffers(1, &existing->second.visible_ssbo); if (existing->second.indirect_buffer) gl_->glDeleteBuffers(1, &existing->second.indirect_buffer); - if (existing->second.aabb_ssbo) gl_->glDeleteBuffers(1, &existing->second.aabb_ssbo); models_gpu_.erase(existing); } @@ -1076,7 +947,6 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } buildBvhForModel(m, model_id); - uploadInstanceAabbs(m); m.finalized = true; models_gpu_.emplace(model_id, std::move(m)); @@ -1137,27 +1007,8 @@ void ViewportWindow::resetScene() { if (m.mesh_info_ssbo) gl_->glDeleteBuffers(1, &m.mesh_info_ssbo); if (m.visible_ssbo) gl_->glDeleteBuffers(1, &m.visible_ssbo); if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer); - if (m.aabb_ssbo) gl_->glDeleteBuffers(1, &m.aabb_ssbo); } models_gpu_.clear(); - if (gpu_cull_survivor_ssbo_) { - gl_->glDeleteBuffers(1, &gpu_cull_survivor_ssbo_); - gpu_cull_survivor_ssbo_ = 0; - gpu_cull_survivor_capacity_ = 0; - } - if (gpu_cull_readback_buf_) { - gl_->glUnmapNamedBuffer(gpu_cull_readback_buf_); - gl_->glDeleteBuffers(1, &gpu_cull_readback_buf_); - gpu_cull_readback_buf_ = 0; - gpu_cull_readback_ptr_ = nullptr; - gpu_cull_readback_capacity_ = 0; - } - if (gpu_cull_fence_) { - gl_->glDeleteSync(gpu_cull_fence_); - gpu_cull_fence_ = nullptr; - } - gpu_cull_pending_.model_targets.clear(); - gpu_cull_pending_.total_in = 0; selected_object_id_ = 0; have_cached_cull_ = false; requestUpdate(); @@ -1193,7 +1044,6 @@ void ViewportWindow::removeModel(uint32_t model_id) { if (it->second.mesh_info_ssbo) gl_->glDeleteBuffers(1, &it->second.mesh_info_ssbo); if (it->second.visible_ssbo) gl_->glDeleteBuffers(1, &it->second.visible_ssbo); if (it->second.indirect_buffer) gl_->glDeleteBuffers(1, &it->second.indirect_buffer); - if (it->second.aabb_ssbo) gl_->glDeleteBuffers(1, &it->second.aabb_ssbo); models_gpu_.erase(it); have_cached_cull_ = false; requestUpdate(); @@ -1775,144 +1625,6 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], cull_emit_ns_ += phase_timer.nsecsElapsed(); } -void ViewportWindow::emitFromGpuSurvivors( - ModelGpuData& m, - const uint32_t* survivor_indices, uint32_t count, - float focal_px, float min_pixel_radius) { - - QElapsedTimer pt; pt.start(); - - auto resize_if = [&](std::vector>& v) { - if (v.size() < m.meshes.size()) v.resize(m.meshes.size()); - }; - resize_if(m.vis_fwd_lod0); - resize_if(m.vis_fwd_lod1); - resize_if(m.vis_rev_lod0); - resize_if(m.vis_rev_lod1); - for (uint32_t mi : m.dirty_meshes) { - m.vis_fwd_lod0[mi].clear(); - m.vis_fwd_lod1[mi].clear(); - m.vis_rev_lod0[mi].clear(); - m.vis_rev_lod1[mi].clear(); - } - m.dirty_meshes.clear(); - - gpu_consume_clear_ns_.fetch_add(pt.nsecsElapsed(), std::memory_order_relaxed); - pt.restart(); - - static const float lod1_px_threshold = []{ - const char* e = std::getenv("IFC_LOD1_PX"); - return (e && *e) ? static_cast(std::atof(e)) : 30.0f; - }(); - - const float cx = camera_eye_.x(); - const float cy = camera_eye_.y(); - const float cz = camera_eye_.z(); - auto pixelRadius = [&](const float mn[3], const float mx[3]) -> float { - if (cx >= mn[0] && cx <= mx[0] && - cy >= mn[1] && cy <= mx[1] && - cz >= mn[2] && cz <= mx[2]) { - return std::numeric_limits::infinity(); - } - float ex = 0.5f * (mx[0] - mn[0]); - float ey = 0.5f * (mx[1] - mn[1]); - float ez = 0.5f * (mx[2] - mn[2]); - float radius = std::sqrt(ex*ex + ey*ey + ez*ez); - float dx = 0.5f * (mx[0] + mn[0]) - cx; - float dy = 0.5f * (mx[1] + mn[1]) - cy; - float dz = 0.5f * (mx[2] + mn[2]) - cz; - float dist = std::sqrt(dx*dx + dy*dy + dz*dz); - return dist > 0.0f ? focal_px * radius / dist - : std::numeric_limits::infinity(); - }; - - const QMatrix4x4 current_vp = proj_matrix_ * view_matrix_; - static const bool hiz_force_motion = []{ - const char* e = std::getenv("IFC_HIZ_MOTION"); - return e && *e && std::atoi(e) != 0; - }(); - const bool hiz_vp_matches = hiz_vp_valid_ - && (hiz_force_motion || hiz_vp_ == current_vp); - const bool hiz_on = hizEnabled() && min_pixel_radius > 0.0f && hiz_vp_matches; - - thread_local std::vector mesh_seen; - mesh_seen.assign(m.meshes.size(), false); - - for (uint32_t si = 0; si < count; ++si) { - uint32_t inst_idx = survivor_indices[si]; - if (inst_idx >= m.bvh_items.size()) continue; - const BvhItem& item = m.bvh_items[inst_idx]; - if (hiz_on && aabbOccludedByHiz(item.aabb_min, item.aabb_max)) { - hiz_reject_count_.fetch_add(1, std::memory_order_relaxed); - continue; - } - const InstanceCpu& inst = m.instances[inst_idx]; - if (inst.mesh_id >= m.meshes.size()) continue; - const MeshInfo& mesh = m.meshes[inst.mesh_id]; - const bool want_lod1 = mesh.lod1_index_count > 0 && - lod1_px_threshold > 0.0f && - pixelRadius(item.aabb_min, item.aabb_max) < lod1_px_threshold; - const bool reflected = inst_idx < m.instance_reflected.size() - && m.instance_reflected[inst_idx] != 0; - auto& bucket = - reflected ? (want_lod1 ? m.vis_rev_lod1 - : m.vis_rev_lod0) - : (want_lod1 ? m.vis_fwd_lod1 - : m.vis_fwd_lod0); - bucket[inst.mesh_id].push_back(inst_idx); - if (!mesh_seen[inst.mesh_id]) { - mesh_seen[inst.mesh_id] = true; - m.dirty_meshes.push_back(inst.mesh_id); - } - } - - gpu_consume_class_ns_.fetch_add(pt.nsecsElapsed(), std::memory_order_relaxed); - pt.restart(); - - m.visible_flat.clear(); - m.indirect_scratch.clear(); - - auto emit_slice = [&](std::vector>& by_mesh, int lod) { - for (uint32_t mi : m.dirty_meshes) { - const auto& mesh = m.meshes[mi]; - const uint32_t vis_count = static_cast(by_mesh[mi].size()); - const uint32_t idx_count = - (lod == 1) ? mesh.lod1_index_count : mesh.index_count; - const uint32_t ebo_off = - (lod == 1) ? mesh.lod1_ebo_byte_offset : mesh.ebo_byte_offset; - if (vis_count == 0 || idx_count == 0) continue; - - DrawElementsIndirectCommand cmd; - cmd.count = idx_count; - cmd.instanceCount = vis_count; - cmd.firstIndex = ebo_off / sizeof(uint32_t); - cmd.baseVertex = mesh.vbo_byte_offset / INSTANCED_VERTEX_STRIDE_BYTES; - cmd.baseInstance = static_cast(m.visible_flat.size()); - m.indirect_scratch.push_back(cmd); - - m.visible_flat.insert(m.visible_flat.end(), - by_mesh[mi].begin(), by_mesh[mi].end()); - } - }; - - emit_slice(m.vis_fwd_lod0, 0); - emit_slice(m.vis_fwd_lod1, 1); - m.indirect_forward_count = static_cast(m.indirect_scratch.size()); - emit_slice(m.vis_rev_lod0, 0); - emit_slice(m.vis_rev_lod1, 1); - m.indirect_command_count = static_cast(m.indirect_scratch.size()); - - uint32_t model_vis_obj = 0, model_vis_tri = 0; - for (const auto& cmd : m.indirect_scratch) { - model_vis_tri += (cmd.count / 3) * cmd.instanceCount; - model_vis_obj += cmd.instanceCount; - } - m.cached_visible_objects = model_vis_obj; - m.cached_visible_triangles = model_vis_tri; - - gpu_consume_emit_ns_.fetch_add(pt.nsecsElapsed(), std::memory_order_relaxed); -} - void ViewportWindow::uploadCullResults(ModelGpuData& m) { QElapsedTimer phase_timer; phase_timer.start(); @@ -2048,10 +1760,6 @@ void ViewportWindow::render() { // back and forth. Harmless when culling is off. gl_->glFrontFace(GL_CCW); - static const bool gpu_cull_enabled = []{ - const char* e = std::getenv("IFC_GPU_CULL"); - return e && e[0] == '1'; - }(); static const bool mt_cull_enabled = []{ const char* e = std::getenv("IFC_CULL_THREADS"); return !(e && e[0] == '0'); @@ -2067,228 +1775,21 @@ void ViewportWindow::render() { cull_targets.push_back(&m); } - // --- Try to consume last frame's GPU cull results (one-frame-late) --- - // Skip GPU consume on the settle re-cull: the pending results were - // dispatched at the motion threshold and would be too aggressively - // culled. Fall through to CPU which culls at the base threshold. - bool gpu_consumed = false; - if (gpu_cull_enabled && gpu_cull_fence_ && !needs_settle_recull) { - GLenum sync_status = gl_->glClientWaitSync( - gpu_cull_fence_, 0, 0); - if (sync_status == GL_ALREADY_SIGNALED || - sync_status == GL_CONDITION_SATISFIED) { - gl_->glDeleteSync(gpu_cull_fence_); - gpu_cull_fence_ = nullptr; - - // Read GPU timestamp delta. - uint64_t ts0 = 0, ts1 = 0; - gl_->glGetQueryObjectui64v(gpu_cull_ts_[0], GL_QUERY_RESULT, &ts0); - gl_->glGetQueryObjectui64v(gpu_cull_ts_[1], GL_QUERY_RESULT, &ts1); - gpu_cull_dispatch_ns_ += (ts1 > ts0) ? (ts1 - ts0) : 0; - - QElapsedTimer readback_timer; readback_timer.start(); - - // Read counter from persistent-mapped readback buffer. - // Counter is at offset 0, survivor indices follow at offset 4. - uint32_t survivor_count = gpu_cull_readback_ptr_[0]; - const uint32_t* surv_data = gpu_cull_readback_ptr_ + 1; - - gpu_cull_last_survivors_ = survivor_count; - gpu_cull_last_input_ = gpu_cull_pending_.total_in; - - // Validate the models from the pending dispatch still match - // the current scene. If models were added/removed between - // frames, the tags are stale — fall through to CPU. - bool targets_match = true; - if (gpu_cull_pending_.model_targets.size() != cull_targets.size()) { - targets_match = false; - } else { - for (size_t ti = 0; ti < cull_targets.size(); ++ti) { - if (gpu_cull_pending_.model_targets[ti].second != cull_targets[ti]) { - targets_match = false; - break; - } - } - } - - gpu_cull_readback_ns_ += readback_timer.nsecsElapsed(); - - if (targets_match && survivor_count <= gpu_cull_pending_.total_in) { - QElapsedTimer consume_timer; consume_timer.start(); - - // Bin survivors by model tag. - const size_t n_models = cull_targets.size(); - std::vector> per_model_survivors(n_models); - for (uint32_t si = 0; si < survivor_count; ++si) { - uint32_t packed = surv_data[si]; - uint32_t model_idx = packed >> 20u; - uint32_t local_idx = packed & 0xFFFFFu; - if (model_idx < n_models) { - per_model_survivors[model_idx].push_back(local_idx); - } - } - gpu_consume_bin_ns_.fetch_add(consume_timer.nsecsElapsed(), - std::memory_order_relaxed); - - // Parallel emit across models. - if (mt_cull_enabled && n_models > 1) { - std::vector> futs; - futs.reserve(n_models); - const float fp = focal_px; - const float mpr = min_pixel_radius; - for (size_t ti = 0; ti < n_models; ++ti) { - futs.emplace_back(std::async(std::launch::async, - [this, ti, &cull_targets, &per_model_survivors, fp, mpr]() { - emitFromGpuSurvivors( - *cull_targets[ti], - per_model_survivors[ti].data(), - static_cast(per_model_survivors[ti].size()), - fp, mpr); - })); - } - for (auto& f : futs) f.get(); - } else { - for (size_t ti = 0; ti < n_models; ++ti) { - emitFromGpuSurvivors( - *cull_targets[ti], - per_model_survivors[ti].data(), - static_cast(per_model_survivors[ti].size()), - focal_px, min_pixel_radius); - } - } - - gpu_cull_consume_ns_ += consume_timer.nsecsElapsed(); - gpu_consumed = true; - } - } else { - // Fence not ready — GPU is still working. Fall through to CPU. - } - } - - // --- CPU fallback if GPU results weren't available --- - if (!gpu_consumed) { - if (mt_cull_enabled && cull_targets.size() > 1) { - std::vector> futs; - futs.reserve(cull_targets.size()); - for (ModelGpuData* mp : cull_targets) { - const float mpr = min_pixel_radius; - futs.emplace_back(std::async(std::launch::async, - [this, mp, &planes, focal_px, mpr]() { - cullModelCpu(*mp, planes, focal_px, mpr); - })); - } - for (auto& f : futs) f.get(); - } else { - for (ModelGpuData* mp : cull_targets) { - cullModelCpu(*mp, planes, focal_px, min_pixel_radius); - } - } - } - - // --- Dispatch this frame's GPU cull (results consumed next frame) --- - if (gpu_cull_enabled && cull_program_) { - // Clean up any lingering fence (shouldn't happen — consumed above). - if (gpu_cull_fence_) { - gl_->glDeleteSync(gpu_cull_fence_); - gpu_cull_fence_ = nullptr; - } - - uint32_t total_in = 0; + if (mt_cull_enabled && cull_targets.size() > 1) { + std::vector> futs; + futs.reserve(cull_targets.size()); for (ModelGpuData* mp : cull_targets) { - total_in += static_cast(mp->instances.size()); + const float mpr = min_pixel_radius; + futs.emplace_back(std::async(std::launch::async, + [this, mp, &planes, focal_px, mpr]() { + cullModelCpu(*mp, planes, focal_px, mpr); + })); } - - // Ensure survivor SSBO + readback buffer are large enough. - // Layout of readback: [uint32 counter][uint32 survivors[total_in]] - const size_t buf_bytes = (1 + total_in) * sizeof(uint32_t); - const size_t needed = std::max(buf_bytes, sizeof(uint32_t)); - if (!gpu_cull_survivor_ssbo_ || gpu_cull_survivor_capacity_ < needed) { - if (gpu_cull_survivor_ssbo_) - gl_->glDeleteBuffers(1, &gpu_cull_survivor_ssbo_); - size_t cap = gpu_cull_survivor_capacity_ ? gpu_cull_survivor_capacity_ : 4096; - while (cap < needed) cap *= 2; - gl_->glCreateBuffers(1, &gpu_cull_survivor_ssbo_); - gl_->glNamedBufferStorage(gpu_cull_survivor_ssbo_, cap, nullptr, - GL_DYNAMIC_STORAGE_BIT); - gpu_cull_survivor_capacity_ = cap; + for (auto& f : futs) f.get(); + } else { + for (ModelGpuData* mp : cull_targets) { + cullModelCpu(*mp, planes, focal_px, min_pixel_radius); } - if (!gpu_cull_readback_buf_ || gpu_cull_readback_capacity_ < needed) { - if (gpu_cull_readback_buf_) { - gl_->glUnmapNamedBuffer(gpu_cull_readback_buf_); - gl_->glDeleteBuffers(1, &gpu_cull_readback_buf_); - gpu_cull_readback_ptr_ = nullptr; - } - size_t cap = gpu_cull_readback_capacity_ ? gpu_cull_readback_capacity_ : 4096; - while (cap < needed) cap *= 2; - gl_->glCreateBuffers(1, &gpu_cull_readback_buf_); - gl_->glNamedBufferStorage(gpu_cull_readback_buf_, cap, nullptr, - GL_MAP_READ_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT); - gpu_cull_readback_ptr_ = static_cast( - gl_->glMapNamedBufferRange(gpu_cull_readback_buf_, 0, cap, - GL_MAP_READ_BIT | GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT)); - gpu_cull_readback_capacity_ = cap; - } - - // Reset counter. - uint32_t zero = 0; - gl_->glNamedBufferSubData(gpu_cull_counter_ssbo_, 0, sizeof(zero), &zero); - - gl_->glUseProgram(cull_program_); - GLint u_planes_loc = gl_->glGetUniformLocation(cull_program_, "u_planes"); - GLint u_count_loc = gl_->glGetUniformLocation(cull_program_, "u_count"); - GLint u_eye_loc = gl_->glGetUniformLocation(cull_program_, "u_camera_eye"); - GLint u_focal_loc = gl_->glGetUniformLocation(cull_program_, "u_focal_px"); - GLint u_minpx_loc = gl_->glGetUniformLocation(cull_program_, "u_min_pixel_radius"); - GLint u_tag_loc = gl_->glGetUniformLocation(cull_program_, "u_model_tag"); - - float planes_flat[24]; - for (int i = 0; i < 6; ++i) { - planes_flat[i*4+0] = planes[i][0]; - planes_flat[i*4+1] = planes[i][1]; - planes_flat[i*4+2] = planes[i][2]; - planes_flat[i*4+3] = planes[i][3]; - } - gl_->glUniform4fv(u_planes_loc, 6, planes_flat); - gl_->glUniform3f(u_eye_loc, camera_eye_.x(), camera_eye_.y(), camera_eye_.z()); - gl_->glUniform1f(u_focal_loc, focal_px); - gl_->glUniform1f(u_minpx_loc, min_pixel_radius); - - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, gpu_cull_counter_ssbo_); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, gpu_cull_survivor_ssbo_); - - gl_->glQueryCounter(gpu_cull_ts_[0], GL_TIMESTAMP); - for (size_t ti = 0; ti < cull_targets.size(); ++ti) { - ModelGpuData* mp = cull_targets[ti]; - const uint32_t n = static_cast(mp->instances.size()); - gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, mp->aabb_ssbo); - gl_->glUniform1ui(u_count_loc, n); - gl_->glUniform1ui(u_tag_loc, static_cast(ti) << 20u); - gl_->glDispatchCompute((n + 63u) / 64u, 1, 1); - } - gl_->glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); - gl_->glQueryCounter(gpu_cull_ts_[1], GL_TIMESTAMP); - - // Copy counter + survivors into the readback buffer. - gl_->glCopyNamedBufferSubData(gpu_cull_counter_ssbo_, gpu_cull_readback_buf_, - 0, 0, sizeof(uint32_t)); - if (total_in > 0) { - gl_->glCopyNamedBufferSubData(gpu_cull_survivor_ssbo_, gpu_cull_readback_buf_, - 0, sizeof(uint32_t), total_in * sizeof(uint32_t)); - } - - gpu_cull_fence_ = gl_->glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); - - // Stash model targets for next frame's consumption. - gpu_cull_pending_.model_targets.clear(); - gpu_cull_pending_.model_targets.reserve(cull_targets.size()); - for (size_t ti = 0; ti < cull_targets.size(); ++ti) { - gpu_cull_pending_.model_targets.emplace_back( - static_cast(cull_targets[ti]->instances.size()), - cull_targets[ti]); - } - gpu_cull_pending_.total_in = total_in; - - gl_->glUseProgram(main_program_); } cull_wall_ns_ += cull_wall_timer.nsecsElapsed(); @@ -2476,26 +1977,10 @@ void ViewportWindow::render() { cull_wall_ns_ = 0; const uint32_t skipped = cull_skipped_frames_; cull_skipped_frames_ = 0; - const double gpu_dispatch_ms = gpu_cull_dispatch_ns_ * 1e-6 * inv_frames; - const double gpu_readback_ms = gpu_cull_readback_ns_ * 1e-6 * inv_frames; - const double gpu_consume_ms = gpu_cull_consume_ns_ * 1e-6 * inv_frames; - const double gc_bin_ms = gpu_consume_bin_ns_.load() * 1e-6 * inv_frames; - const double gc_clear_ms = gpu_consume_clear_ns_.load() * 1e-6 * inv_frames; - const double gc_class_ms = gpu_consume_class_ns_.load() * 1e-6 * inv_frames; - const double gc_emit_ms = gpu_consume_emit_ns_.load() * 1e-6 * inv_frames; - gpu_cull_dispatch_ns_ = 0; - gpu_cull_readback_ns_ = 0; - gpu_cull_consume_ns_ = 0; - gpu_consume_bin_ns_.store(0); - gpu_consume_clear_ns_.store(0); - gpu_consume_class_ns_.store(0); - gpu_consume_emit_ns_.store(0); qDebug("[frame] %.1f fps %.2f ms obj %u/%u tri %u/%u " "meshes %u gl_draws %u sub_draws %u hiz_rej %u " "cull[wall %.2f | work: clr %.2f trv %.2f emt %.2f upl %.2f]ms skipped %u/%u " - "gpu_cull[disp %.2f rdback %.2f consume %.2f " - "(bin %.2f clr %.2f class %.2f emit %.2f)ms in=%u surv=%u] " "vram %.1f MB (vbo %.1f + ebo %.1f + ssbo %.1f) models %zu (%zu hidden)", last_fps_, 1000.0f / last_fps_, visible_objects_, total_obj, @@ -2504,9 +1989,6 @@ void ViewportWindow::render() { hiz_reject_count_.load(), wall_ms, clr_ms, trv_ms, emt_ms, upl_ms, skipped, frames_in_window, - gpu_dispatch_ms, gpu_readback_ms, gpu_consume_ms, - gc_bin_ms, gc_clear_ms, gc_class_ms, gc_emit_ms, - gpu_cull_last_input_, gpu_cull_last_survivors_, (total_vbo + total_ebo + total_ssbo) / (1024.0*1024.0), total_vbo / (1024.0*1024.0), total_ebo / (1024.0*1024.0), diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 261cef6e0f..a8aa7b7577 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -94,14 +94,6 @@ struct ModelGpuData { std::vector bvh_items; ModelBvh bvh; - // Per-instance world AABB on the GPU, 1:1 with `instances`. - // Populated at finalize / applyCachedModel. Consumed by the upcoming - // GPU-compute cull (Phase 3E); the CPU cull still reads from bvh_items. - // Layout: struct { vec3 min; uint mesh_id; vec3 max; uint flags; } = 32 B. - // `flags` bit 0 = reflected (for winding-bucket selection). - GLuint aabb_ssbo = 0; - size_t aabb_ssbo_capacity = 0; // bytes - // Dynamic visible-instance index buffer (std430, binding = 1). // Re-uploaded each frame from visible_flat_. GLuint visible_ssbo = 0; @@ -229,12 +221,6 @@ private: bool growModelSsbo(ModelGpuData& m, size_t needed_total); ModelGpuData& getOrCreateModel(uint32_t model_id); - // (Re)build the per-instance world AABB SSBO from m.instances + - // m.instance_reflected. One-shot upload called after finalizeModel / - // applyCachedModel once instances are settled. Consumed by the GPU - // compute cull (Phase 3E, in progress). - void uploadInstanceAabbs(ModelGpuData& m); - // Frustum-cull m's instances (BVH if available, else linear scan), // build the per-mesh DrawElementsIndirectCommand array + flat visible // list, and upload both to m.indirect_buffer / m.visible_ssbo. @@ -254,13 +240,6 @@ private: void cullModelCpu(ModelGpuData& m, const float planes[6][4], float focal_px, float min_pixel_radius); - // Emit pass for GPU cull path: given a flat list of surviving instance - // indices (already frustum+contribution filtered by GPU), perform HiZ, - // LOD selection, winding bucketing, and build indirect commands. - void emitFromGpuSurvivors(ModelGpuData& m, - const uint32_t* survivor_indices, uint32_t count, - float focal_px, float min_pixel_radius); - // Main-thread only: uploads m.visible_flat / m.indirect_scratch into the // model's SSBO + indirect buffer, growing them if needed. void uploadCullResults(ModelGpuData& m); @@ -280,40 +259,6 @@ private: GLuint pick_program_ = 0; GLuint axis_program_ = 0; - // Phase 3E GPU frustum+contribution cull. When IFC_GPU_CULL=1, replaces - // the CPU BVH walk + frustum + contribution stages. Produces a scene-wide - // compact survivor-index list; CPU still handles LOD, winding, HiZ, emit. - // - // Uses one-frame-late async readback: frame N dispatches and fences, frame - // N+1 reads the results via a persistent-mapped buffer. The first frame - // (or any frame where the previous dispatch hasn't completed) falls back - // to the CPU path. - GLuint cull_program_ = 0; - GLuint gpu_cull_counter_ssbo_ = 0; // single uint32 atomic counter - GLuint gpu_cull_survivor_ssbo_ = 0; // uint32[] packed survivors (GPU write) - size_t gpu_cull_survivor_capacity_= 0; // bytes - GLuint gpu_cull_readback_buf_ = 0; // persistent-mapped readback buffer - size_t gpu_cull_readback_capacity_= 0; - uint32_t* gpu_cull_readback_ptr_ = nullptr; // persistent map pointer - GLsync gpu_cull_fence_ = nullptr; - GLuint gpu_cull_ts_[2] = {}; // GPU timestamp queries - uint32_t gpu_cull_last_survivors_ = 0; - uint32_t gpu_cull_last_input_ = 0; - uint64_t gpu_cull_dispatch_ns_ = 0; // GPU-side dispatch time - uint64_t gpu_cull_readback_ns_ = 0; // CPU-side readback time - uint64_t gpu_cull_consume_ns_ = 0; // CPU-side consume (emit) time - // Consume sub-phase profiling (atomics — safe from worker threads). - std::atomic gpu_consume_bin_ns_{0}; // survivor binning by model - std::atomic gpu_consume_clear_ns_{0}; // per-model bucket clearing - std::atomic gpu_consume_class_ns_{0}; // LOD + winding classification - std::atomic gpu_consume_emit_ns_{0}; // indirect command building - // Stashed per-frame dispatch metadata for one-frame-late consumption. - struct GpuCullPending { - std::vector> model_targets; - uint32_t total_in = 0; - }; - GpuCullPending gpu_cull_pending_; - // Axis gizmo GLuint axis_vao_ = 0; GLuint axis_vbo_ = 0; From dbe68b48f25473e4dc6369cfcc863397354b9c16 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 20 Apr 2026 12:26:11 +1000 Subject: [PATCH 054/120] Fix kernel/mapping plugin output dir and IfcViewer link dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use $ instead of hardcoded ${CMAKE_BINARY_DIR}/ifcgeom/$ for plugin runtime dirs — the old path was wrong on non-MSVC generators where $ expands empty. Add explicit add_dependencies for kernel/mapping plugins so IfcViewer waits for them to build, and drop the redundant direct link against ${kernel_libraries}. Co-Authored-By: Claude Opus 4.6 --- src/ifcgeom/kernels/CMakeLists.txt | 2 +- src/ifcgeom/mapping/CMakeLists.txt | 2 +- src/ifcviewer/CMakeLists.txt | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ifcgeom/kernels/CMakeLists.txt b/src/ifcgeom/kernels/CMakeLists.txt index af5b415ef9..8128e37488 100644 --- a/src/ifcgeom/kernels/CMakeLists.txt +++ b/src/ifcgeom/kernels/CMakeLists.txt @@ -1,6 +1,6 @@ message(STATUS "GEOMETRY_KERNELS ${GEOMETRY_KERNELS}") -set(kernel_plugin_runtime_dir "${CMAKE_BINARY_DIR}/ifcgeom/$") +set(kernel_plugin_runtime_dir "$") foreach(kernel ${GEOMETRY_KERNELS}) string(TOUPPER ${kernel} KERNEL_UPPER) diff --git a/src/ifcgeom/mapping/CMakeLists.txt b/src/ifcgeom/mapping/CMakeLists.txt index 77778df425..63a9bdf65a 100644 --- a/src/ifcgeom/mapping/CMakeLists.txt +++ b/src/ifcgeom/mapping/CMakeLists.txt @@ -1,6 +1,6 @@ find_package(Eigen3 REQUIRED) -set(mapping_plugin_runtime_dir "${CMAKE_BINARY_DIR}/ifcgeom/$") +set(mapping_plugin_runtime_dir "$") foreach(schema ${SCHEMA_VERSIONS}) file(GLOB IFCGEOM_I_FILES *.i) diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index 70642acabf..458c52d9f3 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -40,10 +40,11 @@ set_target_properties(IfcViewer PROPERTIES MACOSX_BUNDLE ON ) +add_dependencies(IfcViewer ${kernel_libraries} ${mapping_libraries}) + target_link_libraries(IfcViewer PRIVATE IfcGeom IfcParse - ${kernel_libraries} ${OpenCASCADE_LIBRARIES} ${Boost_LIBRARIES} ${CGAL_LIBRARIES} From 3015f758ba02bdf3b566654f9748b1089f1d342d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 20 Apr 2026 12:26:29 +1000 Subject: [PATCH 055/120] ifcviewer: shrink vertex format from 16 to 12 bytes (oct i8x2 normals) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace i16x2 octahedral normals with i8x2, filling the 2-byte padding after position and saving 4 bytes per vertex. int8 gives ~1.4 deg worst-case angular error — invisible for BIM geometry which is overwhelmingly axis-aligned. 25% VBO reduction; sidecar files shrink ~15% overall (5.4 GB -> 4.6 GB on a 111-model test scene). Bumps sidecar format to v7. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/InstancedGeometry.h | 16 +-- src/ifcviewer/README.md | 198 ++++++++++-------------------- src/ifcviewer/SidecarCache.h | 6 +- src/ifcviewer/ViewportWindow.cpp | 20 +-- 4 files changed, 85 insertions(+), 155 deletions(-) diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h index 729e4df147..accf155bf9 100644 --- a/src/ifcviewer/InstancedGeometry.h +++ b/src/ifcviewer/InstancedGeometry.h @@ -25,16 +25,18 @@ #include // Per-vertex layout for instanced meshes, stored in local coordinates, -// quantized against each mesh's local AABB. 16 bytes per vertex: +// quantized against each mesh's local AABB. 12 bytes per vertex: // offset 0 pos 3 x uint16 normalized -> [0,1]; dequant to // mix(mesh.aabb_min, mesh.aabb_max, t) -// offset 6 _pad 2 bytes -// offset 8 normal 2 x int16 normalized -> [-1,1]; octahedral-decoded -// offset 12 color 4 x uint8 normalized -> [0,1] +// offset 6 normal 2 x int8 normalized -> [-1,1]; octahedral-decoded +// offset 8 color 4 x uint8 normalized -> [0,1] +// +// int8 normals give ~1.4° worst-case angular error — invisible for BIM +// geometry which is overwhelmingly axis-aligned (walls, floors, slabs). // // Quantization basis is per mesh, stored in the MeshGpu SSBO bound at // binding=2. The vertex shader looks up its basis via the instance's mesh_id. -static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 16; +static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 12; // Streamer-side intermediate format: 7 floats per vertex (pos3 + normal3 + // color-as-float). GeometryStreamer writes this into MeshChunk.vertices; @@ -43,8 +45,8 @@ static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 16; static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7; static constexpr int INSTANCED_VERTEX_POS_OFFSET = 0; -static constexpr int INSTANCED_VERTEX_NORMAL_OFFSET = 8; -static constexpr int INSTANCED_VERTEX_COLOR_OFFSET = 12; +static constexpr int INSTANCED_VERTEX_NORMAL_OFFSET = 6; +static constexpr int INSTANCED_VERTEX_COLOR_OFFSET = 8; // Per-mesh quantization basis, uploaded to a std430 SSBO. Two vec4s so // std430 layout is trivial (no alignment surprises). w components unused. diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 17cc4b7f8f..25e52e8ce3 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -46,12 +46,17 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. - **Per-model GPU buffers**: each loaded model gets its own VAO/VBO/EBO/instance-SSBO/visible-SSBO/indirect-buffer. No cross-model growth copies. Removing a model frees its GPU memory immediately. -- **Quantized local-coordinate vertex format (16 B):** position as +- **Quantized local-coordinate vertex format (12 B):** position as `u16x3` normalised against each mesh's local AABB, octahedral-encoded - normal as `i16x2`, packed RGBA8 colour. Dequantisation basis is per - mesh, uploaded once in a `MeshGpu` SSBO at binding 2. The per-instance - transform is applied in the vertex shader. No world-baked vertex data. - ~43 % smaller VBO and sidecar than the previous 28 B float layout. + normal as `i8x2`, packed RGBA8 colour. The normal fills what was + previously 2 bytes of padding, and shrinks from `i16x2` to `i8x2` — + int8 gives ~1.4° worst-case angular error, invisible for BIM geometry + which is overwhelmingly axis-aligned (walls, floors, slabs encode + exactly). Dequantisation basis is per mesh, uploaded once in a + `MeshGpu` SSBO at binding 2. The per-instance transform is applied in + the vertex shader. No world-baked vertex data. 12/28 = 57 % smaller + VBO than the original 28 B float layout (sidecar files shrink ~15 % + overall since indices/instances/metadata are unchanged). - **Multi-draw indirect:** every frame the CPU builds a flat list of visible instance indices and one `DrawElementsIndirectCommand` per non-empty mesh, then issues a single `glMultiDrawElementsIndirect` per model. 50k visible @@ -111,7 +116,7 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | | `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v6) sidecar read/write | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v7) sidecar read/write | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `SettingsWindow.h/cpp` | Settings dialog | | `CMakeLists.txt` | Build configuration | @@ -288,7 +293,7 @@ while stack not empty: Depth 64 is enough for billions of items on any balanced tree. The stack is on the C++ stack, zero per-frame allocation. -#### Sidecar format (`.ifcview`, v6) +#### Sidecar format (`.ifcview`, v7) Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Stores everything needed to skip the `IfcGeom::Iterator` pass: @@ -296,7 +301,7 @@ Stores everything needed to skip the `IfcGeom::Iterator` pass: ``` SidecarHeader (magic "IFVW", version, endian, ...) uint64_t source_file_size -uint32_t + uint8_t[] vertex data (16 B/vert quantized; per-mesh basis in MeshInfo) +uint32_t + uint8_t[] vertex data (12 B/vert quantized; per-mesh basis in MeshInfo) uint32_t + uint32_t[] index data (mesh-local) uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) uint32_t + InstanceCpu[] per-placement records (transform + AABB + ids) @@ -324,7 +329,7 @@ Per-model state on the GPU: | Buffer | Contents | Lifetime | |--------|----------|----------| -| `VBO` | Quantized local-coord vertex data (16 B/vert: u16x3 pos, oct i16x2 normal, RGBA8). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | +| `VBO` | Quantized local-coord vertex data (12 B/vert: u16x3 pos, oct i8x2 normal, RGBA8). One range per unique representation. | Grow-on-demand during streaming; static after finalize. | | `MeshGpu SSBO` (binding 2) | Per-mesh dequant basis (`vec4 aabb_min`, `vec4 aabb_max`). | Grow-on-demand; static after finalize. | | `EBO` | Mesh-local uint32 indices. One range per unique representation. | Same. | | `SSBO` (binding 0) | `InstanceGpu[]` (80 B each: mat4 transform, object_id, color_override, pad). | Appended during streaming, static after finalize. | @@ -338,7 +343,7 @@ struct DrawElementsIndirectCommand { uint32_t count; // mesh.index_count uint32_t instanceCount; // visible-list length for this mesh uint32_t firstIndex; // mesh.ebo_byte_offset / 4 - uint32_t baseVertex; // mesh.vbo_byte_offset / 16 + uint32_t baseVertex; // mesh.vbo_byte_offset / 12 uint32_t baseInstance; // offset into the flat visible-index array }; ``` @@ -742,143 +747,69 @@ The stats line now reports `cull[wall X | work: clr Y trv Z emt W upl U]`: where CPU cycles went. `IFC_CULL_THREADS=0` forces single-threaded mode for comparison. -#### 3E. GPU compute culling — experiments, results, and current state +#### 3E. GPU compute culling — experiments and removal ##### What we tried -**Attempt 1: Full GPU-driven rendering (reverted).** Five commits -(`4fe32b54`..`d5b7b87b`) moved the entire cull-to-draw pipeline onto -the GPU: a compute shader performed frustum + contribution + HiZ -culling, selected LOD0/LOD1, handled fwd/rev winding bucketing, wrote -indirect draw commands via `glMultiDrawElementsIndirectCount`, and -drove rendering without CPU readback. This was architecturally clean -but complex — the GPU built per-model indirect command buffers with -atomic counters, prefix sums, and per-bucket compaction. It worked -correctly but introduced code smells (extension loaders for -`glMultiDrawElementsIndirectCount` not exposed by Qt6's -`QOpenGLFunctions_4_5_Core`, ad-hoc GPU readbacks for validation). -All five commits were reverted as a single block to keep the codebase -clean while preserving the AABB SSBO upload (`b2044737`) and the -frustum-only validation shader (`b17860fc`). +Five iterations of GPU compute culling were explored on a 1.06 M-instance +/ 111-model scene (GTX 1650): -**Attempt 2: GPU frustum-only validation shader.** A minimal compute -shader (64 threads/workgroup) testing each instance's AABB against 6 -frustum planes. Used as a measurement baseline — no contribution, -HiZ, LOD, or winding. Results on a 1.06 M-instance / 111-model scene -(GTX 1650): +1. **Full GPU-driven rendering** — compute shader doing frustum + + contribution + HiZ + LOD + winding + indirect command building via + `glMultiDrawElementsIndirectCount`. Worked but introduced code smells + (extension loaders, ad-hoc readbacks). Reverted. -| Metric | GPU frustum-only | CPU BVH (parallel) | -|--------|------------------|--------------------| -| Cull time | **0.82 ms** (GPU timestamp) | 9.6–15.2 ms wall | -| Survivors | 279 k (frustum only) | 130 k (frustum + contribution + HiZ) | +2. **GPU frustum-only validation** — minimal compute shader (64 + threads/workgroup), 0.82 ms for 1.06 M instances vs 10–15 ms CPU. + Proved GPU brute-force beats CPU BVH for raw AABB-vs-frustum. -The GPU brute-force scan of 1.06 M instances in 0.82 ms was 12–18× -faster than the CPU BVH walk despite testing every instance. +3. **Hybrid with synchronous readback** — added contribution culling, + read survivors back with `glGetNamedBufferSubData`. The 4–7 ms + pipeline stall negated all GPU savings. -**Attempt 3: Hybrid GPU cull with synchronous readback.** Added -contribution culling to the GPU shader (bounding-sphere screen-space -radius test), then read back the compact survivor list to the CPU with -`glGetNamedBufferSubData`. CPU retains HiZ, LOD selection, winding -bucketing, indirect command building, and all GL draw calls. +4. **Async one-frame-late readback** — persistent-mapped buffer + + fence. Zero stalls, ~5.5 ms total vs ~5.5 ms CPU-only. Matched + but didn't beat. -| Phase | Time | -|-------|------| -| GPU dispatch (frustum + contribution) | 0.92 ms | -| Synchronous readback (`glGetNamedBufferSubData`) | **4.2–7.4 ms** | -| CPU consume (HiZ + LOD + winding + emit) | 6.4–9.8 ms | -| **Total wall** | **~15 ms** | +5. **Dirty-mesh tracking** — reduced emit from O(total meshes) to + O(dirty meshes). Helped the consume phase but didn't change the + bottom line. -The synchronous readback pipeline-stalled the GPU, adding 4–7 ms of -idle wait. Total wall time was roughly equal to the CPU-only path, -negating the GPU cull's speed advantage. +##### Why it was removed -**Attempt 4: Async one-frame-late readback (committed, `30e43ffe`).** -Replaced synchronous readback with a persistent-mapped buffer -(`GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT`) and a `glFenceSync` / -`glClientWaitSync` fence. The GPU writes survivors this frame; the -CPU reads them next frame. One frame of latency, but zero stalls. +Benchmark with motion-adaptive culling + HiZ active (Phase 3G): -| Phase | Time | -|-------|------| -| GPU dispatch | 0.69–0.78 ms | -| Async readback (fence poll) | **0.00 ms** | -| CPU consume | 5.0–6.2 ms | -| **Total wall** | **~5.5 ms** | +| Path | FPS | +|------|-----| +| CPU BVH (parallel) | 51.0 | +| GPU cull + async readback | 52.0 | -vs the CPU-only path at 5.2–6.4 ms wall on the same scene. The GPU -cull + async readback matches or slightly beats the parallel CPU BVH -path, with headroom for scenes where the CPU path can't parallelise -(single large model). +The GPU cull added ~585 lines of code (compute shader, persistent-mapped +readback buffer, fence management, per-model AABB SSBOs, 8 profiling +counters, cleanup at 4 sites) for a 2% improvement that was within +measurement noise. With HiZ + motion culling reducing the visible set +to ~11 k objects, the CPU BVH path handles the work in ~2 ms — there's +nothing left for the GPU to win. -**Attempt 5: Dirty-mesh tracking (committed, `01dd8d57`).** Profiling -the CPU consume phase revealed that `clr` (clearing per-mesh visibility -buckets) and `emit` (building indirect commands) were O(total_meshes) -= O(462 k), not O(survivors). Added a dirty-mesh list so only mesh -buckets that received survivors are cleared and iterated. +**Removed** in favour of keeping the codebase simple. The CPU BVH path +is now the only cull path. -Consume sub-phase breakdown (summed across parallel threads, -~128 k survivors): +##### Lessons learned -| Sub-phase | Before | After | Scales with | -|-----------|--------|-------|-------------| -| bin (model binning) | 0.11 ms | 0.18 ms | O(survivors) | -| clr (bucket clear) | 2.0 ms | **1.6 ms** | O(dirty meshes) | -| class (HiZ + LOD + winding) | 5.1 ms | 5.3 ms | O(survivors) | -| emit (indirect cmd build) | 4.2 ms | **2.2 ms** | O(dirty meshes) | - -Emit improved ~48%, clr ~20%. The dominant cost shifted to `class` -(per-survivor HiZ + LOD + winding classification). - -##### What we learned - -1. **GPU brute-force beats CPU BVH for frustum + contribution.** - 0.82 ms for 1.06 M instances vs 10–15 ms for the CPU BVH walk. - The BVH's hierarchical skip advantage is overwhelmed by the GPU's - raw parallelism — 1 M independent AABB-vs-frustum tests is a - perfect compute workload. - -2. **Synchronous readback kills the advantage.** The 4–7 ms stall from - `glGetNamedBufferSubData` on ~1 MB of data negated all GPU savings. - A pipeline stall is worse than just doing the work on the CPU. - -3. **Async one-frame-late readback works well.** Persistent mapping + - fence polling adds zero measurable overhead. The one-frame latency - is imperceptible for culling — worst case, a few objects at the - frustum edge pop in one frame late during fast camera motion. - -4. **CPU consume is now the bottleneck.** With GPU dispatch at <1 ms - and readback at 0 ms, the 5–6 ms consume phase (HiZ test, LOD - selection, winding classification, indirect command building) - dominates. The `class` sub-phase alone is 5+ ms, scaling linearly - with survivor count. - -5. **Dirty-mesh tracking helps but doesn't transform performance.** - The 462 k total meshes → ~104 k active meshes reduction cut emit - in half, but the per-survivor classification work is the true - bottleneck. - -##### What remains - -The hybrid path (`IFC_GPU_CULL=1`) is functional and committed. It -matches the CPU path's performance today and provides the foundation -for further GPU offload. Remaining opportunities: - -- Move HiZ + LOD + winding classification to the GPU (eliminates the - 5 ms `class` sub-phase entirely — the GPU already has the AABBs and - can sample the HiZ pyramid directly). -- GPU BVH traversal to reduce dispatch from O(total) to O(visible + - tree overhead) — matters when survivor ratio is low. -- GPU-driven indirect command building (eliminates CPU emit entirely). - -Each of these would chip away at the consume phase, but the sub_draw -analysis below reveals a more fundamental bottleneck. +1. **GPU brute-force beats CPU BVH for raw frustum tests** (0.82 ms vs + 10–15 ms for 1 M instances) but the advantage disappears once + higher-level culling (HiZ, contribution) reduces the working set. +2. **Synchronous readback kills GPU cull.** Persistent-mapped async + readback works but adds complexity for negligible gain. +3. **Hybrid GPU/CPU paths are maintenance-heavy** for diminishing + returns when the CPU path is already fast enough. #### 3F. Sub-draw fragmentation analysis ##### The problem -With GPU cull solving the *culling* bottleneck, the dominant cost -shifts to the *drawing* side. On the 1.06 M-instance / 111-model +With the culling pipeline mature (BVH + contribution + HiZ + motion +culling), the dominant cost shifts to the *drawing* side. On the 1.06 M-instance / 111-model scene, frame times are 48–63 ms despite only 24–47 M visible triangles — well within the GTX 1650's throughput. The culprit is the number of indirect sub-draws (individual `DrawElementsIndirectCommand` @@ -996,7 +927,6 @@ Benchmarked on 1.06 M-instance / 111-model scene, 200-frame orbit | MIN_PX_MOTION=10 | 37.67 | 26.5 | 1.6× | 70k | 56k | 0 | | HIZ_MOTION=1 | 21.44 | 46.6 | 2.9× | 33k | 17.5k | 28k | | HIZ_MOTION=1 + MIN_PX_MOTION=10 | 19.62 | 51.0 | 3.1× | 11.4k | 8.7k | 11.5k | -| GPU_CULL + HIZ + MIN_PX | 19.22 | 52.0 | 3.2× | 11.3k | 8.6k | 59k | ##### Conclusions @@ -1010,10 +940,7 @@ Benchmarked on 1.06 M-instance / 111-model scene, 200-frame orbit 3. **Combining both gives diminishing returns** — 3.1× vs 2.9× (HiZ alone) or 1.6× (MIN_PX alone). They compete over the same objects. -4. **GPU cull adds nothing** on top of these — 52.0 vs 51.0 fps. The - CPU BVH path handles the reduced visible set in ~2 ms. - -5. **The ~19 ms floor is GPU rendering**, not culling. At 8.6k +4. **The ~19 ms floor is GPU rendering**, not culling. At 8.6k sub_draws the bottleneck shifts to draw dispatch + triangle rasterization. Further improvement requires reducing sub_draws (static batching) or moving to a more efficient draw model. @@ -1048,7 +975,6 @@ Scene size Bottleneck Fix + Phase 3B LOD (done) multi-million + occluders redundant rasterisation Phase 3C HiZ (done, CPU readback) many models, serial cull single-thread BVH trv Phase 3D parallel cull (done) -single giant model / <18 cores CPU BVH trv Phase 3E GPU cull (hybrid, done) orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling + HiZ (done, 3.1×) 90k+ unique visible meshes per-draw GPU overhead Phase 3F static batching (next) ``` @@ -1067,14 +993,14 @@ orbit fps on 1M+ scenes too many vis objects Phase 3G motion culling - [x] Reflection-aware two-pass draw for mirrored placements - [x] Backface culling (user-toggleable, default on) - [x] `reorient-shells` enabled in iterator -- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`, `IFC_MIN_PX_MOTION`, `IFC_HIZ_MOTION`, `IFC_GPU_CULL`, `IFC_SUBDRAW_DIAG`) +- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`, `IFC_MIN_PX`, `IFC_LOD1_PX`, `IFC_NO_HIZ`, `IFC_HIZ_SIZE`, `IFC_CULL_THREADS`, `IFC_MIN_PX_MOTION`, `IFC_HIZ_MOTION`, `IFC_SUBDRAW_DIAG`) - [x] Phase 3A — screen-space contribution culling - [x] Phase 3B — distance / contribution LOD (meshoptimizer `simplifySloppy`) - [x] Phase 3C — Hierarchical-Z occlusion culling (v1, CPU-side readback) - [x] Phase 3D — Parallel per-model CPU cull (`std::async` fan-out) -- [x] Quantized VBO (16 B/vert, sidecar v6) +- [x] Quantized VBO (12 B/vert: u16x3 pos + oct i8x2 normal + RGBA8, sidecar v7) - [x] Event-driven rendering (zero idle CPU/GPU, cull skipped on still frames) -- [x] Phase 3E — GPU compute-shader culling (hybrid: GPU frustum+contribution, async readback, CPU HiZ+LOD+emit) +- [x] Phase 3E — GPU compute-shader culling (explored, removed — CPU BVH matches at ~585 fewer lines) - [x] Phase 3G — Motion-adaptive culling + HiZ during motion (3.1× orbit speedup on 1M-instance scene) - [x] Benchmark CLI (`--camera`, `--benchmark`, press C to capture camera) - [ ] **Phase 3F — Static batching of single-instance meshes** (next; reduces 90k+ sub_draws to hundreds) diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index e2e34373ab..57d8934ad8 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -39,7 +39,9 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // where meshoptimizer decimation produced useful output. // v6 = VBO vertices quantized to 16 B/vertex (pos u16x3 + normal oct i16x2 + // color u8x4). Dequant basis is per-mesh MeshInfo.local_aabb_min/max. -static constexpr uint32_t SIDECAR_VERSION = 6; +// v7 = VBO vertices shrunk to 12 B/vertex (normal oct i8x2 replaces i16x2, +// eliminating 2-byte pad + saving 2 bytes on normal). +static constexpr uint32_t SIDECAR_VERSION = 7; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs @@ -61,7 +63,7 @@ struct PackedElementInfo { // re-running the iterator. v6 schema: instanced + quantized geometry. struct SidecarData { // Per-model GPU geometry (local coords). Raw VBO bytes at the - // INSTANCED_VERTEX_STRIDE_BYTES layout (16 B/vertex as of v6). + // INSTANCED_VERTEX_STRIDE_BYTES layout (12 B/vertex as of v7). std::vector vertices; std::vector indices; diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index dd0b1a4462..6ae5c04368 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -46,9 +46,9 @@ static_assert(sizeof(DrawElementsIndirectCommand) == 20, "indirect cmd must be 2 // Shaders // ----------------------------------------------------------------------------- // -// Vertex layout (GL side, 16 bytes — quantized; see InstancedGeometry.h): +// Vertex layout (GL side, 12 bytes — quantized; see InstancedGeometry.h): // location 0: vec3 a_position_q (u16x3 normalized, per-mesh AABB basis) -// location 1: vec2 a_normal_oct (i16x2 normalized, octahedral) +// location 1: vec2 a_normal_oct (i8x2 normalized, octahedral) // location 2: vec4 a_color (u8x4 normalized) // // Per-instance record in SSBO std430 (80 bytes): @@ -66,7 +66,7 @@ static const char* MAIN_VERTEX_SHADER = R"( #extension GL_ARB_shader_draw_parameters : require // Quantized vertex inputs — see InstancedGeometry.h for layout. layout(location = 0) in vec3 a_position_q; // u16x3 normalized -> [0,1] -layout(location = 1) in vec2 a_normal_oct; // i16x2 normalized -> [-1,1] +layout(location = 1) in vec2 a_normal_oct; // i8x2 normalized -> [-1,1] layout(location = 2) in vec4 a_color; struct InstanceRecord { @@ -307,7 +307,7 @@ static void octEncode(const float n[3], float out[2]) { } // Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into -// the 16 B VBO record, given the mesh's tight local AABB. `extent_recip` +// the 12 B VBO record, given the mesh's tight local AABB. `extent_recip` // is 1/(max-min) per axis, or 0 for degenerate axes (quantum becomes 0). static void quantizeVertex(const float src[7], const float aabb_min[3], @@ -320,14 +320,14 @@ static void quantizeVertex(const float src[7], if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; p[a] = static_cast(t * 65535.0f + 0.5f); } - // Normal -> oct i16x2. + // Normal -> oct i8x2. int8 gives ~1.4° worst-case error — fine for BIM. float oct[2]; octEncode(src + 3, oct); - int16_t* n = reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); + int8_t* n = reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); for (int a = 0; a < 2; ++a) { float v = oct[a]; if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f; - n[a] = static_cast(std::lrintf(v * 32767.0f)); + n[a] = static_cast(std::lrintf(v * 127.0f)); } // Color passes through — streamer packs 4 bytes into the 7th float slot. std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4); @@ -498,13 +498,13 @@ void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) { INSTANCED_VERTEX_POS_OFFSET); gl_->glVertexArrayAttribBinding(vao, 0, 0); - // normal oct-encoded (2 x i16 normalized @ 8) + // normal oct-encoded (2 x i8 normalized @ 6) gl_->glEnableVertexArrayAttrib(vao, 1); - gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_SHORT, GL_TRUE, + gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_BYTE, GL_TRUE, INSTANCED_VERTEX_NORMAL_OFFSET); gl_->glVertexArrayAttribBinding(vao, 1, 0); - // color (4 x u8 normalized @ 12) + // color (4 x u8 normalized @ 8) gl_->glEnableVertexArrayAttrib(vao, 2); gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, INSTANCED_VERTEX_COLOR_OFFSET); From 5161b0a3f8ffe1fe396294b939734d5228b14119 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 20 Apr 2026 15:41:06 +1000 Subject: [PATCH 056/120] ifcviewer: remove meshopt_simplify path, keep only simplifySloppy Edge-collapse decimation (meshopt_simplify) returns BIM meshes unchanged due to per-triangle vertex duplication and non-manifold topology. The sloppy voxel-clustering decimator is faster, needs no shadow index welding, and produces good results at the sub-30px LOD1 threshold. Remove the non-sloppy branch, shadow buffer, IFC_LOD_SLOPPY and IFC_LOD_LOCK_BORDER env vars. Co-Authored-By: Claude Opus 4.6 --- src/ifcviewer/LodBuilder.cpp | 65 +++++++----------------------------- src/ifcviewer/LodBuilder.h | 4 +-- src/ifcviewer/README.md | 38 ++++++--------------- 3 files changed, 25 insertions(+), 82 deletions(-) diff --git a/src/ifcviewer/LodBuilder.cpp b/src/ifcviewer/LodBuilder.cpp index 35b97df44a..dbda389971 100644 --- a/src/ifcviewer/LodBuilder.cpp +++ b/src/ifcviewer/LodBuilder.cpp @@ -37,28 +37,17 @@ void buildLods(SidecarData& sd, const size_t total_vertex_count = sd.vertices.size() / vtx_stride_bytes; // Env var knobs so we can tune without rebuilding. - // IFC_LOD_LOCK_BORDER=1 re-enable LockBorder (off by default: BIM - // geometry is often non-manifold so locking - // borders prevents any collapse). // IFC_LOD_ERROR= override target_error (default 0.05 → 0.2). // IFC_LOD_RATIO= override target_ratio. // IFC_LOD_MIN_SAVINGS=<0..1> minimum fraction of tris saved to accept // (default 0.25). // IFC_LOD_DEBUG=1 print per-mesh diagnostics for the first // few meshes of each call. - // IFC_LOD_SLOPPY=0 disable sloppy (clustering) decimator. - // Default ON: BIM brep output is usually - // non-manifold, so edge-collapse simplify - // returns the input unchanged. - const char* env_lock = std::getenv("IFC_LOD_LOCK_BORDER"); const char* env_err = std::getenv("IFC_LOD_ERROR"); const char* env_ratio = std::getenv("IFC_LOD_RATIO"); const char* env_savings = std::getenv("IFC_LOD_MIN_SAVINGS"); const char* env_debug = std::getenv("IFC_LOD_DEBUG"); - const char* env_sloppy = std::getenv("IFC_LOD_SLOPPY"); - const bool lock_border = env_lock && env_lock[0] == '1'; - const bool use_sloppy = !(env_sloppy && env_sloppy[0] == '0'); if (env_err) target_error = static_cast(std::atof(env_err)); if (env_ratio) target_ratio = static_cast(std::atof(env_ratio)); float min_savings = 0.25f; @@ -71,10 +60,8 @@ void buildLods(SidecarData& sd, // Scratch buffers reused across meshes so we only allocate once. std::vector simplified; - std::vector shadow; std::vector dequant_pos; // 3 floats/vertex, dequantized simplified.reserve(1024); - shadow.reserve(1024); dequant_pos.reserve(1024 * 3); int dbg_printed = 0; @@ -128,46 +115,18 @@ void buildLods(SidecarData& sd, const size_t target_index_count = std::max( 3, static_cast(mesh.index_count * target_ratio) / 3 * 3); - // The instanced VBO stores each triangle's vertices separately, so the - // mesh's index buffer is topologically disconnected — every edge is - // boundary, every vertex is unique, and meshopt_simplify can't collapse - // anything. Build a shadow index buffer that welds by position, so - // shared-position vertices share an ID; then simplify on that. Output - // indices are still valid mesh-local IDs (canonical representatives), - // usable directly as LOD1 indices against the same VBO. - shadow.resize(mesh.index_count); - meshopt_generateShadowIndexBuffer( - shadow.data(), - indices, mesh.index_count, - positions, mesh.vertex_count, - sizeof(float) * 3, // compare only xyz - local_pos_stride); - + // Cluster-based (sloppy) decimator. Ignores topology entirely; + // ideal for BIM brep output which is usually non-manifold / has + // T-junctions / per-triangle vertex duplication. Quantises + // positions into voxel cells — no welding needed. simplified.resize(mesh.index_count); float result_error = 0.0f; - size_t new_index_count = 0; - - if (use_sloppy) { - // Cluster-based decimator. Ignores topology entirely; great for - // BIM brep output which is usually non-manifold / has T-junctions. - // Operates directly on the original indices — welding isn't - // needed since it quantises positions into voxel cells. - new_index_count = meshopt_simplifySloppy( - simplified.data(), - indices, mesh.index_count, - positions, mesh.vertex_count, local_pos_stride, - target_index_count, target_error, - &result_error); - } else { - const unsigned int options = - lock_border ? static_cast(meshopt_SimplifyLockBorder) : 0u; - new_index_count = meshopt_simplify( - simplified.data(), - shadow.data(), mesh.index_count, - positions, mesh.vertex_count, local_pos_stride, - target_index_count, target_error, - options, &result_error); - } + size_t new_index_count = meshopt_simplifySloppy( + simplified.data(), + indices, mesh.index_count, + positions, mesh.vertex_count, local_pos_stride, + target_index_count, target_error, + &result_error); if (debug && dbg_printed < 8) { std::fprintf(stderr, @@ -202,9 +161,9 @@ void buildLods(SidecarData& sd, if (debug) { std::fprintf(stderr, " [lod] summary: accepted=%d rejected_noreduce=%d rejected_savings=%d " - "(lock_border=%d target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n", + "(target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n", dbg_accepted, dbg_rejected_noreduce, dbg_rejected_savings, - lock_border ? 1 : 0, target_error, target_ratio, min_savings); + target_error, target_ratio, min_savings); } } diff --git a/src/ifcviewer/LodBuilder.h b/src/ifcviewer/LodBuilder.h index 0147ba82f9..df1638b58f 100644 --- a/src/ifcviewer/LodBuilder.h +++ b/src/ifcviewer/LodBuilder.h @@ -23,8 +23,8 @@ #include "SidecarCache.h" // Build a LOD1 index slice for every mesh in `sd` whose triangle count is -// above `min_triangles`, using meshoptimizer's edge-collapse decimator. The -// LOD1 indices are appended to `sd.indices`; each MeshInfo's +// above `min_triangles`, using meshoptimizer's sloppy (voxel-clustering) +// decimator. The LOD1 indices are appended to `sd.indices`; each MeshInfo's // lod1_ebo_byte_offset + lod1_index_count are populated to point at the // appended range. Meshes that don't qualify (too small) or where the // decimator couldn't meet the target within the error budget have diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 25e52e8ce3..eb1e0d88df 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -540,44 +540,28 @@ shader) is unchanged. ##### Decimator choice: `meshopt_simplifySloppy` -The first attempt used `meshopt_simplify`, which is an edge-collapse -decimator. It returned every input mesh unchanged (`err = 0.0`) for two -reasons, both inherent to BIM brep output: - -1. **Per-triangle vertex duplication.** The instanced VBO stores each - triangle's vertices separately so that hard-edge normals can differ - across triangles. Topologically there are no shared vertices, so no - edges exist for `meshopt_simplify` to collapse. A - `meshopt_generateShadowIndexBuffer` welding pass (hash xyz only, - ignore the interleaved normal/colour) fixes this half cheaply — the - VBO isn't touched, only a per-call shadow index buffer is built. -2. **Non-manifold topology even after welding.** BIM brep output has - T-junctions, coplanar slivers, separate solids meeting at a plane, - and multi-material cuts. `meshopt_simplify` needs valid 2-manifold - edge pairs to score collapses; it refuses the non-manifold ones, the - priority queue never fires, and it returns the input untouched. - `meshopt_simplifySloppy` is a **voxel-clustering decimator** — it quantises positions into cells and merges everything in a cell to a -single point. Topology is irrelevant, so it works directly on the -original indices (welding isn't even needed). The trade-off is that it -rounds off sharp corners and can produce slightly degenerate triangles, -so it doesn't look great at mid-screen size. For a LOD1 that only -activates below 30 px projected radius that's invisible in practice. If -you ever want LOD1 to remain active at larger sizes, the only robust -fix is to pre-process BIM meshes into manifold form (fuse coplanar -faces, split at T-junctions) — a significant project unto itself. +single point. This is the only meshoptimizer decimator that works on +BIM brep output, which has per-triangle vertex duplication (hard-edge +normals) and non-manifold topology (T-junctions, coplanar slivers, +separate solids meeting at a plane). The edge-collapse decimator +(`meshopt_simplify`) needs 2-manifold edge pairs to score collapses; +on BIM geometry it returns the input unchanged. + +`simplifySloppy` rounds off sharp corners and can produce slightly +degenerate triangles, so it doesn't look great at mid-screen size. +For a LOD1 that only activates below 30 px projected radius that's +invisible in practice. ##### Tuning knobs (env vars) | Var | Default | Effect | |-----|---------|--------| | `IFC_LOD1_PX` | `30` | Projected sphere radius (px) below which LOD1 kicks in. `0` disables LOD1 entirely. | -| `IFC_LOD_SLOPPY` | `1` | `0` falls back to edge-collapse (`meshopt_simplify`) on shadow-welded indices. Typically produces zero LOD1 output for BIM — useful only for A/B comparison. | | `IFC_LOD_ERROR` | `0.2` | Target relative error passed to meshopt. | | `IFC_LOD_RATIO` | `0.25` | Target triangle-count ratio (LOD1 aims for 25 % of LOD0 tris). | | `IFC_LOD_MIN_SAVINGS` | `0.25` | Reject the LOD1 result if it doesn't shave at least this fraction of triangles. | -| `IFC_LOD_LOCK_BORDER` | `0` | `1` re-enables `meshopt_SimplifyLockBorder` (only meaningful with `IFC_LOD_SLOPPY=0`). | | `IFC_LOD_DEBUG` | `0` | `1` prints per-mesh `tris / target / got / err` for the first 8 candidate meshes plus an accept/reject summary per model. | ##### Measured results From ae938d425bd196e40c18ab1e09bb399b8c293304 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 21 Apr 2026 14:06:44 +1000 Subject: [PATCH 057/120] ifcviewer: split into shared library and two app executables Turn src/ifcviewer into libIfcViewer.so holding the rendering engine + geometry pipeline (ViewportWindow, GeometryStreamer, BvhAccel, InstancedGeometry, SidecarCache, LodBuilder, AppSettings). Move the existing UI shell (MainWindow, SettingsWindow, main.cpp) into src/ifcviewer-full as the IfcViewerFull executable. Add a new src/ifcviewer-minimal target with a MinimalWindow that hosts only the viewport and reuses the sidecar fast-path for benchmark/debug runs. Co-Authored-By: Claude Opus 4.6 --- cmake/CMakeLists.txt | 2 + src/ifcviewer-full/CMakeLists.txt | 36 +++ .../MainWindow.cpp | 0 .../MainWindow.h | 0 .../SettingsWindow.cpp | 0 .../SettingsWindow.h | 0 src/{ifcviewer => ifcviewer-full}/main.cpp | 0 src/ifcviewer-minimal/CMakeLists.txt | 36 +++ src/ifcviewer-minimal/MinimalWindow.cpp | 278 ++++++++++++++++++ src/ifcviewer-minimal/MinimalWindow.h | 85 ++++++ src/ifcviewer-minimal/main.cpp | 65 ++++ src/ifcviewer/CMakeLists.txt | 24 +- 12 files changed, 518 insertions(+), 8 deletions(-) create mode 100644 src/ifcviewer-full/CMakeLists.txt rename src/{ifcviewer => ifcviewer-full}/MainWindow.cpp (100%) rename src/{ifcviewer => ifcviewer-full}/MainWindow.h (100%) rename src/{ifcviewer => ifcviewer-full}/SettingsWindow.cpp (100%) rename src/{ifcviewer => ifcviewer-full}/SettingsWindow.h (100%) rename src/{ifcviewer => ifcviewer-full}/main.cpp (100%) create mode 100644 src/ifcviewer-minimal/CMakeLists.txt create mode 100644 src/ifcviewer-minimal/MinimalWindow.cpp create mode 100644 src/ifcviewer-minimal/MinimalWindow.h create mode 100644 src/ifcviewer-minimal/main.cpp diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 69667eff07..660d55cf3d 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -674,6 +674,8 @@ if(BUILD_IFCGEOM) endif(BUILD_IFCGEOM) if(BUILD_IFCVIEWER) add_subdirectory(../src/ifcviewer ifcviewer) + add_subdirectory(../src/ifcviewer-minimal ifcviewer-minimal) + add_subdirectory(../src/ifcviewer-full ifcviewer-full) endif() # Cmake uninstall target diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt new file mode 100644 index 0000000000..f578d96f70 --- /dev/null +++ b/src/ifcviewer-full/CMakeLists.txt @@ -0,0 +1,36 @@ +################################################################################ +# # +# 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-full") + +file(GLOB IFCVIEWER_FULL_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +file(GLOB IFCVIEWER_FULL_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h) +set(IFCVIEWER_FULL_FILES ${IFCVIEWER_FULL_CPP_FILES} ${IFCVIEWER_FULL_H_FILES}) + +add_executable(IfcViewerFull ${IFCVIEWER_FULL_FILES}) + +set_target_properties(IfcViewerFull PROPERTIES + AUTOMOC ON + WIN32_EXECUTABLE ON + MACOSX_BUNDLE ON +) + +target_link_libraries(IfcViewerFull PRIVATE IfcViewer) + +install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) diff --git a/src/ifcviewer/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp similarity index 100% rename from src/ifcviewer/MainWindow.cpp rename to src/ifcviewer-full/MainWindow.cpp diff --git a/src/ifcviewer/MainWindow.h b/src/ifcviewer-full/MainWindow.h similarity index 100% rename from src/ifcviewer/MainWindow.h rename to src/ifcviewer-full/MainWindow.h diff --git a/src/ifcviewer/SettingsWindow.cpp b/src/ifcviewer-full/SettingsWindow.cpp similarity index 100% rename from src/ifcviewer/SettingsWindow.cpp rename to src/ifcviewer-full/SettingsWindow.cpp diff --git a/src/ifcviewer/SettingsWindow.h b/src/ifcviewer-full/SettingsWindow.h similarity index 100% rename from src/ifcviewer/SettingsWindow.h rename to src/ifcviewer-full/SettingsWindow.h diff --git a/src/ifcviewer/main.cpp b/src/ifcviewer-full/main.cpp similarity index 100% rename from src/ifcviewer/main.cpp rename to src/ifcviewer-full/main.cpp diff --git a/src/ifcviewer-minimal/CMakeLists.txt b/src/ifcviewer-minimal/CMakeLists.txt new file mode 100644 index 0000000000..dda8743f13 --- /dev/null +++ b/src/ifcviewer-minimal/CMakeLists.txt @@ -0,0 +1,36 @@ +################################################################################ +# # +# 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-minimal") + +file(GLOB IFCVIEWER_MINIMAL_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +file(GLOB IFCVIEWER_MINIMAL_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h) +set(IFCVIEWER_MINIMAL_FILES ${IFCVIEWER_MINIMAL_CPP_FILES} ${IFCVIEWER_MINIMAL_H_FILES}) + +add_executable(IfcViewerMinimal ${IFCVIEWER_MINIMAL_FILES}) + +set_target_properties(IfcViewerMinimal PROPERTIES + AUTOMOC ON + WIN32_EXECUTABLE ON + MACOSX_BUNDLE ON +) + +target_link_libraries(IfcViewerMinimal PRIVATE IfcViewer) + +install(TARGETS IfcViewerMinimal EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) diff --git a/src/ifcviewer-minimal/MinimalWindow.cpp b/src/ifcviewer-minimal/MinimalWindow.cpp new file mode 100644 index 0000000000..2d3593165c --- /dev/null +++ b/src/ifcviewer-minimal/MinimalWindow.cpp @@ -0,0 +1,278 @@ +/******************************************************************************** + * * + * 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 "MinimalWindow.h" +#include "AppSettings.h" +#include "SidecarCache.h" + +#include +#include +#include +#include +#include + +#include +#include + +MinimalWindow::MinimalWindow(QWidget* parent) + : QMainWindow(parent) +{ + viewport_ = new ViewportWindow(); + viewport_container_ = QWidget::createWindowContainer(viewport_, this); + viewport_container_->setMinimumSize(400, 300); + viewport_container_->setFocusPolicy(Qt::StrongFocus); + setCentralWidget(viewport_container_); + + status_label_ = new QLabel("Ready"); + stats_label_ = new QLabel(); + stats_label_->setVisible(AppSettings::instance().showStats()); + statusBar()->addWidget(status_label_, 1); + statusBar()->addPermanentWidget(stats_label_); + + connect(viewport_, &ViewportWindow::frameStatsUpdated, this, + [this](const ViewportWindow::FrameStats& s) { + if (!stats_label_->isVisible()) return; + stats_label_->setText( + QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 gl_draws (%8 sub)") + .arg(s.fps, 0, 'f', 1) + .arg(s.frame_time_ms, 0, 'f', 1) + .arg(s.visible_objects) + .arg(s.total_objects) + .arg(s.visible_triangles) + .arg(s.total_triangles) + .arg(s.gl_draw_calls) + .arg(s.indirect_sub_draws)); + }); + + connect(&AppSettings::instance(), &AppSettings::showStatsChanged, this, [this](bool show) { + stats_label_->setVisible(show); + if (!show) stats_label_->clear(); + }); + + // Periodically drain the streamer's pending-elements buffer so it doesn't + // grow unbounded on large models. We don't use the element data here — + // this window has no tree — but the buffer must still be flushed. + connect(&element_drain_timer_, &QTimer::timeout, this, &MinimalWindow::drainStreamerElements); + element_drain_timer_.setInterval(250); + + setWindowTitle("IfcViewerMinimal"); + resize(1200, 800); +} + +MinimalWindow::~MinimalWindow() { + joinSidecarThread(); +} + +void MinimalWindow::joinSidecarThread() { + if (sidecar_read_thread_.joinable()) + sidecar_read_thread_.join(); +} + +void MinimalWindow::addFiles(const QStringList& paths) { + for (const auto& path : paths) { + uint32_t id = next_model_id_++; + ModelEntry entry; + entry.id = id; + entry.file_path = path; + entry.display_name = QFileInfo(path).fileName(); + entry.streamer = new GeometryStreamer(this); + models_[id] = entry; + load_queue_.push_back(id); + } + + if (loading_model_id_ == 0) { + QTimer::singleShot(0, this, &MinimalWindow::startNextLoad); + } +} + +void MinimalWindow::connectStreamer(GeometryStreamer* streamer) { + connect(streamer, &GeometryStreamer::meshReady, + this, &MinimalWindow::onMeshReady, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::instanceReady, + this, &MinimalWindow::onInstanceReady, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::finished, + this, &MinimalWindow::onStreamingFinished, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::errorOccurred, + this, &MinimalWindow::onErrorOccurred, Qt::QueuedConnection); +} + +void MinimalWindow::startNextLoad() { + if (load_queue_.empty()) { + loading_model_id_ = 0; + status_label_->setText(QString("Loaded %1 model(s)").arg(models_.size())); + applyPendingBenchmark(); + return; + } + + loading_model_id_ = load_queue_.front(); + load_queue_.pop_front(); + + auto& model = models_[loading_model_id_]; + + load_timer_.restart(); + status_label_->setText("Loading: " + model.display_name); + + std::string ifc_path = model.file_path.toStdString(); + uint64_t file_size = static_cast(QFileInfo(model.file_path).size()); + uint32_t mid = loading_model_id_; + + joinSidecarThread(); + sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() { + QElapsedTimer rt; rt.start(); + auto cached = readSidecar(ifc_path, file_size); + qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); + auto result = std::make_shared>(std::move(cached)); + QMetaObject::invokeMethod(this, [this, mid, result]() { + if (*result && !(*result)->instances.empty()) { + applySidecarData(mid, std::move(**result)); + } else { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& m = it->second; + connectStreamer(m.streamer); + element_drain_timer_.start(); + m.streamer->loadFile( + m.file_path.toStdString(), next_object_id_, loading_model_id_); + } + }, Qt::QueuedConnection); + }); +} + +void MinimalWindow::applySidecarData(uint32_t mid, SidecarData data) { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& model = it->second; + + qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", + model.file_path.toStdString().c_str(), + data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES, + data.indices.size(), + data.meshes.size(), + data.instances.size(), + data.elements.size()); + + // Rebase object/model IDs onto the current session's ID space. Matches + // MainWindow::applySidecarData — two cached models both starting at + // object_id=1 would collide otherwise. + uint32_t min_oid = UINT32_MAX; + for (const auto& pe : data.elements) { + if (pe.object_id < min_oid) min_oid = pe.object_id; + } + uint32_t oid_offset = 0; + if (!data.elements.empty() && min_oid < UINT32_MAX) { + oid_offset = next_object_id_ - min_oid; + } + for (auto& pe : data.elements) { + pe.object_id += oid_offset; + pe.model_id = mid; + if (pe.object_id >= next_object_id_) + next_object_id_ = pe.object_id + 1; + } + for (auto& inst : data.instances) { + inst.object_id += oid_offset; + inst.model_id = mid; + } + + viewport_->applyCachedModel(mid, std::move(data)); + + 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("%1 loaded from cache in %2") + .arg(model.display_name).arg(elapsed)); + + loading_model_id_ = 0; + QTimer::singleShot(0, this, &MinimalWindow::startNextLoad); +} + +void MinimalWindow::onMeshReady(MeshChunk chunk) { + viewport_->uploadMeshChunk(chunk); +} + +void MinimalWindow::onInstanceReady(InstanceChunk chunk) { + viewport_->uploadInstanceChunk(chunk); +} + +void MinimalWindow::drainStreamerElements() { + if (loading_model_id_ == 0) return; + auto it = models_.find(loading_model_id_); + if (it == models_.end()) return; + (void)it->second.streamer->drainElements(); +} + +void MinimalWindow::onStreamingFinished() { + element_drain_timer_.stop(); + drainStreamerElements(); + + if (loading_model_id_ != 0) { + auto it = models_.find(loading_model_id_); + if (it != models_.end()) { + next_object_id_ = it->second.streamer->lastObjectId(); + viewport_->finalizeModel(loading_model_id_); + } + } + + qint64 ms = load_timer_.elapsed(); + QString elapsed = (ms >= 1000) + ? QString::number(ms / 1000.0, 'f', 2) + " s" + : QString::number(ms) + " ms"; + + auto it = models_.find(loading_model_id_); + QString name = (it != models_.end()) ? it->second.display_name : QString(); + status_label_->setText(QString("%1 streamed in %2").arg(name).arg(elapsed)); + + startNextLoad(); +} + +void MinimalWindow::onErrorOccurred(const QString& message) { + qWarning("IfcViewerMinimal error: %s", qPrintable(message)); + status_label_->setText("Error: " + message); +} + +void MinimalWindow::setPendingCamera(const QString& params) { + pending_camera_ = params; +} + +void MinimalWindow::setPendingBenchmark(int frames) { + pending_benchmark_ = frames; +} + +void MinimalWindow::applyPendingBenchmark() { + if (pending_camera_.isEmpty() && pending_benchmark_ <= 0) return; + + if (!pending_camera_.isEmpty()) { + QStringList parts = pending_camera_.split(','); + if (parts.size() == 6) { + viewport_->setCamera( + parts[0].toFloat(), parts[1].toFloat(), parts[2].toFloat(), + parts[3].toFloat(), parts[4].toFloat(), parts[5].toFloat()); + qDebug("Camera set: %s", qPrintable(pending_camera_)); + } else { + qWarning("--camera expects 6 comma-separated values: tx,ty,tz,dist,yaw,pitch"); + } + pending_camera_.clear(); + } + + if (pending_benchmark_ > 0) { + qDebug("Starting benchmark: %d frames", pending_benchmark_); + viewport_->setBenchmarkFrames(pending_benchmark_); + pending_benchmark_ = 0; + } +} diff --git a/src/ifcviewer-minimal/MinimalWindow.h b/src/ifcviewer-minimal/MinimalWindow.h new file mode 100644 index 0000000000..12ea533c40 --- /dev/null +++ b/src/ifcviewer-minimal/MinimalWindow.h @@ -0,0 +1,85 @@ +/******************************************************************************** + * * + * 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 MINIMALWINDOW_H +#define MINIMALWINDOW_H + +#include +#include +#include +#include + +#include +#include +#include + +#include "ViewportWindow.h" +#include "GeometryStreamer.h" + +class MinimalWindow : public QMainWindow { + Q_OBJECT +public: + explicit MinimalWindow(QWidget* parent = nullptr); + ~MinimalWindow(); + + void addFiles(const QStringList& paths); + void setPendingCamera(const QString& params); + void setPendingBenchmark(int frames); + +private slots: + void onMeshReady(MeshChunk chunk); + void onInstanceReady(InstanceChunk chunk); + void onStreamingFinished(); + void onErrorOccurred(const QString& message); + void drainStreamerElements(); + +private: + struct ModelEntry { + uint32_t id = 0; + QString file_path; + QString display_name; + GeometryStreamer* streamer = nullptr; + }; + + void startNextLoad(); + void connectStreamer(GeometryStreamer* streamer); + void joinSidecarThread(); + void applySidecarData(uint32_t mid, SidecarData data); + void applyPendingBenchmark(); + + ViewportWindow* viewport_ = nullptr; + QWidget* viewport_container_ = nullptr; + QLabel* status_label_ = nullptr; + QLabel* stats_label_ = nullptr; + + std::map models_; + std::deque load_queue_; + uint32_t next_model_id_ = 1; + uint32_t next_object_id_ = 1; + uint32_t loading_model_id_ = 0; + std::thread sidecar_read_thread_; + + QTimer element_drain_timer_; + QElapsedTimer load_timer_; + + QString pending_camera_; + int pending_benchmark_ = 0; +}; + +#endif // MINIMALWINDOW_H diff --git a/src/ifcviewer-minimal/main.cpp b/src/ifcviewer-minimal/main.cpp new file mode 100644 index 0000000000..75db1b212b --- /dev/null +++ b/src/ifcviewer-minimal/main.cpp @@ -0,0 +1,65 @@ +/******************************************************************************** + * * + * 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 "MinimalWindow.h" + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + app.setApplicationName("IfcViewerMinimal"); + app.setOrganizationName("IfcOpenShell"); + + 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 minimal IFC viewer — benchmarking and debugging"); + parser.addHelpOption(); + parser.addPositionalArgument("files", "IFC file(s) to open", "[files...]"); + parser.addOption({{"c", "camera"}, + "Set camera: tx,ty,tz,dist,yaw,pitch", "params"}); + parser.addOption({{"b", "benchmark"}, + "Run N frames then print stats and exit", "frames"}); + parser.process(app); + + MinimalWindow window; + window.show(); + + auto args = parser.positionalArguments(); + if (!args.isEmpty()) { + window.addFiles(args); + } + + if (parser.isSet("camera")) { + window.setPendingCamera(parser.value("camera")); + } + if (parser.isSet("benchmark")) { + window.setPendingBenchmark(parser.value("benchmark").toInt()); + } + + return app.exec(); +} diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index 458c52d9f3..b488cd5050 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -20,9 +20,9 @@ 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. +# IfcViewerLib 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) @@ -32,17 +32,21 @@ 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}) +add_library(IfcViewer ${IFCVIEWER_FILES}) set_target_properties(IfcViewer PROPERTIES AUTOMOC ON - WIN32_EXECUTABLE ON - MACOSX_BUNDLE ON + VERSION "${PROJECT_VERSION}" + SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}" ) +# Consumers include headers as `#include "ViewportWindow.h"`, so expose this +# directory on the public interface. +target_include_directories(IfcViewer PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + add_dependencies(IfcViewer ${kernel_libraries} ${mapping_libraries}) -target_link_libraries(IfcViewer PRIVATE +target_link_libraries(IfcViewer PUBLIC IfcGeom IfcParse ${OpenCASCADE_LIBRARIES} @@ -58,7 +62,11 @@ target_link_libraries(IfcViewer PRIVATE if(UNIX AND NOT APPLE) find_package(Threads REQUIRED) - target_link_libraries(IfcViewer PRIVATE Threads::Threads) + target_link_libraries(IfcViewer PUBLIC Threads::Threads) endif() install(TARGETS IfcViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) + +install(FILES ${IFCVIEWER_H_FILES} + DESTINATION ${INCLUDEDIR}/ifcviewer +) From 29f5132510c7204286a253232b8594dec8c7def1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 21 Apr 2026 18:05:19 +1000 Subject: [PATCH 058/120] ifcviewer: extract SceneLoader, remove duplicated load orchestration MainWindow and MinimalWindow each carried ~150 lines of mirrored load-queue, sidecar-thread, streamer-wiring, and ID-rebase code. Lift all of it into a SceneLoader QObject in the library; both apps now consume it via signals. Sidecar writes stay on the full-app side since they need the consumer's element metadata strings. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 468 ++++++++---------------- src/ifcviewer-full/MainWindow.h | 65 ++-- src/ifcviewer-minimal/MinimalWindow.cpp | 204 ++--------- src/ifcviewer-minimal/MinimalWindow.h | 42 +-- src/ifcviewer/SceneLoader.cpp | 237 ++++++++++++ src/ifcviewer/SceneLoader.h | 126 +++++++ 6 files changed, 582 insertions(+), 560 deletions(-) create mode 100644 src/ifcviewer/SceneLoader.cpp create mode 100644 src/ifcviewer/SceneLoader.h diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 865b464170..7dfaadb143 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -32,6 +32,7 @@ #include #include #include +#include MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) @@ -39,7 +40,26 @@ MainWindow::MainWindow(QWidget* parent) setupUi(); setupMenus(); - connect(viewport_, &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& s) { + loader_ = new SceneLoader(viewport_, this); + connect(loader_, &SceneLoader::loadStarted, + this, &MainWindow::onLoadStarted); + connect(loader_, &SceneLoader::progressChanged, + this, &MainWindow::onLoadProgressChanged); + connect(loader_, &SceneLoader::sidecarElementsReady, + this, &MainWindow::onSidecarElementsReady); + connect(loader_, &SceneLoader::loadedFromSidecar, + this, &MainWindow::onLoadedFromSidecar); + connect(loader_, &SceneLoader::streamedElementsReady, + this, &MainWindow::onStreamedElementsReady); + connect(loader_, &SceneLoader::loadedFromStream, + this, &MainWindow::onLoadedFromStream); + connect(loader_, &SceneLoader::loadError, + this, &MainWindow::onLoadError); + connect(loader_, &SceneLoader::allLoadsFinished, + this, &MainWindow::onAllLoadsFinished); + + connect(viewport_, &ViewportWindow::frameStatsUpdated, this, + [this](const ViewportWindow::FrameStats& s) { if (!stats_label_->isVisible()) return; stats_label_->setText( QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 gl_draws (%8 sub)") @@ -58,24 +78,13 @@ MainWindow::MainWindow(QWidget* parent) if (!show) stats_label_->clear(); }); - connect(&element_poll_timer_, &QTimer::timeout, this, &MainWindow::pollNewElements); - element_poll_timer_.setInterval(100); - setWindowTitle("IfcViewer"); resize(1400, 900); } -MainWindow::~MainWindow() { - joinSidecarThread(); -} - -void MainWindow::joinSidecarThread() { - if (sidecar_read_thread_.joinable()) - sidecar_read_thread_.join(); -} +MainWindow::~MainWindow() = default; void MainWindow::setupUi() { - // 3D Viewport as central widget viewport_ = new ViewportWindow(); viewport_container_ = QWidget::createWindowContainer(viewport_, this); viewport_container_->setMinimumSize(400, 300); @@ -84,7 +93,6 @@ void MainWindow::setupUi() { 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(); @@ -96,7 +104,6 @@ void MainWindow::setupUi() { 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(); @@ -108,7 +115,6 @@ void MainWindow::setupUi() { 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); @@ -148,171 +154,78 @@ void MainWindow::onFileSettings() { } 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 ids = loader_->addFiles(paths); + for (int i = 0; i < paths.size() && i < static_cast(ids.size()); ++i) { + uint32_t id = ids[i]; + QString display = QFileInfo(paths[i]).fileName(); auto* root = new QTreeWidgetItem(element_tree_); - root->setText(0, handle.display_name); + root->setText(0, display); 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) { - QTimer::singleShot(0, this, &MainWindow::startNextLoad); + root->setData(0, Qt::UserRole, static_cast(0)); + tree_roots_[id] = root; } } -void MainWindow::connectStreamer(GeometryStreamer* streamer) { - connect(streamer, &GeometryStreamer::progressChanged, - this, &MainWindow::onProgressChanged, Qt::QueuedConnection); - connect(streamer, &GeometryStreamer::meshReady, - this, &MainWindow::onMeshReady, Qt::QueuedConnection); - connect(streamer, &GeometryStreamer::instanceReady, - this, &MainWindow::onInstanceReady, 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::onLoadStarted(uint32_t /*mid*/, QString display_name) { + progress_bar_->setValue(0); + progress_bar_->setVisible(true); + status_label_->setText("Loading: " + display_name); } -void MainWindow::startNextLoad() { - if (load_queue_.empty()) { - loading_model_id_ = 0; - applyPendingBenchmark(); - return; +void MainWindow::onLoadProgressChanged(int percent) { + progress_bar_->setValue(percent); +} + +void MainWindow::appendElementToTree(uint32_t model_id, + uint32_t object_id, + int ifc_id, + int parent_ifc_id, + const std::string& guid, + const std::string& name, + const std::string& type) { + auto root_it = tree_roots_.find(model_id); + QTreeWidgetItem* parent_item = (root_it != tree_roots_.end()) ? root_it->second : nullptr; + + auto parent_obj_it = scoped_ifc_id_to_object_id_.find( + scopedKey(model_id, parent_ifc_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; + } } - loading_model_id_ = load_queue_.front(); - load_queue_.pop_front(); + QString display_name = QString::fromStdString(name); + if (display_name.isEmpty()) { + display_name = QString::fromStdString(type) + " #" + QString::number(ifc_id); + } - auto& model = models_[loading_model_id_]; + auto* item = new QTreeWidgetItem(parent_item); + item->setText(0, display_name); + item->setText(1, QString::fromStdString(type)); + item->setText(2, QString::fromStdString(guid)); + item->setData(0, Qt::UserRole, object_id); - load_timer_.restart(); - status_label_->setText("Loading: " + model.display_name); - - // Try sidecar on a background thread so the UI stays responsive. - std::string ifc_path = model.file_path.toStdString(); - uint64_t file_size = static_cast(QFileInfo(model.file_path).size()); - ModelId mid = loading_model_id_; - - joinSidecarThread(); - sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() { - QElapsedTimer rt; rt.start(); - auto cached = readSidecar(ifc_path, file_size); - qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); - auto result = std::make_shared>(std::move(cached)); - QMetaObject::invokeMethod(this, [this, mid, result]() { - if (*result && !(*result)->instances.empty()) { - applySidecarData(mid, std::move(**result)); - } else { - // No sidecar — fall back to streaming from IFC. - auto it = models_.find(mid); - if (it == models_.end()) return; - auto& m = it->second; - connectStreamer(m.streamer); - progress_bar_->setValue(0); - progress_bar_->setVisible(true); - status_label_->setText("Loading: " + m.display_name); - element_poll_timer_.start(); - m.streamer->loadFile( - m.file_path.toStdString(), next_object_id_, loading_model_id_); - } - }, Qt::QueuedConnection); - }); + tree_items_[object_id] = item; } -void MainWindow::applySidecarData(ModelId mid, SidecarData data) { - auto it = models_.find(mid); - if (it == models_.end()) return; - auto& model = it->second; - - qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", - model.file_path.toStdString().c_str(), - data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES, - data.indices.size(), - data.meshes.size(), - data.instances.size(), - data.elements.size()); +void MainWindow::onSidecarElementsReady(uint32_t mid, + std::vector elements, + std::string string_table) { + auto str = [&](uint32_t offset, uint32_t length) -> std::string { + if (length == 0 || offset + length > string_table.size()) return {}; + return string_table.substr(offset, length); + }; QElapsedTimer t; t.start(); - - // Sidecars store raw object_ids and model_ids from the session that wrote - // them. On load we must rebase both onto the current session's ID space, - // or two cached models collide (both starting at object_id=1, both - // claiming the original model_id). Offset by (next_object_id_ - min_id) - // so the first cached object takes the next free slot. - uint32_t min_oid = UINT32_MAX; - for (const auto& pe : data.elements) { - if (pe.object_id < min_oid) min_oid = pe.object_id; - } - uint32_t oid_offset = 0; - if (!data.elements.empty() && min_oid < UINT32_MAX) { - oid_offset = next_object_id_ - min_oid; - } - for (auto& pe : data.elements) { - pe.object_id += oid_offset; - pe.model_id = mid; - if (pe.object_id >= next_object_id_) - next_object_id_ = pe.object_id + 1; - } - for (auto& inst : data.instances) { - inst.object_id += oid_offset; - inst.model_id = mid; - } - - // Hand off geometry to GPU in a single call. - std::vector elements = std::move(data.elements); - std::string stbl = std::move(data.string_table); - viewport_->applyCachedModel(mid, std::move(data)); - qDebug(" GL upload: %lld ms", t.elapsed()); - - t.restart(); element_tree_->setUpdatesEnabled(false); - populateTreeFromSidecar(model, elements, stbl); - element_tree_->setUpdatesEnabled(true); - qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), elements.size()); - - 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("%1 elements across %2 model(s) — loaded from cache in %3") - .arg(element_map_.size()) - .arg(models_.size()) - .arg(elapsed)); - - loading_model_id_ = 0; - QTimer::singleShot(0, this, &MainWindow::startNextLoad); -} - -void MainWindow::populateTreeFromSidecar(ModelHandle& model, - const std::vector& elements, - const std::string& stbl) { - auto str = [&](uint32_t offset, uint32_t length) -> std::string { - if (length == 0 || offset + length > stbl.size()) return {}; - return stbl.substr(offset, length); - }; for (const auto& pe : elements) { ElementInfo info; info.object_id = pe.object_id; - info.model_id = pe.model_id; - info.ifc_id = pe.ifc_id; + info.model_id = pe.model_id; + info.ifc_id = pe.ifc_id; info.parent_id = pe.parent_id; info.guid = str(pe.guid_offset, pe.guid_length); info.name = str(pe.name_offset, pe.name_length); @@ -321,131 +234,90 @@ void MainWindow::populateTreeFromSidecar(ModelHandle& model, element_map_[info.object_id] = info; scoped_ifc_id_to_object_id_[scopedKey(info.model_id, info.ifc_id)] = info.object_id; - // Find parent tree item. - 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; - } - } - - QString display_name = QString::fromStdString(info.name); - if (display_name.isEmpty()) { - display_name = QString::fromStdString(info.type) + " #" + QString::number(info.ifc_id); - } - - 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)); - item->setData(0, Qt::UserRole, info.object_id); - - tree_items_[info.object_id] = item; - } -} - -void MainWindow::onProgressChanged(int percent) { - progress_bar_->setValue(percent); -} - -void MainWindow::onMeshReady(MeshChunk chunk) { - viewport_->uploadMeshChunk(chunk); -} - -void MainWindow::onInstanceReady(InstanceChunk chunk) { - viewport_->uploadInstanceChunk(chunk); -} - -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(); - } + appendElementToTree(info.model_id, info.object_id, info.ifc_id, + info.parent_id, info.guid, info.name, info.type); } + element_tree_->setUpdatesEnabled(true); + qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), elements.size()); +} + +void MainWindow::onLoadedFromSidecar(uint32_t /*mid*/, qint64 elapsed_ms) { progress_bar_->setVisible(false); + status_label_->setText(QString("%1 elements across %2 model(s) — loaded from cache in %3") + .arg(element_map_.size()) + .arg(loader_->modelCount()) + .arg(formatElapsed(elapsed_ms))); +} - qint64 ms = load_timer_.elapsed(); - QString elapsed = (ms >= 1000) - ? QString::number(ms / 1000.0, 'f', 2) + " s" - : QString::number(ms) + " ms"; +void MainWindow::onStreamedElementsReady(uint32_t /*mid*/, std::vector elements) { + for (const auto& info : elements) { + element_map_[info.object_id] = info; + scoped_ifc_id_to_object_id_[scopedKey(info.model_id, info.ifc_id)] = info.object_id; + appendElementToTree(info.model_id, info.object_id, info.ifc_id, + info.parent_id, info.guid, info.name, info.type); + } +} - 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)); +void MainWindow::writeSidecarForModel(uint32_t mid) { + SidecarData sd; + if (!viewport_->snapshotModel(mid, sd)) return; - // Sort instances by mesh, upload the per-model instance SSBO, and - // persist a v4 sidecar for next load. - if (loading_model_id_ != 0) { - viewport_->finalizeModel(loading_model_id_); - - auto it = models_.find(loading_model_id_); - if (it != models_.end()) { - SidecarData sd; - if (viewport_->snapshotModel(loading_model_id_, sd)) { - // Pack this model's element metadata + string table. - for (const auto& [oid, info] : element_map_) { - if (info.model_id != loading_model_id_) continue; - PackedElementInfo pe; - pe.object_id = info.object_id; - pe.model_id = info.model_id; - pe.ifc_id = info.ifc_id; - pe.parent_id = info.parent_id; - pe.guid_offset = static_cast(sd.string_table.size()); - pe.guid_length = static_cast(info.guid.size()); - sd.string_table += info.guid; - pe.name_offset = static_cast(sd.string_table.size()); - pe.name_length = static_cast(info.name.size()); - sd.string_table += info.name; - pe.type_offset = static_cast(sd.string_table.size()); - pe.type_length = static_cast(info.type.size()); - sd.string_table += info.type; - sd.elements.push_back(pe); - } - - // Build LOD1 for eligible meshes (extends sd.indices and - // populates MeshInfo::lod1_*), push the extension onto the - // live GPU state so this session benefits too, then cache. - QElapsedTimer t_lod; t_lod.start(); - buildLods(sd); - LodStats ls = summariseLods(sd); - qDebug(" LOD build: %lld ms — %u/%u meshes got LOD1 " - "(%u tris → %u tris for those meshes)", - t_lod.elapsed(), - ls.meshes_with_lod1, ls.meshes_total, - ls.tris_lod0_for_lod1, ls.tris_lod1); - viewport_->applyLodExtension(loading_model_id_, sd); - - std::string ifc_path = it->second.file_path.toStdString(); - uint64_t file_size = static_cast( - QFileInfo(it->second.file_path).size()); - QElapsedTimer t; t.start(); - bool ok = writeSidecar(ifc_path, sd, file_size); - qDebug(" Sidecar write: %lld ms (%s)", - t.elapsed(), ok ? "ok" : "FAILED"); - } - } + for (const auto& [oid, info] : element_map_) { + if (info.model_id != mid) continue; + PackedElementInfo pe; + pe.object_id = info.object_id; + pe.model_id = info.model_id; + pe.ifc_id = info.ifc_id; + pe.parent_id = info.parent_id; + pe.guid_offset = static_cast(sd.string_table.size()); + pe.guid_length = static_cast(info.guid.size()); + sd.string_table += info.guid; + pe.name_offset = static_cast(sd.string_table.size()); + pe.name_length = static_cast(info.name.size()); + sd.string_table += info.name; + pe.type_offset = static_cast(sd.string_table.size()); + pe.type_length = static_cast(info.type.size()); + sd.string_table += info.type; + sd.elements.push_back(pe); } - // Start next model if queued. - startNextLoad(); + QElapsedTimer t_lod; t_lod.start(); + buildLods(sd); + LodStats ls = summariseLods(sd); + qDebug(" LOD build: %lld ms — %u/%u meshes got LOD1 " + "(%u tris -> %u tris for those meshes)", + t_lod.elapsed(), + ls.meshes_with_lod1, ls.meshes_total, + ls.tris_lod0_for_lod1, ls.tris_lod1); + viewport_->applyLodExtension(mid, sd); + + QElapsedTimer t; t.start(); + bool ok = writeSidecar(loader_->filePath(mid).toStdString(), sd, loader_->fileSize(mid)); + qDebug(" Sidecar write: %lld ms (%s)", t.elapsed(), ok ? "ok" : "FAILED"); +} + +void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { + progress_bar_->setVisible(false); + status_label_->setText(QString("%1 elements across %2 model(s) — last loaded in %3") + .arg(element_map_.size()) + .arg(loader_->modelCount()) + .arg(formatElapsed(elapsed_ms))); + + writeSidecarForModel(mid); +} + +void MainWindow::onLoadError(uint32_t /*mid*/, QString message) { + QMessageBox::warning(this, "Error", message); +} + +void MainWindow::onAllLoadsFinished() { + applyPendingBenchmark(); } 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); @@ -465,45 +337,6 @@ void MainWindow::onTreeSelectionChanged() { populateProperties(object_id); } -void MainWindow::pollNewElements() { - 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; - scoped_ifc_id_to_object_id_[scopedKey(info.model_id, info.ifc_id)] = info.object_id; - - // 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; - } - } - - QString display_name = QString::fromStdString(info.name); - if (display_name.isEmpty()) { - display_name = QString::fromStdString(info.type) + " #" + QString::number(info.ifc_id); - } - - 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)); - 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; @@ -525,17 +358,12 @@ void MainWindow::populateProperties(uint32_t object_id) { addRow("Name", QString::fromStdString(info.name)); addRow("Type", QString::fromStdString(info.type)); - // 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(); + auto* file = loader_->ifcFile(info.model_id); 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) { @@ -586,3 +414,9 @@ void MainWindow::applyPendingBenchmark() { pending_benchmark_ = 0; } } + +QString MainWindow::formatElapsed(qint64 ms) const { + return (ms >= 1000) + ? QString::number(ms / 1000.0, 'f', 2) + " s" + : QString::number(ms) + " ms"; +} diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 4f5a946998..83c1990c96 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -26,30 +26,16 @@ #include #include #include -#include #include #include -#include -#include #include #include "ViewportWindow.h" -#include "GeometryStreamer.h" +#include "SceneLoader.h" 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: @@ -63,27 +49,37 @@ public: private slots: void onFileOpen(); void onFileSettings(); - void onProgressChanged(int percent); - void onMeshReady(MeshChunk chunk); - void onInstanceReady(InstanceChunk chunk); - void onStreamingFinished(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); - void pollNewElements(); + + void onLoadStarted(uint32_t mid, QString display_name); + void onLoadProgressChanged(int percent); + void onSidecarElementsReady(uint32_t mid, + std::vector elements, + std::string string_table); + void onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + void onStreamedElementsReady(uint32_t mid, std::vector elements); + void onLoadedFromStream(uint32_t mid, qint64 elapsed_ms); + void onLoadError(uint32_t mid, QString message); + void onAllLoadsFinished(); private: void setupUi(); void setupMenus(); void populateProperties(uint32_t object_id); - void startNextLoad(); - void applySidecarData(ModelId mid, SidecarData data); - void joinSidecarThread(); - void populateTreeFromSidecar(ModelHandle& model, - const std::vector& elements, - const std::string& string_table); - void connectStreamer(GeometryStreamer* streamer); + void appendElementToTree(uint32_t model_id, + uint32_t object_id, + int ifc_id, + int parent_ifc_id, + const std::string& guid, + const std::string& name, + const std::string& type); + void writeSidecarForModel(uint32_t mid); + void applyPendingBenchmark(); + QString formatElapsed(qint64 ms) const; ViewportWindow* viewport_ = nullptr; + SceneLoader* loader_ = nullptr; SettingsWindow* settings_ = nullptr; QWidget* viewport_container_ = nullptr; QTreeWidget* element_tree_ = nullptr; @@ -91,18 +87,11 @@ private: QProgressBar* progress_bar_ = nullptr; QLabel* status_label_ = nullptr; QLabel* stats_label_ = nullptr; - QTimer element_poll_timer_; - QElapsedTimer load_timer_; - // 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; - std::thread sidecar_read_thread_; + // Per-model tree roots, keyed by model_id. + std::map tree_roots_; - // Map object_id -> tree item and element info + // Display-side element registry for tree + property lookup. std::unordered_map element_map_; std::unordered_map tree_items_; // Scoped (model_id, ifc_id) -> object_id @@ -114,8 +103,6 @@ private: QString pending_camera_; int pending_benchmark_ = 0; - - void applyPendingBenchmark(); }; #endif // MAINWINDOW_H diff --git a/src/ifcviewer-minimal/MinimalWindow.cpp b/src/ifcviewer-minimal/MinimalWindow.cpp index 2d3593165c..18e6eaae44 100644 --- a/src/ifcviewer-minimal/MinimalWindow.cpp +++ b/src/ifcviewer-minimal/MinimalWindow.cpp @@ -19,17 +19,10 @@ #include "MinimalWindow.h" #include "AppSettings.h" -#include "SidecarCache.h" -#include -#include #include -#include #include -#include -#include - MinimalWindow::MinimalWindow(QWidget* parent) : QMainWindow(parent) { @@ -45,6 +38,18 @@ MinimalWindow::MinimalWindow(QWidget* parent) statusBar()->addWidget(status_label_, 1); statusBar()->addPermanentWidget(stats_label_); + loader_ = new SceneLoader(viewport_, this); + connect(loader_, &SceneLoader::loadStarted, + this, &MinimalWindow::onLoadStarted); + connect(loader_, &SceneLoader::loadedFromSidecar, + this, &MinimalWindow::onLoadedFromSidecar); + connect(loader_, &SceneLoader::loadedFromStream, + this, &MinimalWindow::onLoadedFromStream); + connect(loader_, &SceneLoader::loadError, + this, &MinimalWindow::onLoadError); + connect(loader_, &SceneLoader::allLoadsFinished, + this, &MinimalWindow::onAllLoadsFinished); + connect(viewport_, &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& s) { if (!stats_label_->isVisible()) return; @@ -65,187 +70,46 @@ MinimalWindow::MinimalWindow(QWidget* parent) if (!show) stats_label_->clear(); }); - // Periodically drain the streamer's pending-elements buffer so it doesn't - // grow unbounded on large models. We don't use the element data here — - // this window has no tree — but the buffer must still be flushed. - connect(&element_drain_timer_, &QTimer::timeout, this, &MinimalWindow::drainStreamerElements); - element_drain_timer_.setInterval(250); - setWindowTitle("IfcViewerMinimal"); resize(1200, 800); } -MinimalWindow::~MinimalWindow() { - joinSidecarThread(); -} - -void MinimalWindow::joinSidecarThread() { - if (sidecar_read_thread_.joinable()) - sidecar_read_thread_.join(); -} - void MinimalWindow::addFiles(const QStringList& paths) { - for (const auto& path : paths) { - uint32_t id = next_model_id_++; - ModelEntry entry; - entry.id = id; - entry.file_path = path; - entry.display_name = QFileInfo(path).fileName(); - entry.streamer = new GeometryStreamer(this); - models_[id] = entry; - load_queue_.push_back(id); - } - - if (loading_model_id_ == 0) { - QTimer::singleShot(0, this, &MinimalWindow::startNextLoad); - } + loader_->addFiles(paths); } -void MinimalWindow::connectStreamer(GeometryStreamer* streamer) { - connect(streamer, &GeometryStreamer::meshReady, - this, &MinimalWindow::onMeshReady, Qt::QueuedConnection); - connect(streamer, &GeometryStreamer::instanceReady, - this, &MinimalWindow::onInstanceReady, Qt::QueuedConnection); - connect(streamer, &GeometryStreamer::finished, - this, &MinimalWindow::onStreamingFinished, Qt::QueuedConnection); - connect(streamer, &GeometryStreamer::errorOccurred, - this, &MinimalWindow::onErrorOccurred, Qt::QueuedConnection); -} - -void MinimalWindow::startNextLoad() { - if (load_queue_.empty()) { - loading_model_id_ = 0; - status_label_->setText(QString("Loaded %1 model(s)").arg(models_.size())); - applyPendingBenchmark(); - return; - } - - loading_model_id_ = load_queue_.front(); - load_queue_.pop_front(); - - auto& model = models_[loading_model_id_]; - - load_timer_.restart(); - status_label_->setText("Loading: " + model.display_name); - - std::string ifc_path = model.file_path.toStdString(); - uint64_t file_size = static_cast(QFileInfo(model.file_path).size()); - uint32_t mid = loading_model_id_; - - joinSidecarThread(); - sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() { - QElapsedTimer rt; rt.start(); - auto cached = readSidecar(ifc_path, file_size); - qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); - auto result = std::make_shared>(std::move(cached)); - QMetaObject::invokeMethod(this, [this, mid, result]() { - if (*result && !(*result)->instances.empty()) { - applySidecarData(mid, std::move(**result)); - } else { - auto it = models_.find(mid); - if (it == models_.end()) return; - auto& m = it->second; - connectStreamer(m.streamer); - element_drain_timer_.start(); - m.streamer->loadFile( - m.file_path.toStdString(), next_object_id_, loading_model_id_); - } - }, Qt::QueuedConnection); - }); -} - -void MinimalWindow::applySidecarData(uint32_t mid, SidecarData data) { - auto it = models_.find(mid); - if (it == models_.end()) return; - auto& model = it->second; - - qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", - model.file_path.toStdString().c_str(), - data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES, - data.indices.size(), - data.meshes.size(), - data.instances.size(), - data.elements.size()); - - // Rebase object/model IDs onto the current session's ID space. Matches - // MainWindow::applySidecarData — two cached models both starting at - // object_id=1 would collide otherwise. - uint32_t min_oid = UINT32_MAX; - for (const auto& pe : data.elements) { - if (pe.object_id < min_oid) min_oid = pe.object_id; - } - uint32_t oid_offset = 0; - if (!data.elements.empty() && min_oid < UINT32_MAX) { - oid_offset = next_object_id_ - min_oid; - } - for (auto& pe : data.elements) { - pe.object_id += oid_offset; - pe.model_id = mid; - if (pe.object_id >= next_object_id_) - next_object_id_ = pe.object_id + 1; - } - for (auto& inst : data.instances) { - inst.object_id += oid_offset; - inst.model_id = mid; - } - - viewport_->applyCachedModel(mid, std::move(data)); - - qint64 ms = load_timer_.elapsed(); - QString elapsed = (ms >= 1000) +static QString formatElapsed(qint64 ms) { + return (ms >= 1000) ? QString::number(ms / 1000.0, 'f', 2) + " s" : QString::number(ms) + " ms"; +} + +void MinimalWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { + status_label_->setText("Loading: " + display_name); +} + +void MinimalWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { status_label_->setText(QString("%1 loaded from cache in %2") - .arg(model.display_name).arg(elapsed)); - - loading_model_id_ = 0; - QTimer::singleShot(0, this, &MinimalWindow::startNextLoad); + .arg(loader_->displayName(mid)) + .arg(formatElapsed(elapsed_ms))); } -void MinimalWindow::onMeshReady(MeshChunk chunk) { - viewport_->uploadMeshChunk(chunk); +void MinimalWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { + status_label_->setText(QString("%1 streamed in %2") + .arg(loader_->displayName(mid)) + .arg(formatElapsed(elapsed_ms))); } -void MinimalWindow::onInstanceReady(InstanceChunk chunk) { - viewport_->uploadInstanceChunk(chunk); -} - -void MinimalWindow::drainStreamerElements() { - if (loading_model_id_ == 0) return; - auto it = models_.find(loading_model_id_); - if (it == models_.end()) return; - (void)it->second.streamer->drainElements(); -} - -void MinimalWindow::onStreamingFinished() { - element_drain_timer_.stop(); - drainStreamerElements(); - - if (loading_model_id_ != 0) { - auto it = models_.find(loading_model_id_); - if (it != models_.end()) { - next_object_id_ = it->second.streamer->lastObjectId(); - viewport_->finalizeModel(loading_model_id_); - } - } - - qint64 ms = load_timer_.elapsed(); - QString elapsed = (ms >= 1000) - ? QString::number(ms / 1000.0, 'f', 2) + " s" - : QString::number(ms) + " ms"; - - auto it = models_.find(loading_model_id_); - QString name = (it != models_.end()) ? it->second.display_name : QString(); - status_label_->setText(QString("%1 streamed in %2").arg(name).arg(elapsed)); - - startNextLoad(); -} - -void MinimalWindow::onErrorOccurred(const QString& message) { +void MinimalWindow::onLoadError(uint32_t /*mid*/, QString message) { qWarning("IfcViewerMinimal error: %s", qPrintable(message)); status_label_->setText("Error: " + message); } +void MinimalWindow::onAllLoadsFinished() { + status_label_->setText(QString("Loaded %1 model(s)").arg(loader_->modelCount())); + applyPendingBenchmark(); +} + void MinimalWindow::setPendingCamera(const QString& params) { pending_camera_ = params; } diff --git a/src/ifcviewer-minimal/MinimalWindow.h b/src/ifcviewer-minimal/MinimalWindow.h index 12ea533c40..723dd82fba 100644 --- a/src/ifcviewer-minimal/MinimalWindow.h +++ b/src/ifcviewer-minimal/MinimalWindow.h @@ -22,62 +22,36 @@ #include #include -#include -#include - -#include -#include -#include #include "ViewportWindow.h" -#include "GeometryStreamer.h" +#include "SceneLoader.h" class MinimalWindow : public QMainWindow { Q_OBJECT public: explicit MinimalWindow(QWidget* parent = nullptr); - ~MinimalWindow(); + ~MinimalWindow() = default; void addFiles(const QStringList& paths); void setPendingCamera(const QString& params); void setPendingBenchmark(int frames); private slots: - void onMeshReady(MeshChunk chunk); - void onInstanceReady(InstanceChunk chunk); - void onStreamingFinished(); - void onErrorOccurred(const QString& message); - void drainStreamerElements(); + void onLoadStarted(uint32_t mid, QString display_name); + void onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + void onLoadedFromStream(uint32_t mid, qint64 elapsed_ms); + void onLoadError(uint32_t mid, QString message); + void onAllLoadsFinished(); private: - struct ModelEntry { - uint32_t id = 0; - QString file_path; - QString display_name; - GeometryStreamer* streamer = nullptr; - }; - - void startNextLoad(); - void connectStreamer(GeometryStreamer* streamer); - void joinSidecarThread(); - void applySidecarData(uint32_t mid, SidecarData data); void applyPendingBenchmark(); ViewportWindow* viewport_ = nullptr; + SceneLoader* loader_ = nullptr; QWidget* viewport_container_ = nullptr; QLabel* status_label_ = nullptr; QLabel* stats_label_ = nullptr; - std::map models_; - std::deque load_queue_; - uint32_t next_model_id_ = 1; - uint32_t next_object_id_ = 1; - uint32_t loading_model_id_ = 0; - std::thread sidecar_read_thread_; - - QTimer element_drain_timer_; - QElapsedTimer load_timer_; - QString pending_camera_; int pending_benchmark_ = 0; }; diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp new file mode 100644 index 0000000000..16587b16bf --- /dev/null +++ b/src/ifcviewer/SceneLoader.cpp @@ -0,0 +1,237 @@ +/******************************************************************************** + * * + * 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 "SceneLoader.h" + +#include +#include +#include + +#include +#include +#include + +SceneLoader::SceneLoader(ViewportWindow* viewport, QObject* parent) + : QObject(parent), viewport_(viewport) +{ + connect(&element_poll_timer_, &QTimer::timeout, + this, &SceneLoader::onElementPollTick); + element_poll_timer_.setInterval(100); +} + +SceneLoader::~SceneLoader() { + joinSidecarThread(); +} + +void SceneLoader::joinSidecarThread() { + if (sidecar_read_thread_.joinable()) + sidecar_read_thread_.join(); +} + +QString SceneLoader::filePath(uint32_t mid) const { + auto it = models_.find(mid); + return it == models_.end() ? QString() : it->second.file_path; +} + +QString SceneLoader::displayName(uint32_t mid) const { + auto it = models_.find(mid); + return it == models_.end() ? QString() : it->second.display_name; +} + +uint64_t SceneLoader::fileSize(uint32_t mid) const { + auto it = models_.find(mid); + if (it == models_.end()) return 0; + return static_cast(QFileInfo(it->second.file_path).size()); +} + +ifcopenshell::file* SceneLoader::ifcFile(uint32_t mid) const { + auto it = models_.find(mid); + return it == models_.end() ? nullptr : it->second.streamer->ifcFile(); +} + +std::vector SceneLoader::addFiles(const QStringList& paths) { + std::vector assigned; + assigned.reserve(paths.size()); + for (const auto& path : paths) { + uint32_t id = next_model_id_++; + Entry entry; + entry.id = id; + entry.file_path = path; + entry.display_name = QFileInfo(path).fileName(); + entry.streamer = new GeometryStreamer(this); + models_[id] = std::move(entry); + load_queue_.push_back(id); + assigned.push_back(id); + } + + if (loading_model_id_ == 0) { + QTimer::singleShot(0, this, &SceneLoader::startNextLoad); + } + return assigned; +} + +void SceneLoader::connectStreamer(GeometryStreamer* streamer) { + connect(streamer, &GeometryStreamer::progressChanged, + this, &SceneLoader::onStreamerProgressChanged, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::meshReady, + this, &SceneLoader::onStreamerMeshReady, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::instanceReady, + this, &SceneLoader::onStreamerInstanceReady, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::finished, + this, &SceneLoader::onStreamerFinished, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::errorOccurred, + this, &SceneLoader::onStreamerError, Qt::QueuedConnection); +} + +void SceneLoader::startNextLoad() { + if (load_queue_.empty()) { + loading_model_id_ = 0; + emit allLoadsFinished(); + return; + } + + loading_model_id_ = load_queue_.front(); + load_queue_.pop_front(); + + auto& model = models_[loading_model_id_]; + model.load_timer.restart(); + + emit loadStarted(model.id, model.display_name); + + std::string ifc_path = model.file_path.toStdString(); + uint64_t file_size = this->fileSize(model.id); + uint32_t mid = loading_model_id_; + + // Sidecar read on a background thread so the UI stays responsive. + joinSidecarThread(); + sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() { + QElapsedTimer rt; rt.start(); + auto cached = readSidecar(ifc_path, file_size); + qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); + auto result = std::make_shared>(std::move(cached)); + QMetaObject::invokeMethod(this, [this, mid, result]() { + if (*result && !(*result)->instances.empty()) { + applySidecarData(mid, std::move(**result)); + } else { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& m = it->second; + connectStreamer(m.streamer); + element_poll_timer_.start(); + m.streamer->loadFile( + m.file_path.toStdString(), next_object_id_, loading_model_id_); + } + }, Qt::QueuedConnection); + }); +} + +void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto& model = it->second; + + qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)", + model.file_path.toStdString().c_str(), + data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES, + data.indices.size(), + data.meshes.size(), + data.instances.size(), + data.elements.size()); + + // Rebase object/model IDs onto the current session's ID space. Two + // cached models both starting at object_id=1 would collide otherwise. + uint32_t min_oid = UINT32_MAX; + for (const auto& pe : data.elements) { + if (pe.object_id < min_oid) min_oid = pe.object_id; + } + uint32_t oid_offset = 0; + if (!data.elements.empty() && min_oid < UINT32_MAX) { + oid_offset = next_object_id_ - min_oid; + } + for (auto& pe : data.elements) { + pe.object_id += oid_offset; + pe.model_id = mid; + if (pe.object_id >= next_object_id_) + next_object_id_ = pe.object_id + 1; + } + for (auto& inst : data.instances) { + inst.object_id += oid_offset; + inst.model_id = mid; + } + + std::vector elements = std::move(data.elements); + std::string stbl = std::move(data.string_table); + + viewport_->applyCachedModel(mid, std::move(data)); + + emit sidecarElementsReady(mid, std::move(elements), std::move(stbl)); + + qint64 ms = model.load_timer.elapsed(); + emit loadedFromSidecar(mid, ms); + + loading_model_id_ = 0; + QTimer::singleShot(0, this, &SceneLoader::startNextLoad); +} + +void SceneLoader::onStreamerProgressChanged(int percent) { + emit progressChanged(percent); +} + +void SceneLoader::onStreamerMeshReady(MeshChunk chunk) { + viewport_->uploadMeshChunk(chunk); +} + +void SceneLoader::onStreamerInstanceReady(InstanceChunk chunk) { + viewport_->uploadInstanceChunk(chunk); +} + +void SceneLoader::onElementPollTick() { + if (loading_model_id_ == 0) return; + auto it = models_.find(loading_model_id_); + if (it == models_.end()) return; + + auto batch = it->second.streamer->drainElements(); + if (!batch.empty()) { + emit streamedElementsReady(loading_model_id_, std::move(batch)); + } +} + +void SceneLoader::onStreamerFinished() { + element_poll_timer_.stop(); + onElementPollTick(); // drain any remaining elements + + uint32_t mid = loading_model_id_; + if (mid != 0) { + auto it = models_.find(mid); + if (it != models_.end()) { + next_object_id_ = it->second.streamer->lastObjectId(); + viewport_->finalizeModel(mid); + + qint64 ms = it->second.load_timer.elapsed(); + emit loadedFromStream(mid, ms); + } + } + + loading_model_id_ = 0; + startNextLoad(); +} + +void SceneLoader::onStreamerError(const QString& msg) { + emit loadError(loading_model_id_, msg); +} diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h new file mode 100644 index 0000000000..e29062808c --- /dev/null +++ b/src/ifcviewer/SceneLoader.h @@ -0,0 +1,126 @@ +/******************************************************************************** + * * + * 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 SCENELOADER_H +#define SCENELOADER_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "ViewportWindow.h" +#include "GeometryStreamer.h" +#include "SidecarCache.h" + +// Drives IFC file loading into a ViewportWindow. Owns the per-model +// GeometryStreamer, the load queue, the sidecar read thread, and the +// next-free object_id counter used to rebase cached models onto the +// current session's ID space. +// +// Consumers (MainWindow, MinimalWindow) observe progress through signals +// and never touch the streamer, sidecar thread, or queue directly. +// Sidecar *writes* are intentionally left to the consumer: they need the +// consumer's element metadata (guid/name/type strings) which SceneLoader +// does not retain. +class SceneLoader : public QObject { + Q_OBJECT +public: + explicit SceneLoader(ViewportWindow* viewport, QObject* parent = nullptr); + ~SceneLoader(); + + // Returns the model_ids assigned to the enqueued paths, in order. + // Callers can use these to set up per-model UI state (tree roots, etc.) + // before any load signal fires. + std::vector addFiles(const QStringList& paths); + bool isLoading() const { return loading_model_id_ != 0 || !load_queue_.empty(); } + size_t modelCount() const { return models_.size(); } + + QString filePath(uint32_t mid) const; + QString displayName(uint32_t mid) const; + uint64_t fileSize(uint32_t mid) const; + ifcopenshell::file* ifcFile(uint32_t mid) const; + +signals: + void progressChanged(int percent); + void loadStarted(uint32_t mid, QString display_name); + + // Fired once per sidecar hit, before loadedFromSidecar, with the full + // packed element set. Consumer is responsible for decoding + tree/ + // property-map population. Moved arguments — avoid unnecessary copies. + void sidecarElementsReady(uint32_t mid, + std::vector elements, + std::string string_table); + void loadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + + // Fired repeatedly while streaming, as the worker thread produces + // elements. Each batch contains whatever accumulated since the last + // poll tick. + void streamedElementsReady(uint32_t mid, std::vector elements); + + // Fired once after the streamer finishes and the viewport has been + // finalized. Consumer may synchronously perform work that needs all + // elements to be known (e.g. sidecar write) — SceneLoader will only + // start the next queued load after all slots return. + void loadedFromStream(uint32_t mid, qint64 elapsed_ms); + + void loadError(uint32_t mid, QString message); + void allLoadsFinished(); + +private slots: + void onStreamerProgressChanged(int percent); + void onStreamerMeshReady(MeshChunk chunk); + void onStreamerInstanceReady(InstanceChunk chunk); + void onStreamerFinished(); + void onStreamerError(const QString& msg); + void onElementPollTick(); + +private: + struct Entry { + uint32_t id = 0; + QString file_path; + QString display_name; + GeometryStreamer* streamer = nullptr; + QElapsedTimer load_timer; + }; + + void startNextLoad(); + void connectStreamer(GeometryStreamer* streamer); + void joinSidecarThread(); + void applySidecarData(uint32_t mid, SidecarData data); + + ViewportWindow* viewport_ = nullptr; + std::map models_; + std::deque load_queue_; + uint32_t next_model_id_ = 1; + uint32_t next_object_id_ = 1; + uint32_t loading_model_id_ = 0; + std::thread sidecar_read_thread_; + QTimer element_poll_timer_; +}; + +#endif // SCENELOADER_H From 4e4553201b0b3a1eccad15bc52ecdacbaab885c9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 22 Apr 2026 11:27:53 +1000 Subject: [PATCH 059/120] Fix viewer load termination Handle streamer success, failure, and cancellation as distinct terminal states so failed or cancelled loads do not finalize as successful models. Clean up partial model/UI state in the full and minimal viewer apps when a load is cancelled or fails. Generated with the assistance of an AI coding tool. --- src/ifcviewer-full/MainWindow.cpp | 49 ++++++++++++++++++++++++- src/ifcviewer-full/MainWindow.h | 2 + src/ifcviewer-minimal/MinimalWindow.cpp | 7 ++++ src/ifcviewer-minimal/MinimalWindow.h | 1 + src/ifcviewer/GeometryStreamer.cpp | 8 +++- src/ifcviewer/GeometryStreamer.h | 2 + src/ifcviewer/SceneLoader.cpp | 35 +++++++++++++++++- src/ifcviewer/SceneLoader.h | 3 ++ 8 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 7dfaadb143..fabd008daf 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -53,6 +53,8 @@ MainWindow::MainWindow(QWidget* parent) this, &MainWindow::onStreamedElementsReady); connect(loader_, &SceneLoader::loadedFromStream, this, &MainWindow::onLoadedFromStream); + connect(loader_, &SceneLoader::loadCancelled, + this, &MainWindow::onLoadCancelled); connect(loader_, &SceneLoader::loadError, this, &MainWindow::onLoadError); connect(loader_, &SceneLoader::allLoadsFinished, @@ -297,6 +299,42 @@ void MainWindow::writeSidecarForModel(uint32_t mid) { qDebug(" Sidecar write: %lld ms (%s)", t.elapsed(), ok ? "ok" : "FAILED"); } +void MainWindow::removeModelUi(uint32_t mid) { + auto root_it = tree_roots_.find(mid); + if (root_it != tree_roots_.end()) { + delete root_it->second; + tree_roots_.erase(root_it); + } + + for (auto it = tree_items_.begin(); it != tree_items_.end();) { + auto info_it = element_map_.find(it->first); + if (info_it != element_map_.end() && info_it->second.model_id == mid) { + it = tree_items_.erase(it); + } else { + ++it; + } + } + + for (auto it = element_map_.begin(); it != element_map_.end();) { + if (it->second.model_id == mid) { + it = element_map_.erase(it); + } else { + ++it; + } + } + + for (auto it = scoped_ifc_id_to_object_id_.begin(); it != scoped_ifc_id_to_object_id_.end();) { + if (static_cast(it->first >> 32) == mid) { + it = scoped_ifc_id_to_object_id_.erase(it); + } else { + ++it; + } + } + + viewport_->setSelectedObjectId(0); + property_table_->setRowCount(0); +} + void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { progress_bar_->setVisible(false); status_label_->setText(QString("%1 elements across %2 model(s) — last loaded in %3") @@ -307,7 +345,16 @@ void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { writeSidecarForModel(mid); } -void MainWindow::onLoadError(uint32_t /*mid*/, QString message) { +void MainWindow::onLoadCancelled(uint32_t mid) { + progress_bar_->setVisible(false); + removeModelUi(mid); + status_label_->setText(QString("%1 load cancelled").arg(loader_->displayName(mid))); +} + +void MainWindow::onLoadError(uint32_t mid, QString message) { + progress_bar_->setVisible(false); + removeModelUi(mid); + status_label_->setText("Error: " + message); QMessageBox::warning(this, "Error", message); } diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 83c1990c96..71de00dbdf 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -60,6 +60,7 @@ private slots: void onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms); void onStreamedElementsReady(uint32_t mid, std::vector elements); void onLoadedFromStream(uint32_t mid, qint64 elapsed_ms); + void onLoadCancelled(uint32_t mid); void onLoadError(uint32_t mid, QString message); void onAllLoadsFinished(); @@ -75,6 +76,7 @@ private: const std::string& name, const std::string& type); void writeSidecarForModel(uint32_t mid); + void removeModelUi(uint32_t mid); void applyPendingBenchmark(); QString formatElapsed(qint64 ms) const; diff --git a/src/ifcviewer-minimal/MinimalWindow.cpp b/src/ifcviewer-minimal/MinimalWindow.cpp index 18e6eaae44..ebca8f303e 100644 --- a/src/ifcviewer-minimal/MinimalWindow.cpp +++ b/src/ifcviewer-minimal/MinimalWindow.cpp @@ -45,6 +45,8 @@ MinimalWindow::MinimalWindow(QWidget* parent) this, &MinimalWindow::onLoadedFromSidecar); connect(loader_, &SceneLoader::loadedFromStream, this, &MinimalWindow::onLoadedFromStream); + connect(loader_, &SceneLoader::loadCancelled, + this, &MinimalWindow::onLoadCancelled); connect(loader_, &SceneLoader::loadError, this, &MinimalWindow::onLoadError); connect(loader_, &SceneLoader::allLoadsFinished, @@ -100,6 +102,11 @@ void MinimalWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { .arg(formatElapsed(elapsed_ms))); } +void MinimalWindow::onLoadCancelled(uint32_t mid) { + status_label_->setText(QString("%1 load cancelled") + .arg(loader_->displayName(mid))); +} + void MinimalWindow::onLoadError(uint32_t /*mid*/, QString message) { qWarning("IfcViewerMinimal error: %s", qPrintable(message)); status_label_->setText("Error: " + message); diff --git a/src/ifcviewer-minimal/MinimalWindow.h b/src/ifcviewer-minimal/MinimalWindow.h index 723dd82fba..a70ed04144 100644 --- a/src/ifcviewer-minimal/MinimalWindow.h +++ b/src/ifcviewer-minimal/MinimalWindow.h @@ -40,6 +40,7 @@ private slots: void onLoadStarted(uint32_t mid, QString display_name); void onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms); void onLoadedFromStream(uint32_t mid, qint64 elapsed_ms); + void onLoadCancelled(uint32_t mid); void onLoadError(uint32_t mid, QString message); void onAllLoadsFinished(); diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index d3edcce19f..3f836b3490 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -89,6 +89,7 @@ void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_i } cancel_requested_ = false; + succeeded_ = false; running_ = true; progress_ = 0; next_object_id_ = start_object_id; @@ -115,7 +116,11 @@ void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_i connect(worker_thread_.get(), &QThread::finished, this, [this]() { running_ = false; - emit finished(); + if (succeeded_.load()) { + emit finished(); + } else if (cancel_requested_.load()) { + emit cancelled(); + } }); worker_thread_->start(); @@ -402,4 +407,5 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { qDebug("Streamer done: %s %.2fs shapes=%u unique_meshes=%u dedup=%.2fx", path.c_str(), stream_timer.elapsed() / 1000.0, total_shapes, total_meshes, dedup_ratio); + succeeded_ = !cancel_requested_.load(); } diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h index f6201517ad..d8cbd2da4b 100644 --- a/src/ifcviewer/GeometryStreamer.h +++ b/src/ifcviewer/GeometryStreamer.h @@ -68,6 +68,7 @@ signals: void meshReady(MeshChunk chunk); void instanceReady(InstanceChunk chunk); void finished(); + void cancelled(); void errorOccurred(const QString& message); private: @@ -77,6 +78,7 @@ private: std::unique_ptr worker_thread_; std::atomic running_{false}; std::atomic cancel_requested_{false}; + std::atomic succeeded_{false}; std::atomic progress_{0}; std::mutex elements_mutex_; diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index 16587b16bf..966e2d3d84 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -95,10 +95,19 @@ void SceneLoader::connectStreamer(GeometryStreamer* streamer) { this, &SceneLoader::onStreamerInstanceReady, Qt::QueuedConnection); connect(streamer, &GeometryStreamer::finished, this, &SceneLoader::onStreamerFinished, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::cancelled, + this, &SceneLoader::onStreamerCancelled, Qt::QueuedConnection); connect(streamer, &GeometryStreamer::errorOccurred, this, &SceneLoader::onStreamerError, Qt::QueuedConnection); } +void SceneLoader::cancelCurrentLoad() { + if (loading_model_id_ == 0) return; + auto it = models_.find(loading_model_id_); + if (it == models_.end() || it->second.streamer == nullptr) return; + it->second.streamer->cancel(); +} + void SceneLoader::startNextLoad() { if (load_queue_.empty()) { loading_model_id_ = 0; @@ -232,6 +241,28 @@ void SceneLoader::onStreamerFinished() { startNextLoad(); } -void SceneLoader::onStreamerError(const QString& msg) { - emit loadError(loading_model_id_, msg); +void SceneLoader::onStreamerCancelled() { + element_poll_timer_.stop(); + + const uint32_t mid = loading_model_id_; + loading_model_id_ = 0; + + if (mid != 0) { + viewport_->removeModel(mid); + emit loadCancelled(mid); + } + QTimer::singleShot(0, this, &SceneLoader::startNextLoad); +} + +void SceneLoader::onStreamerError(const QString& msg) { + element_poll_timer_.stop(); + + const uint32_t mid = loading_model_id_; + loading_model_id_ = 0; + + if (mid != 0) { + viewport_->removeModel(mid); + } + emit loadError(mid, msg); + QTimer::singleShot(0, this, &SceneLoader::startNextLoad); } diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index e29062808c..ad970c8468 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -57,6 +57,7 @@ public: // Callers can use these to set up per-model UI state (tree roots, etc.) // before any load signal fires. std::vector addFiles(const QStringList& paths); + void cancelCurrentLoad(); bool isLoading() const { return loading_model_id_ != 0 || !load_queue_.empty(); } size_t modelCount() const { return models_.size(); } @@ -87,6 +88,7 @@ signals: // elements to be known (e.g. sidecar write) — SceneLoader will only // start the next queued load after all slots return. void loadedFromStream(uint32_t mid, qint64 elapsed_ms); + void loadCancelled(uint32_t mid); void loadError(uint32_t mid, QString message); void allLoadsFinished(); @@ -96,6 +98,7 @@ private slots: void onStreamerMeshReady(MeshChunk chunk); void onStreamerInstanceReady(InstanceChunk chunk); void onStreamerFinished(); + void onStreamerCancelled(); void onStreamerError(const QString& msg); void onElementPollTick(); From f898089b27a4f4acafe06f07aa0b06e013a51f38 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 22 Apr 2026 11:33:22 +1000 Subject: [PATCH 060/120] Queue viewer ops before GL init Buffer viewport model mutations until the OpenGL context is initialized so loads that start before first exposure do not silently drop geometry or model state. Generated with the assistance of an AI coding tool. --- src/ifcviewer/ViewportWindow.cpp | 114 +++++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 24 +++++++ 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 6ae5c04368..a38e726f08 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -483,11 +483,53 @@ void ViewportWindow::initGL() { }); gl_initialized_ = true; + flushPendingOperations(); requestUpdate(); emit initialized(); } +void ViewportWindow::enqueuePendingOperation(PendingOperation op) { + pending_ops_.push_back(std::move(op)); +} + +void ViewportWindow::flushPendingOperations() { + while (!pending_ops_.empty()) { + PendingOperation op = std::move(pending_ops_.front()); + pending_ops_.pop_front(); + + switch (op.type) { + case PendingOpType::UploadMeshChunk: + uploadMeshChunk(op.mesh_chunk); + break; + case PendingOpType::UploadInstanceChunk: + uploadInstanceChunk(op.instance_chunk); + break; + case PendingOpType::FinalizeModel: + finalizeModel(op.model_id); + break; + case PendingOpType::ApplyCachedModel: + applyCachedModel(op.model_id, std::move(op.sidecar_data)); + break; + case PendingOpType::ApplyLodExtension: + applyLodExtension(op.model_id, op.sidecar_data); + break; + case PendingOpType::ResetScene: + resetScene(); + break; + case PendingOpType::HideModel: + hideModel(op.model_id); + break; + case PendingOpType::ShowModel: + showModel(op.model_id); + break; + case PendingOpType::RemoveModel: + removeModel(op.model_id); + break; + } + } +} + void ViewportWindow::setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo) { gl_->glVertexArrayVertexBuffer(vao, 0, vbo, 0, INSTANCED_VERTEX_STRIDE_BYTES); gl_->glVertexArrayElementBuffer(vao, ebo); @@ -643,7 +685,13 @@ ModelGpuData& ViewportWindow::getOrCreateModel(uint32_t model_id) { } void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::UploadMeshChunk; + op.mesh_chunk = chunk; + enqueuePendingOperation(std::move(op)); + return; + } if (chunk.vertices.empty() || chunk.indices.empty()) return; context_->makeCurrent(this); @@ -745,7 +793,13 @@ void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { } void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::UploadInstanceChunk; + op.instance_chunk = chunk; + enqueuePendingOperation(std::move(op)); + return; + } context_->makeCurrent(this); ModelGpuData& m = getOrCreateModel(chunk.model_id); @@ -795,7 +849,13 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { } void ViewportWindow::finalizeModel(uint32_t model_id) { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::FinalizeModel; + op.model_id = model_id; + enqueuePendingOperation(std::move(op)); + return; + } context_->makeCurrent(this); auto it = models_gpu_.find(model_id); @@ -852,7 +912,14 @@ bool ViewportWindow::snapshotModel(uint32_t model_id, SidecarData& out) const { } void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::ApplyCachedModel; + op.model_id = model_id; + op.sidecar_data = std::move(data); + enqueuePendingOperation(std::move(op)); + return; + } context_->makeCurrent(this); // Drop any existing state for this model_id. @@ -965,7 +1032,14 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::ApplyLodExtension; + op.model_id = model_id; + op.sidecar_data = sd; + enqueuePendingOperation(std::move(op)); + return; + } auto it = models_gpu_.find(model_id); if (it == models_gpu_.end() || !it->second.finalized) return; ModelGpuData& m = it->second; @@ -997,7 +1071,13 @@ void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) } void ViewportWindow::resetScene() { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::ResetScene; + pending_ops_.clear(); + enqueuePendingOperation(std::move(op)); + return; + } context_->makeCurrent(this); for (auto& [mid, m] : models_gpu_) { if (m.vao) gl_->glDeleteVertexArrays(1, &m.vao); @@ -1015,6 +1095,13 @@ void ViewportWindow::resetScene() { } void ViewportWindow::hideModel(uint32_t model_id) { + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::HideModel; + op.model_id = model_id; + enqueuePendingOperation(std::move(op)); + return; + } auto it = models_gpu_.find(model_id); if (it != models_gpu_.end()) { it->second.hidden = true; @@ -1024,6 +1111,13 @@ void ViewportWindow::hideModel(uint32_t model_id) { } void ViewportWindow::showModel(uint32_t model_id) { + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::ShowModel; + op.model_id = model_id; + enqueuePendingOperation(std::move(op)); + return; + } auto it = models_gpu_.find(model_id); if (it != models_gpu_.end()) { it->second.hidden = false; @@ -1033,7 +1127,13 @@ void ViewportWindow::showModel(uint32_t model_id) { } void ViewportWindow::removeModel(uint32_t model_id) { - if (!gl_initialized_) return; + if (!gl_initialized_) { + PendingOperation op; + op.type = PendingOpType::RemoveModel; + op.model_id = model_id; + enqueuePendingOperation(std::move(op)); + return; + } context_->makeCurrent(this); auto it = models_gpu_.find(model_id); if (it != models_gpu_.end()) { diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index a8aa7b7577..0cdd712bd5 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -34,6 +34,7 @@ #include #include #include +#include #include "BvhAccel.h" #include "InstancedGeometry.h" @@ -194,7 +195,29 @@ protected: bool event(QEvent* event) override; private: + enum class PendingOpType { + UploadMeshChunk, + UploadInstanceChunk, + FinalizeModel, + ApplyCachedModel, + ApplyLodExtension, + ResetScene, + HideModel, + ShowModel, + RemoveModel, + }; + + struct PendingOperation { + PendingOpType type; + MeshChunk mesh_chunk; + InstanceChunk instance_chunk; + SidecarData sidecar_data; + uint32_t model_id = 0; + }; + void initGL(); + void flushPendingOperations(); + void enqueuePendingOperation(PendingOperation op); void render(); void renderPickPass(); void renderAxisGizmo(); @@ -253,6 +276,7 @@ private: QOpenGLContext* context_ = nullptr; QOpenGLFunctions_4_5_Core* gl_ = nullptr; bool gl_initialized_ = false; + std::deque pending_ops_; // Shaders GLuint main_program_ = 0; From 4f929e90a757ef57e7adcba76b41d62ebebfabb5 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 22 Apr 2026 11:58:04 +1000 Subject: [PATCH 061/120] ifcviewer: key sidecar on path stem, drop staleness check Previously readSidecar/writeSidecar were keyed on (path, file_size) with staleness rejected at read time. Switch to pure path-stem keying: foo.ifc and foo.ifcdb/ both resolve to foo.ifcview, so the same cache serves either source format. Staleness is user-managed (delete the sidecar to force a rebuild), which also lets sidecars be copied or moved independently of the source. v8 header drops the source_file_size field. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 2 +- src/ifcviewer/README.md | 11 +++++----- src/ifcviewer/SceneLoader.cpp | 11 ++-------- src/ifcviewer/SceneLoader.h | 1 - src/ifcviewer/SidecarCache.cpp | 34 +++++++++++++++---------------- src/ifcviewer/SidecarCache.h | 17 +++++++++------- 6 files changed, 36 insertions(+), 40 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index fabd008daf..8a818f948e 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -295,7 +295,7 @@ void MainWindow::writeSidecarForModel(uint32_t mid) { viewport_->applyLodExtension(mid, sd); QElapsedTimer t; t.start(); - bool ok = writeSidecar(loader_->filePath(mid).toStdString(), sd, loader_->fileSize(mid)); + bool ok = writeSidecar(loader_->filePath(mid).toStdString(), sd); qDebug(" Sidecar write: %lld ms (%s)", t.elapsed(), ok ? "ok" : "FAILED"); } diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index eb1e0d88df..301322851c 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -116,7 +116,7 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | | `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v7) sidecar read/write | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v8) sidecar read/write | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `SettingsWindow.h/cpp` | Settings dialog | | `CMakeLists.txt` | Build configuration | @@ -293,14 +293,13 @@ while stack not empty: Depth 64 is enough for billions of items on any balanced tree. The stack is on the C++ stack, zero per-frame allocation. -#### Sidecar format (`.ifcview`, v7) +#### Sidecar format (`.ifcview`, v8) Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Stores everything needed to skip the `IfcGeom::Iterator` pass: ``` SidecarHeader (magic "IFVW", version, endian, ...) -uint64_t source_file_size uint32_t + uint8_t[] vertex data (12 B/vert quantized; per-mesh basis in MeshInfo) uint32_t + uint32_t[] index data (mesh-local) uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) @@ -309,8 +308,10 @@ uint32_t + PackedElementInfo[] element tree records uint32_t + char[] string table ``` -Staleness check: `source_file_size` vs actual file size. Mismatched → -reject and rebuild. Endianness marker rejects cross-arch caches. +Sidecar path is the source stem + `.ifcview` — `foo.ifc` and `foo.ifcdb/` +both map to `foo.ifcview`, so the same cache serves either source format. +Staleness is user-managed: delete the sidecar to force a rebuild. +Endianness marker rejects cross-arch caches. Sidecars store the raw `object_id` / `model_id` values from the session that wrote them. On load they are rebased onto the current session's ID diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index 966e2d3d84..8241b64d5c 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -54,12 +54,6 @@ QString SceneLoader::displayName(uint32_t mid) const { return it == models_.end() ? QString() : it->second.display_name; } -uint64_t SceneLoader::fileSize(uint32_t mid) const { - auto it = models_.find(mid); - if (it == models_.end()) return 0; - return static_cast(QFileInfo(it->second.file_path).size()); -} - ifcopenshell::file* SceneLoader::ifcFile(uint32_t mid) const { auto it = models_.find(mid); return it == models_.end() ? nullptr : it->second.streamer->ifcFile(); @@ -124,14 +118,13 @@ void SceneLoader::startNextLoad() { emit loadStarted(model.id, model.display_name); std::string ifc_path = model.file_path.toStdString(); - uint64_t file_size = this->fileSize(model.id); uint32_t mid = loading_model_id_; // Sidecar read on a background thread so the UI stays responsive. joinSidecarThread(); - sidecar_read_thread_ = std::thread([this, ifc_path, file_size, mid]() { + sidecar_read_thread_ = std::thread([this, ifc_path, mid]() { QElapsedTimer rt; rt.start(); - auto cached = readSidecar(ifc_path, file_size); + auto cached = readSidecar(ifc_path); qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str()); auto result = std::make_shared>(std::move(cached)); QMetaObject::invokeMethod(this, [this, mid, result]() { diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index ad970c8468..6d63e144ef 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -63,7 +63,6 @@ public: QString filePath(uint32_t mid) const; QString displayName(uint32_t mid) const; - uint64_t fileSize(uint32_t mid) const; ifcopenshell::file* ifcFile(uint32_t mid) const; signals: diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index 171bf4bda6..12077e9c4a 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,16 +17,12 @@ * * ********************************************************************************/ -// v6 layout (all multi-byte fields native-endian; endianness marker in header). -// Same sequence as v5; the only change is that vertex data is now raw bytes -// at the 16 B/vertex quantized layout (see InstancedGeometry.h). -// +// v8 layout (all multi-byte fields native-endian; endianness marker in header). // // SidecarHeader (16 bytes) -// uint64_t source_file_size // // uint32_t num_vertex_bytes -// uint8_t[] vertex data (16 B/vertex: pos u16x3 + pad2 + oct-normal i16x2 + rgba8) +// uint8_t[] vertex data (12 B/vertex: pos u16x3 + oct-normal i8x2 + rgba8) // uint32_t num_indices // uint32_t[] index data (mesh-local indices; base_vertex applied at draw time) // @@ -53,8 +49,20 @@ struct SidecarHeader { uint32_t reserved; }; +// foo.ifc -> foo.ifcview +// foo.ifcdb/ -> foo.ifcview +// foo.ifcdb -> foo.ifcview +// foo (no ext) -> foo.ifcview static std::string sidecarPath(const std::string& ifc_path) { - return ifc_path + ".ifcview"; + std::string p = ifc_path; + while (!p.empty() && (p.back() == '/' || p.back() == '\\')) p.pop_back(); + auto slash = p.find_last_of("/\\"); + auto dot = p.find_last_of('.'); + std::string stem = (dot != std::string::npos && + (slash == std::string::npos || dot > slash)) + ? p.substr(0, dot) + : p; + return stem + ".ifcview"; } template @@ -74,16 +82,13 @@ static bool readVec(FILE* f, std::vector& v) { return true; } -bool writeSidecar(const std::string& ifc_path, - const SidecarData& data, - uint64_t ifc_file_size) { +bool writeSidecar(const std::string& ifc_path, const SidecarData& data) { std::string path = sidecarPath(ifc_path); FILE* f = fopen(path.c_str(), "wb"); if (!f) return false; SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN, 0 }; if (fwrite(&hdr, sizeof(hdr), 1, f) != 1) { fclose(f); return false; } - if (fwrite(&ifc_file_size, 8, 1, f) != 1) { fclose(f); return false; } if (!writeVec(f, data.vertices)) { fclose(f); return false; } if (!writeVec(f, data.indices)) { fclose(f); return false; } @@ -101,8 +106,7 @@ bool writeSidecar(const std::string& ifc_path, return true; } -std::optional readSidecar(const std::string& ifc_path, - uint64_t ifc_file_size) { +std::optional readSidecar(const std::string& ifc_path) { std::string path = sidecarPath(ifc_path); FILE* f = fopen(path.c_str(), "rb"); if (!f) return std::nullopt; @@ -115,10 +119,6 @@ std::optional readSidecar(const std::string& ifc_path, hdr.version != SIDECAR_VERSION || hdr.endian != SIDECAR_ENDIAN) return fail(); - uint64_t stored_size; - if (fread(&stored_size, 8, 1, f) != 1) return fail(); - if (stored_size != ifc_file_size) return fail(); - SidecarData data; if (!readVec(f, data.vertices)) return fail(); if (!readVec(f, data.indices)) return fail(); diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 57d8934ad8..47411e5d5f 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -41,7 +41,11 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // color u8x4). Dequant basis is per-mesh MeshInfo.local_aabb_min/max. // v7 = VBO vertices shrunk to 12 B/vertex (normal oct i8x2 replaces i16x2, // eliminating 2-byte pad + saving 2 bytes on normal). -static constexpr uint32_t SIDECAR_VERSION = 7; +// v8 = source_file_size field dropped from header. Sidecar is keyed purely +// on path stem (foo.ifc and foo.ifcdb/ both map to foo.ifcview) so the +// same cache serves either source format. Staleness is user-managed +// (delete the sidecar to force a rebuild). +static constexpr uint32_t SIDECAR_VERSION = 8; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs @@ -76,12 +80,11 @@ struct SidecarData { std::string string_table; }; -// v4 writer/reader are stubbed for Commit A — no disk I/O happens. -bool writeSidecar(const std::string& ifc_path, - const SidecarData& data, - uint64_t ifc_file_size); +// Sidecar is keyed on the path stem: foo.ifc and foo.ifcdb/ both resolve to +// foo.ifcview alongside the source. No staleness check — callers delete the +// file to invalidate. +bool writeSidecar(const std::string& ifc_path, const SidecarData& data); -std::optional readSidecar(const std::string& ifc_path, - uint64_t ifc_file_size); +std::optional readSidecar(const std::string& ifc_path); #endif // SIDECARCACHE_H From 35be7f41903b5c94f0577c92959bf2ecc617e23b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 22 Apr 2026 16:22:48 +1000 Subject: [PATCH 062/120] ifcviewer: load RocksDB-backed IFC models The viewer can now open a .rdb directory (as produced by RocksDbSerializer / convert_path_to_rocksdb) anywhere it accepts an .ifc file. The full GUI gets an "Add Database..." File menu entry that opens a directory chooser; the streamer lets the file constructor autodetect the format and opens the store read-only so multiple viewers can share a database without taking the exclusive RocksDB lock. Parallel mapping on RocksDB-backed files still produces non-deterministic shape counts (the race is outside the instance cache), so force num_threads=1 for the iterator when the storage is RocksDB. Serial RocksDB (~2.6s) and parallel SPF (~0.7s) both produce 107 shapes on AC20-FZK-Haus; @todo in-source points at the remaining thread-safety work. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 10 ++++++++++ src/ifcviewer-full/MainWindow.h | 1 + src/ifcviewer/GeometryStreamer.cpp | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 8a818f948e..8379b77b2c 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -132,6 +132,7 @@ void MainWindow::setupMenus() { auto* file_menu = menuBar()->addMenu("&File"); auto* open_action = file_menu->addAction("&Add Files...", this, &MainWindow::onFileOpen); open_action->setShortcut(QKeySequence::Open); + file_menu->addAction("Add &Database...", this, &MainWindow::onDatabaseOpen); file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings); file_menu->addSeparator(); file_menu->addAction("&Quit", QKeySequence::Quit, qApp, &QApplication::quit); @@ -146,6 +147,15 @@ void MainWindow::onFileOpen() { } } +void MainWindow::onDatabaseOpen() { + QString path = QFileDialog::getExistingDirectory( + this, "Add IFC Database", QString(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (!path.isEmpty()) { + addFiles({ path }); + } +} + void MainWindow::onFileSettings() { if (settings_ == nullptr) { settings_ = new SettingsWindow(this); diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 71de00dbdf..031764d1c4 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -48,6 +48,7 @@ public: private slots: void onFileOpen(); + void onDatabaseOpen(); void onFileSettings(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 3f836b3490..62a276dad3 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -263,7 +263,10 @@ static void worldAabbFromLocal(const float local_min[3], void GeometryStreamer::run(const std::string& path, int num_threads) { try { - ifc_file_ = std::make_unique(path); + // read_only is a no-op for SPF; for RocksDB it allows concurrent + // readers and avoids acquiring the exclusive DB lock. + ifc_file_ = std::make_unique( + path, ifcopenshell::FT_AUTODETECT, /*read_only=*/true); } catch (const std::exception& e) { emit errorOccurred(QString("Failed to parse IFC file: %1").arg(e.what())); return; @@ -281,6 +284,12 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { // time, but results are cached in the sidecar so it's a one-shot hit. settings.set("reorient-shells", true); + // @todo parallel mapping on RocksDB-backed files still races somewhere + // outside the instance cache, producing inconsistent shape counts. Force + // serial iteration for RocksDB until the read path is fully thread-safe. + const bool is_rocksdb = std::holds_alternative(ifc_file_->storage_); + const int effective_threads = is_rocksdb ? 1 : num_threads; + std::unique_ptr iterator; try { const std::string geometry_library = @@ -288,7 +297,8 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { 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); + std::move(kernel), settings, ifc_file_.get(), + std::vector(), effective_threads); } catch (const std::exception& e) { emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what())); return; From eea2398e07a5a0ca8ef02ede8578848b702e5b70 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 24 Apr 2026 07:33:13 +1000 Subject: [PATCH 063/120] ifcopenshell-python: fix broken imports after upstream refactors Two upstream commits on this branch landed without updating all their callers, leaving `import ifcopenshell.geom` unusable: 89c66f62b "Python import fixes: import from wrapper now which inherits from mixins" moved the `file` class out of ifcopenshell/file.py into ifcopenshell_wrapper, but missed geom/main.py and stream.py which still did `from ..file import file`. b022ca7e7 "Some plug-in work" dropped the SWIG exports for `serialise`, `tesselate`, `XmlSerializer` (and other serializers) with a `// @todo bring back serialization` marker, but left geom/main.py referencing them at module-load time. Fix the `file` imports to come from ifcopenshell_wrapper, and guard the removed-serializer references behind `hasattr`, matching the pattern already in use for the other optional serializers (gltf, hdf5, collada, json, ttl). Revert once upstream fixes this. --- src/ifcopenshell-python/ifcopenshell/geom/main.py | 10 ++++++---- src/ifcopenshell-python/ifcopenshell/stream.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 30c2233a89..cf1189ad3c 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, Union, cast, from .. import ifcopenshell_wrapper, open from ..entity_instance import entity_instance -from ..file import file +from ..ifcopenshell_wrapper import file from . import has_occ if TYPE_CHECKING: @@ -625,8 +625,9 @@ def make_shape_function(fn): return _ -serialise = make_shape_function(ifcopenshell_wrapper.serialise) -tesselate = make_shape_function(ifcopenshell_wrapper.tesselate) +if hasattr(ifcopenshell_wrapper, "serialise"): + serialise = make_shape_function(ifcopenshell_wrapper.serialise) + tesselate = make_shape_function(ifcopenshell_wrapper.tesselate) def transform_string(v: Union[str, serializers.buffer]) -> serializers.buffer: @@ -659,7 +660,8 @@ class serializers: # Hdf- Xml- and glTF- serializers don't support writing to a buffer, only to filename # so no wrap_buffer_creation() for these serializers - xml = ifcopenshell_wrapper.XmlSerializer + if hasattr(ifcopenshell_wrapper, "XmlSerializer"): + xml = ifcopenshell_wrapper.XmlSerializer buffer = ifcopenshell_wrapper.buffer # gltf, hdf5, collada and json availability depend on IfcOpenShell configuration settings try: diff --git a/src/ifcopenshell-python/ifcopenshell/stream.py b/src/ifcopenshell-python/ifcopenshell/stream.py index c9556fa708..6afbc8a1f6 100644 --- a/src/ifcopenshell-python/ifcopenshell/stream.py +++ b/src/ifcopenshell-python/ifcopenshell/stream.py @@ -30,7 +30,7 @@ try: from . import ifcopenshell_wrapper from .entity_instance import entity_instance - from .file import file + from .ifcopenshell_wrapper import file class StreamTransformer(Transformer): file: file From 8f7c8dc1d2dce232ecf31beb9fd359da01bd6e69 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 24 Apr 2026 13:45:04 +1000 Subject: [PATCH 064/120] ifcviewer: load rdb/ifc as property data source on sidecar hit Sidecar hits skipped opening the underlying .rdb/.ifc, so ifcFile() was null and the property panel only showed cached name/type/guid. Now, after a sidecar hit, a background thread opens .rdb (preferred) or .ifc and hands the file to GeometryStreamer via setIfcFile(), with a dataSourceReady signal so the UI refreshes the current selection. Gated behind a new AppSettings::loadDataSource toggle (default on) so users can opt into geometry-only viewing; when off, the sidecar-hit thread is skipped and the stream-path ifc_file_ is released after the sidecar write completes. Also adds *.ifcview to the Add Files dialog filter so a cache can be opened directly without its source file present. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 18 +++++- src/ifcviewer-full/MainWindow.h | 1 + src/ifcviewer-full/SettingsWindow.cpp | 9 +++ src/ifcviewer-full/SettingsWindow.h | 1 + src/ifcviewer/AppSettings.cpp | 14 +++++ src/ifcviewer/AppSettings.h | 9 +++ src/ifcviewer/GeometryStreamer.cpp | 4 ++ src/ifcviewer/GeometryStreamer.h | 5 ++ src/ifcviewer/SceneLoader.cpp | 82 +++++++++++++++++++++++++++ src/ifcviewer/SceneLoader.h | 11 ++++ 10 files changed, 153 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 8379b77b2c..e36af95670 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -49,6 +49,8 @@ MainWindow::MainWindow(QWidget* parent) this, &MainWindow::onSidecarElementsReady); connect(loader_, &SceneLoader::loadedFromSidecar, this, &MainWindow::onLoadedFromSidecar); + connect(loader_, &SceneLoader::dataSourceReady, + this, &MainWindow::onDataSourceReady); connect(loader_, &SceneLoader::streamedElementsReady, this, &MainWindow::onStreamedElementsReady); connect(loader_, &SceneLoader::loadedFromStream, @@ -141,7 +143,9 @@ void MainWindow::setupMenus() { void MainWindow::onFileOpen() { QStringList paths = QFileDialog::getOpenFileNames( this, "Add IFC Files", QString(), - "IFC Files (*.ifc *.ifcxml *.ifczip);;All Files (*)"); + "IFC Files (*.ifc *.ifcxml *.ifczip);;" + "IFC Viewer Cache (*.ifcview);;" + "All Files (*)"); if (!paths.isEmpty()) { addFiles(paths); } @@ -254,6 +258,18 @@ void MainWindow::onSidecarElementsReady(uint32_t mid, qDebug(" Tree build: %lld ms (%zu elements)", t.elapsed(), elements.size()); } +void MainWindow::onDataSourceReady(uint32_t mid) { + // Re-populate if the current selection belongs to this model, since + // populateProperties() now has an ifcFile() to query. + auto items = element_tree_->selectedItems(); + if (items.isEmpty()) return; + uint32_t object_id = items.first()->data(0, Qt::UserRole).toUInt(); + auto it = element_map_.find(object_id); + if (it != element_map_.end() && it->second.model_id == mid) { + populateProperties(object_id); + } +} + void MainWindow::onLoadedFromSidecar(uint32_t /*mid*/, qint64 elapsed_ms) { progress_bar_->setVisible(false); status_label_->setText(QString("%1 elements across %2 model(s) — loaded from cache in %3") diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 031764d1c4..b616293d59 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -59,6 +59,7 @@ private slots: std::vector elements, std::string string_table); void onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + void onDataSourceReady(uint32_t mid); void onStreamedElementsReady(uint32_t mid, std::vector elements); void onLoadedFromStream(uint32_t mid, qint64 elapsed_ms); void onLoadCancelled(uint32_t mid); diff --git a/src/ifcviewer-full/SettingsWindow.cpp b/src/ifcviewer-full/SettingsWindow.cpp index 69e1f025b8..1f31ceacf7 100644 --- a/src/ifcviewer-full/SettingsWindow.cpp +++ b/src/ifcviewer-full/SettingsWindow.cpp @@ -50,6 +50,13 @@ void SettingsWindow::setupUi() { "closed solids; disable if you see holes in open geometry."); form->addRow("Backface Culling", backface_culling_check_); + load_data_source_check_ = new QCheckBox(this); + load_data_source_check_->setToolTip( + "Keep the .ifc/.rdb open after loading so element properties can " + "be queried. Disable for geometry-only viewing — saves memory " + "and, on sidecar hits, avoids a second file read."); + form->addRow("Load Property Data Source", load_data_source_check_); + auto* button_box = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); @@ -72,11 +79,13 @@ void SettingsWindow::syncFromSettings() { geometry_library_edit_->setText(AppSettings::instance().geometryLibrary()); show_stats_check_->setChecked(AppSettings::instance().showStats()); backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling()); + load_data_source_check_->setChecked(AppSettings::instance().loadDataSource()); } void SettingsWindow::onAccepted() { AppSettings::instance().setGeometryLibrary(geometry_library_edit_->text()); AppSettings::instance().setShowStats(show_stats_check_->isChecked()); AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked()); + AppSettings::instance().setLoadDataSource(load_data_source_check_->isChecked()); accept(); } diff --git a/src/ifcviewer-full/SettingsWindow.h b/src/ifcviewer-full/SettingsWindow.h index 967938b4a2..d7399c1e7c 100644 --- a/src/ifcviewer-full/SettingsWindow.h +++ b/src/ifcviewer-full/SettingsWindow.h @@ -44,6 +44,7 @@ private: QLineEdit* geometry_library_edit_ = nullptr; QCheckBox* show_stats_check_ = nullptr; QCheckBox* backface_culling_check_ = nullptr; + QCheckBox* load_data_source_check_ = nullptr; }; #endif diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index ff8d3bb3f1..04f56f9567 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -26,6 +26,7 @@ constexpr const char* kGeometryLibraryKey = "geometry/library"; constexpr const char* kGeometryLibraryDefault = "hybrid-cgal-simple-opencascade"; constexpr const char* kShowStatsKey = "viewport/show_stats"; constexpr const char* kBackfaceCullingKey = "viewport/backface_culling"; +constexpr const char* kLoadDataSourceKey = "loading/load_data_source"; } AppSettings& AppSettings::instance() { @@ -70,11 +71,23 @@ void AppSettings::setBackfaceCulling(bool value) { emit backfaceCullingChanged(value); } +bool AppSettings::loadDataSource() const { + return load_data_source_; +} + +void AppSettings::setLoadDataSource(bool value) { + if (load_data_source_ == value) return; + load_data_source_ = value; + persist(); + emit loadDataSourceChanged(value); +} + void AppSettings::load() { QSettings settings; geometry_library_ = settings.value(kGeometryLibraryKey, kGeometryLibraryDefault).toString(); show_stats_ = settings.value(kShowStatsKey, false).toBool(); backface_culling_ = settings.value(kBackfaceCullingKey, true).toBool(); + load_data_source_ = settings.value(kLoadDataSourceKey, true).toBool(); } void AppSettings::persist() { @@ -82,4 +95,5 @@ void AppSettings::persist() { settings.setValue(kGeometryLibraryKey, geometry_library_); settings.setValue(kShowStatsKey, show_stats_); settings.setValue(kBackfaceCullingKey, backface_culling_); + settings.setValue(kLoadDataSourceKey, load_data_source_); } diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index 8b38c61a33..9b909bc48c 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -40,10 +40,18 @@ public: bool backfaceCulling() const; void setBackfaceCulling(bool value); + // When true, the IFC/RocksDB file is kept open (and, on sidecar hits, + // opened in the background) so element properties can be queried. + // When false, only geometry is loaded — saves memory and avoids a + // second file read on sidecar hits, at the cost of no property panel. + bool loadDataSource() const; + void setLoadDataSource(bool value); + signals: void geometryLibraryChanged(const QString& value); void showStatsChanged(bool value); void backfaceCullingChanged(bool value); + void loadDataSourceChanged(bool value); private: AppSettings(); @@ -53,6 +61,7 @@ private: QString geometry_library_; bool show_stats_ = false; bool backface_culling_ = true; + bool load_data_source_ = true; }; #endif // APPSETTINGS_H diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 62a276dad3..668bb1c7ce 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -79,6 +79,10 @@ GeometryStreamer::~GeometryStreamer() { } } +void GeometryStreamer::setIfcFile(std::unique_ptr file) { + ifc_file_ = std::move(file); +} + void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_id, uint32_t model_id, int num_threads) { if (running_.load()) { cancel(); diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h index d8cbd2da4b..ac31035c43 100644 --- a/src/ifcviewer/GeometryStreamer.h +++ b/src/ifcviewer/GeometryStreamer.h @@ -53,6 +53,11 @@ public: void loadFile(const std::string& path, uint32_t start_object_id, uint32_t model_id, int num_threads = 0); void cancel(); + // Adopt an externally-opened ifcopenshell::file as the data source + // (e.g. for the sidecar-hit path, where loadFile never runs). The + // streamer must not be running geometry iteration when this is called. + void setIfcFile(std::unique_ptr file); + bool isRunning() const { return running_.load(); } int progress() const { return progress_.load(); } uint32_t lastObjectId() const { return next_object_id_; } diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index 8241b64d5c..1b8d2c4be3 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -18,10 +18,12 @@ ********************************************************************************/ #include "SceneLoader.h" +#include "AppSettings.h" #include #include #include +#include #include #include @@ -37,6 +39,7 @@ SceneLoader::SceneLoader(ViewportWindow* viewport, QObject* parent) SceneLoader::~SceneLoader() { joinSidecarThread(); + joinDataSourceThreads(); } void SceneLoader::joinSidecarThread() { @@ -44,6 +47,13 @@ void SceneLoader::joinSidecarThread() { sidecar_read_thread_.join(); } +void SceneLoader::joinDataSourceThreads() { + for (auto& t : data_source_threads_) { + if (t.joinable()) t.join(); + } + data_source_threads_.clear(); +} + QString SceneLoader::filePath(uint32_t mid) const { auto it = models_.find(mid); return it == models_.end() ? QString() : it->second.file_path; @@ -187,10 +197,75 @@ void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) { qint64 ms = model.load_timer.elapsed(); emit loadedFromSidecar(mid, ms); + startDataSourceLoad(mid); + loading_model_id_ = 0; QTimer::singleShot(0, this, &SceneLoader::startNextLoad); } +// Match SidecarCache.cpp's sidecarPath() stem logic so we resolve the +// data-source siblings against the same stem the sidecar was keyed on. +static std::string pathStem(const std::string& path) { + std::string p = path; + while (!p.empty() && (p.back() == '/' || p.back() == '\\')) p.pop_back(); + auto slash = p.find_last_of("/\\"); + auto dot = p.find_last_of('.'); + return (dot != std::string::npos && + (slash == std::string::npos || dot > slash)) + ? p.substr(0, dot) + : p; +} + +void SceneLoader::startDataSourceLoad(uint32_t mid) { + if (!AppSettings::instance().loadDataSource()) return; + + auto it = models_.find(mid); + if (it == models_.end()) return; + + std::string original_path = it->second.file_path.toStdString(); + std::string stem = pathStem(original_path); + + // Prefer RocksDB (foo.rdb) over SPF (foo.ifc) for fast random lookups. + QString data_path; + const QString rdb_candidate = QString::fromStdString(stem + ".rdb"); + const QString ifc_candidate = QString::fromStdString(stem + ".ifc"); + if (QFileInfo::exists(rdb_candidate)) { + data_path = rdb_candidate; + } else if (QFileInfo::exists(ifc_candidate)) { + data_path = ifc_candidate; + } else { + return; + } + + std::string data_path_std = data_path.toStdString(); + data_source_threads_.emplace_back([this, mid, data_path_std]() { + QElapsedTimer t; t.start(); + std::unique_ptr file; + try { + file = std::make_unique( + data_path_std, ifcopenshell::FT_AUTODETECT, /*read_only=*/true); + } catch (const std::exception& e) { + qWarning(" Data source load failed: %s (%s)", + data_path_std.c_str(), e.what()); + return; + } + qDebug(" Data source load: %lld ms (%s)", t.elapsed(), data_path_std.c_str()); + + auto shared = std::make_shared>(std::move(file)); + QMetaObject::invokeMethod(this, [this, mid, shared]() { + auto it = models_.find(mid); + if (it == models_.end()) return; + auto* streamer = it->second.streamer; + if (streamer == nullptr) return; + // If the streamer already has a file (e.g. a later stream-fallback + // path somehow populated it), don't clobber it. + if (streamer->ifcFile() != nullptr) return; + streamer->setIfcFile(std::move(*shared)); + emit dataSourceReady(mid); + }, Qt::QueuedConnection); + }); +} + void SceneLoader::onStreamerProgressChanged(int percent) { emit progressChanged(percent); } @@ -227,6 +302,13 @@ void SceneLoader::onStreamerFinished() { qint64 ms = it->second.load_timer.elapsed(); emit loadedFromStream(mid, ms); + + // Slot(s) above run synchronously (sidecar write uses element_map_, + // not ifcFile()); drop the parsed file now to save memory if the + // user has opted out of keeping a property data source. + if (!AppSettings::instance().loadDataSource()) { + it->second.streamer->setIfcFile(nullptr); + } } } diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 6d63e144ef..41f825a35e 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -77,6 +77,11 @@ signals: std::string string_table); void loadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + // Fired after a sidecar-hit model has its .rdb/.ifc opened as a + // property data source in the background. Consumers can refresh + // any UI that queries ifcFile(mid) for attributes/properties. + void dataSourceReady(uint32_t mid); + // Fired repeatedly while streaming, as the worker thread produces // elements. Each batch contains whatever accumulated since the last // poll tick. @@ -113,7 +118,9 @@ private: void startNextLoad(); void connectStreamer(GeometryStreamer* streamer); void joinSidecarThread(); + void joinDataSourceThreads(); void applySidecarData(uint32_t mid, SidecarData data); + void startDataSourceLoad(uint32_t mid); ViewportWindow* viewport_ = nullptr; std::map models_; @@ -122,6 +129,10 @@ private: uint32_t next_object_id_ = 1; uint32_t loading_model_id_ = 0; std::thread sidecar_read_thread_; + // One thread per sidecar-hit model while its .rdb/.ifc opens in the + // background. Joined only at destruction so a slow SPF parse on model + // A never blocks the sidecar-hit path of model B. + std::vector data_source_threads_; QTimer element_poll_timer_; }; From 81a7c5b50e5eef1cd8b70771952c4926bb8bb0db Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 26 Apr 2026 20:12:52 +1000 Subject: [PATCH 065/120] ifcviewer: add Shift+F fly-mode camera WASD strafe, Q/E down/up, mouse-look (cursor hidden + recentered), Shift to sprint, scrollwheel scales speed, click or Esc returns to orbit. Exiting drops back to the same viewpoint because rotation re-pins camera_target_ to keep camera_eye_ stationary. Movement integrates wall-clock dt inside render() and the next frame self-schedules via requestUpdate() while any key is held. A QTimer would fight Qt's event loop during long swapBuffers blocks and produce "camera pauses one frame" stalls; render-driven integration keeps movement phase-locked to vsync and absorbs slow frames in a single catch-up step. IFC_FPS_HITCH_MS= logs frames slower than n ms while in fly mode. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 174 ++++++++++++++++++++++++++++++- src/ifcviewer/ViewportWindow.h | 30 ++++++ 2 files changed, 203 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index a38e726f08..81d75aebd7 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -1186,13 +1187,115 @@ QString ViewportWindow::cameraString() const { } void ViewportWindow::keyPressEvent(QKeyEvent* event) { - if (event->key() == Qt::Key_C && !(event->modifiers() & Qt::ControlModifier)) { + const int key = event->key(); + + // Shift+F toggles FPS/fly mode. Checked before auto-repeat filtering so + // a held Shift+F doesn't thrash between modes. + if (key == Qt::Key_F + && (event->modifiers() & Qt::ShiftModifier) + && !event->isAutoRepeat()) { + if (camera_mode_ == CameraMode::Fps) exitFpsMode(); + else enterFpsMode(); + return; + } + + if (camera_mode_ == CameraMode::Fps) { + if (key == Qt::Key_Escape && !event->isAutoRepeat()) { + exitFpsMode(); + return; + } + switch (key) { + case Qt::Key_W: case Qt::Key_A: case Qt::Key_S: case Qt::Key_D: + case Qt::Key_Q: case Qt::Key_E: case Qt::Key_Shift: + if (!event->isAutoRepeat()) { + const bool was_empty = fps_keys_held_.isEmpty(); + fps_keys_held_.insert(key); + // Kick the render loop; subsequent frames self-schedule while + // any key stays held. Reset the dt baseline so the first + // frame doesn't integrate idle time. + if (was_empty) { + fps_last_tick_.restart(); + requestUpdate(); + } + } + // Swallow auto-repeats too so they don't leak to shortcuts. + return; + default: break; + } + } + + if (key == Qt::Key_C && !(event->modifiers() & Qt::ControlModifier)) { qDebug("--camera %s", qPrintable(cameraString())); return; } QWindow::keyPressEvent(event); } +void ViewportWindow::keyReleaseEvent(QKeyEvent* event) { + if (camera_mode_ == CameraMode::Fps && !event->isAutoRepeat()) { + fps_keys_held_.remove(event->key()); + } + QWindow::keyReleaseEvent(event); +} + +void ViewportWindow::enterFpsMode() { + if (camera_mode_ == CameraMode::Fps) return; + camera_mode_ = CameraMode::Fps; + fps_keys_held_.clear(); + setCursor(Qt::BlankCursor); + fps_ignore_next_mouse_move_ = true; + recenterFpsCursor(); + fps_last_tick_.start(); +} + +void ViewportWindow::exitFpsMode() { + if (camera_mode_ == CameraMode::Orbit) return; + camera_mode_ = CameraMode::Orbit; + fps_keys_held_.clear(); + unsetCursor(); +} + +void ViewportWindow::recenterFpsCursor() { + const QPoint center(width() / 2, height() / 2); + QCursor::setPos(mapToGlobal(center)); + last_mouse_pos_ = center; +} + +void ViewportWindow::fpsIntegrate() { + if (camera_mode_ != CameraMode::Fps || fps_keys_held_.isEmpty()) return; + + qint64 ns = fps_last_tick_.nsecsElapsed(); + fps_last_tick_.restart(); + float dt = static_cast(ns) * 1e-9f; + if (dt > 0.1f) dt = 0.1f; // clamp after stalls + + // View direction is target - eye = -offset. offset components match + // updateCamera() so forward stays consistent when mode flips. + const float yaw_rad = qDegreesToRadians(camera_yaw_); + const float pitch_rad = qDegreesToRadians(camera_pitch_); + const QVector3D offset(cosf(pitch_rad) * cosf(yaw_rad), + cosf(pitch_rad) * sinf(yaw_rad), + sinf(pitch_rad)); + const QVector3D world_up(0, 0, 1); + const QVector3D forward = -offset; + QVector3D right = QVector3D::crossProduct(forward, world_up); + if (right.lengthSquared() > 1e-8f) right.normalize(); + + QVector3D move(0, 0, 0); + if (fps_keys_held_.contains(Qt::Key_W)) move += forward; + if (fps_keys_held_.contains(Qt::Key_S)) move -= forward; + if (fps_keys_held_.contains(Qt::Key_D)) move += right; + if (fps_keys_held_.contains(Qt::Key_A)) move -= right; + if (fps_keys_held_.contains(Qt::Key_E)) move += world_up; + if (fps_keys_held_.contains(Qt::Key_Q)) move -= world_up; + if (move.isNull()) return; + move.normalize(); + + const float speed_mul = fps_keys_held_.contains(Qt::Key_Shift) ? 5.0f : 1.0f; + camera_target_ += move * (fps_move_speed_ * speed_mul * dt); + have_cached_cull_ = false; +} + // --- HiZ occlusion culling (Phase 3C) ----------------------------------- // Baseline HiZ resolution. 256x128 is enough to cull big occluders @@ -1784,6 +1887,13 @@ void ViewportWindow::render() { QElapsedTimer frame_cost_clock; frame_cost_clock.start(); + // Advance FPS-mode camera by wall-clock dt since the last frame, then let + // the view matrix derive from the new target. Driving this from render() + // rather than a QTimer means a long frame costs exactly one missed step + // (caught up on the next frame) instead of a backlog that prints as a + // stall. + fpsIntegrate(); + context_->makeCurrent(this); updateCamera(); @@ -1994,6 +2104,22 @@ void ViewportWindow::render() { // which is what profiling actually wants. const float frame_cost_s = frame_cost_clock.nsecsElapsed() * 1e-9f; + // FPS-mode continuous redraw: keep asking for frames while any movement + // key is held. When all keys release, the loop drops out and the + // viewport goes idle until the next input event. Log hitches if + // IFC_FPS_HITCH_MS is set so stalls can be attributed. + if (camera_mode_ == CameraMode::Fps && !fps_keys_held_.isEmpty()) { + static const float hitch_ms = []{ + const char* e = std::getenv("IFC_FPS_HITCH_MS"); + return (e && *e) ? static_cast(std::atof(e)) : 0.0f; + }(); + if (hitch_ms > 0.0f && frame_cost_s * 1000.0f > hitch_ms) { + qDebug("[fps-hitch] frame %.1f ms vis=%u sub_draws=%u", + frame_cost_s * 1000.0f, visible_objects_, indirect_sub_draws_); + } + requestUpdate(); + } + if (benchmark_total_ > 0) { camera_yaw_ += benchmark_yaw_speed_; have_cached_cull_ = false; @@ -2336,10 +2462,18 @@ bool ViewportWindow::event(QEvent* e) { } void ViewportWindow::handleMousePress(QMouseEvent* e) { + // Blender-style: any click in FPS mode drops back to orbit and is + // swallowed (no pick, no camera state mutation). + if (camera_mode_ == CameraMode::Fps) { + exitFpsMode(); + active_button_ = Qt::NoButton; + return; + } active_button_ = e->button(); last_mouse_pos_ = e->pos(); } void ViewportWindow::handleMouseRelease(QMouseEvent* e) { + if (camera_mode_ == CameraMode::Fps) return; 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; @@ -2349,6 +2483,39 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { active_button_ = Qt::NoButton; } void ViewportWindow::handleMouseMove(QMouseEvent* e) { + if (camera_mode_ == CameraMode::Fps) { + // QCursor::setPos emits a synthetic MouseMove at the center; skip it. + if (fps_ignore_next_mouse_move_) { + fps_ignore_next_mouse_move_ = false; + last_mouse_pos_ = e->pos(); + return; + } + const QPoint center(width() / 2, height() / 2); + QPoint delta = e->pos() - center; + if (delta.isNull()) return; + + camera_yaw_ -= delta.x() * 0.15f; + camera_pitch_ += delta.y() * 0.15f; + camera_pitch_ = qBound(-89.0f, camera_pitch_, 89.0f); + + // Pin target so the eye stays put during rotation. Rebuild offset + // from the new yaw/pitch and set target = eye - offset. camera_eye_ + // is from the last rendered frame, which is fine: it's the eye the + // user is currently seeing out of. + const float yaw_rad = qDegreesToRadians(camera_yaw_); + const float pitch_rad = qDegreesToRadians(camera_pitch_); + const QVector3D new_offset(camera_distance_ * cosf(pitch_rad) * cosf(yaw_rad), + camera_distance_ * cosf(pitch_rad) * sinf(yaw_rad), + camera_distance_ * sinf(pitch_rad)); + camera_target_ = camera_eye_ - new_offset; + + fps_ignore_next_mouse_move_ = true; + recenterFpsCursor(); + have_cached_cull_ = false; + requestUpdate(); + return; + } + QPoint delta = e->pos() - last_mouse_pos_; last_mouse_pos_ = e->pos(); if (active_button_ == Qt::MiddleButton) { @@ -2371,6 +2538,11 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { } } void ViewportWindow::handleWheel(QWheelEvent* e) { + if (camera_mode_ == CameraMode::Fps) { + float factor = e->angleDelta().y() > 0 ? 1.25f : 0.8f; + fps_move_speed_ = qBound(0.05f, fps_move_speed_ * factor, 1000.0f); + return; + } 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 index 0cdd712bd5..68e9893020 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -192,6 +193,7 @@ protected: void exposeEvent(QExposeEvent* event) override; void resizeEvent(QResizeEvent* event) override; void keyPressEvent(QKeyEvent* event) override; + void keyReleaseEvent(QKeyEvent* event) override; bool event(QEvent* event) override; private: @@ -273,6 +275,24 @@ private: void handleMouseMove(QMouseEvent* event); void handleWheel(QWheelEvent* event); + // FPS/fly mode. Toggled with Shift+F; exits on any mouse click or Esc. + // While active, WASD translates the camera in view-space, Q/E moves + // world-down/up, mouse rotates the view (cursor hidden + recentered each + // move), Shift accelerates, and the wheel scales the base move speed + // instead of zooming. The underlying orbit state is preserved: movement + // translates camera_target_ and rotation re-pins it so camera_eye_ stays + // put, so exiting drops the user back into orbit at the same viewpoint. + // + // Movement is integrated inside render() using wall-clock dt and the + // next frame is requestUpdate()'d while any movement key is held — that + // way one long frame only produces a single catch-up step instead of + // also missing a QTimer tick. + enum class CameraMode { Orbit, Fps }; + void enterFpsMode(); + void exitFpsMode(); + void fpsIntegrate(); // called from render() + void recenterFpsCursor(); + QOpenGLContext* context_ = nullptr; QOpenGLFunctions_4_5_Core* gl_ = nullptr; bool gl_initialized_ = false; @@ -383,6 +403,16 @@ private: Qt::MouseButton active_button_ = Qt::NoButton; QPoint last_mouse_pos_; + // FPS/fly mode state. fps_keys_held_ tracks WASD/QE/Shift between + // press+release; fps_last_tick_ gates dt inside render(). + // fps_ignore_next_mouse_move_ swallows the synthetic MouseMove that + // QCursor::setPos() generates after we recenter. + CameraMode camera_mode_ = CameraMode::Orbit; + QSet fps_keys_held_; + float fps_move_speed_ = 5.0f; // m/s at speed=1 + QElapsedTimer fps_last_tick_; + bool fps_ignore_next_mouse_move_ = false; + // Selection uint32_t selected_object_id_ = 0; From 7bc64bef1a068f503ca13578f3f5450eb99db04e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 27 Apr 2026 18:31:59 +1000 Subject: [PATCH 066/120] ifcviewer-full: add .ifcfed federation save/load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation (JSON) tracks an ordered list of model sources plus an optional home-view camera state. Sources are stored relative when under the federation file's directory, absolute otherwise. File menu now exposes New / Open / Save / Save As; Add Files moves to Ctrl+Shift+O. View menu gains Set/Go to Home View. Window title binds to dirty state via setWindowModified, and the close-window prompt offers Save/Discard/Cancel. Per-model transform (4x4 column-major) and visible round-trip through load/save but are not yet applied at the viewport — the georeferencing work uses them. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/Federation.cpp | 308 ++++++++++++++++++++++++++++++ src/ifcviewer-full/Federation.h | 105 ++++++++++ src/ifcviewer-full/MainWindow.cpp | 214 ++++++++++++++++++++- src/ifcviewer-full/MainWindow.h | 22 +++ src/ifcviewer-full/main.cpp | 16 +- src/ifcviewer/ViewportWindow.cpp | 4 + src/ifcviewer/ViewportWindow.h | 8 + 7 files changed, 669 insertions(+), 8 deletions(-) create mode 100644 src/ifcviewer-full/Federation.cpp create mode 100644 src/ifcviewer-full/Federation.h diff --git a/src/ifcviewer-full/Federation.cpp b/src/ifcviewer-full/Federation.cpp new file mode 100644 index 0000000000..a72f783103 --- /dev/null +++ b/src/ifcviewer-full/Federation.cpp @@ -0,0 +1,308 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "Federation.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr const char* kSchema = "ifcfed/1"; + +QString resolvePath(const QString& fed_dir, const QString& stored) { + if (stored.isEmpty()) return stored; + QFileInfo fi(stored); + if (fi.isAbsolute()) return QDir::cleanPath(stored); + return QDir::cleanPath(QDir(fed_dir).absoluteFilePath(stored)); +} + +// Returns abs_path relative to fed_dir if abs_path lives under fed_dir, +// otherwise returns abs_path unchanged. +QString relativizePath(const QString& fed_dir, const QString& abs_path) { + QString fed_canon = QDir::cleanPath(fed_dir); + QString abs_canon = QDir::cleanPath(abs_path); + if (!fed_canon.endsWith('/')) fed_canon += '/'; + if (abs_canon.startsWith(fed_canon)) { + return QDir(fed_canon).relativeFilePath(abs_canon); + } + return abs_canon; +} +} // namespace + +Federation::Federation(QObject* parent) : QObject(parent) {} + +QString Federation::generateId() { + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +bool Federation::isFederationPath(const QString& path) { + return path.endsWith(".ifcfed", Qt::CaseInsensitive); +} + +void Federation::clear() { + file_path_.clear(); + name_.clear(); + created_ = QDateTime(); + modified_ = QDateTime(); + models_.clear(); + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(false); +} + +void Federation::markClean() { + setDirty(false); +} + +void Federation::setDirty(bool d) { + if (dirty_ == d) return; + dirty_ = d; + emit dirtyChanged(d); +} + +const Federation::Model* Federation::findById(const QString& fed_id) const { + for (const auto& m : models_) { + if (m.id == fed_id) return &m; + } + return nullptr; +} + +QString Federation::addModel(const QString& source_path, + const QString& display_name) { + if (source_path.isEmpty()) return {}; + if (isFederationPath(source_path)) return {}; // no nested federations + + Model m; + m.id = generateId(); + m.display_name = display_name.isEmpty() + ? QFileInfo(source_path).fileName() + : display_name; + m.source_kind = "local"; + m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); + models_.push_back(std::move(m)); + setDirty(true); + return models_.back().id; +} + +void Federation::removeModel(const QString& fed_id) { + for (auto it = models_.begin(); it != models_.end(); ++it) { + if (it->id == fed_id) { + models_.erase(it); + setDirty(true); + return; + } + } +} + +void Federation::setHomeView(const HomeView& hv) { + home_view_ = hv; + has_home_view_ = true; + setDirty(true); +} + +void Federation::clearHomeView() { + if (!has_home_view_) return; + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(true); +} + +bool Federation::load(const QString& path, + QStringList* warnings, + QString* err) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + if (err) *err = QString("Cannot open %1: %2").arg(path, f.errorString()); + return false; + } + QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError pe; + QJsonDocument doc = QJsonDocument::fromJson(bytes, &pe); + if (doc.isNull() || !doc.isObject()) { + if (err) *err = QString("Parse error in %1: %2").arg(path, pe.errorString()); + return false; + } + QJsonObject root = doc.object(); + + clear(); + file_path_ = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + QString fed_dir = QFileInfo(file_path_).absolutePath(); + + QString schema = root.value("schema").toString(); + if (schema != kSchema && warnings) { + *warnings << QString("Unknown schema '%1' (expected '%2'); attempting to load anyway.") + .arg(schema, kSchema); + } + + name_ = root.value("name").toString(); + created_ = QDateTime::fromString(root.value("created").toString(), Qt::ISODate); + modified_ = QDateTime::fromString(root.value("modified").toString(), Qt::ISODate); + + QJsonArray arr = root.value("models").toArray(); + for (int i = 0; i < arr.size(); ++i) { + if (!arr[i].isObject()) { + if (warnings) *warnings << QString("models[%1] is not an object; skipping.").arg(i); + continue; + } + QJsonObject mo = arr[i].toObject(); + + Model m; + m.id = mo.value("id").toString(); + if (m.id.isEmpty()) m.id = generateId(); + m.display_name = mo.value("display_name").toString(); + + QJsonObject so = mo.value("source").toObject(); + m.source_kind = so.value("kind").toString("local"); + if (m.source_kind != "local") { + if (warnings) + *warnings << QString("models[%1]: unsupported source kind '%2'; entry kept but not loaded.") + .arg(i).arg(m.source_kind); + // Keep raw stored path so save() round-trips correctly. + m.source_path = so.value("path").toString(); + models_.push_back(std::move(m)); + continue; + } + + QString stored = so.value("path").toString(); + if (stored.isEmpty()) { + if (warnings) *warnings << QString("models[%1]: missing source.path; skipping.").arg(i); + continue; + } + m.source_path = resolvePath(fed_dir, stored); + + if (m.display_name.isEmpty()) + m.display_name = QFileInfo(m.source_path).fileName(); + + QJsonValue tv = mo.value("transform"); + if (tv.isArray()) { + QJsonArray ta = tv.toArray(); + if (ta.size() == 16) { + for (int k = 0; k < 16; ++k) m.transform[k] = float(ta[k].toDouble()); + m.has_transform = true; + } else if (warnings) { + *warnings << QString("models[%1]: transform must be 16 floats; ignored.").arg(i); + } + } + + QJsonValue vv = mo.value("visible"); + if (vv.isBool()) m.visible = vv.toBool(); + + models_.push_back(std::move(m)); + } + + QJsonValue hv = root.value("home_view"); + if (hv.isObject()) { + QJsonObject ho = hv.toObject(); + QJsonArray ta = ho.value("target").toArray(); + HomeView v; + if (ta.size() == 3) { + v.target = QVector3D(float(ta[0].toDouble()), + float(ta[1].toDouble()), + float(ta[2].toDouble())); + } + v.distance = float(ho.value("distance").toDouble(50.0)); + v.yaw = float(ho.value("yaw").toDouble(45.0)); + v.pitch = float(ho.value("pitch").toDouble(30.0)); + home_view_ = v; + has_home_view_ = true; + } + + setDirty(false); + return true; +} + +bool Federation::save(const QString& path, QString* err) { + QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + QString fed_dir = QFileInfo(abs_path).absolutePath(); + + QJsonObject root; + root["schema"] = kSchema; + if (!name_.isEmpty()) root["name"] = name_; + + if (!created_.isValid()) created_ = QDateTime::currentDateTimeUtc(); + modified_ = QDateTime::currentDateTimeUtc(); + root["created"] = created_.toUTC().toString(Qt::ISODate); + root["modified"] = modified_.toUTC().toString(Qt::ISODate); + + QJsonArray arr; + for (const auto& m : models_) { + QJsonObject mo; + mo["id"] = m.id; + mo["display_name"] = m.display_name; + + QJsonObject so; + so["kind"] = m.source_kind; + if (m.source_kind == "local") { + so["path"] = relativizePath(fed_dir, m.source_path); + } else { + // Round-trip raw value for unsupported kinds. + so["path"] = m.source_path; + } + mo["source"] = so; + + if (m.has_transform) { + QJsonArray ta; + for (float v : m.transform) ta.append(double(v)); + mo["transform"] = ta; + } + if (!m.visible) mo["visible"] = false; + + arr.append(mo); + } + root["models"] = arr; + + if (has_home_view_) { + QJsonObject ho; + QJsonArray ta; + ta.append(double(home_view_.target.x())); + ta.append(double(home_view_.target.y())); + ta.append(double(home_view_.target.z())); + ho["target"] = ta; + ho["distance"] = double(home_view_.distance); + ho["yaw"] = double(home_view_.yaw); + ho["pitch"] = double(home_view_.pitch); + root["home_view"] = ho; + } else { + root["home_view"] = QJsonValue(); // null + } + + QSaveFile f(abs_path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (err) *err = QString("Cannot write %1: %2").arg(abs_path, f.errorString()); + return false; + } + f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!f.commit()) { + if (err) *err = QString("Failed to commit %1: %2").arg(abs_path, f.errorString()); + return false; + } + + file_path_ = abs_path; + setDirty(false); + return true; +} diff --git a/src/ifcviewer-full/Federation.h b/src/ifcviewer-full/Federation.h new file mode 100644 index 0000000000..21bb6ba3bd --- /dev/null +++ b/src/ifcviewer-full/Federation.h @@ -0,0 +1,105 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef FEDERATION_H +#define FEDERATION_H + +#include +#include +#include +#include +#include + +#include +#include + +// In-memory representation of an .ifcfed file (IFC federation). +// +// A federation is a named, ordered list of model sources plus an optional +// "home view" camera state. Source paths can be relative (resolved against +// the .ifcfed's directory) or absolute. Save() reserialises paths relative +// when they live under the federation file's directory tree, absolute +// otherwise — Save As recomputes against the new location. +// +// Round-trip-only fields today (no UI to edit, but preserved across load/ +// save): per-model `transform` (4x4, column-major), per-model `visible`, +// future cloud `source.kind`s. +class Federation : public QObject { + Q_OBJECT +public: + struct HomeView { + QVector3D target; + float distance = 50.0f; + float yaw = 45.0f; // degrees + float pitch = 30.0f; // degrees + }; + + struct Model { + QString id; // stable, persisted + QString display_name; + QString source_kind = "local"; // future: "http", "speckle", ... + QString source_path; // resolved absolute when kind == "local" + bool has_transform = false; + std::array transform{}; // column-major; identity when !has_transform + bool visible = true; + }; + + explicit Federation(QObject* parent = nullptr); + + // Round-trip + bool load(const QString& path, QStringList* warnings, QString* err); + bool save(const QString& path, QString* err); + + // Mutations + void clear(); + QString addModel(const QString& source_path, + const QString& display_name = QString()); + void removeModel(const QString& fed_id); + void setHomeView(const HomeView& hv); + void clearHomeView(); + + // Accessors + const std::vector& models() const { return models_; } + const Model* findById(const QString& fed_id) const; + bool isDirty() const { return dirty_; } + void markClean(); + QString filePath() const { return file_path_; } + QString name() const { return name_; } + bool hasHomeView() const { return has_home_view_; } + const HomeView& homeView() const { return home_view_; } + +signals: + void dirtyChanged(bool dirty); + +private: + void setDirty(bool d); + static QString generateId(); + static bool isFederationPath(const QString& path); + + QString file_path_; + QString name_; + QDateTime created_; + QDateTime modified_; + std::vector models_; + bool has_home_view_ = false; + HomeView home_view_; + bool dirty_ = false; +}; + +#endif // FEDERATION_H diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index e36af95670..3105af7cf5 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -19,11 +19,13 @@ #include "MainWindow.h" #include "AppSettings.h" +#include "Federation.h" #include "SettingsWindow.h" #include "LodBuilder.h" #include "SidecarCache.h" #include +#include #include #include #include @@ -40,6 +42,11 @@ MainWindow::MainWindow(QWidget* parent) setupUi(); setupMenus(); + federation_ = new Federation(this); + connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { + setWindowModified(dirty); + }); + loader_ = new SceneLoader(viewport_, this); connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); @@ -82,7 +89,7 @@ MainWindow::MainWindow(QWidget* parent) if (!show) stats_label_->clear(); }); - setWindowTitle("IfcViewer"); + updateWindowTitle(); resize(1400, 900); } @@ -132,12 +139,32 @@ void MainWindow::setupUi() { void MainWindow::setupMenus() { auto* file_menu = menuBar()->addMenu("&File"); - auto* open_action = file_menu->addAction("&Add Files...", this, &MainWindow::onFileOpen); - open_action->setShortcut(QKeySequence::Open); + file_menu->addAction("&New Federation", + this, &MainWindow::onFederationNew, + QKeySequence::New); + file_menu->addAction("&Open Federation...", + this, &MainWindow::onFederationOpen, + QKeySequence::Open); + file_menu->addSeparator(); + file_menu->addAction("&Add Files...", + this, &MainWindow::onFileOpen, + QKeySequence("Ctrl+Shift+O")); file_menu->addAction("Add &Database...", this, &MainWindow::onDatabaseOpen); + file_menu->addSeparator(); + file_menu->addAction("&Save Federation", + this, &MainWindow::onFederationSave, + QKeySequence::Save); + file_menu->addAction("Save Federation &As...", + this, &MainWindow::onFederationSaveAs, + QKeySequence::SaveAs); + file_menu->addSeparator(); file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings); file_menu->addSeparator(); file_menu->addAction("&Quit", QKeySequence::Quit, qApp, &QApplication::quit); + + auto* view_menu = menuBar()->addMenu("&View"); + view_menu->addAction("Set &Home View", this, &MainWindow::onSetHomeView); + view_menu->addAction("&Go to Home View", this, &MainWindow::onGoHomeView); } void MainWindow::onFileOpen() { @@ -170,18 +197,189 @@ void MainWindow::onFileSettings() { } void MainWindow::addFiles(const QStringList& paths) { + QStringList accepted_paths; + QStringList accepted_fed_ids; + for (const auto& p : paths) { + QString fed_id = federation_->addModel(p); + if (fed_id.isEmpty()) continue; // .ifcfed or empty path — silently skipped + accepted_paths << p; + accepted_fed_ids << fed_id; + } + loadModelsFromPaths(accepted_paths, accepted_fed_ids); + updateWindowTitle(); +} + +void MainWindow::loadModelsFromPaths(const QStringList& paths, + const QStringList& fed_ids) { + if (paths.isEmpty()) return; auto ids = loader_->addFiles(paths); for (int i = 0; i < paths.size() && i < static_cast(ids.size()); ++i) { - uint32_t id = ids[i]; + uint32_t mid = ids[i]; + const QString& fed_id = fed_ids[i]; + fed_id_to_model_id_[fed_id] = mid; + model_id_to_fed_id_[mid] = fed_id; + QString display = QFileInfo(paths[i]).fileName(); auto* root = new QTreeWidgetItem(element_tree_); root->setText(0, display); root->setText(1, "IFC Model"); root->setData(0, Qt::UserRole, static_cast(0)); - tree_roots_[id] = root; + tree_roots_[mid] = root; } } +bool MainWindow::openFederation(const QString& path) { + if (!confirmDiscardIfDirty()) return false; + + QStringList warnings; + QString err; + if (!federation_->load(path, &warnings, &err)) { + QMessageBox::warning(this, "Open Federation", + QString("Could not open federation:\n%1").arg(err)); + return false; + } + + clearScene(); + + QStringList paths; + QStringList fed_ids; + QStringList missing; + for (const auto& m : federation_->models()) { + if (m.source_kind != "local") continue; // already warned by load() + if (!QFileInfo::exists(m.source_path)) { + missing << m.source_path; + continue; + } + paths << m.source_path; + fed_ids << m.id; + } + loadModelsFromPaths(paths, fed_ids); + + for (const auto& msg : missing) { + warnings << QString("Source not found, kept in federation: %1").arg(msg); + } + if (!warnings.isEmpty()) { + QMessageBox::warning(this, "Open Federation", + "Federation opened with warnings:\n\n" + warnings.join("\n")); + } + + federation_->markClean(); + updateWindowTitle(); + + if (federation_->hasHomeView()) { + const auto& hv = federation_->homeView(); + viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), + hv.distance, hv.yaw, hv.pitch); + } + return true; +} + +void MainWindow::onFederationNew() { + if (!confirmDiscardIfDirty()) return; + clearScene(); + federation_->clear(); + updateWindowTitle(); +} + +void MainWindow::onFederationOpen() { + QString path = QFileDialog::getOpenFileName( + this, "Open Federation", QString(), + "IFC Federation (*.ifcfed);;All Files (*)"); + if (path.isEmpty()) return; + openFederation(path); +} + +bool MainWindow::onFederationSave() { + if (federation_->filePath().isEmpty()) return onFederationSaveAs(); + QString err; + if (!federation_->save(federation_->filePath(), &err)) { + QMessageBox::warning(this, "Save Federation", + QString("Could not save federation:\n%1").arg(err)); + return false; + } + updateWindowTitle(); + return true; +} + +bool MainWindow::onFederationSaveAs() { + QString suggested = federation_->filePath(); + if (suggested.isEmpty()) suggested = "federation.ifcfed"; + QString path = QFileDialog::getSaveFileName( + this, "Save Federation As", suggested, + "IFC Federation (*.ifcfed);;All Files (*)"); + if (path.isEmpty()) return false; + if (!path.endsWith(".ifcfed", Qt::CaseInsensitive)) path += ".ifcfed"; + + QString err; + if (!federation_->save(path, &err)) { + QMessageBox::warning(this, "Save Federation", + QString("Could not save federation:\n%1").arg(err)); + return false; + } + updateWindowTitle(); + return true; +} + +void MainWindow::onSetHomeView() { + auto cs = viewport_->cameraState(); + Federation::HomeView hv; + hv.target = cs.target; + hv.distance = cs.distance; + hv.yaw = cs.yaw; + hv.pitch = cs.pitch; + federation_->setHomeView(hv); + updateWindowTitle(); +} + +void MainWindow::onGoHomeView() { + if (!federation_->hasHomeView()) { + status_label_->setText("No home view set for this federation."); + return; + } + const auto& hv = federation_->homeView(); + viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), + hv.distance, hv.yaw, hv.pitch); +} + +void MainWindow::clearScene() { + while (!tree_roots_.empty()) { + uint32_t mid = tree_roots_.begin()->first; + viewport_->removeModel(mid); + removeModelUi(mid); + } + fed_id_to_model_id_.clear(); + model_id_to_fed_id_.clear(); +} + +bool MainWindow::confirmDiscardIfDirty() { + if (!federation_->isDirty()) return true; + auto ret = QMessageBox::question( + this, "Unsaved Federation", + "The current federation has unsaved changes. Save before continuing?", + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, + QMessageBox::Save); + if (ret == QMessageBox::Cancel) return false; + if (ret == QMessageBox::Save) return onFederationSave(); + return true; // Discard +} + +void MainWindow::updateWindowTitle() { + QString fed_path = federation_->filePath(); + if (fed_path.isEmpty() && federation_->models().empty()) { + setWindowTitle("IfcViewer"); + } else if (fed_path.isEmpty()) { + setWindowTitle("untitled[*] — IfcViewer"); + } else { + setWindowTitle(QFileInfo(fed_path).fileName() + "[*] — IfcViewer"); + } + setWindowModified(federation_->isDirty()); +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (confirmDiscardIfDirty()) event->accept(); + else event->ignore(); +} + void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { progress_bar_->setValue(0); progress_bar_->setVisible(true); @@ -326,6 +524,12 @@ void MainWindow::writeSidecarForModel(uint32_t mid) { } void MainWindow::removeModelUi(uint32_t mid) { + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it != model_id_to_fed_id_.end()) { + fed_id_to_model_id_.erase(fed_it->second); + model_id_to_fed_id_.erase(fed_it); + } + auto root_it = tree_roots_.find(mid); if (root_it != tree_roots_.end()) { delete root_it->second; diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index b616293d59..13dfecf061 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -34,6 +34,7 @@ #include "ViewportWindow.h" #include "SceneLoader.h" +class Federation; class SettingsWindow; class MainWindow : public QMainWindow { @@ -43,12 +44,22 @@ public: ~MainWindow(); void addFiles(const QStringList& paths); + bool openFederation(const QString& path); void setPendingCamera(const QString& params); void setPendingBenchmark(int frames); +protected: + void closeEvent(QCloseEvent* event) override; + private slots: void onFileOpen(); void onDatabaseOpen(); + void onFederationNew(); + void onFederationOpen(); + bool onFederationSave(); + bool onFederationSaveAs(); + void onSetHomeView(); + void onGoHomeView(); void onFileSettings(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); @@ -69,6 +80,11 @@ private slots: private: void setupUi(); void setupMenus(); + void loadModelsFromPaths(const QStringList& paths, + const QStringList& fed_ids); + void clearScene(); + bool confirmDiscardIfDirty(); + void updateWindowTitle(); void populateProperties(uint32_t object_id); void appendElementToTree(uint32_t model_id, uint32_t object_id, @@ -84,6 +100,7 @@ private: ViewportWindow* viewport_ = nullptr; SceneLoader* loader_ = nullptr; + Federation* federation_ = nullptr; SettingsWindow* settings_ = nullptr; QWidget* viewport_container_ = nullptr; QTreeWidget* element_tree_ = nullptr; @@ -95,6 +112,11 @@ private: // Per-model tree roots, keyed by model_id. std::map tree_roots_; + // Bidirectional federation_id <-> model_id map. Federation owns the + // persistent ids; SceneLoader owns the runtime model_ids. + std::unordered_map fed_id_to_model_id_; + std::unordered_map model_id_to_fed_id_; + // Display-side element registry for tree + property lookup. std::unordered_map element_map_; std::unordered_map tree_items_; diff --git a/src/ifcviewer-full/main.cpp b/src/ifcviewer-full/main.cpp index d15a525c53..8f861e3f31 100644 --- a/src/ifcviewer-full/main.cpp +++ b/src/ifcviewer-full/main.cpp @@ -40,7 +40,9 @@ int main(int argc, char* argv[]) { QCommandLineParser parser; parser.setApplicationDescription("IfcOpenShell IFC Viewer"); parser.addHelpOption(); - parser.addPositionalArgument("files", "IFC file(s) to open", "[files...]"); + parser.addPositionalArgument("files", + "IFC file(s) and/or one .ifcfed federation to open", + "[files...]"); parser.addOption({{"c", "camera"}, "Set camera: tx,ty,tz,dist,yaw,pitch", "params"}); parser.addOption({{"b", "benchmark"}, @@ -51,9 +53,17 @@ int main(int argc, char* argv[]) { window.show(); auto args = parser.positionalArguments(); - if (!args.isEmpty()) { - window.addFiles(args); + QStringList file_args; + QString fed_arg; + for (const auto& a : args) { + if (fed_arg.isEmpty() && a.endsWith(".ifcfed", Qt::CaseInsensitive)) { + fed_arg = a; + } else { + file_args << a; + } } + if (!fed_arg.isEmpty()) window.openFederation(fed_arg); + if (!file_args.isEmpty()) window.addFiles(file_args); if (parser.isSet("camera")) { window.setPendingCamera(parser.value("camera")); diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 81d75aebd7..e928525f03 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1186,6 +1186,10 @@ QString ViewportWindow::cameraString() const { .arg(camera_pitch_, 0, 'f', 2); } +ViewportWindow::CameraState ViewportWindow::cameraState() const { + return { camera_target_, camera_distance_, camera_yaw_, camera_pitch_ }; +} + void ViewportWindow::keyPressEvent(QKeyEvent* event) { const int key = event->key(); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 68e9893020..ec223c559f 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -172,6 +172,14 @@ public: void setBenchmarkFrames(int n); QString cameraString() const; + struct CameraState { + QVector3D target; + float distance; + float yaw; // degrees + float pitch; // degrees + }; + CameraState cameraState() const; + struct FrameStats { float fps; float frame_time_ms; From 8282f691e828d483cc5097765278931db0d317e8 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 28 Apr 2026 19:31:48 +1000 Subject: [PATCH 067/120] ifcviewer: rename SceneLoader::Entry to Model The struct holds per-model bookkeeping; Model describes its contents rather than its container relationship. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/SceneLoader.cpp | 12 ++++++------ src/ifcviewer/SceneLoader.h | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index 1b8d2c4be3..df6e66c71c 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -74,12 +74,12 @@ std::vector SceneLoader::addFiles(const QStringList& paths) { assigned.reserve(paths.size()); for (const auto& path : paths) { uint32_t id = next_model_id_++; - Entry entry; - entry.id = id; - entry.file_path = path; - entry.display_name = QFileInfo(path).fileName(); - entry.streamer = new GeometryStreamer(this); - models_[id] = std::move(entry); + Model model; + model.id = id; + model.file_path = path; + model.display_name = QFileInfo(path).fileName(); + model.streamer = new GeometryStreamer(this); + models_[id] = std::move(model); load_queue_.push_back(id); assigned.push_back(id); } diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 41f825a35e..344c4b985d 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -107,7 +107,7 @@ private slots: void onElementPollTick(); private: - struct Entry { + struct Model { uint32_t id = 0; QString file_path; QString display_name; @@ -123,7 +123,7 @@ private: void startDataSourceLoad(uint32_t mid); ViewportWindow* viewport_ = nullptr; - std::map models_; + std::map models_; std::deque load_queue_; uint32_t next_model_id_ = 1; uint32_t next_object_id_ = 1; From 633c613da21fd823f3e3ac1cbc0f7bc6a2f8680f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 28 Apr 2026 19:40:00 +1000 Subject: [PATCH 068/120] ifcviewer: drop unused SidecarHeader reserved field, bump v8 -> v9 The reserved uint32_t was always written as 0 and never inspected on read. Removing it shrinks the header from 16 to 12 bytes; the version bump makes pre-existing sidecars fail the version check cleanly rather than misreading by 4 bytes. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/README.md | 6 +++--- src/ifcviewer/SidecarCache.cpp | 7 +++---- src/ifcviewer/SidecarCache.h | 3 ++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ifcviewer/README.md b/src/ifcviewer/README.md index 301322851c..4d8290d736 100644 --- a/src/ifcviewer/README.md +++ b/src/ifcviewer/README.md @@ -116,7 +116,7 @@ engine with a Qt6 interface and OpenGL 4.5 rendering. | `InstancedGeometry.h` | Shared structs: `MeshInfo`, `InstanceCpu`, `InstanceGpu`, chunk records | | `BvhAccel.h/cpp` | Median-split BVH builder; operates on instance world-AABBs | | `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | -| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v8) sidecar read/write | +| `SidecarCache.h/cpp` | Raw binary `.ifcview` (v9) sidecar read/write | | `AppSettings.h/cpp` | Persisted preferences (geometry library, stats overlay, backface culling) | | `SettingsWindow.h/cpp` | Settings dialog | | `CMakeLists.txt` | Build configuration | @@ -293,13 +293,13 @@ while stack not empty: Depth 64 is enough for billions of items on any balanced tree. The stack is on the C++ stack, zero per-frame allocation. -#### Sidecar format (`.ifcview`, v8) +#### Sidecar format (`.ifcview`, v9) Raw memory dump, Blender-`.blend`-style — no serialisation, no parsing. Stores everything needed to skip the `IfcGeom::Iterator` pass: ``` -SidecarHeader (magic "IFVW", version, endian, ...) +SidecarHeader (magic "IFVW", version, endian) uint32_t + uint8_t[] vertex data (12 B/vert quantized; per-mesh basis in MeshInfo) uint32_t + uint32_t[] index data (mesh-local) uint32_t + MeshInfo[] per-unique-mesh metadata (56 B each, incl. LOD1 slice) diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index 12077e9c4a..b603dce354 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,9 +17,9 @@ * * ********************************************************************************/ -// v8 layout (all multi-byte fields native-endian; endianness marker in header). +// v9 layout (all multi-byte fields native-endian; endianness marker in header). // -// SidecarHeader (16 bytes) +// SidecarHeader (12 bytes) // // uint32_t num_vertex_bytes // uint8_t[] vertex data (12 B/vertex: pos u16x3 + oct-normal i8x2 + rgba8) @@ -46,7 +46,6 @@ struct SidecarHeader { uint32_t magic; uint32_t version; uint32_t endian; - uint32_t reserved; }; // foo.ifc -> foo.ifcview @@ -87,7 +86,7 @@ bool writeSidecar(const std::string& ifc_path, const SidecarData& data) { FILE* f = fopen(path.c_str(), "wb"); if (!f) return false; - SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN, 0 }; + SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN }; if (fwrite(&hdr, sizeof(hdr), 1, f) != 1) { fclose(f); return false; } if (!writeVec(f, data.vertices)) { fclose(f); return false; } diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 47411e5d5f..95c8a51c38 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -45,7 +45,8 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // on path stem (foo.ifc and foo.ifcdb/ both map to foo.ifcview) so the // same cache serves either source format. Staleness is user-managed // (delete the sidecar to force a rebuild). -static constexpr uint32_t SIDECAR_VERSION = 8; +// v9 = unused `reserved` field dropped from header (16 B -> 12 B). +static constexpr uint32_t SIDECAR_VERSION = 9; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs From e21bd1ac96a1531e7dd8d7e7030d0784bc8d34c2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 28 Apr 2026 21:49:44 +1000 Subject: [PATCH 069/120] ifcviewer: add tier-1 unit tests (Catch2 + CTest) Covers the pure-logic modules with no Qt event loop or GL context: BVH build, LOD decimation, sidecar round-trip, instanced-geometry layout constants, and Federation save/load + relative-path policy. Each test binary compiles only the production source(s) under test, so the unit tier doesn't pull Qt/OpenCASCADE/IfcGeom into the test build. Gated behind BUILD_IFCVIEWER_TESTS=OFF; default builds remain offline. Catch2 v3.5.4 is fetched on demand via FetchContent. --- cmake/CMakeLists.txt | 18 ++ src/ifcviewer-full/CMakeLists.txt | 4 + src/ifcviewer-full/tests/CMakeLists.txt | 41 +++ src/ifcviewer-full/tests/test_federation.cpp | 306 ++++++++++++++++++ src/ifcviewer/CMakeLists.txt | 4 + src/ifcviewer/tests/CMakeLists.txt | 49 +++ src/ifcviewer/tests/test_bvh_accel.cpp | 184 +++++++++++ .../tests/test_instanced_geometry.cpp | 110 +++++++ src/ifcviewer/tests/test_lod_builder.cpp | 191 +++++++++++ src/ifcviewer/tests/test_sidecar_cache.cpp | 238 ++++++++++++++ 10 files changed, 1145 insertions(+) create mode 100644 src/ifcviewer-full/tests/CMakeLists.txt create mode 100644 src/ifcviewer-full/tests/test_federation.cpp create mode 100644 src/ifcviewer/tests/CMakeLists.txt create mode 100644 src/ifcviewer/tests/test_bvh_accel.cpp create mode 100644 src/ifcviewer/tests/test_instanced_geometry.cpp create mode 100644 src/ifcviewer/tests/test_lod_builder.cpp create mode 100644 src/ifcviewer/tests/test_sidecar_cache.cpp diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 660d55cf3d..133c10f818 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -72,6 +72,7 @@ option(BUILD_GEOMSERVER "Build IfcGeomServer executable (Open CASCADE is require 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_IFCVIEWER_TESTS "Build unit tests for IfcViewer (fetches Catch2 v3)" OFF) option(BUILD_PACKAGE "" OFF) option(WITH_OPENCASCADE "Enable geometry interpretation using Open CASCADE" ON) @@ -673,6 +674,23 @@ if(BUILD_IFCGEOM) install(TARGETS ${IFCGEOM_SCHEMA_LIBRARIES} ${kernel_libraries} IfcGeom) endif(BUILD_IFCGEOM) if(BUILD_IFCVIEWER) + if(BUILD_IFCVIEWER_TESTS) + # Catch2 v3 — fetched on demand. Test option is OFF by default so the + # default build remains offline-capable. + include(FetchContent) + FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.4 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(Catch2) + list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) + include(CTest) + include(Catch) + enable_testing() + endif() + add_subdirectory(../src/ifcviewer ifcviewer) add_subdirectory(../src/ifcviewer-minimal ifcviewer-minimal) add_subdirectory(../src/ifcviewer-full ifcviewer-full) diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt index f578d96f70..ad5a5083ad 100644 --- a/src/ifcviewer-full/CMakeLists.txt +++ b/src/ifcviewer-full/CMakeLists.txt @@ -34,3 +34,7 @@ set_target_properties(IfcViewerFull PROPERTIES target_link_libraries(IfcViewerFull PRIVATE IfcViewer) install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) + +if(BUILD_IFCVIEWER_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/ifcviewer-full/tests/CMakeLists.txt b/src/ifcviewer-full/tests/CMakeLists.txt new file mode 100644 index 0000000000..705fec1ad0 --- /dev/null +++ b/src/ifcviewer-full/tests/CMakeLists.txt @@ -0,0 +1,41 @@ +################################################################################ +# # +# This file is part of IfcOpenShell. # +# # +# IfcOpenShell is free software: you can redistribute it and/or modify # +# it under the terms of the Lesser GNU General Public License as published by # +# the Free Software Foundation, either version 3.0 of the License, or # +# (at your option) any later version. # +# # +# IfcOpenShell is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# Lesser GNU General Public License for more details. # +# # +# You should have received a copy of the Lesser GNU General Public License # +# along with this program. If not, see . # +# # +################################################################################ + +# Tier-1 tests for ifcviewer-full. Federation is QObject-derived but only +# uses Qt6::Core (no event loop, no GL), so tests can construct it directly. + +set(IFCVIEWER_FULL_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..) + +# Federation::HomeView holds a QVector3D (defined in QtGui), and QSignalSpy +# / QTest live in Qt6::Test. +find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) + +add_executable(test_federation + test_federation.cpp + ${IFCVIEWER_FULL_SRC}/Federation.cpp +) +set_target_properties(test_federation PROPERTIES AUTOMOC ON) +target_include_directories(test_federation PRIVATE ${IFCVIEWER_FULL_SRC}) +target_link_libraries(test_federation PRIVATE + Catch2::Catch2WithMain + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui + Qt${QT_VERSION}::Test # QSignalSpy +) +catch_discover_tests(test_federation) diff --git a/src/ifcviewer-full/tests/test_federation.cpp b/src/ifcviewer-full/tests/test_federation.cpp new file mode 100644 index 0000000000..6226464220 --- /dev/null +++ b/src/ifcviewer-full/tests/test_federation.cpp @@ -0,0 +1,306 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "Federation.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +// Catch2 owns main(), so QCoreApplication can't live in a TU constructor. +// Lazily construct it (intentionally leaked) the first time any test asks. +void ensureQApp() { + if (QCoreApplication::instance()) return; + static int argc = 1; + static char arg0[] = "test_federation"; + static char* argv[] = { arg0, nullptr }; + new QCoreApplication(argc, argv); +} + +QString writeStubFile(const QString& path) { + // Federation::addModel cleanPath()s + absolutePath()s; the file doesn't + // need to exist to be added, but for some tests we want a real path under + // a temp dir so QFileInfo gives a stable answer. + QFileInfo fi(path); + QDir().mkpath(fi.absolutePath()); + QFile f(path); + REQUIRE(f.open(QIODevice::WriteOnly)); + f.write("stub"); + f.close(); + return QDir::cleanPath(fi.absoluteFilePath()); +} + +QJsonObject readJsonFile(const QString& path) { + QFile f(path); + REQUIRE(f.open(QIODevice::ReadOnly)); + QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); + REQUIRE(doc.isObject()); + return doc.object(); +} + +} // namespace + +TEST_CASE("Federation starts empty and not dirty", "[federation]") { + ensureQApp(); + Federation fed; + REQUIRE(fed.models().empty()); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE_FALSE(fed.hasHomeView()); + REQUIRE(fed.filePath().isEmpty()); +} + +TEST_CASE("addModel emits dirty=true; markClean clears it; remove re-dirties", "[federation]") { + ensureQApp(); + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + + Federation fed; + QSignalSpy spy(&fed, &Federation::dirtyChanged); + + QString abs = writeStubFile(tmp.filePath("a.ifc")); + QString id = fed.addModel(abs); + REQUIRE_FALSE(id.isEmpty()); + REQUIRE(fed.isDirty()); + REQUIRE(spy.count() == 1); + REQUIRE(spy.takeFirst().at(0).toBool() == true); + + fed.markClean(); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE(spy.count() == 1); + REQUIRE(spy.takeFirst().at(0).toBool() == false); + + fed.removeModel(id); + REQUIRE(fed.isDirty()); + REQUIRE(spy.count() == 1); + REQUIRE(spy.takeFirst().at(0).toBool() == true); +} + +TEST_CASE("addModel rejects empty paths and nested .ifcfed sources", "[federation]") { + ensureQApp(); + Federation fed; + REQUIRE(fed.addModel("").isEmpty()); + REQUIRE(fed.addModel("nested.ifcfed").isEmpty()); + REQUIRE(fed.addModel("nested.IfcFed").isEmpty()); // case-insensitive + REQUIRE(fed.models().empty()); + REQUIRE_FALSE(fed.isDirty()); +} + +TEST_CASE("setHomeView / clearHomeView toggle dirty + has_home_view", "[federation]") { + ensureQApp(); + Federation fed; + QSignalSpy spy(&fed, &Federation::dirtyChanged); + + Federation::HomeView hv; + hv.target = QVector3D(1, 2, 3); + hv.distance = 12.5f; + hv.yaw = 33.0f; + hv.pitch = 22.0f; + fed.setHomeView(hv); + REQUIRE(fed.hasHomeView()); + REQUIRE(fed.isDirty()); + REQUIRE(spy.count() == 1); + + fed.markClean(); + spy.clear(); + + fed.clearHomeView(); + REQUIRE_FALSE(fed.hasHomeView()); + REQUIRE(fed.isDirty()); + REQUIRE(spy.count() == 1); + + // Idempotent when already cleared. + fed.markClean(); + spy.clear(); + fed.clearHomeView(); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE(spy.count() == 0); +} + +TEST_CASE("save then load round-trips models, transform, visibility, home view", "[federation]") { + ensureQApp(); + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + + QString src1 = writeStubFile(tmp.filePath("models/wall.ifc")); + QString src2 = writeStubFile(tmp.filePath("models/slab.ifc")); + QString fed_path = tmp.filePath("project.ifcfed"); + + Federation src; + QString id1 = src.addModel(src1, "Wall"); + QString id2 = src.addModel(src2); // default display_name from filename + REQUIRE_FALSE(id1.isEmpty()); + REQUIRE_FALSE(id2.isEmpty()); + + Federation::HomeView hv; + hv.target = QVector3D(10, 20, 30); + hv.distance = 77.0f; + hv.yaw = 11.0f; + hv.pitch = 7.0f; + src.setHomeView(hv); + + QString err; + REQUIRE(src.save(fed_path, &err)); + REQUIRE(err.isEmpty()); + REQUIRE_FALSE(src.isDirty()); + REQUIRE(QFileInfo::exists(fed_path)); + + Federation dst; + QStringList warnings; + REQUIRE(dst.load(fed_path, &warnings, &err)); + REQUIRE(err.isEmpty()); + REQUIRE(warnings.isEmpty()); + + REQUIRE(dst.models().size() == 2); + REQUIRE(dst.models()[0].id == id1); + REQUIRE(dst.models()[0].display_name == "Wall"); + REQUIRE(dst.models()[0].source_path == src1); + REQUIRE(dst.models()[1].id == id2); + REQUIRE(dst.models()[1].display_name == "slab.ifc"); + REQUIRE(dst.models()[1].source_path == src2); + + REQUIRE(dst.hasHomeView()); + REQUIRE(dst.homeView().target == QVector3D(10, 20, 30)); + REQUIRE(dst.homeView().distance == 77.0f); + REQUIRE(dst.homeView().yaw == 11.0f); + REQUIRE(dst.homeView().pitch == 7.0f); + + REQUIRE_FALSE(dst.isDirty()); + REQUIRE(QFileInfo(dst.filePath()) == QFileInfo(fed_path)); +} + +TEST_CASE("save stores paths relative when under fed_dir, absolute otherwise", "[federation]") { + ensureQApp(); + QTemporaryDir root; + REQUIRE(root.isValid()); + + // Layout: + // /fed_root/project.ifcfed + // /fed_root/sub/inside.ifc (under fed_dir) + // /elsewhere/outside.ifc (not under fed_dir) + QString fed_dir = root.filePath("fed_root"); + QDir().mkpath(fed_dir); + QString fed_path = fed_dir + "/project.ifcfed"; + QString inside = writeStubFile(fed_dir + "/sub/inside.ifc"); + QString outside = writeStubFile(root.filePath("elsewhere/outside.ifc")); + + Federation fed; + fed.addModel(inside); + fed.addModel(outside); + + QString err; + REQUIRE(fed.save(fed_path, &err)); + + QJsonObject root_obj = readJsonFile(fed_path); + QJsonArray models = root_obj.value("models").toArray(); + REQUIRE(models.size() == 2); + + QString stored_inside = models[0].toObject().value("source").toObject() + .value("path").toString(); + QString stored_outside = models[1].toObject().value("source").toObject() + .value("path").toString(); + + REQUIRE_FALSE(QFileInfo(stored_inside).isAbsolute()); + REQUIRE(stored_inside == "sub/inside.ifc"); + REQUIRE(QFileInfo(stored_outside).isAbsolute()); + REQUIRE(QDir::cleanPath(stored_outside) == outside); + + // Reload: source_path is resolved back to absolute either way. + Federation reload; + QStringList warnings; + REQUIRE(reload.load(fed_path, &warnings, &err)); + REQUIRE(reload.models()[0].source_path == inside); + REQUIRE(reload.models()[1].source_path == outside); +} + +TEST_CASE("Save-As to a different directory recomputes path relativity", "[federation]") { + ensureQApp(); + QTemporaryDir root; + REQUIRE(root.isValid()); + + // Original layout: source lives under fed_root, fed file under fed_root. + QString fed_dir_a = root.filePath("fed_a"); + QString fed_dir_b = root.filePath("fed_b"); + QDir().mkpath(fed_dir_a); + QDir().mkpath(fed_dir_b); + QString src = writeStubFile(fed_dir_a + "/sub/m.ifc"); + QString fed_a = fed_dir_a + "/proj.ifcfed"; + QString fed_b = fed_dir_b + "/proj.ifcfed"; + + Federation fed; + fed.addModel(src); + + QString err; + REQUIRE(fed.save(fed_a, &err)); + QString stored_a = readJsonFile(fed_a).value("models").toArray()[0] + .toObject().value("source").toObject() + .value("path").toString(); + REQUIRE_FALSE(QFileInfo(stored_a).isAbsolute()); + + // Save-As under a sibling directory: source is no longer under fed_dir, + // so it must be stored as absolute. + REQUIRE(fed.save(fed_b, &err)); + QString stored_b = readJsonFile(fed_b).value("models").toArray()[0] + .toObject().value("source").toObject() + .value("path").toString(); + REQUIRE(QFileInfo(stored_b).isAbsolute()); + REQUIRE(QDir::cleanPath(stored_b) == src); + + // After Save-As, filePath() reflects the new location. + REQUIRE(QFileInfo(fed.filePath()) == QFileInfo(fed_b)); +} + +TEST_CASE("load on a missing file fails with an error and does not crash", "[federation]") { + ensureQApp(); + Federation fed; + QStringList warnings; + QString err; + REQUIRE_FALSE(fed.load("/this/path/does/not/exist.ifcfed", &warnings, &err)); + REQUIRE_FALSE(err.isEmpty()); +} + +TEST_CASE("load on malformed JSON fails with an error", "[federation]") { + ensureQApp(); + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + QString bad = tmp.filePath("bad.ifcfed"); + { + QFile f(bad); + REQUIRE(f.open(QIODevice::WriteOnly)); + f.write("{ this is not json"); + f.close(); + } + Federation fed; + QStringList warnings; + QString err; + REQUIRE_FALSE(fed.load(bad, &warnings, &err)); + REQUIRE_FALSE(err.isEmpty()); +} diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt index b488cd5050..9e8385a2d1 100644 --- a/src/ifcviewer/CMakeLists.txt +++ b/src/ifcviewer/CMakeLists.txt @@ -70,3 +70,7 @@ install(TARGETS IfcViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) install(FILES ${IFCVIEWER_H_FILES} DESTINATION ${INCLUDEDIR}/ifcviewer ) + +if(BUILD_IFCVIEWER_TESTS) + add_subdirectory(tests) +endif() diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt new file mode 100644 index 0000000000..d6739bb22b --- /dev/null +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -0,0 +1,49 @@ +################################################################################ +# # +# This file is part of IfcOpenShell. # +# # +# IfcOpenShell is free software: you can redistribute it and/or modify # +# it under the terms of the Lesser GNU General Public License as published by # +# the Free Software Foundation, either version 3.0 of the License, or # +# (at your option) any later version. # +# # +# IfcOpenShell is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# Lesser GNU General Public License for more details. # +# # +# You should have received a copy of the Lesser GNU General Public License # +# along with this program. If not, see . # +# # +################################################################################ + +# Tier-1 unit tests: pure-logic modules that need neither Qt nor an OpenGL +# context. Each test binary compiles the production source(s) under test +# directly (rather than linking the IfcViewer library) so the binaries stay +# small and don't pull Qt6, OpenCASCADE, IfcGeom, etc. into the test build. + +set(IFCVIEWER_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..) + +function(add_ifcviewer_unit_test name) + cmake_parse_arguments(T "" "" "SOURCES;LIBS" ${ARGN}) + add_executable(${name} ${name}.cpp ${T_SOURCES}) + target_include_directories(${name} PRIVATE ${IFCVIEWER_SRC}) + target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain ${T_LIBS}) + catch_discover_tests(${name}) +endfunction() + +add_ifcviewer_unit_test(test_bvh_accel + SOURCES ${IFCVIEWER_SRC}/BvhAccel.cpp +) + +add_ifcviewer_unit_test(test_lod_builder + SOURCES + ${IFCVIEWER_SRC}/LodBuilder.cpp + LIBS meshoptimizer::meshoptimizer +) + +add_ifcviewer_unit_test(test_sidecar_cache + SOURCES ${IFCVIEWER_SRC}/SidecarCache.cpp +) + +add_ifcviewer_unit_test(test_instanced_geometry) diff --git a/src/ifcviewer/tests/test_bvh_accel.cpp b/src/ifcviewer/tests/test_bvh_accel.cpp new file mode 100644 index 0000000000..3d39f922c4 --- /dev/null +++ b/src/ifcviewer/tests/test_bvh_accel.cpp @@ -0,0 +1,184 @@ +/******************************************************************************** + * * + * 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 "BvhAccel.h" + +#include + +#include +#include + +namespace { + +BvhItem makeItem(float x, float y, float z, float r, uint32_t model_id = 1) { + BvhItem it{}; + it.aabb_min[0] = x - r; + it.aabb_min[1] = y - r; + it.aabb_min[2] = z - r; + it.aabb_max[0] = x + r; + it.aabb_max[1] = y + r; + it.aabb_max[2] = z + r; + it.model_id = model_id; + return it; +} + +bool aabbContains(const float outer_min[3], const float outer_max[3], + const float inner_min[3], const float inner_max[3]) { + for (int a = 0; a < 3; ++a) { + if (inner_min[a] < outer_min[a]) return false; + if (inner_max[a] > outer_max[a]) return false; + } + return true; +} + +void verifyNode(const ModelBvh& mbvh, + const std::vector& items, + uint32_t node_idx) { + REQUIRE(node_idx < mbvh.nodes.size()); + const BvhNode& node = mbvh.nodes[node_idx]; + + if (node.count > 0) { + // Leaf: every item's AABB must be inside the node AABB. + REQUIRE(node.count <= BVH_MAX_LEAF_SIZE); + for (uint32_t k = 0; k < node.count; ++k) { + uint32_t idx = mbvh.item_indices[node.right_or_first + k]; + REQUIRE(idx < items.size()); + REQUIRE(aabbContains(node.aabb_min, node.aabb_max, + items[idx].aabb_min, items[idx].aabb_max)); + } + return; + } + + // Interior: left child is at node_idx + 1, right at node.right_or_first. + uint32_t left_idx = node_idx + 1; + uint32_t right_idx = node.right_or_first; + REQUIRE(left_idx < mbvh.nodes.size()); + REQUIRE(right_idx < mbvh.nodes.size()); + REQUIRE(left_idx != right_idx); + + const BvhNode& l = mbvh.nodes[left_idx]; + const BvhNode& r = mbvh.nodes[right_idx]; + REQUIRE(aabbContains(node.aabb_min, node.aabb_max, l.aabb_min, l.aabb_max)); + REQUIRE(aabbContains(node.aabb_min, node.aabb_max, r.aabb_min, r.aabb_max)); + REQUIRE(node.axis < 3); + + verifyNode(mbvh, items, left_idx); + verifyNode(mbvh, items, right_idx); +} + +} // namespace + +TEST_CASE("BvhNode is 32 bytes (sidecar/cache layout invariant)", "[bvh]") { + REQUIRE(sizeof(BvhNode) == 32); +} + +TEST_CASE("buildModelBvhOne on empty input produces no nodes", "[bvh]") { + std::vector items; + ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/42); + REQUIRE(mbvh.model_id == 42); + REQUIRE(mbvh.nodes.empty()); + REQUIRE(mbvh.item_indices.empty()); +} + +TEST_CASE("buildModelBvhOne with <= BVH_MAX_LEAF_SIZE items yields a single leaf", "[bvh]") { + std::vector items; + for (int i = 0; i < 5; ++i) { + items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f)); + } + ModelBvh mbvh = buildModelBvhOne(items, 1); + REQUIRE(mbvh.nodes.size() == 1); + REQUIRE(mbvh.nodes[0].count == 5); + REQUIRE(mbvh.item_indices.size() == 5); + verifyNode(mbvh, items, 0); +} + +TEST_CASE("buildModelBvhOne with many items splits and respects invariants", "[bvh]") { + std::mt19937 rng(0xC0FFEE); + std::uniform_real_distribution coord(-100.0f, 100.0f); + std::uniform_real_distribution radius(0.1f, 1.0f); + + constexpr int N = 256; + std::vector items; + items.reserve(N); + for (int i = 0; i < N; ++i) { + items.push_back(makeItem(coord(rng), coord(rng), coord(rng), radius(rng))); + } + + ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/7); + REQUIRE(mbvh.model_id == 7); + REQUIRE(!mbvh.nodes.empty()); + REQUIRE(mbvh.item_indices.size() == N); + + // Permutation invariant: each item must appear exactly once. + std::vector seen(N, 0); + for (uint32_t idx : mbvh.item_indices) { + REQUIRE(idx < uint32_t(N)); + seen[idx]++; + } + for (int s : seen) REQUIRE(s == 1); + + // Recursive structural invariants. + verifyNode(mbvh, items, 0); + + // Sum of leaf counts must equal item count. + uint32_t leaf_total = 0; + for (const auto& n : mbvh.nodes) { + if (n.count > 0) leaf_total += n.count; + } + REQUIRE(leaf_total == N); +} + +TEST_CASE("buildBvhSet partitions by model_id and gates on BVH_MIN_OBJECTS", "[bvh]") { + // Model 1: well above BVH_MIN_OBJECTS — should get a BVH. + // Model 2: a single item — below the gate, must be skipped. + std::vector items; + for (uint32_t i = 0; i < BVH_MIN_OBJECTS + 4; ++i) { + items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f, /*model_id=*/1)); + } + items.push_back(makeItem(0.0f, 0.0f, 0.0f, 0.5f, /*model_id=*/2)); + + auto set = buildBvhSet(items); + REQUIRE(set); + REQUIRE(set->bvh_model_ids.count(1) == 1); + REQUIRE(set->bvh_model_ids.count(2) == 0); + REQUIRE(set->models.count(1) == 1); + REQUIRE(set->models.count(2) == 0); + + const auto& mbvh = set->models.at(1); + REQUIRE(mbvh.model_id == 1); + REQUIRE(mbvh.item_indices.size() == BVH_MIN_OBJECTS + 4); + // item_indices reference positions in the *full* items array — the model-1 + // items are at indices [0, BVH_MIN_OBJECTS + 4), so every entry must be + // less than that. + for (uint32_t idx : mbvh.item_indices) { + REQUIRE(idx < BVH_MIN_OBJECTS + 4); + } + verifyNode(mbvh, items, 0); +} + +TEST_CASE("buildBvhSet returns empty set when nothing meets the gate", "[bvh]") { + std::vector items; + for (uint32_t i = 0; i < BVH_MIN_OBJECTS - 1; ++i) { + items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f)); + } + auto set = buildBvhSet(items); + REQUIRE(set); + REQUIRE(set->bvh_model_ids.empty()); + REQUIRE(set->models.empty()); +} diff --git a/src/ifcviewer/tests/test_instanced_geometry.cpp b/src/ifcviewer/tests/test_instanced_geometry.cpp new file mode 100644 index 0000000000..2903811706 --- /dev/null +++ b/src/ifcviewer/tests/test_instanced_geometry.cpp @@ -0,0 +1,110 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// Tier-1 coverage of the instanced-geometry GPU/sidecar layout. +// +// The production quantization helpers currently live inside +// ViewportWindow.cpp (see uploadMeshChunk). Once they're factored out into a +// reusable header (planned tier-3 prerequisite) this test will exercise the +// real implementation directly. For now we cover: +// - runtime size/alignment assertions (defense in depth for the static_asserts) +// - documented constants form a self-consistent layout +// - a reference position-quantization round-trip that pins the error bound +// declared in InstancedGeometry.h ("dequant to mix(aabb_min, aabb_max, t)") + +#include "InstancedGeometry.h" + +#include + +#include +#include + +TEST_CASE("Instanced GPU/CPU struct sizes match the wire format", "[instgeom]") { + REQUIRE(sizeof(MeshGpu) == 32); + REQUIRE(sizeof(MeshInfo) == 56); + REQUIRE(sizeof(InstanceGpu) == 80); + REQUIRE(alignof(MeshGpu) == 16); + REQUIRE(alignof(InstanceGpu) == 16); +} + +TEST_CASE("INSTANCED_VERTEX_* constants are self-consistent", "[instgeom]") { + // Position (u16 x 3 = 6 B) + normal (i8 x 2 = 2 B) + color (u8 x 4 = 4 B) + // packed contiguously with no implicit padding. + REQUIRE(INSTANCED_VERTEX_POS_OFFSET == 0); + REQUIRE(INSTANCED_VERTEX_NORMAL_OFFSET == 6); + REQUIRE(INSTANCED_VERTEX_COLOR_OFFSET == 8); + REQUIRE(INSTANCED_VERTEX_STRIDE_BYTES == 12); + REQUIRE(INSTANCED_VERTEX_STRIDE_FLOATS == 7); +} + +TEST_CASE("Position quantization round-trips within the documented error bound", "[instgeom]") { + // The quantization basis is per-mesh: t = (p - min) / (max - min) packed + // into u16, and dequantized as p' = min + t * (max - min). The round-trip + // error per axis is at most (max - min) / 65535 (one ulp of the u16 grid). + const float aabb_min[3] = {-3.5f, 100.25f, -1000.0f}; + const float aabb_max[3] = { 7.5f, 200.25f, 1000.0f}; + const float extent[3] = { + aabb_max[0] - aabb_min[0], + aabb_max[1] - aabb_min[1], + aabb_max[2] - aabb_min[2], + }; + + constexpr int kSamples = 65; + float worst_err = 0.0f; + for (int s = 0; s <= kSamples; ++s) { + float t = float(s) / float(kSamples); + for (int a = 0; a < 3; ++a) { + float p = aabb_min[a] + t * extent[a]; + + // Pack (the same formula buildLods/the streamer use against an AABB). + float tt = (p - aabb_min[a]) / extent[a]; + if (tt < 0.0f) tt = 0.0f; + if (tt > 1.0f) tt = 1.0f; + uint16_t q = uint16_t(tt * 65535.0f + 0.5f); + + // Unpack (matches the dequant in LodBuilder.cpp). + float pp = aabb_min[a] + (q / 65535.0f) * extent[a]; + + float err = std::fabs(pp - p); + if (err > worst_err) worst_err = err; + } + } + + // The round-trip error must stay within one u16 ulp of the largest extent, + // with a small float-rounding margin. + float ulp = 0.0f; + for (int a = 0; a < 3; ++a) { + ulp = std::max(ulp, extent[a] / 65535.0f); + } + REQUIRE(worst_err <= ulp * 1.01f); +} + +TEST_CASE("MeshChunk and InstanceChunk default-init to zeroed metadata", "[instgeom]") { + MeshChunk mc; + REQUIRE(mc.model_id == 0); + REQUIRE(mc.local_mesh_id == 0); + REQUIRE(mc.vertices.empty()); + REQUIRE(mc.indices.empty()); + + InstanceChunk ic; + REQUIRE(ic.model_id == 0); + REQUIRE(ic.local_mesh_id == 0); + REQUIRE(ic.object_id == 0); + REQUIRE(ic.color_override_rgba8 == 0); +} diff --git a/src/ifcviewer/tests/test_lod_builder.cpp b/src/ifcviewer/tests/test_lod_builder.cpp new file mode 100644 index 0000000000..9d82523ce5 --- /dev/null +++ b/src/ifcviewer/tests/test_lod_builder.cpp @@ -0,0 +1,191 @@ +/******************************************************************************** + * * + * 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 "InstancedGeometry.h" +#include "LodBuilder.h" +#include "SidecarCache.h" + +#include + +#include +#include +#include +#include + +namespace { + +// Wipes LOD env-var knobs so tests run against the documented defaults +// regardless of the host shell. +struct ScopedEnvIsolate { + ScopedEnvIsolate() { +#ifdef _WIN32 + _putenv_s("IFC_LOD_ERROR", ""); + _putenv_s("IFC_LOD_RATIO", ""); + _putenv_s("IFC_LOD_MIN_SAVINGS", ""); + _putenv_s("IFC_LOD_DEBUG", ""); +#else + unsetenv("IFC_LOD_ERROR"); + unsetenv("IFC_LOD_RATIO"); + unsetenv("IFC_LOD_MIN_SAVINGS"); + unsetenv("IFC_LOD_DEBUG"); +#endif + } +}; + +// Append one quantized vertex (positions only — normal/color zeroed) to the +// vertex byte buffer. Quantization basis is the mesh's local AABB. +void appendQuantizedVertex(std::vector& bytes, + const float pos[3], + const float aabb_min[3], + const float aabb_max[3]) { + uint16_t qpos[3]; + for (int a = 0; a < 3; ++a) { + float extent = aabb_max[a] - aabb_min[a]; + float t = extent > 0.0f ? (pos[a] - aabb_min[a]) / extent : 0.0f; + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + qpos[a] = static_cast(t * 65535.0f + 0.5f); + } + size_t before = bytes.size(); + bytes.resize(before + INSTANCED_VERTEX_STRIDE_BYTES, 0); + std::memcpy(bytes.data() + before + INSTANCED_VERTEX_POS_OFFSET, qpos, sizeof(qpos)); +} + +// Build a planar NxN grid mesh: (N-1)^2 quads = 2*(N-1)^2 triangles. Returns +// a single-mesh SidecarData with quantized vertex bytes and uint32 indices. +SidecarData makeGridMesh(int N) { + SidecarData sd; + MeshInfo mesh{}; + mesh.local_aabb_min[0] = 0.0f; mesh.local_aabb_min[1] = 0.0f; mesh.local_aabb_min[2] = 0.0f; + mesh.local_aabb_max[0] = 1.0f; mesh.local_aabb_max[1] = 1.0f; mesh.local_aabb_max[2] = 0.0f; + mesh.vbo_byte_offset = 0; + mesh.ebo_byte_offset = 0; + mesh.vertex_count = uint32_t(N * N); + + for (int j = 0; j < N; ++j) { + for (int i = 0; i < N; ++i) { + float pos[3] = { + float(i) / float(N - 1), + float(j) / float(N - 1), + 0.0f + }; + appendQuantizedVertex(sd.vertices, pos, + mesh.local_aabb_min, mesh.local_aabb_max); + } + } + + for (int j = 0; j < N - 1; ++j) { + for (int i = 0; i < N - 1; ++i) { + uint32_t v00 = uint32_t(j * N + i); + uint32_t v10 = v00 + 1; + uint32_t v01 = v00 + uint32_t(N); + uint32_t v11 = v01 + 1; + sd.indices.push_back(v00); sd.indices.push_back(v10); sd.indices.push_back(v11); + sd.indices.push_back(v00); sd.indices.push_back(v11); sd.indices.push_back(v01); + } + } + mesh.index_count = uint32_t(sd.indices.size()); + sd.meshes.push_back(mesh); + return sd; +} + +} // namespace + +TEST_CASE("buildLods skips meshes below min_triangles", "[lod]") { + ScopedEnvIsolate guard; + // 9x9 grid -> 128 triangles. Default min_triangles is 500. + SidecarData sd = makeGridMesh(9); + REQUIRE(sd.meshes[0].index_count / 3 == 128u); + + size_t indices_before = sd.indices.size(); + buildLods(sd); + + REQUIRE(sd.meshes[0].lod1_index_count == 0); + REQUIRE(sd.meshes[0].lod1_ebo_byte_offset == 0); + REQUIRE(sd.indices.size() == indices_before); // nothing appended +} + +TEST_CASE("buildLods produces a valid LOD1 slice for a high-tri mesh", "[lod]") { + ScopedEnvIsolate guard; + // 30x30 grid -> 1682 triangles. Comfortably above min_triangles. + SidecarData sd = makeGridMesh(30); + const uint32_t lod0_indices = sd.meshes[0].index_count; + const size_t indices_before = sd.indices.size(); + REQUIRE(lod0_indices / 3 >= 500u); + + buildLods(sd); + + const auto& m = sd.meshes[0]; + REQUIRE(m.lod1_index_count > 0); + REQUIRE(m.lod1_index_count % 3 == 0); + REQUIRE(m.lod1_index_count < lod0_indices); // actually decimated + REQUIRE(m.lod1_ebo_byte_offset == indices_before * sizeof(uint32_t)); + REQUIRE(sd.indices.size() == indices_before + m.lod1_index_count); + + // LOD1 indices live in the appended slice and must reference real vertices + // within this mesh. + const uint32_t first = m.lod1_ebo_byte_offset / uint32_t(sizeof(uint32_t)); + for (uint32_t k = 0; k < m.lod1_index_count; ++k) { + REQUIRE(sd.indices[first + k] < m.vertex_count); + } +} + +TEST_CASE("buildLods is deterministic for the same input", "[lod]") { + ScopedEnvIsolate guard; + SidecarData a = makeGridMesh(30); + SidecarData b = makeGridMesh(30); + + buildLods(a); + buildLods(b); + + REQUIRE(a.meshes[0].lod1_index_count == b.meshes[0].lod1_index_count); + REQUIRE(a.meshes[0].lod1_ebo_byte_offset == b.meshes[0].lod1_ebo_byte_offset); + REQUIRE(a.indices == b.indices); +} + +TEST_CASE("buildLods is a no-op when sd is empty", "[lod]") { + ScopedEnvIsolate guard; + SidecarData sd; + buildLods(sd); + REQUIRE(sd.meshes.empty()); + REQUIRE(sd.vertices.empty()); + REQUIRE(sd.indices.empty()); +} + +TEST_CASE("summariseLods is consistent before and after buildLods", "[lod]") { + ScopedEnvIsolate guard; + SidecarData sd = makeGridMesh(30); + + LodStats before = summariseLods(sd); + REQUIRE(before.meshes_total == 1); + REQUIRE(before.meshes_with_lod1 == 0); + REQUIRE(before.tris_lod0 == sd.meshes[0].index_count / 3); + REQUIRE(before.tris_lod1 == 0); + REQUIRE(before.tris_lod0_for_lod1 == 0); + + buildLods(sd); + LodStats after = summariseLods(sd); + + REQUIRE(after.meshes_total == before.meshes_total); + REQUIRE(after.tris_lod0 == before.tris_lod0); // LOD0 untouched + REQUIRE(after.meshes_with_lod1 == 1); + REQUIRE(after.tris_lod0_for_lod1 == before.tris_lod0); + REQUIRE(after.tris_lod1 == sd.meshes[0].lod1_index_count / 3); + REQUIRE(after.tris_lod1 < after.tris_lod0_for_lod1); +} diff --git a/src/ifcviewer/tests/test_sidecar_cache.cpp b/src/ifcviewer/tests/test_sidecar_cache.cpp new file mode 100644 index 0000000000..978305db53 --- /dev/null +++ b/src/ifcviewer/tests/test_sidecar_cache.cpp @@ -0,0 +1,238 @@ +/******************************************************************************** + * * + * 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 "InstancedGeometry.h" +#include "SidecarCache.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +// Each test creates its own scratch directory under the OS tmp root so they +// can run in parallel without colliding on file paths. +fs::path makeScratchDir(const char* tag) { + fs::path base = fs::temp_directory_path() / "ifcviewer_test_sidecar"; + fs::create_directories(base); + static std::atomic counter{0}; + auto unique = std::to_string(counter.fetch_add(1)) + "_" + tag; + fs::path dir = base / unique; + fs::create_directories(dir); + return dir; +} + +SidecarData buildFixture() { + SidecarData sd; + + // 4 vertices worth of arbitrary bytes (12 B/vertex). + sd.vertices.resize(4 * INSTANCED_VERTEX_STRIDE_BYTES); + for (size_t i = 0; i < sd.vertices.size(); ++i) sd.vertices[i] = uint8_t(i * 7); + + // Two meshes share the VBO — second mesh starts at vertex 2. + sd.indices = {0, 1, 2, 1, 2, 3}; + + MeshInfo m1{}; + m1.vbo_byte_offset = 0; + m1.vertex_count = 2; + m1.ebo_byte_offset = 0; + m1.index_count = 3; + m1.local_aabb_min[0] = -1; m1.local_aabb_min[1] = -2; m1.local_aabb_min[2] = -3; + m1.local_aabb_max[0] = 4; m1.local_aabb_max[1] = 5; m1.local_aabb_max[2] = 6; + m1.first_instance = 0; + m1.instance_count = 3; + m1.lod1_ebo_byte_offset = 0; + m1.lod1_index_count = 0; + + MeshInfo m2{}; + m2.vbo_byte_offset = 2 * INSTANCED_VERTEX_STRIDE_BYTES; + m2.vertex_count = 2; + m2.ebo_byte_offset = 3 * sizeof(uint32_t); + m2.index_count = 3; + m2.local_aabb_min[0] = 10; m2.local_aabb_min[1] = 11; m2.local_aabb_min[2] = 12; + m2.local_aabb_max[0] = 13; m2.local_aabb_max[1] = 14; m2.local_aabb_max[2] = 15; + m2.first_instance = 3; + m2.instance_count = 2; + m2.lod1_ebo_byte_offset = 0; + m2.lod1_index_count = 0; + + sd.meshes = {m1, m2}; + + sd.instances.resize(5); + for (size_t i = 0; i < sd.instances.size(); ++i) { + InstanceCpu& inst = sd.instances[i]; + inst.mesh_id = (i < 3) ? 0u : 1u; + inst.object_id = uint32_t(100 + i); + inst.color_override_rgba8 = uint32_t(0xAA000000u | (i * 0x010203u)); + inst.model_id = 1; + for (int k = 0; k < 16; ++k) inst.transform[k] = float(i) * 0.5f + float(k); + inst.world_aabb_min[0] = float(i); + inst.world_aabb_min[1] = float(i + 1); + inst.world_aabb_min[2] = float(i + 2); + inst.world_aabb_max[0] = float(i) + 10.0f; + inst.world_aabb_max[1] = float(i + 1) + 10.0f; + inst.world_aabb_max[2] = float(i + 2) + 10.0f; + } + + sd.string_table = std::string("\0Wall\0Slab\0", 11); // includes embedded NULs + sd.elements.resize(3); + for (size_t i = 0; i < sd.elements.size(); ++i) { + PackedElementInfo& e = sd.elements[i]; + e.object_id = uint32_t(100 + i); + e.model_id = 1; + e.ifc_id = int32_t(1000 + i); + e.parent_id = (i == 0) ? -1 : int32_t(100); + e.guid_offset = 0; e.guid_length = 0; + e.name_offset = 1; e.name_length = 4; // "Wall" + e.type_offset = 6; e.type_length = 4; // "Slab" + } + return sd; +} + +bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) { + if (a.vertices != b.vertices) return false; + if (a.indices != b.indices) return false; + if (a.meshes.size() != b.meshes.size()) return false; + if (a.instances.size() != b.instances.size()) return false; + if (a.elements.size() != b.elements.size()) return false; + if (a.string_table != b.string_table) return false; + + for (size_t i = 0; i < a.meshes.size(); ++i) { + if (std::memcmp(&a.meshes[i], &b.meshes[i], sizeof(MeshInfo)) != 0) return false; + } + for (size_t i = 0; i < a.instances.size(); ++i) { + if (std::memcmp(&a.instances[i], &b.instances[i], sizeof(InstanceCpu)) != 0) return false; + } + for (size_t i = 0; i < a.elements.size(); ++i) { + if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(PackedElementInfo)) != 0) return false; + } + return true; +} + +} // namespace + +TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") { + REQUIRE(sizeof(MeshInfo) == 56); + REQUIRE(sizeof(InstanceGpu) == 80); + REQUIRE(SIDECAR_VERSION == 9); + REQUIRE(SIDECAR_MAGIC == 0x49465657u); +} + +TEST_CASE("writeSidecar then readSidecar round-trips the full fixture", "[sidecar]") { + fs::path dir = makeScratchDir("roundtrip"); + fs::path ifc = dir / "model.ifc"; + fs::path expected = dir / "model.ifcview"; + + SidecarData original = buildFixture(); + REQUIRE(writeSidecar(ifc.string(), original)); + REQUIRE(fs::exists(expected)); + + auto loaded = readSidecar(ifc.string()); + REQUIRE(loaded.has_value()); + REQUIRE(sidecarDataEqual(original, *loaded)); +} + +TEST_CASE("readSidecar returns nullopt when the sidecar is missing", "[sidecar]") { + fs::path dir = makeScratchDir("missing"); + fs::path ifc = dir / "absent.ifc"; + auto loaded = readSidecar(ifc.string()); + REQUIRE_FALSE(loaded.has_value()); +} + +TEST_CASE("readSidecar rejects a truncated header", "[sidecar]") { + fs::path dir = makeScratchDir("truncated"); + fs::path ifc = dir / "bad.ifc"; + fs::path bad = dir / "bad.ifcview"; + { + FILE* f = std::fopen(bad.string().c_str(), "wb"); + REQUIRE(f); + const char junk[] = "X"; + std::fwrite(junk, 1, sizeof(junk), f); + std::fclose(f); + } + auto loaded = readSidecar(ifc.string()); + REQUIRE_FALSE(loaded.has_value()); +} + +TEST_CASE("readSidecar rejects a wrong magic / version", "[sidecar]") { + fs::path dir = makeScratchDir("wrongver"); + fs::path ifc = dir / "old.ifc"; + fs::path old = dir / "old.ifcview"; + struct Hdr { uint32_t magic, version, endian; } h{ + SIDECAR_MAGIC, SIDECAR_VERSION - 1, SIDECAR_ENDIAN + }; + { + FILE* f = std::fopen(old.string().c_str(), "wb"); + REQUIRE(f); + std::fwrite(&h, sizeof(h), 1, f); + // Write zeroed payload so the failure must come from the header check. + uint32_t zero = 0; + for (int i = 0; i < 6; ++i) std::fwrite(&zero, 4, 1, f); + std::fclose(f); + } + auto loaded = readSidecar(ifc.string()); + REQUIRE_FALSE(loaded.has_value()); +} + +TEST_CASE("Empty SidecarData round-trips cleanly", "[sidecar]") { + fs::path dir = makeScratchDir("empty"); + fs::path ifc = dir / "empty.ifc"; + SidecarData empty; + REQUIRE(writeSidecar(ifc.string(), empty)); + auto loaded = readSidecar(ifc.string()); + REQUIRE(loaded.has_value()); + REQUIRE(loaded->vertices.empty()); + REQUIRE(loaded->indices.empty()); + REQUIRE(loaded->meshes.empty()); + REQUIRE(loaded->instances.empty()); + REQUIRE(loaded->elements.empty()); + REQUIRE(loaded->string_table.empty()); +} + +TEST_CASE("Sidecar path stem maps .ifc / .ifcdb / extensionless to .ifcview", "[sidecar]") { + // The mapping is internal but observable: writing under one source name + // must be readable under any other name that maps to the same stem. + fs::path dir = makeScratchDir("stems"); + SidecarData sd = buildFixture(); + + fs::path ifc_path = dir / "shared.ifc"; + fs::path ifcdb_path = dir / "shared.ifcdb"; + fs::path ifcdb_slash = dir / "shared.ifcdb/"; + fs::path noext_path = dir / "shared"; + + REQUIRE(writeSidecar(ifc_path.string(), sd)); + REQUIRE(fs::exists(dir / "shared.ifcview")); + + auto a = readSidecar(ifcdb_path.string()); + auto b = readSidecar(ifcdb_slash.string()); + auto c = readSidecar(noext_path.string()); + REQUIRE(a.has_value()); + REQUIRE(b.has_value()); + REQUIRE(c.has_value()); + REQUIRE(sidecarDataEqual(sd, *a)); + REQUIRE(sidecarDataEqual(sd, *b)); + REQUIRE(sidecarDataEqual(sd, *c)); +} From b73cdd1a0e15a9e163dc5a34ef5838bca744b505 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 29 Apr 2026 08:54:35 +1000 Subject: [PATCH 070/120] ifcviewer: filter iterator to net IfcElements, void-limit setting Mirror bonsai's IfcImporter.process_element_filter so the streamer walks only IfcElement (plus IfcProxy on IFC2X3/IFC4), drops IfcFeatureElement except IfcSurfaceFeature, and routes elements with more openings than the configurable void limit through a second iterator pass with disable-opening-subtractions=true. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/SettingsWindow.cpp | 11 ++ src/ifcviewer-full/SettingsWindow.h | 2 + src/ifcviewer/AppSettings.cpp | 17 ++ src/ifcviewer/AppSettings.h | 8 + src/ifcviewer/GeometryStreamer.cpp | 262 +++++++++++++++++--------- 5 files changed, 211 insertions(+), 89 deletions(-) diff --git a/src/ifcviewer-full/SettingsWindow.cpp b/src/ifcviewer-full/SettingsWindow.cpp index 1f31ceacf7..b5b22279fd 100644 --- a/src/ifcviewer-full/SettingsWindow.cpp +++ b/src/ifcviewer-full/SettingsWindow.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include SettingsWindow::SettingsWindow(QWidget *parent) @@ -57,6 +58,14 @@ void SettingsWindow::setupUi() { "and, on sidecar hits, avoids a second file read."); form->addRow("Load Property Data Source", load_data_source_check_); + void_limit_spin_ = new QSpinBox(this); + void_limit_spin_->setRange(0, 100000); + void_limit_spin_->setToolTip( + "Skip elements with more openings (HasOpenings) than this. " + "A handful of pathological elements can dominate boolean-subtraction " + "time; dropping them keeps load times sane."); + form->addRow("Void Limit", void_limit_spin_); + auto* button_box = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); @@ -80,6 +89,7 @@ void SettingsWindow::syncFromSettings() { show_stats_check_->setChecked(AppSettings::instance().showStats()); backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling()); load_data_source_check_->setChecked(AppSettings::instance().loadDataSource()); + void_limit_spin_->setValue(AppSettings::instance().voidLimit()); } void SettingsWindow::onAccepted() { @@ -87,5 +97,6 @@ void SettingsWindow::onAccepted() { AppSettings::instance().setShowStats(show_stats_check_->isChecked()); AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked()); AppSettings::instance().setLoadDataSource(load_data_source_check_->isChecked()); + AppSettings::instance().setVoidLimit(void_limit_spin_->value()); accept(); } diff --git a/src/ifcviewer-full/SettingsWindow.h b/src/ifcviewer-full/SettingsWindow.h index d7399c1e7c..70c4442fc2 100644 --- a/src/ifcviewer-full/SettingsWindow.h +++ b/src/ifcviewer-full/SettingsWindow.h @@ -25,6 +25,7 @@ class QCheckBox; class QLineEdit; class QShowEvent; +class QSpinBox; class SettingsWindow : public QDialog { Q_OBJECT @@ -45,6 +46,7 @@ private: QCheckBox* show_stats_check_ = nullptr; QCheckBox* backface_culling_check_ = nullptr; QCheckBox* load_data_source_check_ = nullptr; + QSpinBox* void_limit_spin_ = nullptr; }; #endif diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index 04f56f9567..58dbfe33be 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -27,6 +27,8 @@ constexpr const char* kGeometryLibraryDefault = "hybrid-cgal-simple-opencascade" constexpr const char* kShowStatsKey = "viewport/show_stats"; constexpr const char* kBackfaceCullingKey = "viewport/backface_culling"; constexpr const char* kLoadDataSourceKey = "loading/load_data_source"; +constexpr const char* kVoidLimitKey = "loading/void_limit"; +constexpr int kVoidLimitDefault = 30; } AppSettings& AppSettings::instance() { @@ -82,12 +84,26 @@ void AppSettings::setLoadDataSource(bool value) { emit loadDataSourceChanged(value); } +int AppSettings::voidLimit() const { + return void_limit_; +} + +void AppSettings::setVoidLimit(int value) { + if (value < 0) value = 0; + if (void_limit_ == value) return; + void_limit_ = value; + persist(); + emit voidLimitChanged(value); +} + void AppSettings::load() { QSettings settings; geometry_library_ = settings.value(kGeometryLibraryKey, kGeometryLibraryDefault).toString(); show_stats_ = settings.value(kShowStatsKey, false).toBool(); backface_culling_ = settings.value(kBackfaceCullingKey, true).toBool(); load_data_source_ = settings.value(kLoadDataSourceKey, true).toBool(); + void_limit_ = settings.value(kVoidLimitKey, kVoidLimitDefault).toInt(); + if (void_limit_ < 0) void_limit_ = 0; } void AppSettings::persist() { @@ -96,4 +112,5 @@ void AppSettings::persist() { settings.setValue(kShowStatsKey, show_stats_); settings.setValue(kBackfaceCullingKey, backface_culling_); settings.setValue(kLoadDataSourceKey, load_data_source_); + settings.setValue(kVoidLimitKey, void_limit_); } diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index 9b909bc48c..5e796280aa 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -47,11 +47,18 @@ public: bool loadDataSource() const; void setLoadDataSource(bool value); + // Skip elements with more than this many voids (HasOpenings inverse). + // Boolean subtraction of many openings is the dominant cost in some + // pathological exports; dropping those elements keeps load times sane. + int voidLimit() const; + void setVoidLimit(int value); + signals: void geometryLibraryChanged(const QString& value); void showStatsChanged(bool value); void backfaceCullingChanged(bool value); void loadDataSourceChanged(bool value); + void voidLimitChanged(int value); private: AppSettings(); @@ -62,6 +69,7 @@ private: bool show_stats_ = false; bool backface_culling_ = true; bool load_data_source_ = true; + int void_limit_ = 30; }; #endif // APPSETTINGS_H diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 668bb1c7ce..59a5618aa7 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -21,6 +21,8 @@ #include "AppSettings.h" #include "../ifcgeom/hybrid_kernel.h" #include "../ifcgeom/taxonomy.h" +#include "../ifcgeom/IfcGeomFilter.h" +#include "../ifcparse/express.h" #include @@ -30,6 +32,7 @@ #include #include #include +#include #include #include @@ -294,30 +297,59 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { const bool is_rocksdb = std::holds_alternative(ifc_file_->storage_); const int effective_threads = is_rocksdb ? 1 : num_threads; - 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(), effective_threads); - } catch (const std::exception& e) { - emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what())); - return; + // Mirror bonsai's IfcImporter.process_element_filter: walk IfcElement + // (plus IfcProxy on IFC2X3/IFC4), drop IfcFeatureElement except + // IfcSurfaceFeature, and split elements with more openings than the + // configured void limit into a "gross" set that is rendered without + // opening subtractions. Both sets become include filters so we don't + // waste time mapping openings, spaces, grids, etc. + std::set net_ids; + std::set gross_ids; + { + const std::string& schema_name = ifc_file_->schema()->name(); + std::vector elements = + ifc_file_->instances_by_type("IfcElement"); + if (schema_name == "IFC2X3" || schema_name == "IFC4") { + auto proxies = ifc_file_->instances_by_type("IfcProxy"); + elements.insert(elements.end(), proxies.begin(), proxies.end()); + } + + const int void_limit = AppSettings::instance().voidLimit(); + for (const auto& e : elements) { + const auto& decl = e.declaration(); + if (decl.is("IfcFeatureElement") && !decl.is("IfcSurfaceFeature")) { + continue; + } + int opening_count = 0; + if (decl.is("IfcElement")) { + try { + opening_count = static_cast( + e.as().get_inverse("HasOpenings").size()); + } catch (...) { + // HasOpenings not declared on this entity — treat as 0. + } + } + if (opening_count > void_limit) { + gross_ids.insert(e.id()); + } else { + net_ids.insert(e.id()); + } + } } - if (!iterator->initialize()) { - emit errorOccurred("No geometry found in IFC file"); + if (net_ids.empty() && gross_ids.empty()) { + emit errorOccurred("No geometry-bearing elements found in IFC file"); return; } + if (!gross_ids.empty()) { + qDebug("Excessive voids: %zu element(s) will be loaded without " + "opening subtractions", + gross_ids.size()); + } - int last_progress = 0; - - // geom.id() → local_mesh_id within this model. + // Shared dedup + AABB state across passes — same geom.id() across + // net/gross passes still maps to one mesh upload. std::unordered_map geom_to_local_mesh_id; - // local_mesh_id → (local AABB) so we can derive world AABBs for later instances. struct MeshAabb { float lmin[3], lmax[3]; }; std::vector mesh_aabbs; @@ -326,92 +358,144 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { QElapsedTimer stream_timer; stream_timer.start(); - do { - if (cancel_requested_.load()) break; + // Split the 0–100 progress range proportionally to element counts so + // the bar advances roughly with wall time across both passes. + const size_t total_count = net_ids.size() + gross_ids.size(); + const int net_progress_end = total_count == 0 + ? 100 + : static_cast(100.0 * net_ids.size() / total_count + 0.5); - const IfcGeom::Element* elem = iterator->get(); - if (!elem) continue; + auto run_pass = [&](const std::set& include_ids, + bool is_gross, + int progress_lo, + int progress_hi) -> bool { + if (include_ids.empty()) return true; - const auto* tri_elem = dynamic_cast(elem); - if (!tri_elem) continue; - - const auto& geom = tri_elem->geometry(); - if (geom.verts().empty() || geom.faces().empty()) continue; - - uint32_t object_id = next_object_id_++; - - // 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(); - 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)); + ifcopenshell::geometry::Settings pass_settings = settings; + if (is_gross) { + pass_settings.set("disable-opening-subtractions", true); } - // Representation dedup. - const std::string& geom_id = geom.id(); - uint32_t local_mesh_id; - bool first_sight = false; - if (geom_id.empty()) { - // No representation key — treat as unique. - local_mesh_id = total_meshes++; - first_sight = true; - } else { - auto it = geom_to_local_mesh_id.find(geom_id); - if (it == geom_to_local_mesh_id.end()) { + std::vector filters; + IfcGeom::instance_id_filter idf{ + /*include=*/true, /*traverse=*/false, include_ids}; + filters.push_back(idf); + + 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, pass_settings); + iterator = std::make_unique( + std::move(kernel), pass_settings, ifc_file_.get(), + filters, effective_threads); + } catch (const std::exception& e) { + emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what())); + return false; + } + + if (!iterator->initialize()) { + // Empty pass — no geometry survived for these ids. Still + // advance progress to the upper bound so the bar doesn't stall. + progress_ = progress_hi; + emit progressChanged(progress_hi); + return true; + } + + int last_progress = progress_lo; + + 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; + + const auto& geom = tri_elem->geometry(); + if (geom.verts().empty() || geom.faces().empty()) continue; + + uint32_t object_id = next_object_id_++; + + 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(); + 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)); + } + + const std::string& geom_id = geom.id(); + uint32_t local_mesh_id; + bool first_sight = false; + if (geom_id.empty()) { local_mesh_id = total_meshes++; - geom_to_local_mesh_id.emplace(geom_id, local_mesh_id); first_sight = true; } else { - local_mesh_id = it->second; + auto it = geom_to_local_mesh_id.find(geom_id); + if (it == geom_to_local_mesh_id.end()) { + local_mesh_id = total_meshes++; + geom_to_local_mesh_id.emplace(geom_id, local_mesh_id); + first_sight = true; + } else { + local_mesh_id = it->second; + } } - } - if (first_sight) { - MeshChunk mesh_chunk = buildMeshChunk(model_id_, local_mesh_id, tri_elem); - MeshAabb ma; - for (int a = 0; a < 3; ++a) { - ma.lmin[a] = mesh_chunk.local_aabb_min[a]; - ma.lmax[a] = mesh_chunk.local_aabb_max[a]; + if (first_sight) { + MeshChunk mesh_chunk = buildMeshChunk(model_id_, local_mesh_id, tri_elem); + MeshAabb ma; + for (int a = 0; a < 3; ++a) { + ma.lmin[a] = mesh_chunk.local_aabb_min[a]; + ma.lmax[a] = mesh_chunk.local_aabb_max[a]; + } + if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1); + mesh_aabbs[local_mesh_id] = ma; + if (!mesh_chunk.indices.empty()) { + emit meshReady(std::move(mesh_chunk)); + } } - if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1); - mesh_aabbs[local_mesh_id] = ma; - if (!mesh_chunk.indices.empty()) { - emit meshReady(std::move(mesh_chunk)); + + const Eigen::Matrix4d& mat_d = tri_elem->transformation().data()->ccomponents(); + InstanceChunk inst; + inst.model_id = model_id_; + inst.local_mesh_id = local_mesh_id; + inst.object_id = object_id; + inst.color_override_rgba8 = 0; + for (int i = 0; i < 16; ++i) { + inst.transform[i] = static_cast(mat_d.data()[i]); } - } - // Transform (column-major 4x4, cast to float). - const Eigen::Matrix4d& mat_d = tri_elem->transformation().data()->ccomponents(); - InstanceChunk inst; - inst.model_id = model_id_; - inst.local_mesh_id = local_mesh_id; - inst.object_id = object_id; - inst.color_override_rgba8 = 0; // 0 = use baked vertex color - for (int i = 0; i < 16; ++i) { - inst.transform[i] = static_cast(mat_d.data()[i]); - } + const MeshAabb& ma = mesh_aabbs[local_mesh_id]; + worldAabbFromLocal(ma.lmin, ma.lmax, inst.transform, + inst.world_aabb_min, inst.world_aabb_max); - const MeshAabb& ma = mesh_aabbs[local_mesh_id]; - worldAabbFromLocal(ma.lmin, ma.lmax, inst.transform, - inst.world_aabb_min, inst.world_aabb_max); + emit instanceReady(std::move(inst)); + total_shapes++; - emit instanceReady(std::move(inst)); - total_shapes++; + const int p = progress_lo + + (iterator->progress() * (progress_hi - progress_lo)) / 100; + if (p != last_progress) { + last_progress = p; + progress_ = p; + emit progressChanged(p); + } + } while (iterator->next()); - int p = iterator->progress(); - if (p != last_progress) { - last_progress = p; - progress_ = p; - emit progressChanged(p); - } - } while (iterator->next()); + return true; + }; + + if (!run_pass(net_ids, /*is_gross=*/false, 0, net_progress_end)) return; + if (!cancel_requested_.load()) { + run_pass(gross_ids, /*is_gross=*/true, net_progress_end, 100); + } progress_ = 100; emit progressChanged(100); From 3607fbb762750150a7dcfa9cf70942b1dac55574 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 29 Apr 2026 22:20:20 +1000 Subject: [PATCH 071/120] ifcviewer: include spatial elements in iterator filter Match bonsai's process_element_filter for the no-filter branch: IfcSpatialStructureElement on IFC2X3, IfcSpatialElement otherwise. They flow through the same net/gross split as IfcElement. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/GeometryStreamer.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 59a5618aa7..8de928164c 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -299,10 +299,10 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { // Mirror bonsai's IfcImporter.process_element_filter: walk IfcElement // (plus IfcProxy on IFC2X3/IFC4), drop IfcFeatureElement except - // IfcSurfaceFeature, and split elements with more openings than the - // configured void limit into a "gross" set that is rendered without - // opening subtractions. Both sets become include filters so we don't - // waste time mapping openings, spaces, grids, etc. + // IfcSurfaceFeature, pick up spatial elements, and split elements + // with more openings than the configured void limit into a "gross" + // set that is rendered without opening subtractions. Both sets + // become include filters so we don't waste time mapping openings. std::set net_ids; std::set gross_ids; { @@ -313,6 +313,11 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { auto proxies = ifc_file_->instances_by_type("IfcProxy"); elements.insert(elements.end(), proxies.begin(), proxies.end()); } + const char* spatial_root = (schema_name == "IFC2X3") + ? "IfcSpatialStructureElement" + : "IfcSpatialElement"; + auto spatials = ifc_file_->instances_by_type(spatial_root); + elements.insert(elements.end(), spatials.begin(), spatials.end()); const int void_limit = AppSettings::instance().voidLimit(); for (const auto& e : elements) { From 30cdffc27aeb5181d848360482fcde9f6489b394 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 29 Apr 2026 22:37:55 +1000 Subject: [PATCH 072/120] ifcviewer: include settings for deflection tolerances --- src/ifcviewer-full/SettingsWindow.cpp | 24 +++++++++++++++++++ src/ifcviewer-full/SettingsWindow.h | 3 +++ src/ifcviewer/AppSettings.cpp | 34 +++++++++++++++++++++++++++ src/ifcviewer/AppSettings.h | 15 ++++++++++++ src/ifcviewer/GeometryStreamer.cpp | 7 +++++- 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer-full/SettingsWindow.cpp b/src/ifcviewer-full/SettingsWindow.cpp index b5b22279fd..8fd867b3ae 100644 --- a/src/ifcviewer-full/SettingsWindow.cpp +++ b/src/ifcviewer-full/SettingsWindow.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -66,6 +67,25 @@ void SettingsWindow::setupUi() { "time; dropping them keeps load times sane."); form->addRow("Void Limit", void_limit_spin_); + deflection_tolerance_spin_ = new QDoubleSpinBox(this); + deflection_tolerance_spin_->setRange(0.000001, 1000.0); + deflection_tolerance_spin_->setDecimals(6); + deflection_tolerance_spin_->setSingleStep(0.001); + deflection_tolerance_spin_->setToolTip( + "Linear chord error between curved geometry and its triangulation, " + "in model length units. Smaller = smoother curves but more " + "triangles and slower load."); + form->addRow("Deflection Tolerance", deflection_tolerance_spin_); + + angular_tolerance_spin_ = new QDoubleSpinBox(this); + angular_tolerance_spin_->setRange(0.000001, 3.141592); + angular_tolerance_spin_->setDecimals(6); + angular_tolerance_spin_->setSingleStep(0.05); + angular_tolerance_spin_->setToolTip( + "Maximum angle (radians) between adjacent facet normals on a " + "curved surface. Smaller = smoother shading but more triangles."); + form->addRow("Angular Tolerance", angular_tolerance_spin_); + auto* button_box = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); @@ -90,6 +110,8 @@ void SettingsWindow::syncFromSettings() { backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling()); load_data_source_check_->setChecked(AppSettings::instance().loadDataSource()); void_limit_spin_->setValue(AppSettings::instance().voidLimit()); + deflection_tolerance_spin_->setValue(AppSettings::instance().deflectionTolerance()); + angular_tolerance_spin_->setValue(AppSettings::instance().angularTolerance()); } void SettingsWindow::onAccepted() { @@ -98,5 +120,7 @@ void SettingsWindow::onAccepted() { AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked()); AppSettings::instance().setLoadDataSource(load_data_source_check_->isChecked()); AppSettings::instance().setVoidLimit(void_limit_spin_->value()); + AppSettings::instance().setDeflectionTolerance(deflection_tolerance_spin_->value()); + AppSettings::instance().setAngularTolerance(angular_tolerance_spin_->value()); accept(); } diff --git a/src/ifcviewer-full/SettingsWindow.h b/src/ifcviewer-full/SettingsWindow.h index 70c4442fc2..e9a996e94d 100644 --- a/src/ifcviewer-full/SettingsWindow.h +++ b/src/ifcviewer-full/SettingsWindow.h @@ -23,6 +23,7 @@ #include class QCheckBox; +class QDoubleSpinBox; class QLineEdit; class QShowEvent; class QSpinBox; @@ -47,6 +48,8 @@ private: QCheckBox* backface_culling_check_ = nullptr; QCheckBox* load_data_source_check_ = nullptr; QSpinBox* void_limit_spin_ = nullptr; + QDoubleSpinBox* deflection_tolerance_spin_ = nullptr; + QDoubleSpinBox* angular_tolerance_spin_ = nullptr; }; #endif diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index 58dbfe33be..3f7dfea9e0 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -29,6 +29,10 @@ constexpr const char* kBackfaceCullingKey = "viewport/backface_culling"; constexpr const char* kLoadDataSourceKey = "loading/load_data_source"; constexpr const char* kVoidLimitKey = "loading/void_limit"; constexpr int kVoidLimitDefault = 30; +constexpr const char* kDeflectionToleranceKey = "loading/deflection_tolerance"; +constexpr double kDeflectionToleranceDefault = 0.001; +constexpr const char* kAngularToleranceKey = "loading/angular_tolerance"; +constexpr double kAngularToleranceDefault = 0.5; } AppSettings& AppSettings::instance() { @@ -96,6 +100,30 @@ void AppSettings::setVoidLimit(int value) { emit voidLimitChanged(value); } +double AppSettings::deflectionTolerance() const { + return deflection_tolerance_; +} + +void AppSettings::setDeflectionTolerance(double value) { + if (value <= 0.0) value = kDeflectionToleranceDefault; + if (deflection_tolerance_ == value) return; + deflection_tolerance_ = value; + persist(); + emit deflectionToleranceChanged(value); +} + +double AppSettings::angularTolerance() const { + return angular_tolerance_; +} + +void AppSettings::setAngularTolerance(double value) { + if (value <= 0.0) value = kAngularToleranceDefault; + if (angular_tolerance_ == value) return; + angular_tolerance_ = value; + persist(); + emit angularToleranceChanged(value); +} + void AppSettings::load() { QSettings settings; geometry_library_ = settings.value(kGeometryLibraryKey, kGeometryLibraryDefault).toString(); @@ -104,6 +132,10 @@ void AppSettings::load() { load_data_source_ = settings.value(kLoadDataSourceKey, true).toBool(); void_limit_ = settings.value(kVoidLimitKey, kVoidLimitDefault).toInt(); if (void_limit_ < 0) void_limit_ = 0; + deflection_tolerance_ = settings.value(kDeflectionToleranceKey, kDeflectionToleranceDefault).toDouble(); + if (deflection_tolerance_ <= 0.0) deflection_tolerance_ = kDeflectionToleranceDefault; + angular_tolerance_ = settings.value(kAngularToleranceKey, kAngularToleranceDefault).toDouble(); + if (angular_tolerance_ <= 0.0) angular_tolerance_ = kAngularToleranceDefault; } void AppSettings::persist() { @@ -113,4 +145,6 @@ void AppSettings::persist() { settings.setValue(kBackfaceCullingKey, backface_culling_); settings.setValue(kLoadDataSourceKey, load_data_source_); settings.setValue(kVoidLimitKey, void_limit_); + settings.setValue(kDeflectionToleranceKey, deflection_tolerance_); + settings.setValue(kAngularToleranceKey, angular_tolerance_); } diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index 5e796280aa..d435158f70 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -53,12 +53,25 @@ public: int voidLimit() const; void setVoidLimit(int value); + // Mesher tolerances passed straight to the IfcOpenShell iterator. + // Linear deflection bounds the chord error between a curve and its + // triangulation, in model length units; angular deflection bounds the + // angle (radians) between adjacent facet normals on a curved surface. + // Smaller values mean smoother geometry at the cost of more triangles + // and slower iteration. + double deflectionTolerance() const; + void setDeflectionTolerance(double value); + double angularTolerance() const; + void setAngularTolerance(double value); + signals: void geometryLibraryChanged(const QString& value); void showStatsChanged(bool value); void backfaceCullingChanged(bool value); void loadDataSourceChanged(bool value); void voidLimitChanged(int value); + void deflectionToleranceChanged(double value); + void angularToleranceChanged(double value); private: AppSettings(); @@ -70,6 +83,8 @@ private: bool backface_culling_ = true; bool load_data_source_ = true; int void_limit_ = 30; + double deflection_tolerance_ = 0.001; + double angular_tolerance_ = 0.5; }; #endif // APPSETTINGS_H diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 8de928164c..786bce22a1 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -284,12 +284,17 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { // applied on the GPU per instance. settings.set("use-world-coords", false); settings.set("weld-vertices", false); - settings.set("apply-default-materials", true); + settings.set("apply-default-materials", false); // Off by default in IfcOpenShell — makes face winding consistent within // each shell, which we need for GL_CULL_FACE and for per-vertex normals // to shade a solid without dark inside-out patches. Costs some iterator // time, but results are cached in the sidecar so it's a one-shot hit. settings.set("reorient-shells", true); + settings.set("layerset-first", true); + settings.set("mesher-linear-deflection", AppSettings::instance().deflectionTolerance()); + settings.set("mesher-angular-deflection", AppSettings::instance().angularTolerance()); + // Wire intersection checks is prohibitively slow on advanced breps. See bug #5999. + settings.set("no-wire-intersection-check", true); // @todo parallel mapping on RocksDB-backed files still races somewhere // outside the instance cache, producing inconsistent shape counts. Force From b9e5739088c9c1dc4c01fd06dd20c0f521f10e11 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 29 Apr 2026 23:00:33 +1000 Subject: [PATCH 073/120] ifcviewer: stream geometry per prioritised context Port get_prioritised_contexts from ifcopenshell.util.representation to C++ and have GeometryStreamer iterate one context at a time, mirroring bonsai's create_generic_element loop. Each pass sets context-ids to a single context id; elements that yield geometry are dropped from the include set so lower-priority contexts only pick up leftovers. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/GeometryStreamer.cpp | 342 ++++++++++++++++++++--------- 1 file changed, 244 insertions(+), 98 deletions(-) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 786bce22a1..f76521db68 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -246,6 +246,104 @@ static MeshChunk buildMeshChunk(uint32_t model_id, return chunk; } +// Port of ifcopenshell.util.representation.get_prioritised_contexts: rank every +// IfcGeometricRepresentationContext (and SubContext) by (ContextType, +// ContextIdentifier, TargetView, TargetScale) — tuple comparison, descending — +// and return the resulting context ids high-priority first. Used to drive a +// pass-per-context iteration in the streamer (mirrors bonsai's +// create_generic_element loop), so each element is rendered from its +// preferred representation if available, falling back to lower-priority +// contexts only when the preferred one is missing. +static std::vector prioritisedContextIds(ifcopenshell::file* ifc_file) { + static const std::vector type_order = { + // "Annotation" accommodates broken Revit files that put 3D bodies + // under a context typed Annotation. See revit-ifc#187. + "Model", "Plan", "Annotation", + }; + static const std::vector identifier_order = { + "Body", "Body-FallBack", "Facetation", "FootPrint", "Profile", + "Surface", "Reference", "Axis", "Clearance", "Box", "Lighting", + "Annotation", "CoG", + }; + static const std::vector target_view_order = { + "MODEL_VIEW", "PLAN_VIEW", "REFLECTED_PLAN_VIEW", "ELEVATION_VIEW", + "SECTION_VIEW", "GRAPH_VIEW", "SKETCH_VIEW", "USERDEFINED", + "NOTDEFINED", + }; + + auto rank = [](const std::vector& order, + const std::string& value) -> int { + if (value.empty()) return 0; + auto it = std::find(order.begin(), order.end(), value); + if (it == order.end()) return 0; + return static_cast(order.size() - (it - order.begin())); + }; + + struct ContextInfo { + int id; + int type_priority; + int identifier_priority; + int target_view_priority; + double target_scale; + }; + + std::vector infos; + auto contexts = + ifc_file->instances_by_type("IfcGeometricRepresentationContext"); + infos.reserve(contexts.size()); + + for (const auto& ctx : contexts) { + ContextInfo info{}; + info.id = ctx.id(); + + const auto entity = ctx.as(); + const std::string ctype = + entity.get_value("ContextType", ""); + const std::string cident = + entity.get_value("ContextIdentifier", ""); + info.type_priority = rank(type_order, ctype); + info.identifier_priority = rank(identifier_order, cident); + + // TargetView and TargetScale only exist on + // IfcGeometricRepresentationSubContext; get() throws on the parent + // type, so gate by declaration before reading. + if (ctx.declaration().is("IfcGeometricRepresentationSubContext")) { + try { + auto tv = entity.get("TargetView"); + if (!tv.isNull()) { + enumeration_reference er = tv; + info.target_view_priority = + rank(target_view_order, er.value()); + } + } catch (...) {} + try { + auto ts = entity.get("TargetScale"); + if (!ts.isNull()) { + info.target_scale = static_cast(ts); + } + } catch (...) {} + } + + infos.push_back(info); + } + + std::sort(infos.begin(), infos.end(), + [](const ContextInfo& a, const ContextInfo& b) { + if (a.type_priority != b.type_priority) + return a.type_priority > b.type_priority; + if (a.identifier_priority != b.identifier_priority) + return a.identifier_priority > b.identifier_priority; + if (a.target_view_priority != b.target_view_priority) + return a.target_view_priority > b.target_view_priority; + return a.target_scale > b.target_scale; + }); + + std::vector result; + result.reserve(infos.size()); + for (const auto& i : infos) result.push_back(i.id); + return result; +} + // Compute the world-space AABB by transforming the 8 corners of the local // AABB through the column-major 4x4 transform. static void worldAabbFromLocal(const float local_min[3], @@ -375,130 +473,178 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { ? 100 : static_cast(100.0 * net_ids.size() / total_count + 0.5); + // High-priority context first, so each element gets its preferred + // representation; lower-priority contexts only pick up elements the + // earlier passes didn't yield geometry for. Mirrors bonsai's + // create_generic_element loop over context_settings. + const std::vector prioritised_contexts = + prioritisedContextIds(ifc_file_.get()); + auto run_pass = [&](const std::set& include_ids, bool is_gross, int progress_lo, int progress_hi) -> bool { if (include_ids.empty()) return true; - ifcopenshell::geometry::Settings pass_settings = settings; + ifcopenshell::geometry::Settings base_settings = settings; if (is_gross) { - pass_settings.set("disable-opening-subtractions", true); + base_settings.set("disable-opening-subtractions", true); } - std::vector filters; - IfcGeom::instance_id_filter idf{ - /*include=*/true, /*traverse=*/false, include_ids}; - filters.push_back(idf); + // Elements that haven't yet produced geometry from any context. + std::set remaining = include_ids; - 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, pass_settings); - iterator = std::make_unique( - std::move(kernel), pass_settings, ifc_file_.get(), - filters, effective_threads); - } catch (const std::exception& e) { - emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what())); - return false; - } + auto run_iterator = [&](ifcopenshell::geometry::Settings& iter_settings, + int sub_lo, int sub_hi) -> bool { + if (remaining.empty()) return true; - if (!iterator->initialize()) { - // Empty pass — no geometry survived for these ids. Still - // advance progress to the upper bound so the bar doesn't stall. - progress_ = progress_hi; - emit progressChanged(progress_hi); - return true; - } + std::vector filters; + IfcGeom::instance_id_filter idf{ + /*include=*/true, /*traverse=*/false, remaining}; + filters.push_back(idf); - int last_progress = progress_lo; - - 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; - - const auto& geom = tri_elem->geometry(); - if (geom.verts().empty() || geom.faces().empty()) continue; - - uint32_t object_id = next_object_id_++; - - 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(); - 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)); + 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, iter_settings); + iterator = std::make_unique( + std::move(kernel), iter_settings, ifc_file_.get(), + filters, effective_threads); + } catch (const std::exception& e) { + emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what())); + return false; } - const std::string& geom_id = geom.id(); - uint32_t local_mesh_id; - bool first_sight = false; - if (geom_id.empty()) { - local_mesh_id = total_meshes++; - first_sight = true; - } else { - auto it = geom_to_local_mesh_id.find(geom_id); - if (it == geom_to_local_mesh_id.end()) { + if (!iterator->initialize()) { + // No geometry survived this context for the remaining ids. + // Still advance progress so the bar doesn't stall. + progress_ = sub_hi; + emit progressChanged(sub_hi); + return true; + } + + int last_progress = sub_lo; + + 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; + + const auto& geom = tri_elem->geometry(); + if (geom.verts().empty() || geom.faces().empty()) continue; + + // Once an element yields geometry from this context, drop it + // from the remaining set so lower-priority contexts don't + // re-render it. + remaining.erase(tri_elem->id()); + + uint32_t object_id = next_object_id_++; + + 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(); + 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)); + } + + const std::string& geom_id = geom.id(); + uint32_t local_mesh_id; + bool first_sight = false; + if (geom_id.empty()) { local_mesh_id = total_meshes++; - geom_to_local_mesh_id.emplace(geom_id, local_mesh_id); first_sight = true; } else { - local_mesh_id = it->second; + auto it = geom_to_local_mesh_id.find(geom_id); + if (it == geom_to_local_mesh_id.end()) { + local_mesh_id = total_meshes++; + geom_to_local_mesh_id.emplace(geom_id, local_mesh_id); + first_sight = true; + } else { + local_mesh_id = it->second; + } } - } - if (first_sight) { - MeshChunk mesh_chunk = buildMeshChunk(model_id_, local_mesh_id, tri_elem); - MeshAabb ma; - for (int a = 0; a < 3; ++a) { - ma.lmin[a] = mesh_chunk.local_aabb_min[a]; - ma.lmax[a] = mesh_chunk.local_aabb_max[a]; + if (first_sight) { + MeshChunk mesh_chunk = buildMeshChunk(model_id_, local_mesh_id, tri_elem); + MeshAabb ma; + for (int a = 0; a < 3; ++a) { + ma.lmin[a] = mesh_chunk.local_aabb_min[a]; + ma.lmax[a] = mesh_chunk.local_aabb_max[a]; + } + if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1); + mesh_aabbs[local_mesh_id] = ma; + if (!mesh_chunk.indices.empty()) { + emit meshReady(std::move(mesh_chunk)); + } } - if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1); - mesh_aabbs[local_mesh_id] = ma; - if (!mesh_chunk.indices.empty()) { - emit meshReady(std::move(mesh_chunk)); + + const Eigen::Matrix4d& mat_d = tri_elem->transformation().data()->ccomponents(); + InstanceChunk inst; + inst.model_id = model_id_; + inst.local_mesh_id = local_mesh_id; + inst.object_id = object_id; + inst.color_override_rgba8 = 0; + for (int i = 0; i < 16; ++i) { + inst.transform[i] = static_cast(mat_d.data()[i]); } - } - const Eigen::Matrix4d& mat_d = tri_elem->transformation().data()->ccomponents(); - InstanceChunk inst; - inst.model_id = model_id_; - inst.local_mesh_id = local_mesh_id; - inst.object_id = object_id; - inst.color_override_rgba8 = 0; - for (int i = 0; i < 16; ++i) { - inst.transform[i] = static_cast(mat_d.data()[i]); - } + const MeshAabb& ma = mesh_aabbs[local_mesh_id]; + worldAabbFromLocal(ma.lmin, ma.lmax, inst.transform, + inst.world_aabb_min, inst.world_aabb_max); - const MeshAabb& ma = mesh_aabbs[local_mesh_id]; - worldAabbFromLocal(ma.lmin, ma.lmax, inst.transform, - inst.world_aabb_min, inst.world_aabb_max); + emit instanceReady(std::move(inst)); + total_shapes++; - emit instanceReady(std::move(inst)); - total_shapes++; + const int p = sub_lo + + (iterator->progress() * (sub_hi - sub_lo)) / 100; + if (p != last_progress) { + last_progress = p; + progress_ = p; + emit progressChanged(p); + } + } while (iterator->next()); - const int p = progress_lo + - (iterator->progress() * (progress_hi - progress_lo)) / 100; - if (p != last_progress) { - last_progress = p; - progress_ = p; - emit progressChanged(p); - } - } while (iterator->next()); + return true; + }; + if (prioritised_contexts.empty()) { + // No IfcGeometricRepresentationContext entities — fall back to + // a single iterator pass without context-id filtering. + return run_iterator(base_settings, progress_lo, progress_hi); + } + + const int range = progress_hi - progress_lo; + const int n = static_cast(prioritised_contexts.size()); + for (int i = 0; i < n; ++i) { + if (cancel_requested_.load()) break; + if (remaining.empty()) break; + + ifcopenshell::geometry::Settings iter_settings = base_settings; + iter_settings.set("context-ids", + std::set{ prioritised_contexts[i] }); + + const int sub_lo = progress_lo + (range * i) / n; + const int sub_hi = (i + 1 == n) + ? progress_hi + : progress_lo + (range * (i + 1)) / n; + + if (!run_iterator(iter_settings, sub_lo, sub_hi)) return false; + } + + progress_ = progress_hi; + emit progressChanged(progress_hi); return true; }; From 1ca2e12f9251693f8084197c3cfbcafa7242cbcc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 10:21:23 +1000 Subject: [PATCH 074/120] ifcviewer: draw 3D pivot indicator during navigation A small RGB axis cross is rendered at camera_target_ while the user is orbiting, panning, or has just zoomed. Visibility toggles on middle-mouse press/release; the wheel arms a single-shot QTimer that hides it 750 ms after the last notch. Drawn in two passes: GL_GREATER at 30% alpha for the occluded portion (X-ray cue) and GL_LEQUAL at full alpha for the visible portion. Arm length is computed from camera_distance_, fovy, and viewport height so the cross stays ~30 px on screen across zoom levels. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 138 +++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 24 ++++++ 2 files changed, 162 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index e928525f03..df6bbe96a4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -236,6 +237,35 @@ out vec4 frag_color; void main() { frag_color = vec4(v_color, 1.0); } )"; +// Pivot indicator: a small 3D axis cross drawn at u_pivot in world space. +// a_local is the unit endpoint of one of the six arm vertices (±X,±Y,±Z), +// scaled by u_arm_world so the cross keeps a roughly constant on-screen size +// across zoom levels. Drawing in world space (not screen-aligned) is the +// whole point: it tumbles visibly when the camera orbits, which makes it +// unambiguous that the marker is anchored to a 3D point and not glued to +// the screen. Per-vertex color gives RGB axes matching the corner gizmo. +static const char* PIVOT_VERTEX_SHADER = R"( +#version 450 core +layout(location = 0) in vec3 a_local; +layout(location = 1) in vec3 a_color; +uniform mat4 u_mvp; +uniform vec3 u_pivot; +uniform float u_arm_world; +out vec3 v_color; +void main() { + gl_Position = u_mvp * vec4(u_pivot + a_local * u_arm_world, 1.0); + v_color = a_color; +} +)"; + +static const char* PIVOT_FRAGMENT_SHADER = R"( +#version 450 core +in vec3 v_color; +uniform float u_alpha; +out vec4 frag_color; +void main() { frag_color = vec4(v_color, u_alpha); } +)"; + static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) { GLuint shader = gl->glCreateShader(type); gl->glShaderSource(shader, 1, &source, nullptr); @@ -434,9 +464,12 @@ ViewportWindow::~ViewportWindow() { } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); + if (pivot_vao_) gl_->glDeleteVertexArrays(1, &pivot_vao_); + if (pivot_vbo_) gl_->glDeleteBuffers(1, &pivot_vbo_); if (main_program_) gl_->glDeleteProgram(main_program_); if (pick_program_) gl_->glDeleteProgram(pick_program_); if (axis_program_) gl_->glDeleteProgram(axis_program_); + if (pivot_program_) gl_->glDeleteProgram(pivot_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_); @@ -464,6 +497,7 @@ void ViewportWindow::initGL() { buildShaders(); buildAxisGizmo(); + buildPivotIndicator(); gl_->glEnable(GL_DEPTH_TEST); gl_->glEnable(GL_MULTISAMPLE); @@ -570,6 +604,11 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER); axis_program_ = linkProgram(gl_, vs, fs); } + { + GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, PIVOT_VERTEX_SHADER); + GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PIVOT_FRAGMENT_SHADER); + pivot_program_ = linkProgram(gl_, vs, fs); + } { GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, HIZ_DOWNSAMPLE_VS); GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, HIZ_DOWNSAMPLE_FS); @@ -599,6 +638,31 @@ void ViewportWindow::buildAxisGizmo() { gl_->glVertexArrayAttribBinding(axis_vao_, 1, 0); } +void ViewportWindow::buildPivotIndicator() { + // 6 verts = 3 line segments along world ±X, ±Y, ±Z, RGB-coded. + static const float verts[] = { + // local x,y,z r,g,b + -1, 0, 0, 1.0f, 0.30f, 0.30f, + 1, 0, 0, 1.0f, 0.30f, 0.30f, + 0,-1, 0, 0.35f, 0.95f, 0.35f, + 0, 1, 0, 0.35f, 0.95f, 0.35f, + 0, 0,-1, 0.35f, 0.55f, 1.0f, + 0, 0, 1, 0.35f, 0.55f, 1.0f, + }; + pivot_rim_count_ = 6; // total vertex count drawn as GL_LINES + + gl_->glCreateVertexArrays(1, &pivot_vao_); + gl_->glCreateBuffers(1, &pivot_vbo_); + gl_->glNamedBufferStorage(pivot_vbo_, sizeof(verts), verts, 0); + gl_->glVertexArrayVertexBuffer(pivot_vao_, 0, pivot_vbo_, 0, 6 * sizeof(float)); + gl_->glEnableVertexArrayAttrib(pivot_vao_, 0); + gl_->glVertexArrayAttribFormat(pivot_vao_, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribBinding(pivot_vao_, 0, 0); + gl_->glEnableVertexArrayAttrib(pivot_vao_, 1); + gl_->glVertexArrayAttribFormat(pivot_vao_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float)); + gl_->glVertexArrayAttribBinding(pivot_vao_, 1, 0); +} + bool ViewportWindow::growModelVbo(ModelGpuData& m, size_t needed_total) { size_t new_capacity = m.vbo_capacity; while (new_capacity < needed_total) new_capacity *= 2; @@ -2082,6 +2146,7 @@ void ViewportWindow::render() { hiz_reject_count_.load()); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); + renderPivotIndicator(); renderAxisGizmo(); // Build HiZ from this frame's resolved depth for next frame's cull. @@ -2418,6 +2483,69 @@ void ViewportWindow::renderPickPass() { gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); } +void ViewportWindow::renderPivotIndicator() { + if (!pivot_indicator_visible_ || !pivot_program_ || !pivot_vao_) return; + + const int h = height() * devicePixelRatio(); + if (h <= 0) return; + + // Pick a world-space arm length that projects to ~30 pixels at the pivot's + // distance. At fovy=45°, half the visible world height at distance d is + // d * tan(22.5°) ≈ 0.4142 * d. pixels_per_world = (h/2) / (0.4142 * d). + // Inverting: world_per_pixel = 0.8284 * d / h. + const float fovy_rad = qDegreesToRadians(camera_fov_y_deg_); + const float world_per_pixel = camera_distance_ * tanf(fovy_rad * 0.5f) * 2.0f / float(h); + const float arm_pixels = 30.0f * float(devicePixelRatio()); + const float arm_world = arm_pixels * world_per_pixel; + + const QMatrix4x4 mvp = proj_matrix_ * view_matrix_; + + gl_->glUseProgram(pivot_program_); + gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(pivot_program_, "u_mvp"), + 1, GL_FALSE, mvp.constData()); + gl_->glUniform3f(gl_->glGetUniformLocation(pivot_program_, "u_pivot"), + camera_target_.x(), camera_target_.y(), camera_target_.z()); + gl_->glUniform1f(gl_->glGetUniformLocation(pivot_program_, "u_arm_world"), + arm_world); + + gl_->glEnable(GL_BLEND); + gl_->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl_->glLineWidth(2.0f); + gl_->glBindVertexArray(pivot_vao_); + + // Pass 1: occluded portions, depth test reversed, dim — gives an X-ray + // hint that the pivot lives behind geometry. + gl_->glDepthFunc(GL_GREATER); + gl_->glUniform1f(gl_->glGetUniformLocation(pivot_program_, "u_alpha"), 0.30f); + gl_->glDrawArrays(GL_LINES, 0, pivot_rim_count_); + + // Pass 2: visible portions, normal depth, full alpha. + gl_->glDepthFunc(GL_LEQUAL); + gl_->glUniform1f(gl_->glGetUniformLocation(pivot_program_, "u_alpha"), 1.0f); + gl_->glDrawArrays(GL_LINES, 0, pivot_rim_count_); + + gl_->glBindVertexArray(0); + gl_->glDisable(GL_BLEND); + gl_->glDepthFunc(GL_LESS); +} + +void ViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_ms) { + if (!pivot_indicator_hide_timer_) { + pivot_indicator_hide_timer_ = new QTimer(this); + pivot_indicator_hide_timer_->setSingleShot(true); + connect(pivot_indicator_hide_timer_, &QTimer::timeout, this, [this]() { + pivot_indicator_visible_ = false; + requestUpdate(); + }); + } + pivot_indicator_visible_ = visible; + if (visible && hide_after_ms > 0) { + pivot_indicator_hide_timer_->start(hide_after_ms); + } else { + pivot_indicator_hide_timer_->stop(); + } +} + void ViewportWindow::renderAxisGizmo() { if (!axis_program_ || !axis_vao_) return; const int dpr = devicePixelRatio(); @@ -2475,6 +2603,10 @@ void ViewportWindow::handleMousePress(QMouseEvent* e) { } active_button_ = e->button(); last_mouse_pos_ = e->pos(); + if (e->button() == Qt::MiddleButton) { + setPivotIndicatorVisible(true); + requestUpdate(); + } } void ViewportWindow::handleMouseRelease(QMouseEvent* e) { if (camera_mode_ == CameraMode::Fps) return; @@ -2484,7 +2616,12 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { emit objectPicked(id); requestUpdate(); // selection highlight changed } + const bool was_navigating = (active_button_ == Qt::MiddleButton); active_button_ = Qt::NoButton; + if (was_navigating && pivot_indicator_visible_) { + setPivotIndicatorVisible(false); + requestUpdate(); + } } void ViewportWindow::handleMouseMove(QMouseEvent* e) { if (camera_mode_ == CameraMode::Fps) { @@ -2550,5 +2687,6 @@ 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_); + setPivotIndicatorVisible(true, 750); requestUpdate(); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index ec223c559f..5e2e7ad92b 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -28,6 +28,10 @@ #include #include +QT_BEGIN_NAMESPACE +class QTimer; +QT_END_NAMESPACE + #include #include #include @@ -231,9 +235,16 @@ private: void render(); void renderPickPass(); void renderAxisGizmo(); + void renderPivotIndicator(); void updateCamera(); void buildShaders(); void buildAxisGizmo(); + void buildPivotIndicator(); + // Show/hide the orbit-pivot marker. hide_after_ms > 0 starts a single-shot + // timer that auto-hides — used by the wheel handler to give the marker a + // short afterglow after zoom. Drag-based callers pass 0 and toggle + // visibility manually on press/release. + void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0); void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo); // Resolve the default framebuffer's MSAA depth into a single-sample @@ -315,6 +326,14 @@ private: GLuint axis_vao_ = 0; GLuint axis_vbo_ = 0; + // Orbit-pivot indicator: 1 center vertex + (N+1) rim vertices on the unit + // circle (last == first to close the triangle fan). Rendered as a screen- + // space disc at camera_target_, visible only while the user is navigating. + GLuint pivot_program_ = 0; + GLuint pivot_vao_ = 0; + GLuint pivot_vbo_ = 0; + int pivot_rim_count_ = 0; + // Per-model GPU data std::unordered_map models_gpu_; @@ -411,6 +430,11 @@ private: Qt::MouseButton active_button_ = Qt::NoButton; QPoint last_mouse_pos_; + // Pivot indicator visibility — true while drag-navigating, or briefly + // after a wheel notch (the timer auto-clears it). + bool pivot_indicator_visible_ = false; + QTimer* pivot_indicator_hide_timer_ = nullptr; + // FPS/fly mode state. fps_keys_held_ tracks WASD/QE/Shift between // press+release; fps_last_tick_ gates dt inside render(). // fps_ignore_next_mouse_move_ swallows the synthetic MouseMove that From a06e5ecdf53ec3e7d68c7e586514516020092a75 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 11:34:35 +1000 Subject: [PATCH 075/120] ifcviewer: add Focus-on-Object and View-All camera shortcuts F (no modifier) re-aims the orbit camera at the selected object's world AABB centroid and dollies camera_distance_ so the bounding sphere fits the current viewport. Home does the same for the union of all finalized models. Both preserve yaw/pitch so the user keeps their orientation; both no-op in FPS mode. Scene AABB prefers the per-model BVH root when available and falls back to walking InstanceCpu world AABBs. Object AABB unions every matching instance. Distance accounts for portrait windows by using the tighter of the horizontal and vertical FOV constraints. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 116 +++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 19 +++++ 2 files changed, 135 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index df6bbe96a4..0dfc2997a0 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1230,6 +1230,108 @@ void ViewportWindow::setCamera(float tx, float ty, float tz, requestUpdate(); } +bool ViewportWindow::computeObjectAabb(uint32_t object_id, + QVector3D& mn, QVector3D& mx) const { + if (object_id == 0) return false; + bool found = false; + QVector3D lo( std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max()); + QVector3D hi(-std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max()); + for (const auto& [mid, m] : models_gpu_) { + if (!m.finalized || m.hidden) continue; + for (const InstanceCpu& inst : m.instances) { + if (inst.object_id != object_id) continue; + for (int a = 0; a < 3; ++a) { + if (inst.world_aabb_min[a] < lo[a]) lo[a] = inst.world_aabb_min[a]; + if (inst.world_aabb_max[a] > hi[a]) hi[a] = inst.world_aabb_max[a]; + } + found = true; + } + } + if (found) { mn = lo; mx = hi; } + return found; +} + +bool ViewportWindow::computeSceneAabb(QVector3D& mn, QVector3D& mx) const { + bool found = false; + QVector3D lo( std::numeric_limits::max(), + std::numeric_limits::max(), + std::numeric_limits::max()); + QVector3D hi(-std::numeric_limits::max(), + -std::numeric_limits::max(), + -std::numeric_limits::max()); + for (const auto& [mid, m] : models_gpu_) { + if (!m.finalized || m.hidden) continue; + if (!m.bvh.nodes.empty()) { + const BvhNode& root = m.bvh.nodes[0]; + for (int a = 0; a < 3; ++a) { + if (root.aabb_min[a] < lo[a]) lo[a] = root.aabb_min[a]; + if (root.aabb_max[a] > hi[a]) hi[a] = root.aabb_max[a]; + } + found = true; + } else { + for (const InstanceCpu& inst : m.instances) { + for (int a = 0; a < 3; ++a) { + if (inst.world_aabb_min[a] < lo[a]) lo[a] = inst.world_aabb_min[a]; + if (inst.world_aabb_max[a] > hi[a]) hi[a] = inst.world_aabb_max[a]; + } + found = true; + } + } + } + if (found) { mn = lo; mx = hi; } + return found; +} + +void ViewportWindow::frameAabb(const QVector3D& mn, const QVector3D& mx, + float padding) { + const QVector3D centroid = (mn + mx) * 0.5f; + const float radius = ((mx - mn).length() * 0.5f); + + // Empty / point AABB: keep the existing distance so we just recenter. + float new_distance = camera_distance_; + if (radius > 1e-4f) { + const float fovy_rad = qDegreesToRadians(camera_fov_y_deg_); + const float tan_half = tanf(fovy_rad * 0.5f); + // tan_half == 0 is impossible at fov 45°, but guard anyway. + if (tan_half > 1e-6f) { + const int h = qMax(height(), 1); + const float aspect = float(qMax(width(), 1)) / float(h); + // Use the tighter axis: portrait windows need a larger pull-back. + const float min_aspect = aspect < 1.0f ? aspect : 1.0f; + new_distance = (radius / (tan_half * min_aspect)) * padding; + } + } + + camera_target_ = centroid; + camera_distance_ = qMax(0.1f, new_distance); + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::focusOnSelectedObject() { + if (camera_mode_ == CameraMode::Fps) return; + QVector3D mn, mx; + if (!computeObjectAabb(selected_object_id_, mn, mx)) { + qDebug("Focus: no object selected or no AABB available"); + return; + } + frameAabb(mn, mx, 1.30f); // a bit of headroom around small objects +} + +void ViewportWindow::viewAll() { + if (camera_mode_ == CameraMode::Fps) return; + QVector3D mn, mx; + if (!computeSceneAabb(mn, mx)) { + qDebug("View All: scene is empty"); + return; + } + frameAabb(mn, mx, 1.10f); +} + void ViewportWindow::setBenchmarkFrames(int n) { benchmark_total_ = n; benchmark_count_ = 0; @@ -1296,6 +1398,20 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { qDebug("--camera %s", qPrintable(cameraString())); return; } + + // Plain F (no modifiers): focus camera on the currently selected object. + // Shift+F is FPS-mode toggle and was handled above. + if (key == Qt::Key_F + && event->modifiers() == Qt::NoModifier + && !event->isAutoRepeat()) { + focusOnSelectedObject(); + return; + } + // Home: frame the entire scene. + if (key == Qt::Key_Home && !event->isAutoRepeat()) { + viewAll(); + return; + } QWindow::keyPressEvent(event); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 5e2e7ad92b..41bd01b58d 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -176,6 +176,14 @@ public: void setBenchmarkFrames(int n); QString cameraString() const; + // Move camera_target_ to the selected object's world-AABB centroid and + // dolly camera_distance_ so the object's bounding sphere fits the + // current viewport. Yaw/pitch are preserved. No-op if no object is + // selected or its AABB is unknown. + void focusOnSelectedObject(); + // Frame the union of all finalized models. No-op if the scene is empty. + void viewAll(); + struct CameraState { QVector3D target; float distance; @@ -237,6 +245,17 @@ private: void renderAxisGizmo(); void renderPivotIndicator(); void updateCamera(); + + // Geometry queries used by focusOnSelectedObject() / viewAll(). Both + // return false when nothing matched (caller should leave the camera + // alone). Bounds are world-space AABBs. + bool computeObjectAabb(uint32_t object_id, QVector3D& mn, QVector3D& mx) const; + bool computeSceneAabb(QVector3D& mn, QVector3D& mx) const; + // Re-aim the orbit camera so the bounding sphere of [mn, mx] just fits + // vertically and horizontally within the current FOV, with `padding` + // headroom (1.0 = tight). Yaw/pitch are preserved; only target and + // distance change. + void frameAabb(const QVector3D& mn, const QVector3D& mx, float padding); void buildShaders(); void buildAxisGizmo(); void buildPivotIndicator(); From 309e20009ca6c8342e84533accba711d3ddf3eec Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 12:13:17 +1000 Subject: [PATCH 076/120] ifcviewer: add section-plane clipping plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a clip-plane pipeline used by the upcoming section tool: - Up to 8 SectionPlane{n, d} entries, AND-combined as fragment-shader discard against world position. Main and pick fragment shaders both honour the planes, so cut areas are neither drawn nor selectable. - Main vertex shader now passes v_world_pos through. - Pick FBO grows two attachments (RGB32F world position, RGB16F world normal) and the pick shader writes both alongside the object id. pickSurfaceAt() does a single readback of all three. Existing pickObjectAt() still works unchanged for callers that just want the id. - addSectionPlaneAtSurface(point, normal) auto-flips the normal toward the camera so the first click immediately cuts the camera-facing half. No UI yet — that's the next commit (gizmo, drag, K shortcut). Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 171 +++++++++++++++++++++++++++++-- src/ifcviewer/ViewportWindow.h | 39 ++++++- 2 files changed, 201 insertions(+), 9 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 0dfc2997a0..7f1a717ec5 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -94,6 +94,7 @@ uniform uint u_selected_id; out vec3 v_normal; out vec4 v_color; +out vec3 v_world_pos; flat out uint v_object_id; flat out uint v_selected; @@ -115,6 +116,7 @@ void main() { vec3 pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, a_position_q); vec4 world = inst.transform * vec4(pos_local, 1.0); + v_world_pos = world.xyz; gl_Position = u_view_projection * world; // Rotate the normal by the upper-3x3 of the transform. BIM placements @@ -149,14 +151,25 @@ static const char* MAIN_FRAGMENT_SHADER = R"( #version 450 core in vec3 v_normal; in vec4 v_color; +in vec3 v_world_pos; flat in uint v_object_id; flat in uint v_selected; uniform vec3 u_light_dir; +// Section planes — clip the half-space dot(n,p)+d > 0. AND-combined. +const int MAX_CLIP_PLANES = 8; +uniform int u_clip_count; +uniform vec4 u_clip_planes[MAX_CLIP_PLANES]; + out vec4 frag_color; void main() { + for (int i = 0; i < u_clip_count; ++i) { + if (dot(u_clip_planes[i].xyz, v_world_pos) + u_clip_planes[i].w > 0.0) { + discard; + } + } // v_normal already has the reflection flip applied in the vertex // shader. When backface culling is off, open shells let us see the // "wrong" side of a face — flip based on gl_FrontFacing so both @@ -177,6 +190,7 @@ static const char* PICK_VERTEX_SHADER = R"( #version 450 core #extension GL_ARB_shader_draw_parameters : require layout(location = 0) in vec3 a_position_q; +layout(location = 1) in vec2 a_normal_oct; struct InstanceRecord { mat4 transform; @@ -199,6 +213,15 @@ layout(std430, binding = 2) readonly buffer Meshes { uniform mat4 u_view_projection; flat out uint v_object_id; +out vec3 v_world_pos; +out vec3 v_world_normal; + +vec3 octDecode(vec2 e) { + vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y)); + if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + return normalize(n); +} void main() { uint slot = uint(gl_BaseInstanceARB) + uint(gl_InstanceID); @@ -206,7 +229,16 @@ void main() { InstanceRecord inst = instances[iid]; MeshQuant mq = meshes[inst.mesh_id]; vec3 pos_local = mix(mq.aabb_min.xyz, mq.aabb_max.xyz, a_position_q); - gl_Position = u_view_projection * inst.transform * vec4(pos_local, 1.0); + vec4 world = inst.transform * vec4(pos_local, 1.0); + v_world_pos = world.xyz; + gl_Position = u_view_projection * world; + + vec3 n_local = octDecode(a_normal_oct); + mat3 rot = mat3(inst.transform); + vec3 n = rot * n_local; + if (determinant(rot) < 0.0) n = -n; + v_world_normal = normalize(n); + v_object_id = inst.object_id; } )"; @@ -214,8 +246,27 @@ void main() { 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; } +in vec3 v_world_pos; +in vec3 v_world_normal; + +const int MAX_CLIP_PLANES = 8; +uniform int u_clip_count; +uniform vec4 u_clip_planes[MAX_CLIP_PLANES]; + +layout(location = 0) out uint frag_id; +layout(location = 1) out vec3 frag_pos; +layout(location = 2) out vec3 frag_normal; + +void main() { + for (int i = 0; i < u_clip_count; ++i) { + if (dot(u_clip_planes[i].xyz, v_world_pos) + u_clip_planes[i].w > 0.0) { + discard; + } + } + frag_id = v_object_id; + frag_pos = v_world_pos; + frag_normal = normalize(v_world_normal); +} )"; static const char* AXIS_VERTEX_SHADER = R"( @@ -471,7 +522,9 @@ ViewportWindow::~ViewportWindow() { if (axis_program_) gl_->glDeleteProgram(axis_program_); if (pivot_program_) gl_->glDeleteProgram(pivot_program_); if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_); - if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_); + if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_); + if (pick_pos_tex_) gl_->glDeleteTextures(1, &pick_pos_tex_); + if (pick_normal_tex_) gl_->glDeleteTextures(1, &pick_normal_tex_); if (pick_depth_rbo_) gl_->glDeleteRenderbuffers(1, &pick_depth_rbo_); if (hiz_fbo_) gl_->glDeleteFramebuffers(1, &hiz_fbo_); if (hiz_depth_tex_) gl_->glDeleteTextures(1, &hiz_depth_tex_); @@ -1745,15 +1798,27 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { int h = height() * devicePixelRatio(); 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_); + if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_); + if (pick_pos_tex_) gl_->glDeleteTextures(1, &pick_pos_tex_); + if (pick_normal_tex_) gl_->glDeleteTextures(1, &pick_normal_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_->glCreateTextures(GL_TEXTURE_2D, 1, &pick_pos_tex_); + gl_->glTextureStorage2D(pick_pos_tex_, 1, GL_RGB32F, w, h); + gl_->glNamedFramebufferTexture(pick_fbo_, GL_COLOR_ATTACHMENT1, pick_pos_tex_, 0); + gl_->glCreateTextures(GL_TEXTURE_2D, 1, &pick_normal_tex_); + gl_->glTextureStorage2D(pick_normal_tex_, 1, GL_RGB16F, w, h); + gl_->glNamedFramebufferTexture(pick_fbo_, GL_COLOR_ATTACHMENT2, pick_normal_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_); + static const GLenum draw_bufs[3] = { + GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 + }; + gl_->glNamedFramebufferDrawBuffers(pick_fbo_, 3, draw_bufs); pick_width_ = w; pick_height_ = h; } @@ -1775,6 +1840,91 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) { return pixel; } +bool ViewportWindow::pickSurfaceAt(int x, int y, + uint32_t& object_id_out, + QVector3D& world_pos_out, + QVector3D& world_normal_out) { + const uint32_t id = pickObjectAt(x, y); + if (id == 0) return false; + + const int px = x * devicePixelRatio(); + const int py = (height() - y) * devicePixelRatio(); + float pos[3] = {0, 0, 0}; + float normal[3] = {0, 0, 0}; + gl_->glGetTextureSubImage(pick_pos_tex_, 0, px, py, 0, 1, 1, 1, + GL_RGB, GL_FLOAT, sizeof(pos), pos); + gl_->glGetTextureSubImage(pick_normal_tex_, 0, px, py, 0, 1, 1, 1, + GL_RGB, GL_FLOAT, sizeof(normal), normal); + + QVector3D n(normal[0], normal[1], normal[2]); + if (n.lengthSquared() < 1e-8f) { + // Pick succeeded for object_id but normal attachment was empty — + // unexpected (shader writes it on every covered fragment), so bail + // rather than hand the caller a degenerate plane. + return false; + } + object_id_out = id; + world_pos_out = QVector3D(pos[0], pos[1], pos[2]); + world_normal_out = n.normalized(); + return true; +} + +void ViewportWindow::uploadClipPlaneUniforms(GLuint program) { + const GLint u_count = gl_->glGetUniformLocation(program, "u_clip_count"); + if (u_count < 0) return; // program does not declare clipping uniforms + const int n = qMin(int(section_planes_.size()), MaxSectionPlanes); + gl_->glUniform1i(u_count, n); + if (n == 0) return; + float packed[MaxSectionPlanes * 4] = {}; + for (int i = 0; i < n; ++i) { + packed[i * 4 + 0] = section_planes_[i].n.x(); + packed[i * 4 + 1] = section_planes_[i].n.y(); + packed[i * 4 + 2] = section_planes_[i].n.z(); + packed[i * 4 + 3] = section_planes_[i].d; + } + const GLint u_planes = gl_->glGetUniformLocation(program, "u_clip_planes"); + if (u_planes >= 0) { + gl_->glUniform4fv(u_planes, n, packed); + } +} + +bool ViewportWindow::addSectionPlaneAtSurface(const QVector3D& point, + const QVector3D& normal) { + if (int(section_planes_.size()) >= MaxSectionPlanes) { + qWarning("Section plane cap (%d) reached", MaxSectionPlanes); + return false; + } + QVector3D n = normal; + if (n.lengthSquared() < 1e-8f) return false; + n.normalize(); + // Auto-flip so the plane clips the camera-facing half — first click + // immediately cuts away what's between the user and the clicked surface. + const QVector3D eye_dir = (camera_eye_ - point); + if (QVector3D::dotProduct(n, eye_dir) < 0.0f) n = -n; + + SectionPlane p; + p.n = n; + p.d = -QVector3D::dotProduct(n, point); + section_planes_.push_back(p); + have_cached_cull_ = false; + requestUpdate(); + return true; +} + +void ViewportWindow::removeSectionPlane(int index) { + if (index < 0 || index >= int(section_planes_.size())) return; + section_planes_.erase(section_planes_.begin() + index); + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::clearSectionPlanes() { + if (section_planes_.empty()) return; + section_planes_.clear(); + have_cached_cull_ = false; + requestUpdate(); +} + void ViewportWindow::cullAndUploadVisible(ModelGpuData& m, const float planes[6][4], float focal_px, float min_pixel_radius) { cullModelCpu(m, planes, focal_px, min_pixel_radius); @@ -2112,6 +2262,7 @@ void ViewportWindow::render() { gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); gl_->glUniform3f(u_light, 0.3f, 0.5f, 0.8f); gl_->glUniform1ui(u_sel, selected_object_id_); + uploadClipPlaneUniforms(main_program_); visible_triangles_ = 0; visible_objects_ = 0; @@ -2550,8 +2701,11 @@ void ViewportWindow::render() { 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); + const GLuint zero_id = 0; + const float zero3[4] = {0, 0, 0, 0}; + gl_->glClearBufferuiv(GL_COLOR, 0, &zero_id); + gl_->glClearBufferfv (GL_COLOR, 1, zero3); + gl_->glClearBufferfv (GL_COLOR, 2, zero3); gl_->glClear(GL_DEPTH_BUFFER_BIT); QMatrix4x4 vp = proj_matrix_ * view_matrix_; @@ -2561,6 +2715,7 @@ void ViewportWindow::renderPickPass() { gl_->glUseProgram(pick_program_); GLint u_vp = gl_->glGetUniformLocation(pick_program_, "u_view_projection"); gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); + uploadClipPlaneUniforms(pick_program_); gl_->glFrontFace(GL_CCW); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 41bd01b58d..17ab693164 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -172,6 +172,30 @@ public: void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); + // Extended pick: returns the object id, world-space hit point, and + // world-space surface normal at (x, y). Renders the same pick pass as + // pickObjectAt but reads back from two extra color attachments + // (world_pos in RGB32F, world_normal in RGB16F). Returns false if the + // click missed all geometry; the out-params are then untouched. + bool pickSurfaceAt(int x, int y, + uint32_t& object_id_out, + QVector3D& world_pos_out, + QVector3D& world_normal_out); + + // Section planes — fragment-shader clipping with up to MaxSectionPlanes + // active. Each plane clips the half-space dot(n, p) + d > 0. addSection- + // PlaneAtSurface auto-flips the normal toward the camera so the first + // click immediately cuts away the camera-facing side. + static constexpr int MaxSectionPlanes = 8; + struct SectionPlane { + QVector3D n; // unit world-space normal + float d; // -dot(n, p) for some p on the plane + }; + int sectionPlaneCount() const { return int(section_planes_.size()); } + bool addSectionPlaneAtSurface(const QVector3D& point, const QVector3D& normal); + void removeSectionPlane(int index); + void clearSectionPlanes(); + void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch); void setBenchmarkFrames(int n); QString cameraString() const; @@ -356,9 +380,15 @@ private: // Per-model GPU data std::unordered_map models_gpu_; - // Pick framebuffer + // Pick framebuffer. Three color attachments: + // 0: R32UI — object_id + // 1: RGB32F — world position at hit + // 2: RGB16F — world normal at hit (already flipped for reflections in + // the vertex shader) GLuint pick_fbo_ = 0; GLuint pick_color_tex_ = 0; + GLuint pick_pos_tex_ = 0; + GLuint pick_normal_tex_ = 0; GLuint pick_depth_rbo_ = 0; int pick_width_ = 0; int pick_height_ = 0; @@ -467,6 +497,13 @@ private: // Selection uint32_t selected_object_id_ = 0; + // Active section planes. Uploaded as uniform array each frame to the + // main + pick programs; capped at MaxSectionPlanes. + std::vector section_planes_; + // Push the current plane list into a freshly-bound program. No-op if + // the program does not declare u_clip_count / u_clip_planes. + void uploadClipPlaneUniforms(GLuint program); + // FPS smoothing int frame_count_ = 0; float accumulated_time_ = 0.0f; From 51971dd493bf24457598fc467d3863782054a9bc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 12:32:49 +1000 Subject: [PATCH 077/120] =?UTF-8?q?ifcviewer:=20section=20tool=20=E2=80=94?= =?UTF-8?q?=20gizmo,=20drag,=20K=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up the user-facing section-cut tool on top of the clipping plumbing landed in the previous commit. - K toggles the tool. - LMB while the tool is active: * On an existing plane's arrow gizmo (screen-space line-segment hit test, 12 px grab radius) → select + start drag. * Otherwise on geometry → pickSurfaceAt + addSectionPlaneAt- Surface, select the new plane. * Otherwise → deselect. - LMB drag updates the plane's origin by projecting the cursor delta onto the screen-space normal axis and converting back to metres. d is rederived from the new origin each frame. - Delete removes the selected plane; Esc exits the tool. - Each plane renders a 2x2 m quad outline plus a yellow arrow along +n at its origin. Selected plane draws cyan and thicker. LMB object-pick is suppressed while the tool is active so plane creation does not also change selection. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 301 ++++++++++++++++++++++++++++++- src/ifcviewer/ViewportWindow.h | 36 +++- 2 files changed, 334 insertions(+), 3 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 7f1a717ec5..3dfe0fa2a4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -317,6 +317,39 @@ out vec4 frag_color; void main() { frag_color = vec4(v_color, u_alpha); } )"; +// Section plane gizmo: a unit-extent quad + arrow expressed in plane-local +// space, transformed to world via the per-plane basis (u, v, n). The arrow +// shaft sits along +z (== +n in plane-local terms), so dragging the arrow +// always slides the plane along its normal regardless of camera orientation. +static const char* PLANE_VERTEX_SHADER = R"( +#version 450 core +layout(location = 0) in vec3 a_local; // (u, v, n) in plane-local space +layout(location = 1) in vec3 a_color; +uniform mat4 u_vp; +uniform vec3 u_origin; +uniform vec3 u_axis_u; +uniform vec3 u_axis_v; +uniform vec3 u_axis_n; +uniform float u_size; +uniform vec4 u_tint; +out vec4 v_color; +void main() { + vec3 world = u_origin + + a_local.x * u_axis_u * u_size + + a_local.y * u_axis_v * u_size + + a_local.z * u_axis_n * u_size; + gl_Position = u_vp * vec4(world, 1.0); + v_color = vec4(a_color, 1.0) * u_tint; +} +)"; + +static const char* PLANE_FRAGMENT_SHADER = R"( +#version 450 core +in vec4 v_color; +out vec4 frag_color; +void main() { frag_color = v_color; } +)"; + static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) { GLuint shader = gl->glCreateShader(type); gl->glShaderSource(shader, 1, &source, nullptr); @@ -517,10 +550,13 @@ ViewportWindow::~ViewportWindow() { if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); if (pivot_vao_) gl_->glDeleteVertexArrays(1, &pivot_vao_); if (pivot_vbo_) gl_->glDeleteBuffers(1, &pivot_vbo_); + if (plane_vao_) gl_->glDeleteVertexArrays(1, &plane_vao_); + if (plane_vbo_) gl_->glDeleteBuffers(1, &plane_vbo_); if (main_program_) gl_->glDeleteProgram(main_program_); if (pick_program_) gl_->glDeleteProgram(pick_program_); if (axis_program_) gl_->glDeleteProgram(axis_program_); if (pivot_program_) gl_->glDeleteProgram(pivot_program_); + if (plane_program_) gl_->glDeleteProgram(plane_program_); if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_); if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_); if (pick_pos_tex_) gl_->glDeleteTextures(1, &pick_pos_tex_); @@ -551,6 +587,7 @@ void ViewportWindow::initGL() { buildShaders(); buildAxisGizmo(); buildPivotIndicator(); + buildSectionPlaneGizmo(); gl_->glEnable(GL_DEPTH_TEST); gl_->glEnable(GL_MULTISAMPLE); @@ -662,6 +699,11 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PIVOT_FRAGMENT_SHADER); pivot_program_ = linkProgram(gl_, vs, fs); } + { + GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, PLANE_VERTEX_SHADER); + GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PLANE_FRAGMENT_SHADER); + plane_program_ = linkProgram(gl_, vs, fs); + } { GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, HIZ_DOWNSAMPLE_VS); GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, HIZ_DOWNSAMPLE_FS); @@ -1465,6 +1507,28 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { viewAll(); return; } + // K toggles the section tool. + if (key == Qt::Key_K + && event->modifiers() == Qt::NoModifier + && !event->isAutoRepeat()) { + toggleSectionTool(); + return; + } + // While the section tool is active: Esc exits the tool, Delete removes + // the selected plane. + if (section_tool_active_) { + if (key == Qt::Key_Escape && !event->isAutoRepeat()) { + toggleSectionTool(); + return; + } + if ((key == Qt::Key_Delete || key == Qt::Key_Backspace) + && !event->isAutoRepeat() + && section_plane_selected_ >= 0) { + removeSectionPlane(section_plane_selected_); + section_plane_selected_ = -1; + return; + } + } QWindow::keyPressEvent(event); } @@ -1904,6 +1968,7 @@ bool ViewportWindow::addSectionPlaneAtSurface(const QVector3D& point, SectionPlane p; p.n = n; + p.origin = point; p.d = -QVector3D::dotProduct(n, point); section_planes_.push_back(p); have_cached_cull_ = false; @@ -1911,6 +1976,15 @@ bool ViewportWindow::addSectionPlaneAtSurface(const QVector3D& point, return true; } +void ViewportWindow::toggleSectionTool() { + section_tool_active_ = !section_tool_active_; + if (!section_tool_active_) { + section_drag_active_ = false; + section_drag_index_ = -1; + } + requestUpdate(); +} + void ViewportWindow::removeSectionPlane(int index) { if (index < 0 || index >= int(section_planes_.size())) return; section_planes_.erase(section_planes_.begin() + index); @@ -2414,6 +2488,7 @@ void ViewportWindow::render() { gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); renderPivotIndicator(); + renderSectionPlanes(); renderAxisGizmo(); // Build HiZ from this frame's resolved depth for next frame's cull. @@ -2754,6 +2829,189 @@ void ViewportWindow::renderPickPass() { gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); } +void ViewportWindow::buildSectionPlaneGizmo() { + // Plane-local geometry, all GL_LINES. Quad is drawn in plane-local + // (u, v); arrow shaft + head extend along +n. Layout per vertex: + // x,y,z (plane-local) r,g,b (color, modulated by u_tint) + static const float verts[] = { + // --- quad outline (4 segments = 8 verts, white) --- + -1, -1, 0, 1,1,1, 1, -1, 0, 1,1,1, + 1, -1, 0, 1,1,1, 1, 1, 0, 1,1,1, + 1, 1, 0, 1,1,1, -1, 1, 0, 1,1,1, + -1, 1, 0, 1,1,1, -1, -1, 0, 1,1,1, + // --- arrow shaft along +n (yellow) --- + 0, 0, 0, 1.0f, 0.85f, 0.2f, + 0, 0, 1, 1.0f, 0.85f, 0.2f, + // --- arrow head (4 diagonals from tip back to a ring at z=0.7) --- + 0, 0, 1, 1.0f, 0.85f, 0.2f, + -0.18f, 0, 0.78f, 1.0f, 0.85f, 0.2f, + 0, 0, 1, 1.0f, 0.85f, 0.2f, + 0.18f, 0, 0.78f, 1.0f, 0.85f, 0.2f, + 0, 0, 1, 1.0f, 0.85f, 0.2f, + 0, -0.18f, 0.78f, 1.0f, 0.85f, 0.2f, + 0, 0, 1, 1.0f, 0.85f, 0.2f, + 0, 0.18f, 0.78f, 1.0f, 0.85f, 0.2f, + }; + plane_quad_offset_ = 0; + plane_quad_count_ = 8; + plane_arrow_offset_ = 8; + plane_arrow_count_ = 10; // 2 shaft + 8 head diagonals + + gl_->glCreateVertexArrays(1, &plane_vao_); + gl_->glCreateBuffers(1, &plane_vbo_); + gl_->glNamedBufferStorage(plane_vbo_, sizeof(verts), verts, 0); + gl_->glVertexArrayVertexBuffer(plane_vao_, 0, plane_vbo_, 0, 6 * sizeof(float)); + gl_->glEnableVertexArrayAttrib(plane_vao_, 0); + gl_->glVertexArrayAttribFormat(plane_vao_, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribBinding(plane_vao_, 0, 0); + gl_->glEnableVertexArrayAttrib(plane_vao_, 1); + gl_->glVertexArrayAttribFormat(plane_vao_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float)); + gl_->glVertexArrayAttribBinding(plane_vao_, 1, 0); +} + +// Pick any unit vector orthogonal to n. Avoids the degenerate case where n +// is parallel to the seed by choosing the seed with the smallest |n_i|. +static QVector3D anyOrthogonal(const QVector3D& n) { + const float ax = std::abs(n.x()), ay = std::abs(n.y()), az = std::abs(n.z()); + QVector3D seed = (ax < ay && ax < az) ? QVector3D(1, 0, 0) + : (ay < az) ? QVector3D(0, 1, 0) + : QVector3D(0, 0, 1); + QVector3D u = QVector3D::crossProduct(n, seed); + if (u.lengthSquared() < 1e-12f) u = QVector3D(1, 0, 0); + return u.normalized(); +} + +void ViewportWindow::renderSectionPlanes() { + if (section_planes_.empty() || !plane_program_ || !plane_vao_) return; + + // Plane half-extent in metres — quad is drawn at (±1, ±1) in plane-local + // space, so size = 1.0 produces the 2x2m footprint we want. + constexpr float kHalfSize = 1.0f; + + const QMatrix4x4 vp = proj_matrix_ * view_matrix_; + + gl_->glUseProgram(plane_program_); + gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(plane_program_, "u_vp"), + 1, GL_FALSE, vp.constData()); + gl_->glUniform1f(gl_->glGetUniformLocation(plane_program_, "u_size"), kHalfSize); + + gl_->glEnable(GL_BLEND); + gl_->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl_->glBindVertexArray(plane_vao_); + + const GLint loc_origin = gl_->glGetUniformLocation(plane_program_, "u_origin"); + const GLint loc_u = gl_->glGetUniformLocation(plane_program_, "u_axis_u"); + const GLint loc_v = gl_->glGetUniformLocation(plane_program_, "u_axis_v"); + const GLint loc_n = gl_->glGetUniformLocation(plane_program_, "u_axis_n"); + const GLint loc_tint = gl_->glGetUniformLocation(plane_program_, "u_tint"); + + for (int i = 0; i < int(section_planes_.size()); ++i) { + const SectionPlane& p = section_planes_[i]; + const QVector3D u = anyOrthogonal(p.n); + const QVector3D v = QVector3D::crossProduct(p.n, u).normalized(); + + gl_->glUniform3f(loc_origin, p.origin.x(), p.origin.y(), p.origin.z()); + gl_->glUniform3f(loc_u, u.x(), u.y(), u.z()); + gl_->glUniform3f(loc_v, v.x(), v.y(), v.z()); + gl_->glUniform3f(loc_n, p.n.x(), p.n.y(), p.n.z()); + + const bool selected = (i == section_plane_selected_); + // Selected plane: cyan-tinted, full alpha. Unselected: warm tint, + // dimmer. Multiplied onto the per-vertex color. + if (selected) gl_->glUniform4f(loc_tint, 0.55f, 0.95f, 1.0f, 1.0f); + else gl_->glUniform4f(loc_tint, 1.0f, 0.85f, 0.4f, 0.75f); + + gl_->glLineWidth(selected ? 2.5f : 1.5f); + gl_->glDrawArrays(GL_LINES, plane_quad_offset_, plane_quad_count_); + gl_->glDrawArrays(GL_LINES, plane_arrow_offset_, plane_arrow_count_); + } + + gl_->glBindVertexArray(0); + gl_->glDisable(GL_BLEND); +} + +int ViewportWindow::hitTestSectionGizmo(int x, int y) const { + if (section_planes_.empty()) return -1; + const int w = width(); + const int h = height(); + if (w <= 0 || h <= 0) return -1; + const QMatrix4x4 vp = proj_matrix_ * view_matrix_; + const float grab_px = 12.0f; + int best = -1; + float best_d2 = grab_px * grab_px; + + auto project = [&](const QVector3D& world, QVector2D& out) -> bool { + QVector4D clip = vp * QVector4D(world, 1.0f); + if (clip.w() <= 0.0f) return false; // behind camera + const float invw = 1.0f / clip.w(); + // Qt mouse coords have y down from the top — match that here. + out = QVector2D( + (clip.x() * invw * 0.5f + 0.5f) * float(w), + (1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(h)); + return true; + }; + + for (int i = 0; i < int(section_planes_.size()); ++i) { + const SectionPlane& p = section_planes_[i]; + QVector2D s_origin, s_tip; + if (!project(p.origin, s_origin)) continue; + if (!project(p.origin + p.n * 1.0f, s_tip)) continue; + + // Distance from (x,y) to the line segment (s_origin, s_tip). + const QVector2D q{float(x), float(y)}; + const QVector2D ab = s_tip - s_origin; + const float ab_len2 = ab.lengthSquared(); + if (ab_len2 < 1e-3f) continue; // degenerate (axis edge-on) + float t = QVector2D::dotProduct(q - s_origin, ab) / ab_len2; + t = qBound(0.0f, t, 1.0f); + const QVector2D proj = s_origin + ab * t; + const float d2 = (q - proj).lengthSquared(); + if (d2 < best_d2) { best_d2 = d2; best = i; } + } + return best; +} + +void ViewportWindow::updateSectionDrag(int x, int y) { + if (!section_drag_active_) return; + if (section_drag_index_ < 0 + || section_drag_index_ >= int(section_planes_.size())) return; + SectionPlane& p = section_planes_[section_drag_index_]; + + const int w = width(); + const int h = height(); + if (w <= 0 || h <= 0) return; + const QMatrix4x4 vp = proj_matrix_ * view_matrix_; + + auto project = [&](const QVector3D& world, QVector2D& out) -> bool { + QVector4D clip = vp * QVector4D(world, 1.0f); + if (clip.w() <= 0.0f) return false; + const float invw = 1.0f / clip.w(); + out = QVector2D( + (clip.x() * invw * 0.5f + 0.5f) * float(w), + (1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(h)); + return true; + }; + + QVector2D s_origin, s_n; + if (!project(section_drag_start_origin_, s_origin)) return; + if (!project(section_drag_start_origin_ + p.n, s_n)) return; + const QVector2D screen_axis = s_n - s_origin; + const float screen_axis_len2 = screen_axis.lengthSquared(); + if (screen_axis_len2 < 1e-3f) return; // axis is edge-on; drag would be ill-conditioned + + // Project the cursor delta onto the screen-space axis to get the world- + // space slide along n. delta_pixels / |screen_axis_pixels_per_meter|. + const QVector2D delta_px(float(x - section_drag_start_mouse_.x()), + float(y - section_drag_start_mouse_.y())); + const float delta_along_axis_px = QVector2D::dotProduct(delta_px, screen_axis); + const float meters = delta_along_axis_px / screen_axis_len2; + + p.origin = section_drag_start_origin_ + p.n * meters; + p.d = -QVector3D::dotProduct(p.n, p.origin); + have_cached_cull_ = false; + requestUpdate(); +} + void ViewportWindow::renderPivotIndicator() { if (!pivot_indicator_visible_ || !pivot_program_ || !pivot_vao_) return; @@ -2878,10 +3136,46 @@ void ViewportWindow::handleMousePress(QMouseEvent* e) { setPivotIndicatorVisible(true); requestUpdate(); } + if (section_tool_active_ && e->button() == Qt::LeftButton) { + // First try to grab an existing plane's arrow gizmo. + const int hit = hitTestSectionGizmo(e->pos().x(), e->pos().y()); + if (hit >= 0) { + section_plane_selected_ = hit; + section_drag_active_ = true; + section_drag_index_ = hit; + section_drag_start_origin_ = section_planes_[hit].origin; + section_drag_start_mouse_ = e->pos(); + requestUpdate(); + return; + } + // Otherwise: create a new plane at the clicked surface. + uint32_t obj_id = 0; + QVector3D pos, normal; + if (pickSurfaceAt(e->pos().x(), e->pos().y(), obj_id, pos, normal)) { + if (addSectionPlaneAtSurface(pos, normal)) { + section_plane_selected_ = int(section_planes_.size()) - 1; + requestUpdate(); + } + } else { + section_plane_selected_ = -1; + requestUpdate(); + } + } } void ViewportWindow::handleMouseRelease(QMouseEvent* e) { if (camera_mode_ == CameraMode::Fps) return; - if (active_button_ == Qt::LeftButton && (e->pos() - last_mouse_pos_).manhattanLength() < 5) { + if (section_drag_active_ && active_button_ == Qt::LeftButton) { + section_drag_active_ = false; + section_drag_index_ = -1; + active_button_ = Qt::NoButton; + return; + } + // LMB pick is suppressed in section-tool mode — LMB there creates or + // selects planes (handled in handleMousePress) and the release should + // not also trigger object selection. + if (active_button_ == Qt::LeftButton + && !section_tool_active_ + && (e->pos() - last_mouse_pos_).manhattanLength() < 5) { uint32_t id = pickObjectAt(e->pos().x(), e->pos().y()); selected_object_id_ = id; emit objectPicked(id); @@ -2928,6 +3222,11 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { return; } + if (section_drag_active_) { + updateSectionDrag(e->pos().x(), e->pos().y()); + last_mouse_pos_ = e->pos(); + return; + } QPoint delta = e->pos() - last_mouse_pos_; last_mouse_pos_ = e->pos(); if (active_button_ == Qt::MiddleButton) { diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 17ab693164..41311ea975 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -188,14 +188,22 @@ public: // click immediately cuts away the camera-facing side. static constexpr int MaxSectionPlanes = 8; struct SectionPlane { - QVector3D n; // unit world-space normal - float d; // -dot(n, p) for some p on the plane + QVector3D n; // unit world-space normal + QVector3D origin; // point on the plane — the gizmo's anchor + float d; // = -dot(n, origin); kept in sync with origin }; int sectionPlaneCount() const { return int(section_planes_.size()); } bool addSectionPlaneAtSurface(const QVector3D& point, const QVector3D& normal); void removeSectionPlane(int index); void clearSectionPlanes(); + // Section tool: when active, LMB on geometry creates a new plane (using + // pickSurfaceAt for hit-point + normal); LMB on an existing plane's + // arrow gizmo selects + drags it; Delete removes the selected plane; + // Esc or another K-press exits the tool. + void toggleSectionTool(); + bool sectionToolActive() const { return section_tool_active_; } + void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch); void setBenchmarkFrames(int n); QString cameraString() const; @@ -268,6 +276,15 @@ private: void renderPickPass(); void renderAxisGizmo(); void renderPivotIndicator(); + void renderSectionPlanes(); + void buildSectionPlaneGizmo(); + // Returns the index of the section plane whose arrow gizmo is under + // (x, y), or -1 if none. Screen-space line-segment distance test. + int hitTestSectionGizmo(int x, int y) const; + // Update section_planes_[section_drag_index_] from the current cursor + // position by projecting the move onto the plane's normal axis in + // screen space. + void updateSectionDrag(int x, int y); void updateCamera(); // Geometry queries used by focusOnSelectedObject() / viewAll(). Both @@ -500,10 +517,25 @@ private: // Active section planes. Uploaded as uniform array each frame to the // main + pick programs; capped at MaxSectionPlanes. std::vector section_planes_; + bool section_tool_active_ = false; + int section_plane_selected_ = -1; + bool section_drag_active_ = false; + int section_drag_index_ = -1; + QVector3D section_drag_start_origin_; + QPoint section_drag_start_mouse_; // Push the current plane list into a freshly-bound program. No-op if // the program does not declare u_clip_count / u_clip_planes. void uploadClipPlaneUniforms(GLuint program); + // GL resources for the per-plane visualization (quad outline + arrow). + GLuint plane_program_ = 0; + GLuint plane_vao_ = 0; + GLuint plane_vbo_ = 0; + int plane_quad_offset_ = 0; + int plane_quad_count_ = 0; + int plane_arrow_offset_ = 0; + int plane_arrow_count_ = 0; + // FPS smoothing int frame_count_ = 0; float accumulated_time_ = 0.0f; From 6341d7dd3172b7e77ed7072d727b1722832e7f6c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 12:37:37 +1000 Subject: [PATCH 078/120] ifcviewer: bind Shift+K to clear all section planes Convenient escape hatch when the user has stacked several cuts and wants to start over without exiting the tool first. Also resets the selection and drag state. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 3dfe0fa2a4..29a5a97144 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1514,6 +1514,16 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { toggleSectionTool(); return; } + // Shift+K clears every section plane (and the selection). + if (key == Qt::Key_K + && event->modifiers() == Qt::ShiftModifier + && !event->isAutoRepeat()) { + clearSectionPlanes(); + section_plane_selected_ = -1; + section_drag_active_ = false; + section_drag_index_ = -1; + return; + } // While the section tool is active: Esc exits the tool, Delete removes // the selected plane. if (section_tool_active_) { From 6f0327d4b50b966544fdab14334ab653c45b2f93 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 13:30:18 +1000 Subject: [PATCH 079/120] ifcviewer: orthographic toggle and standard axis-aligned views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P toggles ortho/perspective. The ortho box is sized so the visible rectangle at the pivot's distance matches what the perspective camera would show — toggling at any zoom keeps the framing identical, and the wheel keeps working by rescaling the box. Contribution culling is disabled in ortho since its r_px = focal_px * r / dist formula assumes perspective; frustum and HiZ culling still run. - X / Y / Z snap the camera to look from +X / +Y / +Z; Shift+X / Y / Z snap to the negative side. Yaw and pitch are set directly so top/bottom land on exactly ±90°. - updateCamera() picks the lookAt up vector dynamically: world +Z except within 1° of the pole, where it switches to world +Y. That keeps lookAt well-conditioned at the poles and gives top views the architectural "Y as north" screen orientation. - Pan now derives screen-right / screen-up from the real camera basis instead of from yaw/pitch alone — the old derivation assumed up = world +Z and silently inverted at top/bottom. - Standard views preserve target and distance — rotate only. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 101 ++++++++++++++++++++++++++----- src/ifcviewer/ViewportWindow.h | 12 ++++ 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 29a5a97144..9a124cf9b3 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1507,6 +1507,32 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { viewAll(); return; } + // P toggles orthographic / perspective projection. + if (key == Qt::Key_P + && event->modifiers() == Qt::NoModifier + && !event->isAutoRepeat()) { + toggleProjection(); + return; + } + // Standard axis-aligned views: X/Y/Z look toward the target from the + // positive axis (eye on +X/+Y/+Z), Shift+X/Y/Z from the negative side. + // Yaw is the orbit angle in the world XY plane (0° = +X, 90° = +Y); + // pitch is the elevation (0° = horizon, +90° = looking down). Top/ + // bottom intentionally use pitch = ±90° — the up-vector switch in + // updateCamera() keeps lookAt well-conditioned there and yields + // world-Y as screen-up (architectural "north"). + if ((key == Qt::Key_X || key == Qt::Key_Y || key == Qt::Key_Z) + && (event->modifiers() == Qt::NoModifier + || event->modifiers() == Qt::ShiftModifier) + && !event->isAutoRepeat()) { + const bool neg = (event->modifiers() & Qt::ShiftModifier); + switch (key) { + case Qt::Key_X: setStandardView(neg ? 180.0f : 0.0f, 0.0f); break; + case Qt::Key_Y: setStandardView(neg ? 270.0f : 90.0f, 0.0f); break; + case Qt::Key_Z: setStandardView(camera_yaw_, neg ? -90.0f : 90.0f); break; + } + return; + } // K toggles the section tool. if (key == Qt::Key_K && event->modifiers() == Qt::NoModifier @@ -1995,6 +2021,22 @@ void ViewportWindow::toggleSectionTool() { requestUpdate(); } +void ViewportWindow::toggleProjection() { + projection_ortho_ = !projection_ortho_; + have_cached_cull_ = false; // proj_matrix_ changes -> frustum planes change + requestUpdate(); +} + +void ViewportWindow::setStandardView(float yaw_deg, float pitch_deg) { + // Bypasses the orbit-MMB pitch clamp so top/bottom can land exactly on + // ±90°. updateCamera() picks the up vector based on |pitch|, so the + // resulting view is well-conditioned at the poles too. + camera_yaw_ = yaw_deg; + camera_pitch_ = pitch_deg; + have_cached_cull_ = false; + requestUpdate(); +} + void ViewportWindow::removeSectionPlane(int index) { if (index < 0 || index >= int(section_planes_.size())) return; section_planes_.erase(section_planes_.begin() + index); @@ -2293,10 +2335,28 @@ void ViewportWindow::updateCamera() { eye.setZ(camera_target_.z() + camera_distance_ * sinf(pitch_rad)); camera_eye_ = eye; view_matrix_.setToIdentity(); - view_matrix_.lookAt(eye, camera_target_, QVector3D(0, 0, 1)); + // Default up is world +Z. Within ~1° of straight-up/down, switch to + // world +Y so lookAt's side vector doesn't degenerate (forward × up + // → 0). Y-as-north is the architectural top-view convention. + const QVector3D up = (std::abs(camera_pitch_) >= 89.0f) + ? QVector3D(0, 1, 0) + : QVector3D(0, 0, 1); + view_matrix_.lookAt(eye, camera_target_, up); proj_matrix_.setToIdentity(); float aspect = width() > 0 ? float(width()) / float(height()) : 1.0f; - proj_matrix_.perspective(camera_fov_y_deg_, aspect, 0.1f, camera_distance_ * 10.0f); + if (projection_ortho_) { + // Size the ortho box so it shows the same world rectangle the + // perspective camera would see at the pivot's distance — toggling + // at any zoom keeps the framing roughly identical. + const float half_h = camera_distance_ * tanf(qDegreesToRadians(camera_fov_y_deg_ * 0.5f)); + const float half_w = half_h * aspect; + // Near/far span ±10× distance; matches the perspective far so + // typical scenes always fit between the planes. + const float depth = camera_distance_ * 10.0f; + proj_matrix_.ortho(-half_w, half_w, -half_h, half_h, -depth, depth); + } else { + proj_matrix_.perspective(camera_fov_y_deg_, aspect, 0.1f, camera_distance_ * 10.0f); + } } void ViewportWindow::render() { @@ -2376,8 +2436,11 @@ void ViewportWindow::render() { // depth would normally populate the pyramid), causing false occlusion. if (needs_settle_recull) hiz_vp_valid_ = false; - const float min_pixel_radius = use_motion_threshold - ? motion_min_pixel_radius : base_min_pixel_radius; + // Contribution culling assumes perspective (r_px = focal_px * r / dist); + // in ortho the per-instance distance is irrelevant, so disable it rather + // than ship wrong results. Frustum + HiZ culling still run. + const float min_pixel_radius = projection_ortho_ ? 0.0f + : (use_motion_threshold ? motion_min_pixel_radius : base_min_pixel_radius); if (cull_this_frame) { hiz_reject_count_.store(0, std::memory_order_relaxed); last_cull_was_motion_ = camera_moving; @@ -3028,10 +3091,12 @@ void ViewportWindow::renderPivotIndicator() { const int h = height() * devicePixelRatio(); if (h <= 0) return; - // Pick a world-space arm length that projects to ~30 pixels at the pivot's - // distance. At fovy=45°, half the visible world height at distance d is - // d * tan(22.5°) ≈ 0.4142 * d. pixels_per_world = (h/2) / (0.4142 * d). - // Inverting: world_per_pixel = 0.8284 * d / h. + // Pick a world-space arm length that projects to ~30 pixels. In + // perspective, world-per-pixel grows with distance (factored from + // fovy and viewport height); in ortho it's set by the box height + // (camera_distance * tan(fovy/2)) and is independent of distance — + // both reduce to the same formula here because the ortho box is + // sized to match perspective at the pivot's distance. const float fovy_rad = qDegreesToRadians(camera_fov_y_deg_); const float world_per_pixel = camera_distance_ * tanf(fovy_rad * 0.5f) * 2.0f / float(h); const float arm_pixels = 30.0f * float(devicePixelRatio()); @@ -3241,15 +3306,19 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) { last_mouse_pos_ = e->pos(); if (active_button_ == Qt::MiddleButton) { if (e->modifiers() & Qt::ShiftModifier) { - 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)); + const float pan_speed = camera_distance_ * 0.002f; + // Derive screen-right and screen-up from the actual camera basis + // rather than yaw/pitch alone — the latter assumed up = world +Z, + // which breaks at top/bottom views where updateCamera switches + // the lookAt up vector to world +Y. + const QVector3D forward = (camera_target_ - camera_eye_).normalized(); + const QVector3D up_ref = (std::abs(camera_pitch_) >= 89.0f) + ? QVector3D(0, 1, 0) + : QVector3D(0, 0, 1); + const QVector3D right = QVector3D::crossProduct(forward, up_ref).normalized(); + const QVector3D up = QVector3D::crossProduct(right, forward).normalized(); camera_target_ -= right * delta.x() * pan_speed; - camera_target_ += up * delta.y() * pan_speed; + camera_target_ += up * delta.y() * pan_speed; } else { camera_yaw_ -= delta.x() * 0.3f; camera_pitch_ += delta.y() * 0.3f; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 41311ea975..25e71c9d48 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -204,6 +204,17 @@ public: void toggleSectionTool(); bool sectionToolActive() const { return section_tool_active_; } + // Projection: orthographic vs perspective. In ortho mode the visible + // box is sized to match what the perspective camera would show at the + // pivot's distance, so toggling at any zoom level keeps the framing. + void toggleProjection(); + bool projectionOrtho() const { return projection_ortho_; } + + // Snap the camera to a canonical axis-aligned view. Yaw/pitch are + // clamped according to the orbit convention; target and distance are + // preserved (the user explicitly asked for a rotate-only behavior). + void setStandardView(float yaw_deg, float pitch_deg); + void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch); void setBenchmarkFrames(int n); QString cameraString() const; @@ -489,6 +500,7 @@ private: float camera_yaw_ = 45.0f; float camera_pitch_ = 30.0f; float camera_fov_y_deg_ = 45.0f; + bool projection_ortho_ = false; QMatrix4x4 view_matrix_; QMatrix4x4 proj_matrix_; From e7787b6aad105328852fa8c8d2485d44d96d8be2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 14:22:08 +1000 Subject: [PATCH 080/120] ifcviewer: hemisphere ambient + fill light + cavity hint Replaces the flat 0.25 ambient + single-Lambert key with three cheap shape-readability tricks, all in the fragment shader: - Hemisphere ambient (sky/ground tint mixed by n.z) so floors, ceilings, and walls get visibly different ambient colour even when shadowed. +Z is world-up. - Secondary fill light at 35% intensity from roughly the opposite horizontal direction so backs of objects are not pitch black. - Cavity hint: clamp(length(fwidth(n)) * 1.5, 0, 0.35) darkens fragments where adjacent normals diverge sharply. Catches wall-floor seams, column-slab joints, and stair edges as faint dark lines without any post-process. Total cost: ~8 extra ALU ops per fragment, no extra passes, no extra buffers. No change to cull/HiZ/MDI. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 45 +++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 9a124cf9b3..c393f8c994 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -155,7 +155,10 @@ in vec3 v_world_pos; flat in uint v_object_id; flat in uint v_selected; -uniform vec3 u_light_dir; +uniform vec3 u_light_dir; // primary key direction (world-space) +uniform vec3 u_fill_dir; // secondary fill direction +uniform vec3 u_sky_color; // hemisphere top tint +uniform vec3 u_ground_color; // hemisphere bottom tint // Section planes — clip the half-space dot(n,p)+d > 0. AND-combined. const int MAX_CLIP_PLANES = 8; @@ -177,10 +180,28 @@ void main() { // true and has no effect. vec3 n = normalize(v_normal); if (!gl_FrontFacing) n = -n; - 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); + + // Hemisphere ambient: faces pointing up read sky, down read ground. + // World-up is +Z so n.z drives the mix. Floors/ceilings/walls get + // visibly distinct ambient even when shadowed. + float hemi_t = 0.5 + 0.5 * n.z; + vec3 ambient = mix(u_ground_color, u_sky_color, hemi_t); + + // Key + fill direct light. Fill is a softer secondary so backs of + // objects don't go pitch black — keeps shape readable from any angle. + float key = max(dot(n, u_light_dir), 0.0); + float fill = max(dot(n, u_fill_dir), 0.0) * 0.35; + + vec3 color = v_color.rgb * (ambient + (key + fill) * 0.7); + + // Cavity shading: where adjacent fragments have a sharp normal change + // (concave creases, edges where two faces meet), darken slightly. + // length(fwidth(n)) spikes at those boundaries; the clamp caps how + // dark a single edge can get so the effect is a hint, not a heavy + // outline. + float cavity = clamp(length(fwidth(n)) * 1.5, 0.0, 0.35); + color *= (1.0 - cavity); + if (v_selected == 1u) color = mix(color, vec3(0.2, 0.6, 1.0), 0.5); frag_color = vec4(color, v_color.a); } @@ -2402,9 +2423,21 @@ void ViewportWindow::render() { gl_->glUseProgram(main_program_); GLint u_vp = gl_->glGetUniformLocation(main_program_, "u_view_projection"); GLint u_light = gl_->glGetUniformLocation(main_program_, "u_light_dir"); + GLint u_fill = gl_->glGetUniformLocation(main_program_, "u_fill_dir"); + GLint u_sky = gl_->glGetUniformLocation(main_program_, "u_sky_color"); + GLint u_ground = gl_->glGetUniformLocation(main_program_, "u_ground_color"); GLint u_sel = gl_->glGetUniformLocation(main_program_, "u_selected_id"); gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData()); - gl_->glUniform3f(u_light, 0.3f, 0.5f, 0.8f); + // Key light: high noon-ish from off-camera; fill ~120° away so back-of- + // object surfaces still get some direct contribution. Sky/ground tints + // are a neutral cool-on-warm pairing — readable on white walls and grey + // slabs without colouring shaded faces noticeably. + // Both directions are roughly unit-length (~0.99) — matches how + // u_light_dir was already supplied and keeps the brightness math sane. + gl_->glUniform3f(u_light, 0.3f, 0.5f, 0.8f); + gl_->glUniform3f(u_fill, -0.3f, -0.5f, 0.8f); + gl_->glUniform3f(u_sky, 0.55f, 0.60f, 0.70f); + gl_->glUniform3f(u_ground, 0.35f, 0.32f, 0.28f); gl_->glUniform1ui(u_sel, selected_object_id_); uploadClipPlaneUniforms(main_program_); From db1a2705a3021e434dddb44ded535289c4c7a550 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 14:34:38 +1000 Subject: [PATCH 081/120] ifcviewer: edge enhancement post-pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-frame depth-laplacian pass that darkens pixels at sharp depth discontinuities — silhouettes, overlapping-surface boundaries, section-cut edges. Catches the wall-against-wall and slab-against- ceiling cases that the cavity hint in the lighting shader misses. Implementation: - New edge_depth_fbo_ / edge_depth_tex_ — single-sample D24S8 the size of the window. After the main draw, blit the default FB depth into it (handles MSAA resolve in the same call). - Fullscreen triangle generated from gl_VertexID, samples four cardinal neighbours, computes |4c - n - s - e - w| on linearized depth. Linearization branches between perspective and ortho via u_is_ortho. Threshold scales with depth so distant edges still register. - Output is multiplicatively blended (GL_DST_COLOR, GL_ZERO) so colours just darken; no separate composite step. - Runs before the pivot/section/axis gizmos so they aren't outlined themselves. HiZ pyramid build still runs after, unchanged. Per-frame cost is one MSAA depth blit + one fullscreen pass with five depth samples. Sub-millisecond at 1080p on a mid GPU. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 130 +++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 15 ++++ 2 files changed, 145 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index c393f8c994..69fb587088 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -371,6 +371,60 @@ out vec4 frag_color; void main() { frag_color = v_color; } )"; +// Edge pass — fullscreen triangle generated from gl_VertexID, samples +// resolved depth at four cardinal neighbours, computes a laplacian, and +// outputs a per-pixel darkening factor that gets multiplied into the +// existing colour via GL_DST_COLOR * GL_ZERO blending. +static const char* EDGE_VERTEX_SHADER = R"( +#version 450 core +out vec2 v_uv; +void main() { + vec2 pos = vec2((gl_VertexID & 1) << 2, (gl_VertexID & 2) << 1) - 1.0; + v_uv = pos * 0.5 + 0.5; + gl_Position = vec4(pos, 0.0, 1.0); +} +)"; + +static const char* EDGE_FRAGMENT_SHADER = R"( +#version 450 core +in vec2 v_uv; +uniform sampler2D u_depth; +uniform vec2 u_texel; // 1.0 / depth-texture size +uniform float u_near; +uniform float u_far; +uniform float u_is_ortho; // 0 or 1 +uniform float u_scale; // edge-darkening multiplier +uniform float u_threshold; // laplacian floor (relative to depth) +out vec4 frag_color; + +float linearize(float z) { + if (u_is_ortho > 0.5) { + // Ortho: depth buffer is already a linear remap of view-z. + return mix(u_near, u_far, z); + } + // Perspective: standard NDC -> view-z reverse projection. + float ndc = z * 2.0 - 1.0; + return (2.0 * u_near * u_far) / (u_far + u_near - ndc * (u_far - u_near)); +} + +void main() { + float c = linearize(texture(u_depth, v_uv).r); + float n = linearize(texture(u_depth, v_uv + vec2( 0.0, u_texel.y)).r); + float s = linearize(texture(u_depth, v_uv + vec2( 0.0, -u_texel.y)).r); + float e = linearize(texture(u_depth, v_uv + vec2( u_texel.x, 0.0)).r); + float w = linearize(texture(u_depth, v_uv + vec2(-u_texel.x, 0.0)).r); + + // Laplacian magnitude: ~0 on smooth surfaces, large at depth jumps. + // Threshold scales with depth so distant edges still register. + float lap = abs(4.0 * c - n - s - e - w); + float t = u_threshold * c; + float edge = clamp((lap - t) * u_scale, 0.0, 0.6); + + // Multiplicative blend (GL_DST_COLOR, GL_ZERO): out = dst * rgb. + frag_color = vec4(vec3(1.0 - edge), 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); @@ -573,11 +627,15 @@ ViewportWindow::~ViewportWindow() { if (pivot_vbo_) gl_->glDeleteBuffers(1, &pivot_vbo_); if (plane_vao_) gl_->glDeleteVertexArrays(1, &plane_vao_); if (plane_vbo_) gl_->glDeleteBuffers(1, &plane_vbo_); + if (edge_vao_) gl_->glDeleteVertexArrays(1, &edge_vao_); + if (edge_depth_fbo_) gl_->glDeleteFramebuffers(1, &edge_depth_fbo_); + if (edge_depth_tex_) gl_->glDeleteTextures(1, &edge_depth_tex_); if (main_program_) gl_->glDeleteProgram(main_program_); if (pick_program_) gl_->glDeleteProgram(pick_program_); if (axis_program_) gl_->glDeleteProgram(axis_program_); if (pivot_program_) gl_->glDeleteProgram(pivot_program_); if (plane_program_) gl_->glDeleteProgram(plane_program_); + if (edge_program_) gl_->glDeleteProgram(edge_program_); if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_); if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_); if (pick_pos_tex_) gl_->glDeleteTextures(1, &pick_pos_tex_); @@ -725,6 +783,12 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PLANE_FRAGMENT_SHADER); plane_program_ = linkProgram(gl_, vs, fs); } + { + GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, EDGE_VERTEX_SHADER); + GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, EDGE_FRAGMENT_SHADER); + edge_program_ = linkProgram(gl_, vs, fs); + gl_->glCreateVertexArrays(1, &edge_vao_); + } { GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, HIZ_DOWNSAMPLE_VS); GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, HIZ_DOWNSAMPLE_FS); @@ -2593,6 +2657,7 @@ void ViewportWindow::render() { hiz_reject_count_.load()); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); + renderEdgePass(); renderPivotIndicator(); renderSectionPlanes(); renderAxisGizmo(); @@ -3118,6 +3183,71 @@ void ViewportWindow::updateSectionDrag(int x, int y) { requestUpdate(); } +void ViewportWindow::renderEdgePass() { + if (!edge_program_) return; + const int w = width() * devicePixelRatio(); + const int h = height() * devicePixelRatio(); + if (w <= 0 || h <= 0) return; + + // Lazy resize. D24S8 to match Qt's default FBO format (depth+stencil + // even though we only sample depth) so the blit doesn't fail. + if (edge_w_ != w || edge_h_ != h) { + if (edge_depth_fbo_) gl_->glDeleteFramebuffers(1, &edge_depth_fbo_); + if (edge_depth_tex_) gl_->glDeleteTextures(1, &edge_depth_tex_); + gl_->glCreateTextures(GL_TEXTURE_2D, 1, &edge_depth_tex_); + gl_->glTextureStorage2D(edge_depth_tex_, 1, GL_DEPTH24_STENCIL8, w, h); + gl_->glCreateFramebuffers(1, &edge_depth_fbo_); + gl_->glNamedFramebufferTexture(edge_depth_fbo_, GL_DEPTH_STENCIL_ATTACHMENT, + edge_depth_tex_, 0); + edge_w_ = w; edge_h_ = h; + } + + // Step 1: resolve MSAA depth from default FB → single-sample texture. + gl_->glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + gl_->glBindFramebuffer(GL_DRAW_FRAMEBUFFER, edge_depth_fbo_); + gl_->glBlitFramebuffer(0, 0, w, h, 0, 0, w, h, + GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, + GL_NEAREST); + + // Step 2: fullscreen darkening pass into default FB colour. + gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); + gl_->glViewport(0, 0, w, h); + gl_->glDisable(GL_DEPTH_TEST); + gl_->glDepthMask(GL_FALSE); + gl_->glEnable(GL_BLEND); + gl_->glBlendFunc(GL_DST_COLOR, GL_ZERO); + + gl_->glUseProgram(edge_program_); + gl_->glTextureParameteri(edge_depth_tex_, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl_->glTextureParameteri(edge_depth_tex_, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl_->glTextureParameteri(edge_depth_tex_, GL_TEXTURE_COMPARE_MODE, GL_NONE); + gl_->glBindTextureUnit(0, edge_depth_tex_); + + // Match the (near, far) used in updateCamera(). For ortho the near + // plane is at -depth_extent, far at +depth_extent. + const float depth_extent = camera_distance_ * 10.0f; + const float near_z = projection_ortho_ ? -depth_extent : 0.1f; + const float far_z = depth_extent; + + gl_->glUniform1i(gl_->glGetUniformLocation(edge_program_, "u_depth"), 0); + gl_->glUniform2f(gl_->glGetUniformLocation(edge_program_, "u_texel"), + 1.0f / float(w), 1.0f / float(h)); + gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_near"), near_z); + gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_far"), far_z); + gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_is_ortho"), + projection_ortho_ ? 1.0f : 0.0f); + gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_scale"), 6.0f); + gl_->glUniform1f(gl_->glGetUniformLocation(edge_program_, "u_threshold"), 0.004f); + + gl_->glBindVertexArray(edge_vao_); + gl_->glDrawArrays(GL_TRIANGLES, 0, 3); + gl_->glBindVertexArray(0); + + gl_->glDisable(GL_BLEND); + gl_->glDepthMask(GL_TRUE); + gl_->glEnable(GL_DEPTH_TEST); +} + void ViewportWindow::renderPivotIndicator() { if (!pivot_indicator_visible_ || !pivot_program_ || !pivot_vao_) return; diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 25e71c9d48..a64657fbc2 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -289,6 +289,11 @@ private: void renderPivotIndicator(); void renderSectionPlanes(); void buildSectionPlaneGizmo(); + // Post-process edge enhancement: resolve MSAA depth into a single- + // sample texture, then run a fullscreen pass that detects sharp + // depth-laplacian peaks and darkens the colour buffer there. Catches + // silhouettes and overlapping-surface boundaries as faint dark lines. + void renderEdgePass(); // Returns the index of the section plane whose arrow gizmo is under // (x, y), or -1 if none. Screen-space line-segment distance test. int hitTestSectionGizmo(int x, int y) const; @@ -548,6 +553,16 @@ private: int plane_arrow_offset_ = 0; int plane_arrow_count_ = 0; + // Edge-enhancement pass resources. edge_depth_tex_ is a single-sample + // resolve target the size of the window; we blit the default FB depth + // into it each frame, then sample it from the fullscreen edge shader. + GLuint edge_program_ = 0; + GLuint edge_depth_fbo_ = 0; + GLuint edge_depth_tex_ = 0; + GLuint edge_vao_ = 0; // empty VAO for fullscreen-triangle draw + int edge_w_ = 0; + int edge_h_ = 0; + // FPS smoothing int frame_count_ = 0; float accumulated_time_ = 0.0f; From 3e2869b6aaf39fad73939856cd6509f565f694e2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 15:05:32 +1000 Subject: [PATCH 082/120] ifcviewer: re-enable contribution culling in ortho mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous projection-toggle commit short-circuited contribution culling when projection_ortho_ was set — the formula r_px = focal_px * r / dist looks like it depends on per-instance distance, which doesn't apply in ortho. Result: every frustum- visible object drew, including sub-pixel ones, and FPS tanked on top-down plan views. In ortho the projected pixel size of a bounding sphere is constant: r_px = pixels_per_world * r, where pixels_per_world equals the existing focal_px / camera_distance_ (the ortho box was sized to match perspective at the pivot's distance). So the same formula gives the right answer if we replace per-instance dist with camera_distance_. cullModelCpu now does that substitution for both contributionPasses and pixelRadius (the latter feeds LOD1 selection too — sub-pixel objects pick LOD1 in ortho the same way they do in perspective). The "camera inside AABB" early-return is kept; it only fires in perspective where dist→0 would otherwise blow up r_px, and is harmless in ortho. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 49 +++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 69fb587088..d9aaa1f733 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -2189,9 +2189,20 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], const float cx = camera_eye_.x(); const float cy = camera_eye_.y(); const float cz = camera_eye_.z(); + // In ortho the projected pixel size of a bounding sphere doesn't depend + // on per-instance distance — the ortho box scales the entire scene by a + // constant. Substitute that constant (camera_distance_, which is + // exactly the half-height/tan(fovy/2) used to size the box) for the + // per-instance dist so the same `r_px = focal_px * r / dist` formula + // and threshold survive both modes. Snapshot now so the worker threads + // see a consistent value. + const bool ortho_mode = projection_ortho_; + const float ortho_dist = camera_distance_; + auto contributionPasses = [&](const float mn[3], const float mx[3]) -> bool { if (min_pixel_radius <= 0.0f) return true; - // Camera inside AABB? Always keep. + // Camera inside AABB? Always keep — only relevant in perspective + // where dist→0 would make r_px blow up; harmless in ortho too. if (cx >= mn[0] && cx <= mx[0] && cy >= mn[1] && cy <= mx[1] && cz >= mn[2] && cz <= mx[2]) { @@ -2201,10 +2212,15 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], float ey = 0.5f * (mx[1] - mn[1]); float ez = 0.5f * (mx[2] - mn[2]); float radius = std::sqrt(ex*ex + ey*ey + ez*ez); - float dx = 0.5f * (mx[0] + mn[0]) - cx; - float dy = 0.5f * (mx[1] + mn[1]) - cy; - float dz = 0.5f * (mx[2] + mn[2]) - cz; - float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + float dist; + if (ortho_mode) { + dist = ortho_dist; + } else { + float dx = 0.5f * (mx[0] + mn[0]) - cx; + float dy = 0.5f * (mx[1] + mn[1]) - cy; + float dz = 0.5f * (mx[2] + mn[2]) - cz; + dist = std::sqrt(dx*dx + dy*dy + dz*dz); + } // r_px = focal_px * radius / dist; compare r_px >= min_pixel_radius, // rearranged to avoid the divide. return focal_px * radius >= min_pixel_radius * dist; @@ -2223,10 +2239,15 @@ void ViewportWindow::cullModelCpu(ModelGpuData& m, const float planes[6][4], float ey = 0.5f * (mx[1] - mn[1]); float ez = 0.5f * (mx[2] - mn[2]); float radius = std::sqrt(ex*ex + ey*ey + ez*ez); - float dx = 0.5f * (mx[0] + mn[0]) - cx; - float dy = 0.5f * (mx[1] + mn[1]) - cy; - float dz = 0.5f * (mx[2] + mn[2]) - cz; - float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + float dist; + if (ortho_mode) { + dist = ortho_dist; + } else { + float dx = 0.5f * (mx[0] + mn[0]) - cx; + float dy = 0.5f * (mx[1] + mn[1]) - cy; + float dz = 0.5f * (mx[2] + mn[2]) - cz; + dist = std::sqrt(dx*dx + dy*dy + dz*dz); + } return dist > 0.0f ? focal_px * radius / dist : std::numeric_limits::infinity(); }; @@ -2533,11 +2554,11 @@ void ViewportWindow::render() { // depth would normally populate the pyramid), causing false occlusion. if (needs_settle_recull) hiz_vp_valid_ = false; - // Contribution culling assumes perspective (r_px = focal_px * r / dist); - // in ortho the per-instance distance is irrelevant, so disable it rather - // than ship wrong results. Frustum + HiZ culling still run. - const float min_pixel_radius = projection_ortho_ ? 0.0f - : (use_motion_threshold ? motion_min_pixel_radius : base_min_pixel_radius); + // Contribution culling works in both modes: cullModelCpu substitutes + // camera_distance_ for the per-instance dist when projection_ortho_ is + // set, which matches the ortho box's constant pixels-per-world. + const float min_pixel_radius = use_motion_threshold + ? motion_min_pixel_radius : base_min_pixel_radius; if (cull_this_frame) { hiz_reject_count_.store(0, std::memory_order_relaxed); last_cull_was_motion_ = camera_moving; From a2db0a68a49f4856ed757367d916e9e792619756 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 11:58:36 +1000 Subject: [PATCH 083/120] ifcviewer: port auto_local2global to C++ in Geolocation.{h,cpp} Mirrors ifcopenshell.util.geolocation: HelmertTransformation parameters (IfcMapConversion / IfcMapConversionScaled / IfcRigidOperation, plus IFC2X3 ePSet_MapConversion), get_wcs from IfcGeometricRepresentationContext, local2global, and auto_local2global. Lives in src/ifcviewer/ for now; will move out when ifcopenshell.util is ported to C++. Not yet wired into the streamer. A subsequent commit fixes the unit handling for the iterator's meter-by-default output. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Geolocation.cpp | 276 ++++++++++++++++++++++++++++++++++ src/ifcviewer/Geolocation.h | 73 +++++++++ 2 files changed, 349 insertions(+) create mode 100644 src/ifcviewer/Geolocation.cpp create mode 100644 src/ifcviewer/Geolocation.h diff --git a/src/ifcviewer/Geolocation.cpp b/src/ifcviewer/Geolocation.cpp new file mode 100644 index 0000000000..9649dc1255 --- /dev/null +++ b/src/ifcviewer/Geolocation.cpp @@ -0,0 +1,276 @@ +/******************************************************************************** + * * + * 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 "Geolocation.h" + +#include "../ifcparse/express.h" +#include "../ifcparse/file.h" +#include "../ifcparse/instance_data.h" +#include "../ifcparse/schema.h" + +#include +#include +#include + +namespace { + +// IfcAxis2Placement3D / IfcAxis2PlacementLinear -> column-major 4x4 matrix. +// Mirrors ifcopenshell.util.placement.a2p + get_axis2placement, but only the +// branches needed for IfcGeometricRepresentationContext.WorldCoordinateSystem. +std::optional getAxis2Placement(express::Base placement) { + if (!placement) return std::nullopt; + const auto& decl = placement.declaration(); + if (!(decl.is("IfcAxis2Placement3D") || decl.is("IfcAxis2PlacementLinear"))) { + return std::nullopt; + } + + auto entity = placement.as(); + + Eigen::Vector3d z(0.0, 0.0, 1.0); + Eigen::Vector3d x(1.0, 0.0, 0.0); + + auto axis_attr = entity.get("Axis"); + if (!axis_attr.isNull()) { + express::Base axis = axis_attr; + std::vector dr = + axis.as().get("DirectionRatios"); + if (dr.size() >= 3) z = Eigen::Vector3d(dr[0], dr[1], dr[2]); + } + + auto refdir_attr = entity.get("RefDirection"); + if (!refdir_attr.isNull()) { + express::Base refdir = refdir_attr; + std::vector dr = + refdir.as().get("DirectionRatios"); + if (dr.size() >= 3) x = Eigen::Vector3d(dr[0], dr[1], dr[2]); + } + + auto loc_attr = entity.get("Location"); + if (loc_attr.isNull()) return std::nullopt; + express::Base location = loc_attr; + auto coords_attr = location.as().get("Coordinates"); + if (coords_attr.isNull()) return std::nullopt; + std::vector coords = coords_attr; + if (coords.size() < 3) return std::nullopt; + + Eigen::Vector3d xn = x.normalized(); + Eigen::Vector3d zn = z.normalized(); + Eigen::Vector3d yn = zn.cross(xn).normalized(); + + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.block<3, 1>(0, 0) = xn; + m.block<3, 1>(0, 1) = yn; + m.block<3, 1>(0, 2) = zn; + m(0, 3) = coords[0]; + m(1, 3) = coords[1]; + m(2, 3) = coords[2]; + return m; +} + +// Read a numeric NominalValue out of an IfcPropertySingleValue. IFC2X3 +// ePSet_MapConversion stores eastings/northings/scale as IfcLengthMeasure or +// IfcReal wrapped inside IfcValue (a SELECT) — get_attribute_value(0) peels +// the wrapper. Returns nullopt if the value is missing or non-numeric. +std::optional readPropertyValueDouble(const express::Base& property) { + if (!property.declaration().is("IfcPropertySingleValue")) return std::nullopt; + auto pe = property.as(); + auto nv = pe.get("NominalValue"); + if (nv.isNull()) return std::nullopt; + express::Base wrapper = nv; + auto inner = wrapper.get_attribute_value(0); + if (inner.isNull()) return std::nullopt; + switch (inner.type()) { + case ifcopenshell::Argument_DOUBLE: return (double) inner; + case ifcopenshell::Argument_INT: return (double)(int) inner; + default: return std::nullopt; + } +} + +} // namespace + +std::optional +getHelmertTransformationParameters(ifcopenshell::file* ifc_file) { + HelmertTransformation p; + const std::string schema_name = ifc_file->schema()->name(); + + if (schema_name == "IFC2X3") { + auto projects = ifc_file->instances_by_type("IfcProject"); + if (projects.empty()) return std::nullopt; + const auto& project = projects[0]; + + bool found = false; + auto rels = project.as().get_inverse("IsDefinedBy"); + for (const auto& rel : rels) { + if (!rel.declaration().is("IfcRelDefinesByProperties")) continue; + express::Base pset_base = rel.get("RelatingPropertyDefinition"); + if (!pset_base.declaration().is("IfcPropertySet")) continue; + auto pset = pset_base.as(); + auto name_attr = pset.get("Name"); + if (name_attr.isNull()) continue; + std::string pset_name = name_attr; + if (pset_name != "ePSet_MapConversion") continue; + + std::vector props = pset.get("HasProperties"); + for (const auto& prop : props) { + if (!prop.declaration().is("IfcPropertySingleValue")) continue; + auto pe = prop.as(); + auto pname_attr = pe.get("Name"); + if (pname_attr.isNull()) continue; + std::string pname = pname_attr; + auto value = readPropertyValueDouble(prop); + if (!value) continue; + if (pname == "Eastings") p.e = *value; + else if (pname == "Northings") p.n = *value; + else if (pname == "OrthogonalHeight") p.h = *value; + else if (pname == "XAxisAbscissa") p.xaa = *value; + else if (pname == "XAxisOrdinate") p.xao = *value; + else if (pname == "Scale") p.scale = *value; + } + found = true; + break; + } + if (!found) return std::nullopt; + + // Python: `conversion.get("Scale", None) or 1` — 0 falls back to 1. + if (p.scale == 0.0) p.scale = 1.0; + p.factor_x = p.factor_y = p.factor_z = 1.0; + } else { + std::vector conversions; + try { + conversions = ifc_file->instances_by_type("IfcCoordinateOperation"); + } catch (...) { + // Schema doesn't know IfcCoordinateOperation. + return std::nullopt; + } + if (conversions.empty()) return std::nullopt; + const auto& conversion = conversions[0]; + auto entity = conversion.as(); + const std::string type_name = conversion.declaration().name(); + + auto get_or = [&](const std::string& name, double fallback) { + auto a = entity.get(name); + return a.isNull() ? fallback : (double) a; + }; + + if (conversion.declaration().is("IfcMapConversion")) { + p.e = get_or("Eastings", 0.0); + p.n = get_or("Northings", 0.0); + p.h = get_or("OrthogonalHeight", 0.0); + p.xaa = get_or("XAxisAbscissa", 0.0); + p.xao = get_or("XAxisOrdinate", 0.0); + p.scale = get_or("Scale", 1.0); + if (p.scale == 0.0) p.scale = 1.0; + + if (type_name == "IfcMapConversionScaled") { + p.factor_x = entity.get("FactorX"); + p.factor_y = entity.get("FactorY"); + p.factor_z = entity.get("FactorZ"); + } else { + p.factor_x = p.factor_y = p.factor_z = 1.0; + } + } else if (type_name == "IfcRigidOperation") { + // FirstCoordinate / SecondCoordinate are IfcLengthMeasure-typed + // values; the C++ binding auto-unwraps defined types of REAL. + p.e = get_or("FirstCoordinate", 0.0); + p.n = get_or("SecondCoordinate", 0.0); + p.h = get_or("Height", 0.0); + p.xaa = 1.0; + p.xao = 0.0; + p.scale = p.factor_x = p.factor_y = p.factor_z = 1.0; + } else { + return std::nullopt; + } + } + + if (p.xaa == 0.0 && p.xao == 0.0) { + p.xaa = 1.0; + p.xao = 0.0; + } + return p; +} + +std::optional getWcs(ifcopenshell::file* ifc_file) { + auto contexts = ifc_file->instances_by_type_excl_subtypes( + "IfcGeometricRepresentationContext"); + express::Base wcs; + bool found = false; + for (const auto& ctx : contexts) { + auto entity = ctx.as(); + auto wcs_attr = entity.get("WorldCoordinateSystem"); + if (wcs_attr.isNull()) continue; + wcs = (express::Base) wcs_attr; + found = true; + auto ctype_attr = entity.get("ContextType"); + if (!ctype_attr.isNull()) { + std::string ctype = ctype_attr; + if (ctype == "Model") break; + } + } + if (!found) return std::nullopt; + return getAxis2Placement(wcs); +} + +Eigen::Matrix4d local2global(const Eigen::Matrix4d& matrix, + const HelmertTransformation& p) { + const double theta = std::atan2(p.xao, p.xaa); + const double c = std::cos(theta); + const double s = std::sin(theta); + + Eigen::Matrix4d S = Eigen::Matrix4d::Identity(); + S(0, 0) = p.scale * p.factor_x; + S(1, 1) = p.scale * p.factor_y; + S(2, 2) = p.scale * p.factor_z; + + Eigen::Matrix4d R = Eigen::Matrix4d::Identity(); + R(0, 0) = c; R(0, 1) = -s; + R(1, 0) = s; R(1, 1) = c; + + Eigen::Matrix4d result = R * S * matrix; + // The scale was baked into the rotation+scale matrix so each axis column + // ended up scaled. Renormalise so the rotation part is pure orientation + // and the translation alone carries the scaled offsets. + for (int col = 0; col < 3; ++col) { + Eigen::Vector3d v = result.block<3, 1>(0, col); + const double n = v.norm(); + if (n > 0.0) result.block<3, 1>(0, col) = v / n; + } + result(0, 3) += p.e; + result(1, 3) += p.n; + result(2, 3) += p.h; + return result; +} + +Eigen::Matrix4d autoLocal2Global(ifcopenshell::file* ifc_file, + const Eigen::Matrix4d& matrix, + bool should_return_in_map_units) { + auto params = getHelmertTransformationParameters(ifc_file); + if (!params) return matrix; + + Eigen::Matrix4d m = matrix; + if (auto wcs = getWcs(ifc_file)) { + m = wcs->inverse() * m; + } + Eigen::Matrix4d result = local2global(m, *params); + if (!should_return_in_map_units) { + result(0, 3) /= params->scale; + result(1, 3) /= params->scale; + result(2, 3) /= params->scale; + } + return result; +} diff --git a/src/ifcviewer/Geolocation.h b/src/ifcviewer/Geolocation.h new file mode 100644 index 0000000000..e83640a718 --- /dev/null +++ b/src/ifcviewer/Geolocation.h @@ -0,0 +1,73 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +// Port of selected helpers from +// src/ifcopenshell-python/ifcopenshell/util/geolocation.py — primarily +// auto_local2global, which builds a 4x4 matrix that lifts an element's local +// transform into the model's global (georeferenced) frame. The python utils +// are expected to be ported to C++ in their own module later; this file is +// the temporary home until that lands. + +#ifndef GEOLOCATION_H +#define GEOLOCATION_H + +#include + +#include + +namespace ifcopenshell { class file; } + +struct HelmertTransformation { + double e = 0.0; // eastings offset + double n = 0.0; // northings offset + double h = 0.0; // orthogonal-height offset + double xaa = 1.0; // X-axis abscissa (cos of grid-rotation angle) + double xao = 0.0; // X-axis ordinate (sin of grid-rotation angle) + double scale = 1.0; // unit scale (project unit -> map unit) + double factor_x = 1.0; // combined scale factor along X + double factor_y = 1.0; // combined scale factor along Y + double factor_z = 1.0; // combined scale factor along Z +}; + +// Detect a Helmert transformation in the IFC model. Reads IfcMapConversion / +// IfcMapConversionScaled / IfcRigidOperation in IFC4+, or the +// IfcProject.ePSet_MapConversion property set in IFC2X3. Returns nullopt +// when the model has no map conversion. +std::optional +getHelmertTransformationParameters(ifcopenshell::file* ifc_file); + +// Read the IfcGeometricRepresentationContext.WorldCoordinateSystem (preferring +// the "Model" context) as a 4x4 matrix. Returns nullopt when the model has +// no parseable WCS. +std::optional getWcs(ifcopenshell::file* ifc_file); + +// Apply a Helmert transformation to a 4x4 local matrix. +Eigen::Matrix4d local2global(const Eigen::Matrix4d& matrix, + const HelmertTransformation& params); + +// Lift a 4x4 local matrix into global (map) coordinates using the IFC model's +// georeferencing data. When no map conversion is present the matrix is +// returned unchanged. When should_return_in_map_units is false, the +// translation column is divided by the map scale so the result is expressed +// in project length units. +Eigen::Matrix4d autoLocal2Global(ifcopenshell::file* ifc_file, + const Eigen::Matrix4d& matrix, + bool should_return_in_map_units = true); + +#endif // GEOLOCATION_H From 0d3849737cbfbd1ea3b2f4c33b04130adfdce20b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 12:12:29 +1000 Subject: [PATCH 084/120] ifcviewer: port unit utilities to C++ in Unit.{h,cpp} Mirrors selected helpers from ifcopenshell.util.unit: SI prefix multipliers, the conversion-based-unit table (foot/inch/etc -> SI metres), siScaleFromNamedUnit (walks IfcConversionBasedUnit chains down to IfcSIUnit), getUnitAssignment / getProjectUnit / calculateUnitScale, and convert / convertUnit. Lives in src/ifcviewer/ for now alongside Geolocation; will move out when ifcopenshell.util is ported to C++. Needed by upcoming Geolocation fix (e/n/h on IfcMapConversion are in MapUnit, must be converted to metres for the meter-by-default iterator output) and by the federation module (display-unit conversion when the user changes the federation unit). Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Unit.cpp | 329 +++++++++++++++++++++++++++++++++++++++++ src/ifcviewer/Unit.h | 89 +++++++++++ 2 files changed, 418 insertions(+) create mode 100644 src/ifcviewer/Unit.cpp create mode 100644 src/ifcviewer/Unit.h diff --git a/src/ifcviewer/Unit.cpp b/src/ifcviewer/Unit.cpp new file mode 100644 index 0000000000..9a7162ee24 --- /dev/null +++ b/src/ifcviewer/Unit.cpp @@ -0,0 +1,329 @@ +/******************************************************************************** + * * + * 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 "Unit.h" + +#include "../ifcparse/file.h" +#include "../ifcparse/instance_data.h" +#include "../ifcparse/schema.h" + +#include +#include + +const std::unordered_map kSiPrefixes = { + { "EXA", 1e18 }, + { "PETA", 1e15 }, + { "TERA", 1e12 }, + { "GIGA", 1e9 }, + { "MEGA", 1e6 }, + { "KILO", 1e3 }, + { "HECTO", 1e2 }, + { "DECA", 1e1 }, + { "DECI", 1e-1 }, + { "CENTI", 1e-2 }, + { "MILLI", 1e-3 }, + { "MICRO", 1e-6 }, + { "NANO", 1e-9 }, + { "PICO", 1e-12 }, + { "FEMTO", 1e-15 }, + { "ATTO", 1e-18 }, +}; + +const std::unordered_map kSiPrefixSymbols = { + { "EXA", "E" }, + { "PETA", "P" }, + { "TERA", "T" }, + { "GIGA", "G" }, + { "MEGA", "M" }, + { "KILO", "k" }, + { "HECTO", "h" }, + { "DECA", "da" }, + { "DECI", "d" }, + { "CENTI", "c" }, + { "MILLI", "m" }, + { "MICRO", "\xCE\xBC" }, // μ (UTF-8) + { "NANO", "n" }, + { "PICO", "p" }, + { "FEMTO", "f" }, + { "ATTO", "a" }, +}; + +const std::unordered_map kSiConversions = { + { "thou", 0.0000254 }, + { "inch", 0.0254 }, + { "foot", 0.3048 }, + { "yard", 0.914 }, + { "mile", 1609.0 }, + { "square thou", 6.4516e-10 }, + { "square inch", 0.0006452 }, + { "square foot", 0.09290304 }, + { "square yard", 0.83612736 }, + { "acre", 4046.86 }, + { "square mile", 2588881.0 }, + { "cubic thou", 1.6387064e-14 }, + { "cubic inch", 0.00001639 }, + { "cubic foot", 0.02831684671168849 }, + { "cubic yard", 0.7636 }, + { "cubic mile", 4165509529.0 }, + { "litre", 0.001 }, + { "fluid ounce uk", 0.0000284130625 }, + { "fluid ounce us", 0.00002957353 }, + { "pint uk", 0.000568 }, + { "pint us", 0.000473 }, + { "gallon uk", 0.004546 }, + { "gallon us", 0.003785 }, + { "degree", 0.0174532925199433 }, // pi / 180 + { "ounce", 0.02835 }, + { "pound", 0.454 }, + { "ton uk", 1016.0469088 }, + { "ton us", 907.18474 }, + { "tonne", 1000.0 }, + { "lbf", 4.4482216153 }, + { "kip", 4448.2216153 }, + { "psi", 6894.7572932 }, + { "ksi", 6894757.2932 }, + { "minute", 60.0 }, + { "hour", 3600.0 }, + { "day", 86400.0 }, + { "btu", 1055.056 }, + { "fahrenheit", 1.8 }, +}; + +const std::unordered_map kImperialTypes = { + { "thou", "LENGTHUNIT" }, { "inch", "LENGTHUNIT" }, { "foot", "LENGTHUNIT" }, + { "yard", "LENGTHUNIT" }, { "mile", "LENGTHUNIT" }, + { "square thou", "AREAUNIT" }, { "square inch", "AREAUNIT" }, + { "square foot", "AREAUNIT" }, { "square yard", "AREAUNIT" }, + { "acre", "AREAUNIT" }, { "square mile", "AREAUNIT" }, + { "cubic thou", "VOLUMEUNIT" }, { "cubic inch", "VOLUMEUNIT" }, + { "cubic foot", "VOLUMEUNIT" }, { "cubic yard", "VOLUMEUNIT" }, + { "cubic mile", "VOLUMEUNIT" }, { "litre", "VOLUMEUNIT" }, + { "fluid ounce uk", "VOLUMEUNIT" }, { "fluid ounce us", "VOLUMEUNIT" }, + { "pint uk", "VOLUMEUNIT" }, { "pint us", "VOLUMEUNIT" }, + { "gallon uk", "VOLUMEUNIT" }, { "gallon us", "VOLUMEUNIT" }, + { "degree", "PLANEANGLEUNIT" }, + { "ounce", "MASSUNIT" }, { "pound", "MASSUNIT" }, + { "ton uk", "MASSUNIT" }, { "ton us", "MASSUNIT" }, { "tonne", "MASSUNIT" }, + { "lbf", "FORCEUNIT" }, { "kip", "FORCEUNIT" }, + { "psi", "PRESSUREUNIT" }, { "ksi", "PRESSUREUNIT" }, + { "minute", "TIMEUNIT" }, { "hour", "TIMEUNIT" }, { "day", "TIMEUNIT" }, + { "btu", "ENERGYUNIT" }, + { "fahrenheit", "THERMODYNAMICTEMPERATUREUNIT" }, +}; + +const std::unordered_map kUnitSymbols = { + // SI base / derived + { "CUBIC_METRE", "m3" }, + { "GRAM", "g" }, + { "SECOND", "s" }, + { "SQUARE_METRE", "m2" }, + { "METRE", "m" }, + { "NEWTON", "N" }, + { "PASCAL", "Pa" }, + // Conversion-based + { "pound-force", "lbf" }, + { "pound-force per square inch", "psi" }, + { "thou", "th" }, { "inch", "in" }, { "foot", "ft" }, + { "yard", "yd" }, { "mile", "mi" }, + { "square thou", "th2" }, { "square inch", "in2" }, + { "square foot", "ft2" }, { "square yard", "yd2" }, + { "acre", "ac" }, { "square mile", "mi2" }, + { "cubic thou", "th3" }, { "cubic inch", "in3" }, + { "cubic foot", "ft3" }, { "cubic yard", "yd3" }, + { "cubic mile", "mi3" }, { "litre", "L" }, + { "fluid ounce uk", "fl oz" }, { "fluid ounce us", "fl oz" }, + { "pint uk", "pt" }, { "pint us", "pt" }, + { "gallon uk", "gal" }, { "gallon us", "gal" }, + { "degree", "\xC2\xB0" }, // ° + { "ounce", "oz" }, { "pound", "lb" }, + { "ton uk", "ton" }, { "ton us", "ton" }, { "tonne", "t" }, + { "lbf", "lbf" }, { "kip", "kip" }, + { "psi", "psi" }, { "ksi", "ksi" }, + { "minute", "min" }, { "hour", "hr" }, { "day", "day" }, + { "btu", "btu" }, + { "fahrenheit", "\xC2\xB0\x46" }, // °F +}; + +namespace { + +std::string toLower(const std::string& s) { + std::string r; + r.resize(s.size()); + std::transform(s.begin(), s.end(), r.begin(), + [](unsigned char c) { return std::tolower(c); }); + return r; +} + +// Pull an enumeration string off an attribute_value, or "" if null/invalid. +std::string enumString(const attribute_value& av) { + if (av.isNull()) return {}; + if (av.type() != ifcopenshell::Argument_ENUMERATION) return {}; + enumeration_reference er = av; + return std::string(er.value() ? er.value() : ""); +} + +} // namespace + +double getPrefixMultiplier(const std::string& prefix) { + if (prefix.empty()) return 1.0; + auto it = kSiPrefixes.find(prefix); + return (it == kSiPrefixes.end()) ? 1.0 : it->second; +} + +std::optional siScaleFromNamedUnit(express::Base unit) { + double scale = 1.0; + while (unit && unit.declaration().is("IfcConversionBasedUnit")) { + auto e = unit.as(); + + // Fast path: name in si_conversions table — matches python. + std::string name; + auto name_attr = e.get("Name"); + if (!name_attr.isNull()) name = (std::string) name_attr; + if (auto it = kSiConversions.find(toLower(name)); + it != kSiConversions.end()) { + return scale * it->second; + } + + // Otherwise walk the ConversionFactor chain. + auto cf_attr = e.get("ConversionFactor"); + if (cf_attr.isNull()) return std::nullopt; + express::Base cf = cf_attr; + auto cf_e = cf.as(); + auto vc_attr = cf_e.get("ValueComponent"); + if (vc_attr.isNull()) return std::nullopt; + express::Base vc = vc_attr; + // ValueComponent is an IfcValue SELECT wrapping a measure. + scale *= (double) vc.get_attribute_value(0); + auto uc_attr = cf_e.get("UnitComponent"); + if (uc_attr.isNull()) return std::nullopt; + unit = (express::Base) uc_attr; + } + + if (unit && unit.declaration().is("IfcSIUnit")) { + auto e = unit.as(); + const std::string prefix = enumString(e.get("Prefix")); + const std::string name = enumString(e.get("Name")); + double m = getPrefixMultiplier(prefix); + // SQUARE_/CUBIC_-prefixed SI names: prefix multiplier squared/cubed. + if (name.find("SQUARE") != std::string::npos) { + m *= getPrefixMultiplier(prefix); + } else if (name.find("CUBIC") != std::string::npos) { + m *= getPrefixMultiplier(prefix); + m *= getPrefixMultiplier(prefix); + } + return scale * m; + } + + if (unit && unit.declaration().is("IfcContextDependentUnit")) { + // No conversion to SI is possible for a context-dependent unit. + return std::nullopt; + } + + return scale; +} + +std::optional getUnitAssignment(ifcopenshell::file* ifc_file) { + auto projects = ifc_file->instances_by_type("IfcProject"); + if (projects.empty()) return std::nullopt; + auto ua_attr = projects[0].as().get("UnitsInContext"); + if (ua_attr.isNull()) return std::nullopt; + return (express::Base) ua_attr; +} + +std::optional getProjectUnit(ifcopenshell::file* ifc_file, + const std::string& unit_type) { + auto ua = getUnitAssignment(ifc_file); + if (!ua) return std::nullopt; + auto units_attr = ua->as().get("Units"); + if (units_attr.isNull()) return std::nullopt; + std::vector units = units_attr; + for (const auto& unit : units) { + // IfcMonetaryUnit has no UnitType — guard via declaration check. + if (!unit.declaration().is("IfcNamedUnit") && + !unit.declaration().is("IfcDerivedUnit")) { + continue; + } + auto ut = unit.as().get("UnitType"); + if (enumString(ut) == unit_type) return unit; + } + return std::nullopt; +} + +double calculateUnitScale(ifcopenshell::file* ifc_file, + const std::string& unit_type) { + auto unit = getProjectUnit(ifc_file, unit_type); + if (!unit) return 1.0; + auto scale = siScaleFromNamedUnit(*unit); + return scale.value_or(1.0); +} + +double convert(double value, + const std::string& from_prefix, const std::string& from_unit, + const std::string& to_prefix, const std::string& to_unit) { + const std::string fl = toLower(from_unit); + const std::string tl = toLower(to_unit); + + if (auto it = kSiConversions.find(fl); it != kSiConversions.end()) { + value *= it->second; + } else if (!from_prefix.empty()) { + value *= getPrefixMultiplier(from_prefix); + if (from_unit.find("SQUARE") != std::string::npos) { + value *= getPrefixMultiplier(from_prefix); + } else if (from_unit.find("CUBIC") != std::string::npos) { + value *= getPrefixMultiplier(from_prefix); + value *= getPrefixMultiplier(from_prefix); + } + } + + if (auto it = kSiConversions.find(tl); it != kSiConversions.end()) { + return value * (1.0 / it->second); + } else if (!to_prefix.empty()) { + value *= 1.0 / getPrefixMultiplier(to_prefix); + // NB: python ifcopenshell.util.unit.convert checks `from_unit` (not + // `to_unit`) here. Mirrored for parity — from_unit and to_unit are + // always the same dimension in valid calls, so behaviour is the same. + if (from_unit.find("SQUARE") != std::string::npos) { + value *= 1.0 / getPrefixMultiplier(to_prefix); + } else if (from_unit.find("CUBIC") != std::string::npos) { + value *= 1.0 / getPrefixMultiplier(to_prefix); + value *= 1.0 / getPrefixMultiplier(to_prefix); + } + } + return value; +} + +double convertUnit(double value, express::Base from_unit, express::Base to_unit) { + auto pull = [](express::Base u, std::string& prefix, std::string& name) { + auto e = u.as(); + if (u.declaration().is("IfcSIUnit")) { + prefix = enumString(e.get("Prefix")); + name = enumString(e.get("Name")); + } else { + // IfcConversionBasedUnit / IfcContextDependentUnit: no Prefix, + // Name is a string attribute. + prefix.clear(); + auto name_attr = e.get("Name"); + name = name_attr.isNull() ? "" : (std::string) name_attr; + } + }; + std::string fp, fn, tp, tn; + pull(from_unit, fp, fn); + pull(to_unit, tp, tn); + return convert(value, fp, fn, tp, tn); +} diff --git a/src/ifcviewer/Unit.h b/src/ifcviewer/Unit.h new file mode 100644 index 0000000000..e171021960 --- /dev/null +++ b/src/ifcviewer/Unit.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 . * + * * + ********************************************************************************/ + +// Port of selected helpers from +// src/ifcopenshell-python/ifcopenshell/util/unit.py. Lives in src/ifcviewer/ +// for now alongside Geolocation; will move out once ifcopenshell.util is +// ported to C++. + +#ifndef UNIT_H +#define UNIT_H + +#include "../ifcparse/express.h" + +#include +#include +#include + +namespace ifcopenshell { class file; } + +// SI prefix multipliers, e.g. "MILLI" -> 1e-3. Empty key not present; +// callers should pass an empty prefix string for "no prefix". +extern const std::unordered_map kSiPrefixes; + +// SI prefix display symbols, e.g. "MILLI" -> "m". +extern const std::unordered_map kSiPrefixSymbols; + +// Conversion-based unit name (lowercase, IFC convention) -> SI base scale. +// e.g. "foot" -> 0.3048, "square foot" -> 0.09290304. +extern const std::unordered_map kSiConversions; + +// Conversion-based unit name -> IFC unit type, e.g. "foot" -> "LENGTHUNIT". +extern const std::unordered_map kImperialTypes; + +// Display symbol per unit name. Covers IfcSIUnit names ("METRE" -> "m") and +// IfcConversionBasedUnit names ("foot" -> "ft"). +extern const std::unordered_map kUnitSymbols; + +// Returns the multiplier for an SI prefix. Empty string returns 1.0. +double getPrefixMultiplier(const std::string& prefix); + +// Returns the SI scale for an IfcNamedUnit such that +// value_in_unit * scale == value_in_si_base +// Walks IfcConversionBasedUnit chains down to IfcSIUnit. Returns nullopt +// when the chain bottoms out in IfcContextDependentUnit (cannot convert). +std::optional siScaleFromNamedUnit(express::Base named_unit); + +// IfcProject.UnitsInContext (the IfcUnitAssignment). Returns nullopt if +// the file has no project or no assignment. +std::optional getUnitAssignment(ifcopenshell::file* ifc_file); + +// First unit in the project's IfcUnitAssignment matching `unit_type` +// (e.g. "LENGTHUNIT"). Returns nullopt if not found. +std::optional getProjectUnit(ifcopenshell::file* ifc_file, + const std::string& unit_type); + +// Project unit -> SI base scale (e.g. project in mm => 0.001). Defaults +// to 1.0 when no project unit of the requested type is set. +double calculateUnitScale(ifcopenshell::file* ifc_file, + const std::string& unit_type = "LENGTHUNIT"); + +// Convert between two units identified by name + optional SI prefix. +// SQUARE_/CUBIC_ prefixed SI names get the prefix multiplier squared/cubed +// (matches python ifcopenshell.util.unit.convert). +double convert(double value, + const std::string& from_prefix, const std::string& from_unit, + const std::string& to_prefix, const std::string& to_unit); + +// Convert between two IfcNamedUnit entities. Pulls Name and Prefix off each +// and delegates to convert(). IfcConversionBasedUnit names that don't appear +// in kSiConversions return the value unchanged. +double convertUnit(double value, express::Base from_unit, express::Base to_unit); + +#endif // UNIT_H From b3d29c40816dc13d47ca501baf1cf02a8fa30163 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 12:43:27 +1000 Subject: [PATCH 085/120] ifcviewer: add helmertMetersFromParameters and getMapUnit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit helmertMetersFromParameters builds the helmert transformation as a meter-input/meter-output 4x4 directly from parsed parameters, bypassing autoLocal2Global's normalisation step. This preserves IfcMapConversionScaled.FactorX/Y/Z in the rotation block so the factor applies to placement translations when the matrix is precomputed per-model and composed with placements at upload time. For ordinary IfcMapConversion (factor = 1) this is bit-identical to autoLocal2Global; only diverges on rare surveyed models with non-unit factors, where it is the only correct behaviour. getMapUnit returns IfcCoordinateOperation.TargetCRS.MapUnit so callers can resolve the unit-to-metres scale via Unit.h's siScaleFromNamedUnit. autoLocal2Global is unchanged — kept as a clean port of the python ifcopenshell.util.geolocation reference impl for one-shot project-units-in / map-units-out callers. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Geolocation.cpp | 37 +++++++++++++++++++++++++++++++++++ src/ifcviewer/Geolocation.h | 30 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/ifcviewer/Geolocation.cpp b/src/ifcviewer/Geolocation.cpp index 9649dc1255..80833ec062 100644 --- a/src/ifcviewer/Geolocation.cpp +++ b/src/ifcviewer/Geolocation.cpp @@ -274,3 +274,40 @@ Eigen::Matrix4d autoLocal2Global(ifcopenshell::file* ifc_file, } return result; } + +Eigen::Matrix4d helmertMetersFromParameters(const HelmertTransformation& p, + double map_unit_to_meters) { + const double theta = std::atan2(p.xao, p.xaa); + const double c = std::cos(theta); + const double s = std::sin(theta); + + Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); + // R_z(theta) · diag(fx, fy, fz). Factors stay in the rotation block so + // they apply to placement translations on compose; this is the behaviour + // IfcMapConversionScaled actually wants ("grid distance ≠ ground + // distance" — buildings on the grid should appear scaled by f). + M(0, 0) = c * p.factor_x; M(0, 1) = -s * p.factor_y; M(0, 2) = 0.0; + M(1, 0) = s * p.factor_x; M(1, 1) = c * p.factor_y; M(1, 2) = 0.0; + M(2, 0) = 0.0; M(2, 1) = 0.0; M(2, 2) = p.factor_z; + M(0, 3) = p.e * map_unit_to_meters; + M(1, 3) = p.n * map_unit_to_meters; + M(2, 3) = p.h * map_unit_to_meters; + return M; +} + +std::optional getMapUnit(ifcopenshell::file* ifc_file) { + std::vector coordops; + try { + coordops = ifc_file->instances_by_type("IfcCoordinateOperation"); + } catch (...) { + return std::nullopt; + } + if (coordops.empty()) return std::nullopt; + auto target_attr = coordops[0].as().get("TargetCRS"); + if (target_attr.isNull()) return std::nullopt; + express::Base target = target_attr; + if (!target.declaration().is("IfcProjectedCRS")) return std::nullopt; + auto mu_attr = target.as().get("MapUnit"); + if (mu_attr.isNull()) return std::nullopt; + return (express::Base) mu_attr; +} diff --git a/src/ifcviewer/Geolocation.h b/src/ifcviewer/Geolocation.h index e83640a718..d5f14c196e 100644 --- a/src/ifcviewer/Geolocation.h +++ b/src/ifcviewer/Geolocation.h @@ -27,6 +27,8 @@ #ifndef GEOLOCATION_H #define GEOLOCATION_H +#include "../ifcparse/express.h" + #include #include @@ -70,4 +72,32 @@ Eigen::Matrix4d autoLocal2Global(ifcopenshell::file* ifc_file, const Eigen::Matrix4d& matrix, bool should_return_in_map_units = true); +// Build the Helmert transformation as a meter-input / meter-output 4x4 matrix +// directly from parsed parameters, bypassing autoLocal2Global's normalisation +// step. Used by callers that want a single per-model georef matrix to compose +// with placement matrices at upload time. +// +// Result has shape: +// [ R_z(theta) · diag(fx, fy, fz) | (e, n, h) · u_m ] +// [ 0 | 1 ] +// +// `map_unit_to_meters` is the SI scale of IfcProjectedCRS.MapUnit (or the +// project's LENGTHUNIT scale if MapUnit is absent). The caller composes any +// IfcGeometricRepresentationContext WCS on the right: +// G = helmertMetersFromParameters(...) · inv(wcs_meters) +// (where wcs_meters has its translation column converted from project units +// to meters via calculateUnitScale). +// +// Unlike autoLocal2Global, this preserves IfcMapConversionScaled.FactorX/Y/Z +// in the rotation block, so they apply correctly to placement translations +// when composing per-model. +Eigen::Matrix4d helmertMetersFromParameters(const HelmertTransformation& params, + double map_unit_to_meters); + +// IfcCoordinateOperation.TargetCRS.MapUnit (the IfcNamedUnit), if present. +// Returns nullopt for IFC2X3, models without an IfcCoordinateOperation, or +// when MapUnit is absent on the IfcProjectedCRS. Callers fall back to +// calculateUnitScale(file, "LENGTHUNIT") in that case. +std::optional getMapUnit(ifcopenshell::file* ifc_file); + #endif // GEOLOCATION_H From 540f3acf523edd23cf5993b9d3fddb8d34bc33be Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 12:56:23 +1000 Subject: [PATCH 086/120] ifcviewer: add Federation data model in Federation.{h,cpp} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FederationConfig holds the federation-wide display unit (defaults to METRE; on load the first model's MapUnit becomes the default). FederationOrigin captures stage 3 — XYZ in federation unit + Z-rot — and composes to R_z · T(-xyz_meters), nominating a point as the new origin and rotating around it. ModelTransform captures stage 4 — A in model project or map unit (per AFrame), B and pivot in federation unit, full intrinsic-XYZ Euler rotation — and composes to T(B - R_pivot · A) · R_pivot, rotating first then translating so the rotated A lands at B. ModelUnits caches per-model project/map unit-to-metres scales so the compose helpers don't need to re-read the IFC each call. All composed matrices are in metres; user-typed numbers are stored in source units to round-trip without precision loss, and converted on compose via Unit.h. Not yet wired into the streamer or .ifcfed I/O — pure data model and maths, integrated in subsequent commits. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Federation.cpp | 103 +++++++++++++++++++++++++++++++ src/ifcviewer/Federation.h | 116 +++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/ifcviewer/Federation.cpp create mode 100644 src/ifcviewer/Federation.h diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp new file mode 100644 index 0000000000..12ba3acb0f --- /dev/null +++ b/src/ifcviewer/Federation.cpp @@ -0,0 +1,103 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "Federation.h" +#include "Unit.h" + +#include + +namespace { + +constexpr double kPi = 3.14159265358979323846; +constexpr double kDegToRad = kPi / 180.0; + +Eigen::Matrix4d translation4(const Eigen::Vector3d& t) { + Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); + M(0, 3) = t.x(); + M(1, 3) = t.y(); + M(2, 3) = t.z(); + return M; +} + +// Intrinsic XYZ Euler: R = R_z(z) · R_y(y) · R_x(x). +Eigen::Matrix4d eulerXYZ(const Eigen::Vector3d& rxyz_rad) { + const Eigen::Matrix3d R3 = + (Eigen::AngleAxisd(rxyz_rad.z(), Eigen::Vector3d::UnitZ()) * + Eigen::AngleAxisd(rxyz_rad.y(), Eigen::Vector3d::UnitY()) * + Eigen::AngleAxisd(rxyz_rad.x(), Eigen::Vector3d::UnitX())).matrix(); + Eigen::Matrix4d R = Eigen::Matrix4d::Identity(); + R.block<3, 3>(0, 0) = R3; + return R; +} + +} // namespace + +double federationUnitToMeters(const FederationConfig& cfg) { + return convert(1.0, cfg.unit_prefix, cfg.unit_name, "", "METRE"); +} + +Eigen::Matrix4d composeFederationOrigin(const FederationOrigin& origin, + const FederationConfig& cfg) { + const double u = federationUnitToMeters(cfg); + const Eigen::Vector3d xyz_m = origin.xyz * u; + const double rz_rad = origin.rz_deg * kDegToRad; + + const Eigen::Matrix3d Rz = + Eigen::AngleAxisd(rz_rad, Eigen::Vector3d::UnitZ()).matrix(); + Eigen::Matrix4d Rz4 = Eigen::Matrix4d::Identity(); + Rz4.block<3, 3>(0, 0) = Rz; + + return Rz4 * translation4(-xyz_m); +} + +Eigen::Matrix4d composeModelTransform(const ModelTransform& xf, + const FederationConfig& fed_cfg, + const ModelUnits& model_units, + const Eigen::Matrix4d& stage2_meters) { + const double u_fed = federationUnitToMeters(fed_cfg); + + Eigen::Vector3d A_m; + if (xf.a_frame == AFrame::ModelLocal) { + // a is in the model's project length unit, expressed in the + // pre-stage2 frame. Convert to metres, then lift through stage 2. + const Eigen::Vector4d a_h( + xf.a.x() * model_units.project_length_to_meters, + xf.a.y() * model_units.project_length_to_meters, + xf.a.z() * model_units.project_length_to_meters, + 1.0); + A_m = (stage2_meters * a_h).head<3>(); + } else { + // a is in the model's map unit, expressed in the post-stage2 frame. + A_m = xf.a * model_units.map_unit_to_meters; + } + + const Eigen::Vector3d B_m = xf.b * u_fed; + const Eigen::Vector3d pivot_m = xf.pivot * u_fed; + + const Eigen::Matrix4d R_local = eulerXYZ(xf.rxyz_deg * kDegToRad); + const Eigen::Matrix4d R_at_pivot = + translation4(pivot_m) * R_local * translation4(-pivot_m); + + // Translate so R_at_pivot · A lands at B. + const Eigen::Vector4d Ah(A_m.x(), A_m.y(), A_m.z(), 1.0); + const Eigen::Vector3d RA = (R_at_pivot * Ah).head<3>(); + const Eigen::Matrix4d T = translation4(B_m - RA); + + return T * R_at_pivot; +} diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h new file mode 100644 index 0000000000..885ee51ed9 --- /dev/null +++ b/src/ifcviewer/Federation.h @@ -0,0 +1,116 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +// Federation-level transformation data model and compose helpers. +// +// A federation is the user's working scene, composed of one or more IFC +// models. Each model lives at: +// +// stage3 · stage4 · stage2 · placement_stage1 +// +// where: +// - stage1 is per-mesh vertex rebasing (applied to the geometry buffers) +// - stage2 is the per-model georef matrix (immutable, derived from the IFC) +// - stage3 is the federation-wide false origin (mutable, this header) +// - stage4 is the per-model placement within the federation (mutable, this header) +// +// All composed matrices are in metres. The user-authored intent is stored +// in source units (model project unit / model map unit / federation unit) to +// preserve precision; conversion to metres happens in the compose helpers. + +#ifndef FEDERATION_H +#define FEDERATION_H + +#include + +#include + +// Federation-wide settings persisted in .ifcfed. +struct FederationConfig { + // IfcSIUnit name ("METRE") or IfcConversionBasedUnit name ("foot", "inch", ...). + std::string unit_name = "METRE"; + // SI prefix ("MILLI", "KILO", ...) — empty for unprefixed or for + // conversion-based units. + std::string unit_prefix = ""; +}; + +// Stage 3 — the federation false origin. Authoring intent is "nominate this +// XYZ as the new origin, with optional Z-axis heading rotation". Composed as +// +// stage3 = R_z(rz_deg) · T(-xyz_in_metres) +// +// i.e. translate the federation so the nominated point lands at the origin, +// then rotate around the new origin. Translation is given in federation unit; +// rotation is in degrees. +struct FederationOrigin { + Eigen::Vector3d xyz = Eigen::Vector3d::Zero(); // federation unit + double rz_deg = 0.0; // degrees +}; + +// Frame in which ModelTransform.a is expressed. +// ModelLocal — pre-stage2 model coordinates, in the model's project length unit +// ModelGlobal — post-stage2 model coordinates, in the model's map unit +enum class AFrame { ModelLocal, ModelGlobal }; + +// Stage 4 — the per-model placement within the federation. Authoring intent +// is "rotate the model around `pivot`, then translate so that point `a` lands +// at point `b`". Composed as +// +// R_local = R_z(rz) · R_y(ry) · R_x(rx) [intrinsic XYZ] +// R_at_pivot = T(pivot_m) · R_local · T(-pivot_m) +// stage4 = T(b_m - R_at_pivot · a_m) · R_at_pivot +// +// Numbers are stored in their original input unit (a in model project or map +// unit per a_frame, b/pivot in federation unit) so that the user's typed +// values round-trip without precision loss. +struct ModelTransform { + AFrame a_frame = AFrame::ModelGlobal; + Eigen::Vector3d a = Eigen::Vector3d::Zero(); // model project / map unit + Eigen::Vector3d b = Eigen::Vector3d::Zero(); // federation unit + Eigen::Vector3d rxyz_deg = Eigen::Vector3d::Zero(); // degrees, intrinsic XYZ + Eigen::Vector3d pivot = Eigen::Vector3d::Zero(); // federation unit +}; + +// Per-model unit scales captured at load time. project_length_to_meters +// comes from calculateUnitScale(file, "LENGTHUNIT"); map_unit_to_meters from +// siScaleFromNamedUnit(getMapUnit(file)) and falls back to the project length +// scale when the model has no MapUnit. +struct ModelUnits { + double project_length_to_meters = 1.0; + double map_unit_to_meters = 1.0; +}; + +// 1 federation_unit -> N metres. Cached at the call site if needed. +double federationUnitToMeters(const FederationConfig&); + +// Compose stage 3 (federation false origin) into a 4x4 matrix in metres. +Eigen::Matrix4d composeFederationOrigin(const FederationOrigin&, + const FederationConfig&); + +// Compose stage 4 (per-model placement within the federation) into a 4x4 +// matrix in metres. `stage2_meters` is the model's georef matrix (e.g. +// helmertMetersFromParameters · inv(wcs_meters)) — needed to lift `a` into +// metres when a_frame == ModelLocal. Pass identity when stage 2 is disabled +// or absent. +Eigen::Matrix4d composeModelTransform(const ModelTransform&, + const FederationConfig& fed_cfg, + const ModelUnits& model_units, + const Eigen::Matrix4d& stage2_meters); + +#endif // FEDERATION_H From ecf0a5a4e1878f33c64f7dadc37bf4898f6deea3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 14:41:41 +1000 Subject: [PATCH 087/120] ifcviewer: merge Federation classes into the lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move src/ifcviewer-full/Federation.{h,cpp} (and its tests) into src/ifcviewer/ so the lib stays the single source of truth for the federation data model. Restores the original "agnostic lib usable from ifcviewer-full and ifcviewer-minimal alike" framing. Drop the unused per-model transform[16] / has_transform field — it was round-trip-only with no UI to author it, and is being replaced by an intent-based ModelTransform in the next commit. No real .ifcfed in the wild populated this field; old files still load (unknown JSON keys ignored), they just lose the unused transform. Replaces the pure-data-model Federation.{h,cpp} that was added a few commits earlier — that file's structs and compose helpers return as part of the merged Federation in commit 6. ifcviewer-full's per-app tests dir is removed (test_federation was the only one); BUILD_IFCVIEWER_TESTS now wires test_federation in under src/ifcviewer/tests/, with the Qt6::Core/Gui/Test dependency declared inline since unlike the other Tier-1 tests it has to pull Qt in. All 31 tests pass. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/CMakeLists.txt | 4 - src/ifcviewer-full/Federation.cpp | 308 ----------------- src/ifcviewer-full/Federation.h | 105 ------ src/ifcviewer-full/tests/CMakeLists.txt | 41 --- src/ifcviewer/Federation.cpp | 315 ++++++++++++++---- src/ifcviewer/Federation.h | 159 ++++----- src/ifcviewer/tests/CMakeLists.txt | 19 ++ .../tests/test_federation.cpp | 0 8 files changed, 343 insertions(+), 608 deletions(-) delete mode 100644 src/ifcviewer-full/Federation.cpp delete mode 100644 src/ifcviewer-full/Federation.h delete mode 100644 src/ifcviewer-full/tests/CMakeLists.txt rename src/{ifcviewer-full => ifcviewer}/tests/test_federation.cpp (100%) diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt index ad5a5083ad..f578d96f70 100644 --- a/src/ifcviewer-full/CMakeLists.txt +++ b/src/ifcviewer-full/CMakeLists.txt @@ -34,7 +34,3 @@ set_target_properties(IfcViewerFull PROPERTIES target_link_libraries(IfcViewerFull PRIVATE IfcViewer) install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) - -if(BUILD_IFCVIEWER_TESTS) - add_subdirectory(tests) -endif() diff --git a/src/ifcviewer-full/Federation.cpp b/src/ifcviewer-full/Federation.cpp deleted file mode 100644 index a72f783103..0000000000 --- a/src/ifcviewer-full/Federation.cpp +++ /dev/null @@ -1,308 +0,0 @@ -/******************************************************************************** - * * - * This file is part of IfcOpenShell. * - * * - * IfcOpenShell is free software: you can redistribute it and/or modify * - * it under the terms of the Lesser GNU General Public License as published by * - * the Free Software Foundation, either version 3.0 of the License, or * - * (at your option) any later version. * - * * - * IfcOpenShell is distributed in the hope that it will be useful, * - * but WITHOUT ANY WARRANTY; without even the implied warranty of * - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * - * Lesser GNU General Public License for more details. * - * * - * You should have received a copy of the Lesser GNU General Public License * - * along with this program. If not, see . * - * * - ********************************************************************************/ - -#include "Federation.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -constexpr const char* kSchema = "ifcfed/1"; - -QString resolvePath(const QString& fed_dir, const QString& stored) { - if (stored.isEmpty()) return stored; - QFileInfo fi(stored); - if (fi.isAbsolute()) return QDir::cleanPath(stored); - return QDir::cleanPath(QDir(fed_dir).absoluteFilePath(stored)); -} - -// Returns abs_path relative to fed_dir if abs_path lives under fed_dir, -// otherwise returns abs_path unchanged. -QString relativizePath(const QString& fed_dir, const QString& abs_path) { - QString fed_canon = QDir::cleanPath(fed_dir); - QString abs_canon = QDir::cleanPath(abs_path); - if (!fed_canon.endsWith('/')) fed_canon += '/'; - if (abs_canon.startsWith(fed_canon)) { - return QDir(fed_canon).relativeFilePath(abs_canon); - } - return abs_canon; -} -} // namespace - -Federation::Federation(QObject* parent) : QObject(parent) {} - -QString Federation::generateId() { - return QUuid::createUuid().toString(QUuid::WithoutBraces); -} - -bool Federation::isFederationPath(const QString& path) { - return path.endsWith(".ifcfed", Qt::CaseInsensitive); -} - -void Federation::clear() { - file_path_.clear(); - name_.clear(); - created_ = QDateTime(); - modified_ = QDateTime(); - models_.clear(); - has_home_view_ = false; - home_view_ = HomeView{}; - setDirty(false); -} - -void Federation::markClean() { - setDirty(false); -} - -void Federation::setDirty(bool d) { - if (dirty_ == d) return; - dirty_ = d; - emit dirtyChanged(d); -} - -const Federation::Model* Federation::findById(const QString& fed_id) const { - for (const auto& m : models_) { - if (m.id == fed_id) return &m; - } - return nullptr; -} - -QString Federation::addModel(const QString& source_path, - const QString& display_name) { - if (source_path.isEmpty()) return {}; - if (isFederationPath(source_path)) return {}; // no nested federations - - Model m; - m.id = generateId(); - m.display_name = display_name.isEmpty() - ? QFileInfo(source_path).fileName() - : display_name; - m.source_kind = "local"; - m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); - models_.push_back(std::move(m)); - setDirty(true); - return models_.back().id; -} - -void Federation::removeModel(const QString& fed_id) { - for (auto it = models_.begin(); it != models_.end(); ++it) { - if (it->id == fed_id) { - models_.erase(it); - setDirty(true); - return; - } - } -} - -void Federation::setHomeView(const HomeView& hv) { - home_view_ = hv; - has_home_view_ = true; - setDirty(true); -} - -void Federation::clearHomeView() { - if (!has_home_view_) return; - has_home_view_ = false; - home_view_ = HomeView{}; - setDirty(true); -} - -bool Federation::load(const QString& path, - QStringList* warnings, - QString* err) { - QFile f(path); - if (!f.open(QIODevice::ReadOnly)) { - if (err) *err = QString("Cannot open %1: %2").arg(path, f.errorString()); - return false; - } - QByteArray bytes = f.readAll(); - f.close(); - - QJsonParseError pe; - QJsonDocument doc = QJsonDocument::fromJson(bytes, &pe); - if (doc.isNull() || !doc.isObject()) { - if (err) *err = QString("Parse error in %1: %2").arg(path, pe.errorString()); - return false; - } - QJsonObject root = doc.object(); - - clear(); - file_path_ = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); - QString fed_dir = QFileInfo(file_path_).absolutePath(); - - QString schema = root.value("schema").toString(); - if (schema != kSchema && warnings) { - *warnings << QString("Unknown schema '%1' (expected '%2'); attempting to load anyway.") - .arg(schema, kSchema); - } - - name_ = root.value("name").toString(); - created_ = QDateTime::fromString(root.value("created").toString(), Qt::ISODate); - modified_ = QDateTime::fromString(root.value("modified").toString(), Qt::ISODate); - - QJsonArray arr = root.value("models").toArray(); - for (int i = 0; i < arr.size(); ++i) { - if (!arr[i].isObject()) { - if (warnings) *warnings << QString("models[%1] is not an object; skipping.").arg(i); - continue; - } - QJsonObject mo = arr[i].toObject(); - - Model m; - m.id = mo.value("id").toString(); - if (m.id.isEmpty()) m.id = generateId(); - m.display_name = mo.value("display_name").toString(); - - QJsonObject so = mo.value("source").toObject(); - m.source_kind = so.value("kind").toString("local"); - if (m.source_kind != "local") { - if (warnings) - *warnings << QString("models[%1]: unsupported source kind '%2'; entry kept but not loaded.") - .arg(i).arg(m.source_kind); - // Keep raw stored path so save() round-trips correctly. - m.source_path = so.value("path").toString(); - models_.push_back(std::move(m)); - continue; - } - - QString stored = so.value("path").toString(); - if (stored.isEmpty()) { - if (warnings) *warnings << QString("models[%1]: missing source.path; skipping.").arg(i); - continue; - } - m.source_path = resolvePath(fed_dir, stored); - - if (m.display_name.isEmpty()) - m.display_name = QFileInfo(m.source_path).fileName(); - - QJsonValue tv = mo.value("transform"); - if (tv.isArray()) { - QJsonArray ta = tv.toArray(); - if (ta.size() == 16) { - for (int k = 0; k < 16; ++k) m.transform[k] = float(ta[k].toDouble()); - m.has_transform = true; - } else if (warnings) { - *warnings << QString("models[%1]: transform must be 16 floats; ignored.").arg(i); - } - } - - QJsonValue vv = mo.value("visible"); - if (vv.isBool()) m.visible = vv.toBool(); - - models_.push_back(std::move(m)); - } - - QJsonValue hv = root.value("home_view"); - if (hv.isObject()) { - QJsonObject ho = hv.toObject(); - QJsonArray ta = ho.value("target").toArray(); - HomeView v; - if (ta.size() == 3) { - v.target = QVector3D(float(ta[0].toDouble()), - float(ta[1].toDouble()), - float(ta[2].toDouble())); - } - v.distance = float(ho.value("distance").toDouble(50.0)); - v.yaw = float(ho.value("yaw").toDouble(45.0)); - v.pitch = float(ho.value("pitch").toDouble(30.0)); - home_view_ = v; - has_home_view_ = true; - } - - setDirty(false); - return true; -} - -bool Federation::save(const QString& path, QString* err) { - QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); - QString fed_dir = QFileInfo(abs_path).absolutePath(); - - QJsonObject root; - root["schema"] = kSchema; - if (!name_.isEmpty()) root["name"] = name_; - - if (!created_.isValid()) created_ = QDateTime::currentDateTimeUtc(); - modified_ = QDateTime::currentDateTimeUtc(); - root["created"] = created_.toUTC().toString(Qt::ISODate); - root["modified"] = modified_.toUTC().toString(Qt::ISODate); - - QJsonArray arr; - for (const auto& m : models_) { - QJsonObject mo; - mo["id"] = m.id; - mo["display_name"] = m.display_name; - - QJsonObject so; - so["kind"] = m.source_kind; - if (m.source_kind == "local") { - so["path"] = relativizePath(fed_dir, m.source_path); - } else { - // Round-trip raw value for unsupported kinds. - so["path"] = m.source_path; - } - mo["source"] = so; - - if (m.has_transform) { - QJsonArray ta; - for (float v : m.transform) ta.append(double(v)); - mo["transform"] = ta; - } - if (!m.visible) mo["visible"] = false; - - arr.append(mo); - } - root["models"] = arr; - - if (has_home_view_) { - QJsonObject ho; - QJsonArray ta; - ta.append(double(home_view_.target.x())); - ta.append(double(home_view_.target.y())); - ta.append(double(home_view_.target.z())); - ho["target"] = ta; - ho["distance"] = double(home_view_.distance); - ho["yaw"] = double(home_view_.yaw); - ho["pitch"] = double(home_view_.pitch); - root["home_view"] = ho; - } else { - root["home_view"] = QJsonValue(); // null - } - - QSaveFile f(abs_path); - if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - if (err) *err = QString("Cannot write %1: %2").arg(abs_path, f.errorString()); - return false; - } - f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); - if (!f.commit()) { - if (err) *err = QString("Failed to commit %1: %2").arg(abs_path, f.errorString()); - return false; - } - - file_path_ = abs_path; - setDirty(false); - return true; -} diff --git a/src/ifcviewer-full/Federation.h b/src/ifcviewer-full/Federation.h deleted file mode 100644 index 21bb6ba3bd..0000000000 --- a/src/ifcviewer-full/Federation.h +++ /dev/null @@ -1,105 +0,0 @@ -/******************************************************************************** - * * - * This file is part of IfcOpenShell. * - * * - * IfcOpenShell is free software: you can redistribute it and/or modify * - * it under the terms of the Lesser GNU General Public License as published by * - * the Free Software Foundation, either version 3.0 of the License, or * - * (at your option) any later version. * - * * - * IfcOpenShell is distributed in the hope that it will be useful, * - * but WITHOUT ANY WARRANTY; without even the implied warranty of * - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * - * Lesser GNU General Public License for more details. * - * * - * You should have received a copy of the Lesser GNU General Public License * - * along with this program. If not, see . * - * * - ********************************************************************************/ - -#ifndef FEDERATION_H -#define FEDERATION_H - -#include -#include -#include -#include -#include - -#include -#include - -// In-memory representation of an .ifcfed file (IFC federation). -// -// A federation is a named, ordered list of model sources plus an optional -// "home view" camera state. Source paths can be relative (resolved against -// the .ifcfed's directory) or absolute. Save() reserialises paths relative -// when they live under the federation file's directory tree, absolute -// otherwise — Save As recomputes against the new location. -// -// Round-trip-only fields today (no UI to edit, but preserved across load/ -// save): per-model `transform` (4x4, column-major), per-model `visible`, -// future cloud `source.kind`s. -class Federation : public QObject { - Q_OBJECT -public: - struct HomeView { - QVector3D target; - float distance = 50.0f; - float yaw = 45.0f; // degrees - float pitch = 30.0f; // degrees - }; - - struct Model { - QString id; // stable, persisted - QString display_name; - QString source_kind = "local"; // future: "http", "speckle", ... - QString source_path; // resolved absolute when kind == "local" - bool has_transform = false; - std::array transform{}; // column-major; identity when !has_transform - bool visible = true; - }; - - explicit Federation(QObject* parent = nullptr); - - // Round-trip - bool load(const QString& path, QStringList* warnings, QString* err); - bool save(const QString& path, QString* err); - - // Mutations - void clear(); - QString addModel(const QString& source_path, - const QString& display_name = QString()); - void removeModel(const QString& fed_id); - void setHomeView(const HomeView& hv); - void clearHomeView(); - - // Accessors - const std::vector& models() const { return models_; } - const Model* findById(const QString& fed_id) const; - bool isDirty() const { return dirty_; } - void markClean(); - QString filePath() const { return file_path_; } - QString name() const { return name_; } - bool hasHomeView() const { return has_home_view_; } - const HomeView& homeView() const { return home_view_; } - -signals: - void dirtyChanged(bool dirty); - -private: - void setDirty(bool d); - static QString generateId(); - static bool isFederationPath(const QString& path); - - QString file_path_; - QString name_; - QDateTime created_; - QDateTime modified_; - std::vector models_; - bool has_home_view_ = false; - HomeView home_view_; - bool dirty_ = false; -}; - -#endif // FEDERATION_H diff --git a/src/ifcviewer-full/tests/CMakeLists.txt b/src/ifcviewer-full/tests/CMakeLists.txt deleted file mode 100644 index 705fec1ad0..0000000000 --- a/src/ifcviewer-full/tests/CMakeLists.txt +++ /dev/null @@ -1,41 +0,0 @@ -################################################################################ -# # -# This file is part of IfcOpenShell. # -# # -# IfcOpenShell is free software: you can redistribute it and/or modify # -# it under the terms of the Lesser GNU General Public License as published by # -# the Free Software Foundation, either version 3.0 of the License, or # -# (at your option) any later version. # -# # -# IfcOpenShell is distributed in the hope that it will be useful, # -# but WITHOUT ANY WARRANTY; without even the implied warranty of # -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # -# Lesser GNU General Public License for more details. # -# # -# You should have received a copy of the Lesser GNU General Public License # -# along with this program. If not, see . # -# # -################################################################################ - -# Tier-1 tests for ifcviewer-full. Federation is QObject-derived but only -# uses Qt6::Core (no event loop, no GL), so tests can construct it directly. - -set(IFCVIEWER_FULL_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..) - -# Federation::HomeView holds a QVector3D (defined in QtGui), and QSignalSpy -# / QTest live in Qt6::Test. -find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) - -add_executable(test_federation - test_federation.cpp - ${IFCVIEWER_FULL_SRC}/Federation.cpp -) -set_target_properties(test_federation PROPERTIES AUTOMOC ON) -target_include_directories(test_federation PRIVATE ${IFCVIEWER_FULL_SRC}) -target_link_libraries(test_federation PRIVATE - Catch2::Catch2WithMain - Qt${QT_VERSION}::Core - Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui - Qt${QT_VERSION}::Test # QSignalSpy -) -catch_discover_tests(test_federation) diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 12ba3acb0f..166a48910d 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -18,86 +18,275 @@ ********************************************************************************/ #include "Federation.h" -#include "Unit.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace { +constexpr const char* kSchema = "ifcfed/1"; -constexpr double kPi = 3.14159265358979323846; -constexpr double kDegToRad = kPi / 180.0; - -Eigen::Matrix4d translation4(const Eigen::Vector3d& t) { - Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); - M(0, 3) = t.x(); - M(1, 3) = t.y(); - M(2, 3) = t.z(); - return M; +QString resolvePath(const QString& fed_dir, const QString& stored) { + if (stored.isEmpty()) return stored; + QFileInfo fi(stored); + if (fi.isAbsolute()) return QDir::cleanPath(stored); + return QDir::cleanPath(QDir(fed_dir).absoluteFilePath(stored)); } -// Intrinsic XYZ Euler: R = R_z(z) · R_y(y) · R_x(x). -Eigen::Matrix4d eulerXYZ(const Eigen::Vector3d& rxyz_rad) { - const Eigen::Matrix3d R3 = - (Eigen::AngleAxisd(rxyz_rad.z(), Eigen::Vector3d::UnitZ()) * - Eigen::AngleAxisd(rxyz_rad.y(), Eigen::Vector3d::UnitY()) * - Eigen::AngleAxisd(rxyz_rad.x(), Eigen::Vector3d::UnitX())).matrix(); - Eigen::Matrix4d R = Eigen::Matrix4d::Identity(); - R.block<3, 3>(0, 0) = R3; - return R; +// Returns abs_path relative to fed_dir if abs_path lives under fed_dir, +// otherwise returns abs_path unchanged. +QString relativizePath(const QString& fed_dir, const QString& abs_path) { + QString fed_canon = QDir::cleanPath(fed_dir); + QString abs_canon = QDir::cleanPath(abs_path); + if (!fed_canon.endsWith('/')) fed_canon += '/'; + if (abs_canon.startsWith(fed_canon)) { + return QDir(fed_canon).relativeFilePath(abs_canon); + } + return abs_canon; } - } // namespace -double federationUnitToMeters(const FederationConfig& cfg) { - return convert(1.0, cfg.unit_prefix, cfg.unit_name, "", "METRE"); +Federation::Federation(QObject* parent) : QObject(parent) {} + +QString Federation::generateId() { + return QUuid::createUuid().toString(QUuid::WithoutBraces); } -Eigen::Matrix4d composeFederationOrigin(const FederationOrigin& origin, - const FederationConfig& cfg) { - const double u = federationUnitToMeters(cfg); - const Eigen::Vector3d xyz_m = origin.xyz * u; - const double rz_rad = origin.rz_deg * kDegToRad; - - const Eigen::Matrix3d Rz = - Eigen::AngleAxisd(rz_rad, Eigen::Vector3d::UnitZ()).matrix(); - Eigen::Matrix4d Rz4 = Eigen::Matrix4d::Identity(); - Rz4.block<3, 3>(0, 0) = Rz; - - return Rz4 * translation4(-xyz_m); +bool Federation::isFederationPath(const QString& path) { + return path.endsWith(".ifcfed", Qt::CaseInsensitive); } -Eigen::Matrix4d composeModelTransform(const ModelTransform& xf, - const FederationConfig& fed_cfg, - const ModelUnits& model_units, - const Eigen::Matrix4d& stage2_meters) { - const double u_fed = federationUnitToMeters(fed_cfg); +void Federation::clear() { + file_path_.clear(); + name_.clear(); + created_ = QDateTime(); + modified_ = QDateTime(); + models_.clear(); + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(false); +} - Eigen::Vector3d A_m; - if (xf.a_frame == AFrame::ModelLocal) { - // a is in the model's project length unit, expressed in the - // pre-stage2 frame. Convert to metres, then lift through stage 2. - const Eigen::Vector4d a_h( - xf.a.x() * model_units.project_length_to_meters, - xf.a.y() * model_units.project_length_to_meters, - xf.a.z() * model_units.project_length_to_meters, - 1.0); - A_m = (stage2_meters * a_h).head<3>(); - } else { - // a is in the model's map unit, expressed in the post-stage2 frame. - A_m = xf.a * model_units.map_unit_to_meters; +void Federation::markClean() { + setDirty(false); +} + +void Federation::setDirty(bool d) { + if (dirty_ == d) return; + dirty_ = d; + emit dirtyChanged(d); +} + +const Federation::Model* Federation::findById(const QString& fed_id) const { + for (const auto& m : models_) { + if (m.id == fed_id) return &m; + } + return nullptr; +} + +QString Federation::addModel(const QString& source_path, + const QString& display_name) { + if (source_path.isEmpty()) return {}; + if (isFederationPath(source_path)) return {}; // no nested federations + + Model m; + m.id = generateId(); + m.display_name = display_name.isEmpty() + ? QFileInfo(source_path).fileName() + : display_name; + m.source_kind = "local"; + m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); + models_.push_back(std::move(m)); + setDirty(true); + return models_.back().id; +} + +void Federation::removeModel(const QString& fed_id) { + for (auto it = models_.begin(); it != models_.end(); ++it) { + if (it->id == fed_id) { + models_.erase(it); + setDirty(true); + return; + } + } +} + +void Federation::setHomeView(const HomeView& hv) { + home_view_ = hv; + has_home_view_ = true; + setDirty(true); +} + +void Federation::clearHomeView() { + if (!has_home_view_) return; + has_home_view_ = false; + home_view_ = HomeView{}; + setDirty(true); +} + +bool Federation::load(const QString& path, + QStringList* warnings, + QString* err) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + if (err) *err = QString("Cannot open %1: %2").arg(path, f.errorString()); + return false; + } + QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError pe; + QJsonDocument doc = QJsonDocument::fromJson(bytes, &pe); + if (doc.isNull() || !doc.isObject()) { + if (err) *err = QString("Parse error in %1: %2").arg(path, pe.errorString()); + return false; + } + QJsonObject root = doc.object(); + + clear(); + file_path_ = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + QString fed_dir = QFileInfo(file_path_).absolutePath(); + + QString schema = root.value("schema").toString(); + if (schema != kSchema && warnings) { + *warnings << QString("Unknown schema '%1' (expected '%2'); attempting to load anyway.") + .arg(schema, kSchema); } - const Eigen::Vector3d B_m = xf.b * u_fed; - const Eigen::Vector3d pivot_m = xf.pivot * u_fed; + name_ = root.value("name").toString(); + created_ = QDateTime::fromString(root.value("created").toString(), Qt::ISODate); + modified_ = QDateTime::fromString(root.value("modified").toString(), Qt::ISODate); - const Eigen::Matrix4d R_local = eulerXYZ(xf.rxyz_deg * kDegToRad); - const Eigen::Matrix4d R_at_pivot = - translation4(pivot_m) * R_local * translation4(-pivot_m); + QJsonArray arr = root.value("models").toArray(); + for (int i = 0; i < arr.size(); ++i) { + if (!arr[i].isObject()) { + if (warnings) *warnings << QString("models[%1] is not an object; skipping.").arg(i); + continue; + } + QJsonObject mo = arr[i].toObject(); - // Translate so R_at_pivot · A lands at B. - const Eigen::Vector4d Ah(A_m.x(), A_m.y(), A_m.z(), 1.0); - const Eigen::Vector3d RA = (R_at_pivot * Ah).head<3>(); - const Eigen::Matrix4d T = translation4(B_m - RA); + Model m; + m.id = mo.value("id").toString(); + if (m.id.isEmpty()) m.id = generateId(); + m.display_name = mo.value("display_name").toString(); - return T * R_at_pivot; + QJsonObject so = mo.value("source").toObject(); + m.source_kind = so.value("kind").toString("local"); + if (m.source_kind != "local") { + if (warnings) + *warnings << QString("models[%1]: unsupported source kind '%2'; entry kept but not loaded.") + .arg(i).arg(m.source_kind); + // Keep raw stored path so save() round-trips correctly. + m.source_path = so.value("path").toString(); + models_.push_back(std::move(m)); + continue; + } + + QString stored = so.value("path").toString(); + if (stored.isEmpty()) { + if (warnings) *warnings << QString("models[%1]: missing source.path; skipping.").arg(i); + continue; + } + m.source_path = resolvePath(fed_dir, stored); + + if (m.display_name.isEmpty()) + m.display_name = QFileInfo(m.source_path).fileName(); + + QJsonValue vv = mo.value("visible"); + if (vv.isBool()) m.visible = vv.toBool(); + + models_.push_back(std::move(m)); + } + + QJsonValue hv = root.value("home_view"); + if (hv.isObject()) { + QJsonObject ho = hv.toObject(); + QJsonArray ta = ho.value("target").toArray(); + HomeView v; + if (ta.size() == 3) { + v.target = QVector3D(float(ta[0].toDouble()), + float(ta[1].toDouble()), + float(ta[2].toDouble())); + } + v.distance = float(ho.value("distance").toDouble(50.0)); + v.yaw = float(ho.value("yaw").toDouble(45.0)); + v.pitch = float(ho.value("pitch").toDouble(30.0)); + home_view_ = v; + has_home_view_ = true; + } + + setDirty(false); + return true; +} + +bool Federation::save(const QString& path, QString* err) { + QString abs_path = QDir::cleanPath(QFileInfo(path).absoluteFilePath()); + QString fed_dir = QFileInfo(abs_path).absolutePath(); + + QJsonObject root; + root["schema"] = kSchema; + if (!name_.isEmpty()) root["name"] = name_; + + if (!created_.isValid()) created_ = QDateTime::currentDateTimeUtc(); + modified_ = QDateTime::currentDateTimeUtc(); + root["created"] = created_.toUTC().toString(Qt::ISODate); + root["modified"] = modified_.toUTC().toString(Qt::ISODate); + + QJsonArray arr; + for (const auto& m : models_) { + QJsonObject mo; + mo["id"] = m.id; + mo["display_name"] = m.display_name; + + QJsonObject so; + so["kind"] = m.source_kind; + if (m.source_kind == "local") { + so["path"] = relativizePath(fed_dir, m.source_path); + } else { + // Round-trip raw value for unsupported kinds. + so["path"] = m.source_path; + } + mo["source"] = so; + + if (!m.visible) mo["visible"] = false; + + arr.append(mo); + } + root["models"] = arr; + + if (has_home_view_) { + QJsonObject ho; + QJsonArray ta; + ta.append(double(home_view_.target.x())); + ta.append(double(home_view_.target.y())); + ta.append(double(home_view_.target.z())); + ho["target"] = ta; + ho["distance"] = double(home_view_.distance); + ho["yaw"] = double(home_view_.yaw); + ho["pitch"] = double(home_view_.pitch); + root["home_view"] = ho; + } else { + root["home_view"] = QJsonValue(); // null + } + + QSaveFile f(abs_path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (err) *err = QString("Cannot write %1: %2").arg(abs_path, f.errorString()); + return false; + } + f.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + if (!f.commit()) { + if (err) *err = QString("Failed to commit %1: %2").arg(abs_path, f.errorString()); + return false; + } + + file_path_ = abs_path; + setDirty(false); + return true; } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 885ee51ed9..f3174582cc 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -17,100 +17,85 @@ * * ********************************************************************************/ -// Federation-level transformation data model and compose helpers. -// -// A federation is the user's working scene, composed of one or more IFC -// models. Each model lives at: -// -// stage3 · stage4 · stage2 · placement_stage1 -// -// where: -// - stage1 is per-mesh vertex rebasing (applied to the geometry buffers) -// - stage2 is the per-model georef matrix (immutable, derived from the IFC) -// - stage3 is the federation-wide false origin (mutable, this header) -// - stage4 is the per-model placement within the federation (mutable, this header) -// -// All composed matrices are in metres. The user-authored intent is stored -// in source units (model project unit / model map unit / federation unit) to -// preserve precision; conversion to metres happens in the compose helpers. - #ifndef FEDERATION_H #define FEDERATION_H -#include +#include +#include +#include +#include +#include -#include +#include -// Federation-wide settings persisted in .ifcfed. -struct FederationConfig { - // IfcSIUnit name ("METRE") or IfcConversionBasedUnit name ("foot", "inch", ...). - std::string unit_name = "METRE"; - // SI prefix ("MILLI", "KILO", ...) — empty for unprefixed or for - // conversion-based units. - std::string unit_prefix = ""; -}; - -// Stage 3 — the federation false origin. Authoring intent is "nominate this -// XYZ as the new origin, with optional Z-axis heading rotation". Composed as +// In-memory representation of an .ifcfed file (IFC federation). // -// stage3 = R_z(rz_deg) · T(-xyz_in_metres) +// A federation is a named, ordered list of model sources plus an optional +// "home view" camera state. Source paths can be relative (resolved against +// the .ifcfed's directory) or absolute. Save() reserialises paths relative +// when they live under the federation file's directory tree, absolute +// otherwise — Save As recomputes against the new location. // -// i.e. translate the federation so the nominated point lands at the origin, -// then rotate around the new origin. Translation is given in federation unit; -// rotation is in degrees. -struct FederationOrigin { - Eigen::Vector3d xyz = Eigen::Vector3d::Zero(); // federation unit - double rz_deg = 0.0; // degrees +// Round-trip-only fields today (no UI to edit, but preserved across load/ +// save): per-model `visible`, future cloud `source.kind`s. +class Federation : public QObject { + Q_OBJECT +public: + struct HomeView { + QVector3D target; + float distance = 50.0f; + float yaw = 45.0f; // degrees + float pitch = 30.0f; // degrees + }; + + struct Model { + QString id; // stable, persisted + QString display_name; + QString source_kind = "local"; // future: "http", "speckle", ... + QString source_path; // resolved absolute when kind == "local" + bool visible = true; + }; + + explicit Federation(QObject* parent = nullptr); + + // Round-trip + bool load(const QString& path, QStringList* warnings, QString* err); + bool save(const QString& path, QString* err); + + // Mutations + void clear(); + QString addModel(const QString& source_path, + const QString& display_name = QString()); + void removeModel(const QString& fed_id); + void setHomeView(const HomeView& hv); + void clearHomeView(); + + // Accessors + const std::vector& models() const { return models_; } + const Model* findById(const QString& fed_id) const; + bool isDirty() const { return dirty_; } + void markClean(); + QString filePath() const { return file_path_; } + QString name() const { return name_; } + bool hasHomeView() const { return has_home_view_; } + const HomeView& homeView() const { return home_view_; } + +signals: + void dirtyChanged(bool dirty); + +private: + void setDirty(bool d); + static QString generateId(); + static bool isFederationPath(const QString& path); + + QString file_path_; + QString name_; + QDateTime created_; + QDateTime modified_; + std::vector models_; + bool has_home_view_ = false; + HomeView home_view_; + bool dirty_ = false; }; -// Frame in which ModelTransform.a is expressed. -// ModelLocal — pre-stage2 model coordinates, in the model's project length unit -// ModelGlobal — post-stage2 model coordinates, in the model's map unit -enum class AFrame { ModelLocal, ModelGlobal }; - -// Stage 4 — the per-model placement within the federation. Authoring intent -// is "rotate the model around `pivot`, then translate so that point `a` lands -// at point `b`". Composed as -// -// R_local = R_z(rz) · R_y(ry) · R_x(rx) [intrinsic XYZ] -// R_at_pivot = T(pivot_m) · R_local · T(-pivot_m) -// stage4 = T(b_m - R_at_pivot · a_m) · R_at_pivot -// -// Numbers are stored in their original input unit (a in model project or map -// unit per a_frame, b/pivot in federation unit) so that the user's typed -// values round-trip without precision loss. -struct ModelTransform { - AFrame a_frame = AFrame::ModelGlobal; - Eigen::Vector3d a = Eigen::Vector3d::Zero(); // model project / map unit - Eigen::Vector3d b = Eigen::Vector3d::Zero(); // federation unit - Eigen::Vector3d rxyz_deg = Eigen::Vector3d::Zero(); // degrees, intrinsic XYZ - Eigen::Vector3d pivot = Eigen::Vector3d::Zero(); // federation unit -}; - -// Per-model unit scales captured at load time. project_length_to_meters -// comes from calculateUnitScale(file, "LENGTHUNIT"); map_unit_to_meters from -// siScaleFromNamedUnit(getMapUnit(file)) and falls back to the project length -// scale when the model has no MapUnit. -struct ModelUnits { - double project_length_to_meters = 1.0; - double map_unit_to_meters = 1.0; -}; - -// 1 federation_unit -> N metres. Cached at the call site if needed. -double federationUnitToMeters(const FederationConfig&); - -// Compose stage 3 (federation false origin) into a 4x4 matrix in metres. -Eigen::Matrix4d composeFederationOrigin(const FederationOrigin&, - const FederationConfig&); - -// Compose stage 4 (per-model placement within the federation) into a 4x4 -// matrix in metres. `stage2_meters` is the model's georef matrix (e.g. -// helmertMetersFromParameters · inv(wcs_meters)) — needed to lift `a` into -// metres when a_frame == ModelLocal. Pass identity when stage 2 is disabled -// or absent. -Eigen::Matrix4d composeModelTransform(const ModelTransform&, - const FederationConfig& fed_cfg, - const ModelUnits& model_units, - const Eigen::Matrix4d& stage2_meters); - #endif // FEDERATION_H diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index d6739bb22b..70436a9b73 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -47,3 +47,22 @@ add_ifcviewer_unit_test(test_sidecar_cache ) add_ifcviewer_unit_test(test_instanced_geometry) + +# Federation is Qt-derived (QObject + signals + QVector3D + QJson*). Unlike +# the other Tier-1 tests it has to pull Qt6::Core/Gui/Test in directly and +# enable AUTOMOC for the Q_OBJECT moc-generation. +find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) + +add_executable(test_federation + test_federation.cpp + ${IFCVIEWER_SRC}/Federation.cpp +) +set_target_properties(test_federation PROPERTIES AUTOMOC ON) +target_include_directories(test_federation PRIVATE ${IFCVIEWER_SRC}) +target_link_libraries(test_federation PRIVATE + Catch2::Catch2WithMain + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui + Qt${QT_VERSION}::Test # QSignalSpy +) +catch_discover_tests(test_federation) diff --git a/src/ifcviewer-full/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp similarity index 100% rename from src/ifcviewer-full/tests/test_federation.cpp rename to src/ifcviewer/tests/test_federation.cpp From 5386c9ec69b79a68a586ec8f9f2d262b64c84c88 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 15:15:37 +1000 Subject: [PATCH 088/120] ifcviewer: add stage 3+4 data model and compose helpers to Federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the structs that were briefly in src/ifcviewer/Federation.{h,cpp} two commits ago, now folded into the merged Federation alongside the file persistence layer: - FederationConfig: federation-wide unit ({prefix, name}). Default METRE; one-of an IfcSIUnit name with optional prefix or an IfcConversionBasedUnit name. - FederationOrigin: stage 3 — XYZ in federation unit + Z-rot. Composes to R_z · T(-xyz_meters), nominating a point as origin. - AFrame + ModelTransform: stage 4 intent — A (model project or map unit, per a_frame), B and pivot (federation unit), full intrinsic-XYZ Euler rotation in degrees. - ModelUnits: per-model project_length_to_meters / map_unit_to_meters cached at load time. Free functions composeFederationOrigin and composeModelTransform return Eigen::Matrix4d in metres. composeModelTransform takes the model's stage-2 georef matrix so it can lift `a` into metres when authored in ModelLocal. Federation gains config_, origin_ members + setters that emit dirtyChanged. Each Model carries a transform_intent. JSON I/O emits config / origin always; transform_intent only when non-default. Schema stays "ifcfed/1" — additive, optional, sane defaults. Five new tests: round-trip of the new fields, default-omission behaviour, two compose smoke tests for FederationOrigin, and one verifying the "pivot at B preserves A→B" invariant of composeModelTransform. All 36 ctest cases pass. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Federation.cpp | 178 ++++++++++++++++++++++++ src/ifcviewer/Federation.h | 114 +++++++++++++-- src/ifcviewer/tests/CMakeLists.txt | 8 ++ src/ifcviewer/tests/test_federation.cpp | 127 +++++++++++++++++ 4 files changed, 417 insertions(+), 10 deletions(-) diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 166a48910d..b7237dcde5 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "Federation.h" +#include "Unit.h" #include #include @@ -29,9 +30,33 @@ #include #include +#include + namespace { constexpr const char* kSchema = "ifcfed/1"; +constexpr double kPi = 3.14159265358979323846; +constexpr double kDegToRad = kPi / 180.0; + +Eigen::Matrix4d translation4(const Eigen::Vector3d& t) { + Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); + M(0, 3) = t.x(); + M(1, 3) = t.y(); + M(2, 3) = t.z(); + return M; +} + +// Intrinsic XYZ Euler: R = R_z · R_y · R_x. +Eigen::Matrix4d eulerXYZ(const Eigen::Vector3d& rxyz_rad) { + const Eigen::Matrix3d R3 = + (Eigen::AngleAxisd(rxyz_rad.z(), Eigen::Vector3d::UnitZ()) * + Eigen::AngleAxisd(rxyz_rad.y(), Eigen::Vector3d::UnitY()) * + Eigen::AngleAxisd(rxyz_rad.x(), Eigen::Vector3d::UnitX())).matrix(); + Eigen::Matrix4d R = Eigen::Matrix4d::Identity(); + R.block<3, 3>(0, 0) = R3; + return R; +} + QString resolvePath(const QString& fed_dir, const QString& stored) { if (stored.isEmpty()) return stored; QFileInfo fi(stored); @@ -52,6 +77,61 @@ QString relativizePath(const QString& fed_dir, const QString& abs_path) { } } // namespace +// === Stage-3/4 compose helpers === + +double federationUnitToMeters(const FederationConfig& cfg) { + return convert(1.0, cfg.unit_prefix, cfg.unit_name, "", "METRE"); +} + +Eigen::Matrix4d composeFederationOrigin(const FederationOrigin& origin, + const FederationConfig& cfg) { + const double u = federationUnitToMeters(cfg); + const Eigen::Vector3d xyz_m = origin.xyz * u; + const double rz_rad = origin.rz_deg * kDegToRad; + const Eigen::Matrix3d Rz = + Eigen::AngleAxisd(rz_rad, Eigen::Vector3d::UnitZ()).matrix(); + Eigen::Matrix4d Rz4 = Eigen::Matrix4d::Identity(); + Rz4.block<3, 3>(0, 0) = Rz; + return Rz4 * translation4(-xyz_m); +} + +Eigen::Matrix4d composeModelTransform(const ModelTransform& xf, + const FederationConfig& fed_cfg, + const ModelUnits& model_units, + const Eigen::Matrix4d& stage2_meters) { + const double u_fed = federationUnitToMeters(fed_cfg); + + Eigen::Vector3d A_m; + if (xf.a_frame == AFrame::ModelLocal) { + // a is in the model's project length unit, expressed in the + // pre-stage2 frame. Convert to metres, then lift through stage 2. + const Eigen::Vector4d a_h( + xf.a.x() * model_units.project_length_to_meters, + xf.a.y() * model_units.project_length_to_meters, + xf.a.z() * model_units.project_length_to_meters, + 1.0); + A_m = (stage2_meters * a_h).head<3>(); + } else { + // a is in the model's map unit, expressed in the post-stage2 frame. + A_m = xf.a * model_units.map_unit_to_meters; + } + + const Eigen::Vector3d B_m = xf.b * u_fed; + const Eigen::Vector3d pivot_m = xf.pivot * u_fed; + + const Eigen::Matrix4d R_local = eulerXYZ(xf.rxyz_deg * kDegToRad); + const Eigen::Matrix4d R_at_pivot = + translation4(pivot_m) * R_local * translation4(-pivot_m); + + const Eigen::Vector4d Ah(A_m.x(), A_m.y(), A_m.z(), 1.0); + const Eigen::Vector3d RA = (R_at_pivot * Ah).head<3>(); + const Eigen::Matrix4d T = translation4(B_m - RA); + + return T * R_at_pivot; +} + +// === Federation class === + Federation::Federation(QObject* parent) : QObject(parent) {} QString Federation::generateId() { @@ -68,11 +148,36 @@ void Federation::clear() { created_ = QDateTime(); modified_ = QDateTime(); models_.clear(); + config_ = FederationConfig{}; + origin_ = FederationOrigin{}; has_home_view_ = false; home_view_ = HomeView{}; setDirty(false); } +void Federation::setConfig(const FederationConfig& c) { + if (config_.unit_name == c.unit_name && config_.unit_prefix == c.unit_prefix) + return; + config_ = c; + setDirty(true); +} + +void Federation::setOrigin(const FederationOrigin& o) { + if (origin_.xyz == o.xyz && origin_.rz_deg == o.rz_deg) return; + origin_ = o; + setDirty(true); +} + +void Federation::setModelTransform(const QString& fed_id, + const ModelTransform& xf) { + for (auto& m : models_) { + if (m.id != fed_id) continue; + m.transform_intent = xf; + setDirty(true); + return; + } +} + void Federation::markClean() { setDirty(false); } @@ -163,6 +268,23 @@ bool Federation::load(const QString& path, created_ = QDateTime::fromString(root.value("created").toString(), Qt::ISODate); modified_ = QDateTime::fromString(root.value("modified").toString(), Qt::ISODate); + if (QJsonValue cv = root.value("config"); cv.isObject()) { + QJsonObject co = cv.toObject(); + QJsonObject uo = co.value("unit").toObject(); + config_.unit_name = uo.value("name").toString("METRE").toStdString(); + config_.unit_prefix = uo.value("prefix").toString("").toStdString(); + } + + if (QJsonValue ov = root.value("origin"); ov.isObject()) { + QJsonObject oo = ov.toObject(); + QJsonArray xyz = oo.value("xyz").toArray(); + if (xyz.size() == 3) { + origin_.xyz = Eigen::Vector3d( + xyz[0].toDouble(), xyz[1].toDouble(), xyz[2].toDouble()); + } + origin_.rz_deg = oo.value("rz_deg").toDouble(0.0); + } + QJsonArray arr = root.value("models").toArray(); for (int i = 0; i < arr.size(); ++i) { if (!arr[i].isObject()) { @@ -198,6 +320,22 @@ bool Federation::load(const QString& path, if (m.display_name.isEmpty()) m.display_name = QFileInfo(m.source_path).fileName(); + if (QJsonValue tv = mo.value("transform_intent"); tv.isObject()) { + QJsonObject to = tv.toObject(); + const QString af = to.value("a_frame").toString("ModelGlobal"); + m.transform_intent.a_frame = + (af == "ModelLocal") ? AFrame::ModelLocal : AFrame::ModelGlobal; + auto readVec3 = [](QJsonArray ja) { + if (ja.size() != 3) return Eigen::Vector3d::Zero().eval(); + return Eigen::Vector3d( + ja[0].toDouble(), ja[1].toDouble(), ja[2].toDouble()); + }; + m.transform_intent.a = readVec3(to.value("a").toArray()); + m.transform_intent.b = readVec3(to.value("b").toArray()); + m.transform_intent.rxyz_deg = readVec3(to.value("rxyz_deg").toArray()); + m.transform_intent.pivot = readVec3(to.value("pivot").toArray()); + } + QJsonValue vv = mo.value("visible"); if (vv.isBool()) m.visible = vv.toBool(); @@ -238,6 +376,24 @@ bool Federation::save(const QString& path, QString* err) { root["created"] = created_.toUTC().toString(Qt::ISODate); root["modified"] = modified_.toUTC().toString(Qt::ISODate); + { + QJsonObject co, uo; + uo["name"] = QString::fromStdString(config_.unit_name); + uo["prefix"] = QString::fromStdString(config_.unit_prefix); + co["unit"] = uo; + root["config"] = co; + } + { + QJsonObject oo; + QJsonArray xyz; + xyz.append(origin_.xyz.x()); + xyz.append(origin_.xyz.y()); + xyz.append(origin_.xyz.z()); + oo["xyz"] = xyz; + oo["rz_deg"] = origin_.rz_deg; + root["origin"] = oo; + } + QJsonArray arr; for (const auto& m : models_) { QJsonObject mo; @@ -254,6 +410,28 @@ bool Federation::save(const QString& path, QString* err) { } mo["source"] = so; + // Skip transform_intent when it's at defaults (identity placement). + const ModelTransform def; + const ModelTransform& xf = m.transform_intent; + const bool xf_is_default = + xf.a_frame == def.a_frame && xf.a == def.a && xf.b == def.b && + xf.rxyz_deg == def.rxyz_deg && xf.pivot == def.pivot; + if (!xf_is_default) { + QJsonObject to; + to["a_frame"] = (xf.a_frame == AFrame::ModelLocal) + ? "ModelLocal" : "ModelGlobal"; + auto writeVec3 = [](const Eigen::Vector3d& v) { + QJsonArray a; + a.append(v.x()); a.append(v.y()); a.append(v.z()); + return a; + }; + to["a"] = writeVec3(xf.a); + to["b"] = writeVec3(xf.b); + to["rxyz_deg"] = writeVec3(xf.rxyz_deg); + to["pivot"] = writeVec3(xf.pivot); + mo["transform_intent"] = to; + } + if (!m.visible) mo["visible"] = false; arr.append(mo); diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index f3174582cc..721ecfba34 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -20,24 +20,109 @@ #ifndef FEDERATION_H #define FEDERATION_H +#include + #include #include #include #include #include +#include #include +// === Stage-3/4 data model === +// +// A federation places one or more IFC models in a shared scene. Each model's +// final per-instance transform is composed as +// +// stage3 · stage4 · stage2 · placement_stage1 +// +// where: +// - stage1 is per-mesh vertex rebasing (load-time, immutable) +// - stage2 is the per-model georef matrix from IfcMapConversion etc. +// (load-time, immutable; can be toggled off) +// - stage3 is the federation-wide false origin (mutable, federation-scope) +// - stage4 is the per-model placement within the federation (mutable, per-model) +// +// All composed matrices are in metres. User-authored numbers are stored in +// their source units (model project unit / model map unit / federation unit) +// to round-trip without precision loss; conversion happens in the compose +// helpers. + +// Federation-wide unit; the value space for FederationOrigin.xyz and +// ModelTransform::{b, pivot}. +struct FederationConfig { + // IfcSIUnit name ("METRE") or IfcConversionBasedUnit name ("foot", "inch"). + std::string unit_name = "METRE"; + // SI prefix ("MILLI", "KILO", ...) — empty for unprefixed or for + // conversion-based units. + std::string unit_prefix = ""; +}; + +// Stage 3 — the federation false origin. Authoring intent is "nominate this +// XYZ as the new origin, with optional Z-axis heading rotation". Composed +// as stage3 = R_z(rz_deg) · T(-xyz_in_metres). +struct FederationOrigin { + Eigen::Vector3d xyz = Eigen::Vector3d::Zero(); // federation unit + double rz_deg = 0.0; +}; + +// Frame in which ModelTransform.a is expressed. +// ModelLocal — pre-stage2 model coordinates, in the model's project length unit +// ModelGlobal — post-stage2 model coordinates, in the model's map unit +enum class AFrame { ModelLocal, ModelGlobal }; + +// Stage 4 — the per-model placement within the federation. Authoring intent +// is "rotate the model around `pivot`, then translate so that point `a` lands +// at point `b`". Composed as +// R_local = R_z(rz) · R_y(ry) · R_x(rx) [intrinsic XYZ] +// R_at_pivot = T(pivot_m) · R_local · T(-pivot_m) +// stage4 = T(b_m - R_at_pivot · a_m) · R_at_pivot +struct ModelTransform { + AFrame a_frame = AFrame::ModelGlobal; + Eigen::Vector3d a = Eigen::Vector3d::Zero(); // model project / map unit + Eigen::Vector3d b = Eigen::Vector3d::Zero(); // federation unit + Eigen::Vector3d rxyz_deg = Eigen::Vector3d::Zero(); // degrees, intrinsic XYZ + Eigen::Vector3d pivot = Eigen::Vector3d::Zero(); // federation unit +}; + +// Per-model unit scales captured at load time. project_length_to_meters +// comes from calculateUnitScale(file, "LENGTHUNIT"); map_unit_to_meters from +// siScaleFromNamedUnit(getMapUnit(file)) and falls back to the project length +// scale when the model has no MapUnit. +struct ModelUnits { + double project_length_to_meters = 1.0; + double map_unit_to_meters = 1.0; +}; + +// 1 federation_unit -> N metres. +double federationUnitToMeters(const FederationConfig&); + +// Compose stage 3 (federation false origin) into a 4x4 matrix in metres. +Eigen::Matrix4d composeFederationOrigin(const FederationOrigin&, + const FederationConfig&); + +// Compose stage 4 (per-model placement within the federation) into a 4x4 +// matrix in metres. `stage2_meters` is the model's georef matrix (e.g. +// helmertMetersFromParameters · inv(wcs_meters)) — needed to lift `a` into +// metres when a_frame == ModelLocal. Pass identity when stage 2 is disabled +// or absent. +Eigen::Matrix4d composeModelTransform(const ModelTransform&, + const FederationConfig& fed_cfg, + const ModelUnits& model_units, + const Eigen::Matrix4d& stage2_meters); + +// === Federation persistence (.ifcfed) === +// // In-memory representation of an .ifcfed file (IFC federation). // // A federation is a named, ordered list of model sources plus an optional -// "home view" camera state. Source paths can be relative (resolved against -// the .ifcfed's directory) or absolute. Save() reserialises paths relative -// when they live under the federation file's directory tree, absolute -// otherwise — Save As recomputes against the new location. -// -// Round-trip-only fields today (no UI to edit, but preserved across load/ -// save): per-model `visible`, future cloud `source.kind`s. +// "home view" camera state, a federation-wide unit + false origin, and per +// model an optional transform intent. Source paths can be relative +// (resolved against the .ifcfed's directory) or absolute. Save() reserialises +// paths relative when they live under the federation file's directory tree, +// absolute otherwise — Save As recomputes against the new location. class Federation : public QObject { Q_OBJECT public: @@ -49,10 +134,11 @@ public: }; struct Model { - QString id; // stable, persisted + QString id; // stable, persisted QString display_name; - QString source_kind = "local"; // future: "http", "speckle", ... - QString source_path; // resolved absolute when kind == "local" + QString source_kind = "local"; // future: "http", "speckle", ... + QString source_path; // resolved absolute when kind == "local" + ModelTransform transform_intent; // stage 4 bool visible = true; }; @@ -70,6 +156,10 @@ public: void setHomeView(const HomeView& hv); void clearHomeView(); + void setConfig(const FederationConfig&); + void setOrigin(const FederationOrigin&); + void setModelTransform(const QString& fed_id, const ModelTransform&); + // Accessors const std::vector& models() const { return models_; } const Model* findById(const QString& fed_id) const; @@ -79,6 +169,8 @@ public: QString name() const { return name_; } bool hasHomeView() const { return has_home_view_; } const HomeView& homeView() const { return home_view_; } + const FederationConfig& config() const { return config_; } + const FederationOrigin& origin() const { return origin_; } signals: void dirtyChanged(bool dirty); @@ -93,6 +185,8 @@ private: QDateTime created_; QDateTime modified_; std::vector models_; + FederationConfig config_; + FederationOrigin origin_; bool has_home_view_ = false; HomeView home_view_; bool dirty_ = false; diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index 70436a9b73..bcf72ac87e 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -53,9 +53,15 @@ add_ifcviewer_unit_test(test_instanced_geometry) # enable AUTOMOC for the Q_OBJECT moc-generation. find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR}) +find_package(Eigen3 REQUIRED) + add_executable(test_federation test_federation.cpp ${IFCVIEWER_SRC}/Federation.cpp + # Federation pulls in Unit::convert for federation_unit_to_meters; compile + # Unit.cpp directly so the test doesn't have to link the whole IfcViewer + # library (which would drag in Qt6::OpenGL, OpenCASCADE, etc.). + ${IFCVIEWER_SRC}/Unit.cpp ) set_target_properties(test_federation PROPERTIES AUTOMOC ON) target_include_directories(test_federation PRIVATE ${IFCVIEWER_SRC}) @@ -64,5 +70,7 @@ target_link_libraries(test_federation PRIVATE Qt${QT_VERSION}::Core Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui Qt${QT_VERSION}::Test # QSignalSpy + Eigen3::Eigen # Federation.h: composed matrices use Eigen + IfcParse # Unit.cpp uses express::Base / file APIs ) catch_discover_tests(test_federation) diff --git a/src/ifcviewer/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp index 6226464220..42b2bfb4e6 100644 --- a/src/ifcviewer/tests/test_federation.cpp +++ b/src/ifcviewer/tests/test_federation.cpp @@ -304,3 +304,130 @@ TEST_CASE("load on malformed JSON fails with an error", "[federation]") { REQUIRE_FALSE(fed.load(bad, &warnings, &err)); REQUIRE_FALSE(err.isEmpty()); } + +TEST_CASE("config / origin / transform_intent round-trip through save+load", + "[federation]") { + ensureQApp(); + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + + QString src1 = writeStubFile(tmp.filePath("models/wall.ifc")); + QString fed_path = tmp.filePath("project.ifcfed"); + + Federation src; + QString id1 = src.addModel(src1, "Wall"); + + FederationConfig cfg; + cfg.unit_name = "FOOT"; + cfg.unit_prefix = ""; + src.setConfig(cfg); + + FederationOrigin org; + org.xyz = Eigen::Vector3d(100.0, 200.0, 30.0); + org.rz_deg = 45.0; + src.setOrigin(org); + + ModelTransform xf; + xf.a_frame = AFrame::ModelLocal; + xf.a = Eigen::Vector3d(1.0, 2.0, 3.0); + xf.b = Eigen::Vector3d(4.0, 5.0, 6.0); + xf.rxyz_deg = Eigen::Vector3d(90.0, 0.0, 0.0); + xf.pivot = Eigen::Vector3d(7.0, 8.0, 9.0); + src.setModelTransform(id1, xf); + + QString err; + REQUIRE(src.save(fed_path, &err)); + REQUIRE(err.isEmpty()); + + Federation dst; + QStringList warnings; + REQUIRE(dst.load(fed_path, &warnings, &err)); + REQUIRE(err.isEmpty()); + REQUIRE(warnings.isEmpty()); + + REQUIRE(dst.config().unit_name == "FOOT"); + REQUIRE(dst.config().unit_prefix == ""); + + REQUIRE(dst.origin().xyz == org.xyz); + REQUIRE(dst.origin().rz_deg == 45.0); + + REQUIRE(dst.models().size() == 1); + const auto& m = dst.models()[0]; + REQUIRE(m.id == id1); + REQUIRE(m.transform_intent.a_frame == AFrame::ModelLocal); + REQUIRE(m.transform_intent.a == xf.a); + REQUIRE(m.transform_intent.b == xf.b); + REQUIRE(m.transform_intent.rxyz_deg == xf.rxyz_deg); + REQUIRE(m.transform_intent.pivot == xf.pivot); +} + +TEST_CASE("default ModelTransform is omitted from saved JSON", "[federation]") { + ensureQApp(); + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + + QString src1 = writeStubFile(tmp.filePath("models/wall.ifc")); + QString fed_path = tmp.filePath("project.ifcfed"); + + Federation src; + src.addModel(src1, "Wall"); + QString err; + REQUIRE(src.save(fed_path, &err)); + + QJsonObject root = readJsonFile(fed_path); + QJsonArray models = root.value("models").toArray(); + REQUIRE(models.size() == 1); + REQUIRE_FALSE(models[0].toObject().contains("transform_intent")); +} + +TEST_CASE("composeFederationOrigin moves the nominated point to the origin", + "[federation][compose]") { + FederationConfig cfg; // METRE, no prefix + FederationOrigin org; + org.xyz = Eigen::Vector3d(10.0, 20.0, 5.0); + org.rz_deg = 0.0; + + Eigen::Matrix4d M = composeFederationOrigin(org, cfg); + + // The nominated point (10, 20, 5) should map to (0, 0, 0). + Eigen::Vector4d p(10.0, 20.0, 5.0, 1.0); + Eigen::Vector4d r = M * p; + REQUIRE(std::abs(r.x()) < 1e-9); + REQUIRE(std::abs(r.y()) < 1e-9); + REQUIRE(std::abs(r.z()) < 1e-9); +} + +TEST_CASE("composeFederationOrigin scales by federation unit", + "[federation][compose]") { + FederationConfig cfg; + cfg.unit_name = "FOOT"; // 1 ft = 0.3048 m + FederationOrigin org; + org.xyz = Eigen::Vector3d(1.0, 0.0, 0.0); // 1 foot in fed coords + + Eigen::Matrix4d M = composeFederationOrigin(org, cfg); + // Translation column should be -1 ft = -0.3048 m. + REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9); +} + +TEST_CASE("composeModelTransform with pivot=B keeps A landing on B", + "[federation][compose]") { + // A in ModelGlobal frame, federation in metres, model has identity stage 2. + FederationConfig fed_cfg; // METRE + ModelUnits mu; // 1.0 / 1.0 (already in metres) + Eigen::Matrix4d stage2 = Eigen::Matrix4d::Identity(); + + ModelTransform xf; + xf.a_frame = AFrame::ModelGlobal; + xf.a = Eigen::Vector3d(5.0, 0.0, 0.0); + xf.b = Eigen::Vector3d(100.0, 50.0, 10.0); + xf.rxyz_deg = Eigen::Vector3d(0.0, 0.0, 30.0); + xf.pivot = xf.b; // pivot at B preserves A->B regardless of rotation + + Eigen::Matrix4d M = composeModelTransform(xf, fed_cfg, mu, stage2); + + Eigen::Vector4d a(xf.a.x(), xf.a.y(), xf.a.z(), 1.0); + Eigen::Vector4d r = M * a; + REQUIRE(std::abs(r.x() - xf.b.x()) < 1e-9); + REQUIRE(std::abs(r.y() - xf.b.y()) < 1e-9); + REQUIRE(std::abs(r.z() - xf.b.z()) < 1e-9); +} From bea4e38e6580277bab90e924726c068256a19c6d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 17:31:08 +1000 Subject: [PATCH 089/120] ifcviewer: cache per-model georef in SceneLoader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ModelGeoref { ModelUnits units; Eigen::Matrix4d stage2_meters; bool has_stage2; } and computeModelGeoref(file*) in Federation.{h,cpp}. The helper reads the project length unit, IfcProjectedCRS.MapUnit, helmert parameters and WCS, and reduces them to a metres-in/metres-out stage 2 matrix using the existing Geolocation + Unit primitives. When the model has no IfcMapConversion it returns an identity stage_2 with has_stage2 == false, so the upload pipeline can branch cheaply. SceneLoader::Model gains a cached ModelGeoref; SceneLoader::modelGeoref (uint32_t mid) computes lazily on first call (returns nullptr when the IFC file isn't available yet — happens on the sidecar-hit path before the data-source thread populates the streamer) and serves from cache afterwards. Not yet consumed by the upload pipeline; that's the next commit. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Federation.cpp | 40 ++++++++++++++++++++++++++++++ src/ifcviewer/Federation.h | 17 +++++++++++++ src/ifcviewer/SceneLoader.cpp | 12 +++++++++ src/ifcviewer/SceneLoader.h | 12 +++++++++ src/ifcviewer/tests/CMakeLists.txt | 9 ++++--- 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index b7237dcde5..ac1fb3a894 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "Federation.h" +#include "Geolocation.h" #include "Unit.h" #include @@ -95,6 +96,45 @@ Eigen::Matrix4d composeFederationOrigin(const FederationOrigin& origin, return Rz4 * translation4(-xyz_m); } +ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file) { + ModelGeoref out; + if (!ifc_file) return out; + + out.units.project_length_to_meters = + calculateUnitScale(ifc_file, "LENGTHUNIT"); + + if (auto map_unit = getMapUnit(ifc_file)) { + if (auto s = siScaleFromNamedUnit(*map_unit)) { + out.units.map_unit_to_meters = *s; + } else { + out.units.map_unit_to_meters = out.units.project_length_to_meters; + } + } else { + // No MapUnit on the IfcProjectedCRS — fall back to project length unit. + out.units.map_unit_to_meters = out.units.project_length_to_meters; + } + + auto params = getHelmertTransformationParameters(ifc_file); + if (!params) return out; + + Eigen::Matrix4d helmert = + helmertMetersFromParameters(*params, out.units.map_unit_to_meters); + + if (auto wcs = getWcs(ifc_file)) { + // getWcs returns the WCS in project units (translation in project + // length units). Convert translation to metres before inverting. + Eigen::Matrix4d wcs_m = *wcs; + wcs_m(0, 3) *= out.units.project_length_to_meters; + wcs_m(1, 3) *= out.units.project_length_to_meters; + wcs_m(2, 3) *= out.units.project_length_to_meters; + out.stage2_meters = helmert * wcs_m.inverse(); + } else { + out.stage2_meters = helmert; + } + out.has_stage2 = true; + return out; +} + Eigen::Matrix4d composeModelTransform(const ModelTransform& xf, const FederationConfig& fed_cfg, const ModelUnits& model_units, diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 721ecfba34..d93603e45e 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -31,6 +31,8 @@ #include #include +namespace ifcopenshell { class file; } + // === Stage-3/4 data model === // // A federation places one or more IFC models in a shared scene. Each model's @@ -96,6 +98,21 @@ struct ModelUnits { double map_unit_to_meters = 1.0; }; +// Per-model georeferencing data derived from the IFC. `stage2_meters` is +// the helmert · inv(wcs) georef matrix in metres; consumers compose it +// before stage 3 / stage 4 at upload time. When the model has no map +// conversion, `has_stage2 == false` and `stage2_meters` is identity. +struct ModelGeoref { + ModelUnits units; + Eigen::Matrix4d stage2_meters = Eigen::Matrix4d::Identity(); + bool has_stage2 = false; +}; + +// Read a model's project length unit, map unit, helmert parameters, and WCS +// from `ifc_file` and reduce them to a metres-in / metres-out georef matrix. +// Pure compute; safe to call repeatedly if the caller doesn't want to cache. +ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file); + // 1 federation_unit -> N metres. double federationUnitToMeters(const FederationConfig&); diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index df6e66c71c..d455615376 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -69,6 +69,18 @@ ifcopenshell::file* SceneLoader::ifcFile(uint32_t mid) const { return it == models_.end() ? nullptr : it->second.streamer->ifcFile(); } +const ModelGeoref* SceneLoader::modelGeoref(uint32_t mid) { + auto it = models_.find(mid); + if (it == models_.end()) return nullptr; + auto& m = it->second; + if (m.has_georef) return &m.georef; + auto* file = m.streamer ? m.streamer->ifcFile() : nullptr; + if (!file) return nullptr; + m.georef = computeModelGeoref(file); + m.has_georef = true; + return &m.georef; +} + std::vector SceneLoader::addFiles(const QStringList& paths) { std::vector assigned; assigned.reserve(paths.size()); diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 344c4b985d..6efca5a555 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -33,6 +33,7 @@ #include #include +#include "Federation.h" #include "ViewportWindow.h" #include "GeometryStreamer.h" #include "SidecarCache.h" @@ -65,6 +66,12 @@ public: QString displayName(uint32_t mid) const; ifcopenshell::file* ifcFile(uint32_t mid) const; + // Lazily computes the model's georef matrix + unit scales the first + // time it's asked for, caches the result, and returns a pointer into the + // cache. Returns nullptr when the IFC file isn't available yet (e.g. + // sidecar-hit path before the data-source thread populates the streamer). + const ModelGeoref* modelGeoref(uint32_t mid); + signals: void progressChanged(int percent); void loadStarted(uint32_t mid, QString display_name); @@ -113,6 +120,11 @@ private: QString display_name; GeometryStreamer* streamer = nullptr; QElapsedTimer load_timer; + + // Cached on first SceneLoader::modelGeoref(mid) call once the + // streamer has its IFC file loaded. + ModelGeoref georef; + bool has_georef = false; }; void startNextLoad(); diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index bcf72ac87e..c07512d66d 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -58,10 +58,13 @@ find_package(Eigen3 REQUIRED) add_executable(test_federation test_federation.cpp ${IFCVIEWER_SRC}/Federation.cpp - # Federation pulls in Unit::convert for federation_unit_to_meters; compile - # Unit.cpp directly so the test doesn't have to link the whole IfcViewer - # library (which would drag in Qt6::OpenGL, OpenCASCADE, etc.). + # Federation pulls in Unit::convert for federationUnitToMeters and + # Geolocation helpers (helmertMetersFromParameters, getWcs, getMapUnit) + # for computeModelGeoref; compile both directly so the test doesn't + # have to link the whole IfcViewer library (which would drag in + # Qt6::OpenGL, OpenCASCADE, etc.). ${IFCVIEWER_SRC}/Unit.cpp + ${IFCVIEWER_SRC}/Geolocation.cpp ) set_target_properties(test_federation PROPERTIES AUTOMOC ON) target_include_directories(test_federation PRIVATE ${IFCVIEWER_SRC}) From 600d3a346077fb7895a734f7164eef6c5262682c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 17:39:13 +1000 Subject: [PATCH 090/120] ifcviewer: stage 1 mesh-vertex rebasing in the streamer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-mesh, when the iterator's first source vertex is more than 1 km from origin (matching bonsai's distance_limit default), pick that vertex as a rebase offset. buildMeshChunk subtracts the offset from every emitted vertex (in double precision, narrowed to float at the end), and each instance's placement matrix is post-multiplied by T(+offset) so world position is preserved by construction: T(+offset) · (verts - offset) ≡ T · verts The offset is stored on the per-mesh MeshAabb so all instances of the same mesh apply the same compensation. When the mesh's first vert is near origin (the common case), offset is zero and the work is a no-op beyond a couple of FP ops per vertex. Improves float32 precision in the vertex buffer for georeferenced models (UTM coords etc.) where verts would otherwise have to encode million-metre magnitudes directly — at 1e6 m, float32 resolves about 6 cm, ruining sub-millimetre detail in the buildings themselves. Visual verification on a real UTM-coords model still pending — the math preserves world position by construction but precision claims warrant a hand-test in the viewer. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/GeometryStreamer.cpp | 63 ++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index f76521db68..1b418fc2c9 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -147,9 +147,16 @@ std::vector GeometryStreamer::drainElements() { // Build a mesh chunk (local coords, 28-byte interleaved vertices) from a // TriangulationElement. Per-vertex color is baked from material_ids so that // triangulations with per-face materials still render correctly. +// Stage 1 — vertex rebasing. When `offset` is non-zero, every vertex +// position is subtracted by it so the emitted mesh-local coordinates stay +// near the origin (and float32 precision survives upload to the GPU). +// Caller compensates by post-multiplying each instance's placement by +// T(+offset), which is mathematically the identity overall but moves the +// "magnitude" off the float-precision-sensitive vertex column. static MeshChunk buildMeshChunk(uint32_t model_id, uint32_t local_mesh_id, - const IfcGeom::TriangulationElement* elem) { + const IfcGeom::TriangulationElement* elem, + const Eigen::Vector3d& offset) { MeshChunk chunk; chunk.model_id = model_id; chunk.local_mesh_id = local_mesh_id; @@ -196,9 +203,11 @@ static MeshChunk buildMeshChunk(uint32_t model_id, const uint32_t new_idx = static_cast( chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS); - float px = static_cast(verts[orig_idx * 3 + 0]); - float py = static_cast(verts[orig_idx * 3 + 1]); - float pz = static_cast(verts[orig_idx * 3 + 2]); + // Subtract in double, narrow to float — preserves precision when + // verts are far from origin and offset cancels the magnitude. + float px = static_cast(verts[orig_idx * 3 + 0] - offset.x()); + float py = static_cast(verts[orig_idx * 3 + 1] - offset.y()); + float pz = static_cast(verts[orig_idx * 3 + 2] - offset.z()); chunk.vertices.push_back(px); chunk.vertices.push_back(py); chunk.vertices.push_back(pz); @@ -458,7 +467,14 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { // Shared dedup + AABB state across passes — same geom.id() across // net/gross passes still maps to one mesh upload. std::unordered_map geom_to_local_mesh_id; - struct MeshAabb { float lmin[3], lmax[3]; }; + // Per-unique-mesh state shared across instances. `offset` is the stage-1 + // rebase applied to verts (zero when the mesh's first vert is near origin + // and rebasing wasn't worth it). + struct MeshAabb { + float lmin[3], lmax[3]; + double offset[3] = {0.0, 0.0, 0.0}; + bool has_offset = false; + }; std::vector mesh_aabbs; uint32_t total_shapes = 0; @@ -577,12 +593,33 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } if (first_sight) { - MeshChunk mesh_chunk = buildMeshChunk(model_id_, local_mesh_id, tri_elem); + // Stage 1: pick a rebase offset when the mesh's first + // source vertex is far from origin (>1 km in metres, + // matching bonsai's distance_limit default). Iterator + // outputs metres, so the threshold is in metres directly. + Eigen::Vector3d offset = Eigen::Vector3d::Zero(); + constexpr double kFarAwayThresholdMeters = 1000.0; + const auto& src_verts = tri_elem->geometry().verts(); + if (src_verts.size() >= 3) { + const double x = src_verts[0]; + const double y = src_verts[1]; + const double z = src_verts[2]; + if (std::abs(x) > kFarAwayThresholdMeters || + std::abs(y) > kFarAwayThresholdMeters || + std::abs(z) > kFarAwayThresholdMeters) { + offset = Eigen::Vector3d(x, y, z); + } + } + + MeshChunk mesh_chunk = + buildMeshChunk(model_id_, local_mesh_id, tri_elem, offset); MeshAabb ma; for (int a = 0; a < 3; ++a) { ma.lmin[a] = mesh_chunk.local_aabb_min[a]; ma.lmax[a] = mesh_chunk.local_aabb_max[a]; + ma.offset[a] = offset[a]; } + ma.has_offset = (offset.squaredNorm() > 0.0); if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1); mesh_aabbs[local_mesh_id] = ma; if (!mesh_chunk.indices.empty()) { @@ -590,7 +627,19 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } } - const Eigen::Matrix4d& mat_d = tri_elem->transformation().data()->ccomponents(); + // Stage 1 cont.: post-multiply the per-instance placement by + // T(+offset) so world position is preserved. Matrix arithmetic + // is in double; narrow to float at the end. + Eigen::Matrix4d mat_d = + tri_elem->transformation().data()->ccomponents(); + if (mesh_aabbs[local_mesh_id].has_offset) { + const Eigen::Vector3d off( + mesh_aabbs[local_mesh_id].offset[0], + mesh_aabbs[local_mesh_id].offset[1], + mesh_aabbs[local_mesh_id].offset[2]); + mat_d.block<3, 1>(0, 3) += mat_d.block<3, 3>(0, 0) * off; + } + InstanceChunk inst; inst.model_id = model_id_; inst.local_mesh_id = local_mesh_id; From 92c3b4c308539086cec994428e6e6c14d86e6326 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 20:06:06 +1000 Subject: [PATCH 091/120] ifcviewer: rename stage1/2/3/4 to their proper IFC-mapped names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder "stage1/2/3/4" terminology with names that mirror the IFC concepts each step represents: stage 1 -> PlacementTransformation (per-instance, derived from IfcObjectPlacement) stage 2 -> CoordinateOperation (per-model, IfcCoordinateOperation / IfcMapConversion) stage 3 -> FederatedFalseOrigin (federation-wide, user-nominated) stage 4 -> ModelTransformation (per-model, user-authored within the federation) API renames: FederationOrigin -> FederatedFalseOrigin ModelTransform -> ModelTransformation composeFederationOrigin -> composeFederatedFalseOrigin composeModelTransform -> composeModelTransformation Federation::setOrigin -> Federation::setFederatedFalseOrigin Federation::setModelTransform -> Federation::setModelTransformation Federation::origin() -> Federation::federatedFalseOrigin() Federation::Model::transform_intent -> ::model_transformation ModelGeoref::stage2_meters -> ::coordinate_operation_meters ModelGeoref::has_stage2 -> ::has_coordinate_operation JSON keys in .ifcfed renamed in lockstep: origin -> federated_false_origin transform_intent -> model_transformation The streamer's per-mesh "stage 1 vertex rebasing" comment is reframed: the rebase isn't its own stage — it's a precision optimisation applied inside the PlacementTransformation step. All 36 ctest cases pass under the new names. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/Federation.cpp | 79 ++++++++-------- src/ifcviewer/Federation.h | 114 +++++++++++++----------- src/ifcviewer/GeometryStreamer.cpp | 23 ++--- src/ifcviewer/tests/test_federation.cpp | 53 +++++------ 4 files changed, 143 insertions(+), 126 deletions(-) diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index ac1fb3a894..9dc500cbd1 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -84,8 +84,8 @@ double federationUnitToMeters(const FederationConfig& cfg) { return convert(1.0, cfg.unit_prefix, cfg.unit_name, "", "METRE"); } -Eigen::Matrix4d composeFederationOrigin(const FederationOrigin& origin, - const FederationConfig& cfg) { +Eigen::Matrix4d composeFederatedFalseOrigin(const FederatedFalseOrigin& origin, + const FederationConfig& cfg) { const double u = federationUnitToMeters(cfg); const Eigen::Vector3d xyz_m = origin.xyz * u; const double rz_rad = origin.rz_deg * kDegToRad; @@ -127,32 +127,34 @@ ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file) { wcs_m(0, 3) *= out.units.project_length_to_meters; wcs_m(1, 3) *= out.units.project_length_to_meters; wcs_m(2, 3) *= out.units.project_length_to_meters; - out.stage2_meters = helmert * wcs_m.inverse(); + out.coordinate_operation_meters = helmert * wcs_m.inverse(); } else { - out.stage2_meters = helmert; + out.coordinate_operation_meters = helmert; } - out.has_stage2 = true; + out.has_coordinate_operation = true; return out; } -Eigen::Matrix4d composeModelTransform(const ModelTransform& xf, - const FederationConfig& fed_cfg, - const ModelUnits& model_units, - const Eigen::Matrix4d& stage2_meters) { +Eigen::Matrix4d composeModelTransformation(const ModelTransformation& xf, + const FederationConfig& fed_cfg, + const ModelUnits& model_units, + const Eigen::Matrix4d& coordinate_operation_meters) { const double u_fed = federationUnitToMeters(fed_cfg); Eigen::Vector3d A_m; if (xf.a_frame == AFrame::ModelLocal) { // a is in the model's project length unit, expressed in the - // pre-stage2 frame. Convert to metres, then lift through stage 2. + // pre-CoordinateOperation frame. Convert to metres, then lift + // through the CoordinateOperation. const Eigen::Vector4d a_h( xf.a.x() * model_units.project_length_to_meters, xf.a.y() * model_units.project_length_to_meters, xf.a.z() * model_units.project_length_to_meters, 1.0); - A_m = (stage2_meters * a_h).head<3>(); + A_m = (coordinate_operation_meters * a_h).head<3>(); } else { - // a is in the model's map unit, expressed in the post-stage2 frame. + // a is in the model's map unit, expressed in the + // post-CoordinateOperation frame. A_m = xf.a * model_units.map_unit_to_meters; } @@ -188,8 +190,8 @@ void Federation::clear() { created_ = QDateTime(); modified_ = QDateTime(); models_.clear(); - config_ = FederationConfig{}; - origin_ = FederationOrigin{}; + config_ = FederationConfig{}; + federated_false_origin_ = FederatedFalseOrigin{}; has_home_view_ = false; home_view_ = HomeView{}; setDirty(false); @@ -202,17 +204,18 @@ void Federation::setConfig(const FederationConfig& c) { setDirty(true); } -void Federation::setOrigin(const FederationOrigin& o) { - if (origin_.xyz == o.xyz && origin_.rz_deg == o.rz_deg) return; - origin_ = o; +void Federation::setFederatedFalseOrigin(const FederatedFalseOrigin& o) { + if (federated_false_origin_.xyz == o.xyz && + federated_false_origin_.rz_deg == o.rz_deg) return; + federated_false_origin_ = o; setDirty(true); } -void Federation::setModelTransform(const QString& fed_id, - const ModelTransform& xf) { +void Federation::setModelTransformation(const QString& fed_id, + const ModelTransformation& xf) { for (auto& m : models_) { if (m.id != fed_id) continue; - m.transform_intent = xf; + m.model_transformation = xf; setDirty(true); return; } @@ -315,14 +318,14 @@ bool Federation::load(const QString& path, config_.unit_prefix = uo.value("prefix").toString("").toStdString(); } - if (QJsonValue ov = root.value("origin"); ov.isObject()) { + if (QJsonValue ov = root.value("federated_false_origin"); ov.isObject()) { QJsonObject oo = ov.toObject(); QJsonArray xyz = oo.value("xyz").toArray(); if (xyz.size() == 3) { - origin_.xyz = Eigen::Vector3d( + federated_false_origin_.xyz = Eigen::Vector3d( xyz[0].toDouble(), xyz[1].toDouble(), xyz[2].toDouble()); } - origin_.rz_deg = oo.value("rz_deg").toDouble(0.0); + federated_false_origin_.rz_deg = oo.value("rz_deg").toDouble(0.0); } QJsonArray arr = root.value("models").toArray(); @@ -360,20 +363,20 @@ bool Federation::load(const QString& path, if (m.display_name.isEmpty()) m.display_name = QFileInfo(m.source_path).fileName(); - if (QJsonValue tv = mo.value("transform_intent"); tv.isObject()) { + if (QJsonValue tv = mo.value("model_transformation"); tv.isObject()) { QJsonObject to = tv.toObject(); const QString af = to.value("a_frame").toString("ModelGlobal"); - m.transform_intent.a_frame = + m.model_transformation.a_frame = (af == "ModelLocal") ? AFrame::ModelLocal : AFrame::ModelGlobal; auto readVec3 = [](QJsonArray ja) { if (ja.size() != 3) return Eigen::Vector3d::Zero().eval(); return Eigen::Vector3d( ja[0].toDouble(), ja[1].toDouble(), ja[2].toDouble()); }; - m.transform_intent.a = readVec3(to.value("a").toArray()); - m.transform_intent.b = readVec3(to.value("b").toArray()); - m.transform_intent.rxyz_deg = readVec3(to.value("rxyz_deg").toArray()); - m.transform_intent.pivot = readVec3(to.value("pivot").toArray()); + m.model_transformation.a = readVec3(to.value("a").toArray()); + m.model_transformation.b = readVec3(to.value("b").toArray()); + m.model_transformation.rxyz_deg = readVec3(to.value("rxyz_deg").toArray()); + m.model_transformation.pivot = readVec3(to.value("pivot").toArray()); } QJsonValue vv = mo.value("visible"); @@ -426,12 +429,12 @@ bool Federation::save(const QString& path, QString* err) { { QJsonObject oo; QJsonArray xyz; - xyz.append(origin_.xyz.x()); - xyz.append(origin_.xyz.y()); - xyz.append(origin_.xyz.z()); + xyz.append(federated_false_origin_.xyz.x()); + xyz.append(federated_false_origin_.xyz.y()); + xyz.append(federated_false_origin_.xyz.z()); oo["xyz"] = xyz; - oo["rz_deg"] = origin_.rz_deg; - root["origin"] = oo; + oo["rz_deg"] = federated_false_origin_.rz_deg; + root["federated_false_origin"] = oo; } QJsonArray arr; @@ -450,9 +453,9 @@ bool Federation::save(const QString& path, QString* err) { } mo["source"] = so; - // Skip transform_intent when it's at defaults (identity placement). - const ModelTransform def; - const ModelTransform& xf = m.transform_intent; + // Skip model_transformation when it's at defaults (identity placement). + const ModelTransformation def; + const ModelTransformation& xf = m.model_transformation; const bool xf_is_default = xf.a_frame == def.a_frame && xf.a == def.a && xf.b == def.b && xf.rxyz_deg == def.rxyz_deg && xf.pivot == def.pivot; @@ -469,7 +472,7 @@ bool Federation::save(const QString& path, QString* err) { to["b"] = writeVec3(xf.b); to["rxyz_deg"] = writeVec3(xf.rxyz_deg); to["pivot"] = writeVec3(xf.pivot); - mo["transform_intent"] = to; + mo["model_transformation"] = to; } if (!m.visible) mo["visible"] = false; diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index d93603e45e..97fe072cff 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -33,27 +33,33 @@ namespace ifcopenshell { class file; } -// === Stage-3/4 data model === +// === Federation transformation pipeline === // // A federation places one or more IFC models in a shared scene. Each model's -// final per-instance transform is composed as +// final per-instance transform is the composition of four named stages: // -// stage3 · stage4 · stage2 · placement_stage1 +// FederatedFalseOrigin · ModelTransformation · CoordinateOperation +// · PlacementTransformation // // where: -// - stage1 is per-mesh vertex rebasing (load-time, immutable) -// - stage2 is the per-model georef matrix from IfcMapConversion etc. -// (load-time, immutable; can be toggled off) -// - stage3 is the federation-wide false origin (mutable, federation-scope) -// - stage4 is the per-model placement within the federation (mutable, per-model) +// - PlacementTransformation: per-instance, derived from the IFC's +// IfcObjectPlacement chain (load-time, immutable). This is the +// iterator's per-shape transform. +// - CoordinateOperation: per-model, derived from the IFC's +// IfcCoordinateOperation (e.g. IfcMapConversion + IfcProjectedCRS). +// Load-time, immutable; can be toggled on/off. +// - FederatedFalseOrigin: federation-wide. Mutable, persisted in +// `.ifcfed`. Re-applied to every model. +// - ModelTransformation: per-model, user-authored within the federation. +// Mutable, persisted in `.ifcfed`. // // All composed matrices are in metres. User-authored numbers are stored in // their source units (model project unit / model map unit / federation unit) // to round-trip without precision loss; conversion happens in the compose // helpers. -// Federation-wide unit; the value space for FederationOrigin.xyz and -// ModelTransform::{b, pivot}. +// Federation-wide unit; the value space for FederatedFalseOrigin.xyz and +// ModelTransformation::{b, pivot}. struct FederationConfig { // IfcSIUnit name ("METRE") or IfcConversionBasedUnit name ("foot", "inch"). std::string unit_name = "METRE"; @@ -62,26 +68,29 @@ struct FederationConfig { std::string unit_prefix = ""; }; -// Stage 3 — the federation false origin. Authoring intent is "nominate this -// XYZ as the new origin, with optional Z-axis heading rotation". Composed -// as stage3 = R_z(rz_deg) · T(-xyz_in_metres). -struct FederationOrigin { +// FederatedFalseOrigin — the user-nominated federation origin. Authoring +// intent is "nominate this XYZ as the new origin, with optional Z-axis +// heading rotation". Composed as R_z(rz_deg) · T(-xyz_in_metres). +struct FederatedFalseOrigin { Eigen::Vector3d xyz = Eigen::Vector3d::Zero(); // federation unit double rz_deg = 0.0; }; -// Frame in which ModelTransform.a is expressed. -// ModelLocal — pre-stage2 model coordinates, in the model's project length unit -// ModelGlobal — post-stage2 model coordinates, in the model's map unit +// Frame in which ModelTransformation.a is expressed. +// ModelLocal — pre-CoordinateOperation model coordinates, in the model's +// project length unit +// ModelGlobal — post-CoordinateOperation model coordinates, in the model's +// map unit enum class AFrame { ModelLocal, ModelGlobal }; -// Stage 4 — the per-model placement within the federation. Authoring intent -// is "rotate the model around `pivot`, then translate so that point `a` lands -// at point `b`". Composed as +// ModelTransformation — the per-model placement within the federation. +// Authoring intent is "rotate the model around `pivot`, then translate so +// that point `a` lands at point `b`". Composed as +// // R_local = R_z(rz) · R_y(ry) · R_x(rx) [intrinsic XYZ] // R_at_pivot = T(pivot_m) · R_local · T(-pivot_m) -// stage4 = T(b_m - R_at_pivot · a_m) · R_at_pivot -struct ModelTransform { +// result = T(b_m - R_at_pivot · a_m) · R_at_pivot +struct ModelTransformation { AFrame a_frame = AFrame::ModelGlobal; Eigen::Vector3d a = Eigen::Vector3d::Zero(); // model project / map unit Eigen::Vector3d b = Eigen::Vector3d::Zero(); // federation unit @@ -98,37 +107,40 @@ struct ModelUnits { double map_unit_to_meters = 1.0; }; -// Per-model georeferencing data derived from the IFC. `stage2_meters` is -// the helmert · inv(wcs) georef matrix in metres; consumers compose it -// before stage 3 / stage 4 at upload time. When the model has no map -// conversion, `has_stage2 == false` and `stage2_meters` is identity. +// Per-model georeferencing data derived from the IFC. +// `coordinate_operation_meters` is the helmert · inv(wcs) matrix in metres +// representing the IfcCoordinateOperation; consumers compose it before +// FederatedFalseOrigin / ModelTransformation at upload time. When the +// model has no map conversion, `has_coordinate_operation == false` and the +// matrix is identity. struct ModelGeoref { ModelUnits units; - Eigen::Matrix4d stage2_meters = Eigen::Matrix4d::Identity(); - bool has_stage2 = false; + Eigen::Matrix4d coordinate_operation_meters = Eigen::Matrix4d::Identity(); + bool has_coordinate_operation = false; }; // Read a model's project length unit, map unit, helmert parameters, and WCS -// from `ifc_file` and reduce them to a metres-in / metres-out georef matrix. -// Pure compute; safe to call repeatedly if the caller doesn't want to cache. +// from `ifc_file` and reduce them to a metres-in / metres-out +// CoordinateOperation matrix. Pure compute; safe to call repeatedly if the +// caller doesn't want to cache. ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file); // 1 federation_unit -> N metres. double federationUnitToMeters(const FederationConfig&); -// Compose stage 3 (federation false origin) into a 4x4 matrix in metres. -Eigen::Matrix4d composeFederationOrigin(const FederationOrigin&, - const FederationConfig&); +// Compose FederatedFalseOrigin into a 4x4 matrix in metres. +Eigen::Matrix4d composeFederatedFalseOrigin(const FederatedFalseOrigin&, + const FederationConfig&); -// Compose stage 4 (per-model placement within the federation) into a 4x4 -// matrix in metres. `stage2_meters` is the model's georef matrix (e.g. -// helmertMetersFromParameters · inv(wcs_meters)) — needed to lift `a` into -// metres when a_frame == ModelLocal. Pass identity when stage 2 is disabled -// or absent. -Eigen::Matrix4d composeModelTransform(const ModelTransform&, - const FederationConfig& fed_cfg, - const ModelUnits& model_units, - const Eigen::Matrix4d& stage2_meters); +// Compose ModelTransformation into a 4x4 matrix in metres. +// `coordinate_operation_meters` is the model's CoordinateOperation matrix +// (e.g. helmertMetersFromParameters · inv(wcs_meters)) — needed to lift +// `a` into metres when a_frame == ModelLocal. Pass identity when the +// CoordinateOperation is disabled or absent. +Eigen::Matrix4d composeModelTransformation(const ModelTransformation&, + const FederationConfig& fed_cfg, + const ModelUnits& model_units, + const Eigen::Matrix4d& coordinate_operation_meters); // === Federation persistence (.ifcfed) === // @@ -151,11 +163,11 @@ public: }; struct Model { - QString id; // stable, persisted + QString id; // stable, persisted QString display_name; - QString source_kind = "local"; // future: "http", "speckle", ... - QString source_path; // resolved absolute when kind == "local" - ModelTransform transform_intent; // stage 4 + QString source_kind = "local"; // future: "http", "speckle", ... + QString source_path; // resolved absolute when kind == "local" + ModelTransformation model_transformation; bool visible = true; }; @@ -174,8 +186,8 @@ public: void clearHomeView(); void setConfig(const FederationConfig&); - void setOrigin(const FederationOrigin&); - void setModelTransform(const QString& fed_id, const ModelTransform&); + void setFederatedFalseOrigin(const FederatedFalseOrigin&); + void setModelTransformation(const QString& fed_id, const ModelTransformation&); // Accessors const std::vector& models() const { return models_; } @@ -187,7 +199,7 @@ public: bool hasHomeView() const { return has_home_view_; } const HomeView& homeView() const { return home_view_; } const FederationConfig& config() const { return config_; } - const FederationOrigin& origin() const { return origin_; } + const FederatedFalseOrigin& federatedFalseOrigin() const { return federated_false_origin_; } signals: void dirtyChanged(bool dirty); @@ -202,8 +214,8 @@ private: QDateTime created_; QDateTime modified_; std::vector models_; - FederationConfig config_; - FederationOrigin origin_; + FederationConfig config_; + FederatedFalseOrigin federated_false_origin_; bool has_home_view_ = false; HomeView home_view_; bool dirty_ = false; diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index 1b418fc2c9..da7f6fc624 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -147,12 +147,12 @@ std::vector GeometryStreamer::drainElements() { // Build a mesh chunk (local coords, 28-byte interleaved vertices) from a // TriangulationElement. Per-vertex color is baked from material_ids so that // triangulations with per-face materials still render correctly. -// Stage 1 — vertex rebasing. When `offset` is non-zero, every vertex -// position is subtracted by it so the emitted mesh-local coordinates stay -// near the origin (and float32 precision survives upload to the GPU). -// Caller compensates by post-multiplying each instance's placement by -// T(+offset), which is mathematically the identity overall but moves the -// "magnitude" off the float-precision-sensitive vertex column. +// Vertex rebasing: when `offset` is non-zero, every vertex position is +// subtracted by it so the emitted mesh-local coordinates stay near the +// origin (and float32 precision survives upload to the GPU). Caller +// compensates by post-multiplying each instance's PlacementTransformation +// by T(+offset), which is mathematically the identity overall but moves +// the magnitude off the float-precision-sensitive vertex column. static MeshChunk buildMeshChunk(uint32_t model_id, uint32_t local_mesh_id, const IfcGeom::TriangulationElement* elem, @@ -593,8 +593,8 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } if (first_sight) { - // Stage 1: pick a rebase offset when the mesh's first - // source vertex is far from origin (>1 km in metres, + // Vertex rebasing: pick a rebase offset when the mesh's + // first source vertex is far from origin (>1 km in metres, // matching bonsai's distance_limit default). Iterator // outputs metres, so the threshold is in metres directly. Eigen::Vector3d offset = Eigen::Vector3d::Zero(); @@ -627,9 +627,10 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } } - // Stage 1 cont.: post-multiply the per-instance placement by - // T(+offset) so world position is preserved. Matrix arithmetic - // is in double; narrow to float at the end. + // Vertex rebasing cont.: post-multiply the per-instance + // PlacementTransformation by T(+offset) so world position is + // preserved. Matrix arithmetic is in double; narrow to float + // at the end. Eigen::Matrix4d mat_d = tri_elem->transformation().data()->ccomponents(); if (mesh_aabbs[local_mesh_id].has_offset) { diff --git a/src/ifcviewer/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp index 42b2bfb4e6..223075c66f 100644 --- a/src/ifcviewer/tests/test_federation.cpp +++ b/src/ifcviewer/tests/test_federation.cpp @@ -305,8 +305,8 @@ TEST_CASE("load on malformed JSON fails with an error", "[federation]") { REQUIRE_FALSE(err.isEmpty()); } -TEST_CASE("config / origin / transform_intent round-trip through save+load", - "[federation]") { +TEST_CASE("config / federated_false_origin / model_transformation round-trip " + "through save+load", "[federation]") { ensureQApp(); QTemporaryDir tmp; REQUIRE(tmp.isValid()); @@ -322,18 +322,18 @@ TEST_CASE("config / origin / transform_intent round-trip through save+load", cfg.unit_prefix = ""; src.setConfig(cfg); - FederationOrigin org; + FederatedFalseOrigin org; org.xyz = Eigen::Vector3d(100.0, 200.0, 30.0); org.rz_deg = 45.0; - src.setOrigin(org); + src.setFederatedFalseOrigin(org); - ModelTransform xf; + ModelTransformation xf; xf.a_frame = AFrame::ModelLocal; xf.a = Eigen::Vector3d(1.0, 2.0, 3.0); xf.b = Eigen::Vector3d(4.0, 5.0, 6.0); xf.rxyz_deg = Eigen::Vector3d(90.0, 0.0, 0.0); xf.pivot = Eigen::Vector3d(7.0, 8.0, 9.0); - src.setModelTransform(id1, xf); + src.setModelTransformation(id1, xf); QString err; REQUIRE(src.save(fed_path, &err)); @@ -348,20 +348,21 @@ TEST_CASE("config / origin / transform_intent round-trip through save+load", REQUIRE(dst.config().unit_name == "FOOT"); REQUIRE(dst.config().unit_prefix == ""); - REQUIRE(dst.origin().xyz == org.xyz); - REQUIRE(dst.origin().rz_deg == 45.0); + REQUIRE(dst.federatedFalseOrigin().xyz == org.xyz); + REQUIRE(dst.federatedFalseOrigin().rz_deg == 45.0); REQUIRE(dst.models().size() == 1); const auto& m = dst.models()[0]; REQUIRE(m.id == id1); - REQUIRE(m.transform_intent.a_frame == AFrame::ModelLocal); - REQUIRE(m.transform_intent.a == xf.a); - REQUIRE(m.transform_intent.b == xf.b); - REQUIRE(m.transform_intent.rxyz_deg == xf.rxyz_deg); - REQUIRE(m.transform_intent.pivot == xf.pivot); + REQUIRE(m.model_transformation.a_frame == AFrame::ModelLocal); + REQUIRE(m.model_transformation.a == xf.a); + REQUIRE(m.model_transformation.b == xf.b); + REQUIRE(m.model_transformation.rxyz_deg == xf.rxyz_deg); + REQUIRE(m.model_transformation.pivot == xf.pivot); } -TEST_CASE("default ModelTransform is omitted from saved JSON", "[federation]") { +TEST_CASE("default ModelTransformation is omitted from saved JSON", + "[federation]") { ensureQApp(); QTemporaryDir tmp; REQUIRE(tmp.isValid()); @@ -377,17 +378,17 @@ TEST_CASE("default ModelTransform is omitted from saved JSON", "[federation]") { QJsonObject root = readJsonFile(fed_path); QJsonArray models = root.value("models").toArray(); REQUIRE(models.size() == 1); - REQUIRE_FALSE(models[0].toObject().contains("transform_intent")); + REQUIRE_FALSE(models[0].toObject().contains("model_transformation")); } -TEST_CASE("composeFederationOrigin moves the nominated point to the origin", +TEST_CASE("composeFederatedFalseOrigin moves the nominated point to the origin", "[federation][compose]") { FederationConfig cfg; // METRE, no prefix - FederationOrigin org; + FederatedFalseOrigin org; org.xyz = Eigen::Vector3d(10.0, 20.0, 5.0); org.rz_deg = 0.0; - Eigen::Matrix4d M = composeFederationOrigin(org, cfg); + Eigen::Matrix4d M = composeFederatedFalseOrigin(org, cfg); // The nominated point (10, 20, 5) should map to (0, 0, 0). Eigen::Vector4d p(10.0, 20.0, 5.0, 1.0); @@ -397,33 +398,33 @@ TEST_CASE("composeFederationOrigin moves the nominated point to the origin", REQUIRE(std::abs(r.z()) < 1e-9); } -TEST_CASE("composeFederationOrigin scales by federation unit", +TEST_CASE("composeFederatedFalseOrigin scales by federation unit", "[federation][compose]") { FederationConfig cfg; cfg.unit_name = "FOOT"; // 1 ft = 0.3048 m - FederationOrigin org; + FederatedFalseOrigin org; org.xyz = Eigen::Vector3d(1.0, 0.0, 0.0); // 1 foot in fed coords - Eigen::Matrix4d M = composeFederationOrigin(org, cfg); + Eigen::Matrix4d M = composeFederatedFalseOrigin(org, cfg); // Translation column should be -1 ft = -0.3048 m. REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9); } -TEST_CASE("composeModelTransform with pivot=B keeps A landing on B", +TEST_CASE("composeModelTransformation with pivot=B keeps A landing on B", "[federation][compose]") { - // A in ModelGlobal frame, federation in metres, model has identity stage 2. + // A in ModelGlobal frame, federation in metres, identity CoordinateOperation. FederationConfig fed_cfg; // METRE ModelUnits mu; // 1.0 / 1.0 (already in metres) - Eigen::Matrix4d stage2 = Eigen::Matrix4d::Identity(); + Eigen::Matrix4d coord_op = Eigen::Matrix4d::Identity(); - ModelTransform xf; + ModelTransformation xf; xf.a_frame = AFrame::ModelGlobal; xf.a = Eigen::Vector3d(5.0, 0.0, 0.0); xf.b = Eigen::Vector3d(100.0, 50.0, 10.0); xf.rxyz_deg = Eigen::Vector3d(0.0, 0.0, 30.0); xf.pivot = xf.b; // pivot at B preserves A->B regardless of rotation - Eigen::Matrix4d M = composeModelTransform(xf, fed_cfg, mu, stage2); + Eigen::Matrix4d M = composeModelTransformation(xf, fed_cfg, mu, coord_op); Eigen::Vector4d a(xf.a.x(), xf.a.y(), xf.a.z(), 1.0); Eigen::Vector4d r = M * a; From 7f29850022f983d8a36430189a5dc8b0fb8a9a37 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 1 May 2026 20:41:25 +1000 Subject: [PATCH 092/120] ifcviewer: compose federation pipeline at SSBO upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstanceCpu now carries both placement_transformation (raw streamer output, the iterator's per-shape transform with vertex-rebasing offset folded in) and transform (the composed FederatedFalseOrigin · ModelTransformation · CoordinateOperation · placement_transformation result that lands in the SSBO). World AABBs are recomputed from the composed transform — frustum/BVH culling sees the actual rendered position regardless of stage state. ViewportWindow gains: - ModelGpuData::coordinate_operation_meters / model_transformation_meters - federated_false_origin_meters_ (federation-wide member) - composeInstanceFromPlacement / recomposeAndUploadModel helpers - public setFederatedFalseOrigin / setModelCoordinateOperation / setModelTransformation Each setter rewrites the affected model's SSBO, refreshes the reflection flags, and rebuilds the BVH. Defaults are identity, so behaviour is unchanged until something wires a setter up — that's the next commit (MainWindow listening to Federation::dirtyChanged and SceneLoader::modelGeoref ready signals). Sidecar bumped 9 -> 10: InstanceCpu grew 104 B -> 168 B. Existing sidecars rebuild on next load. v10 sidecars store placement_transformation, so they remain reusable across .ifcfeds — the composed transform on disk is overwritten with the right one on load. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/InstancedGeometry.h | 17 +++- src/ifcviewer/SidecarCache.h | 9 +- src/ifcviewer/ViewportWindow.cpp | 147 +++++++++++++++++++++++++++++- src/ifcviewer/ViewportWindow.h | 39 ++++++++ 4 files changed, 204 insertions(+), 8 deletions(-) diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h index accf155bf9..9b2eb02bba 100644 --- a/src/ifcviewer/InstancedGeometry.h +++ b/src/ifcviewer/InstancedGeometry.h @@ -98,11 +98,20 @@ static_assert(sizeof(InstanceGpu) == 80, "InstanceGpu must be 80 bytes"); // CPU-side per-instance data. The GPU record above is derived from this; // we also retain the world AABB for BVH construction and the mesh_id. +// +// `placement_transformation` is the raw streamer output (the iterator's +// transform with vertex-rebasing offset folded in; pre-CoordinateOperation +// / FederatedFalseOrigin / ModelTransformation). `transform` is the +// composed FederatedFalseOrigin · ModelTransformation · CoordinateOperation +// · placement_transformation result — what gets uploaded to the SSBO and +// used to compute world_aabb_*. When ViewportWindow's stage matrices are +// all identity (default), the two are equal. struct InstanceCpu { - uint32_t mesh_id = 0; // index into meshes array - uint32_t object_id = 0; - uint32_t color_override_rgba8 = 0; - uint32_t model_id = 0; + uint32_t mesh_id = 0; // index into meshes array + uint32_t object_id = 0; + uint32_t color_override_rgba8 = 0; + uint32_t model_id = 0; + float placement_transformation[16]{}; float transform[16]{}; float world_aabb_min[3]{}; float world_aabb_max[3]{}; diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 95c8a51c38..916a42acb1 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -46,7 +46,14 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // same cache serves either source format. Staleness is user-managed // (delete the sidecar to force a rebuild). // v9 = unused `reserved` field dropped from header (16 B -> 12 B). -static constexpr uint32_t SIDECAR_VERSION = 9; +// v10 = InstanceCpu gains placement_transformation[16] alongside transform[16] +// — record grew from 104 B to 168 B. placement_transformation is the +// raw streamer output; transform is the composed FederatedFalseOrigin · +// ModelTransformation · CoordinateOperation · placement_transformation +// result. Sidecar serialises both; on load the transform is recomputed +// from placement_transformation + the ViewportWindow's current stage +// matrices, so v10 sidecars are reusable across .ifcfeds. +static constexpr uint32_t SIDECAR_VERSION = 10; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index d9aaa1f733..8aeb3300f3 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -567,6 +567,29 @@ static void extractFrustumPlanes(const QMatrix4x4& vp, float planes[6][4]) { } } +// Compute world AABB by transforming the 8 corners of `local_min..local_max` +// through the column-major 4x4 `M` and bounding the result. Same maths as +// GeometryStreamer's worldAabbFromLocal; duplicated here so the viewport +// can recompute world AABBs independently when stage matrices change. +static void worldAabbFromLocalVp(const float local_min[3], + const float local_max[3], + const float M[16], + float out_min[3], float out_max[3]) { + out_min[0] = out_min[1] = out_min[2] = std::numeric_limits::max(); + out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits::max(); + for (int c = 0; c < 8; ++c) { + float x = (c & 1) ? local_max[0] : local_min[0]; + float y = (c & 2) ? local_max[1] : local_min[1]; + float z = (c & 4) ? local_max[2] : local_min[2]; + float wx = M[0]*x + M[4]*y + M[8]*z + M[12]; + float wy = M[1]*x + M[5]*y + M[9]*z + M[13]; + float wz = M[2]*x + M[6]*y + M[10]*z + M[14]; + if (wx < out_min[0]) out_min[0] = wx; if (wx > out_max[0]) out_max[0] = wx; + if (wy < out_min[1]) out_min[1] = wy; if (wy > out_max[1]) out_max[1] = wy; + if (wz < out_min[2]) out_min[2] = wz; if (wz > out_max[2]) out_max[2] = wz; + } +} + // Build bvh_items (one per instance, 1:1 ordering) and a per-model BVH. // Items with instances.size() < BVH_MIN_OBJECTS leave bvh empty — the // render path falls back to drawing every instance. @@ -1054,9 +1077,16 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { inst.object_id = chunk.object_id; inst.color_override_rgba8 = chunk.color_override_rgba8; inst.model_id = chunk.model_id; - std::memcpy(inst.transform, chunk.transform, sizeof(inst.transform)); - std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min)); - std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max)); + // Streamer's `chunk.transform` is the placement_transformation (the + // iterator's per-shape transform with vertex-rebasing offset folded in). + std::memcpy(inst.placement_transformation, chunk.transform, + sizeof(inst.placement_transformation)); + // Compose against the model's current stage matrices to fill in + // inst.transform + inst.world_aabb_*. When all stages are identity + // (the default until a setter is called), this reduces to + // transform == placement_transformation and the world AABB matches + // the streamer's pre-computed chunk.world_aabb_* exactly. + composeInstanceFromPlacement(inst, m); m.instances.push_back(inst); m.instance_reflected.push_back(transformIsReflected(inst.transform) ? 1 : 0); @@ -1209,6 +1239,14 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) { } m.total_triangles = total_tri; + // Recompose every instance against the model's current stage matrices. + // The cached InstanceCpu::transform / world_aabb_* may have been frozen + // against a different .ifcfed's stages — placement_transformation is the + // authoritative input, so we always rebuild transform + world_aabb here. + for (auto& inst : m.instances) { + composeInstanceFromPlacement(inst, m); + } + // Build and upload the instance SSBO. std::vector gpu(m.instances.size()); for (size_t i = 0; i < m.instances.size(); ++i) { @@ -3523,3 +3561,106 @@ void ViewportWindow::handleWheel(QWheelEvent* e) { setPivotIndicatorVisible(true, 750); requestUpdate(); } + +// === Federation pipeline composition === + +void ViewportWindow::composeInstanceFromPlacement(InstanceCpu& inst, + const ModelGpuData& m) const { + // Read placement_transformation as float-column-major and lift to double. + using Mat4fCol = Eigen::Matrix; + const Eigen::Matrix4d P = + Eigen::Map(inst.placement_transformation).cast(); + + // FederatedFalseOrigin · ModelTransformation · CoordinateOperation · P. + const Eigen::Matrix4d composed = + federated_false_origin_meters_ * + m.model_transformation_meters * + m.coordinate_operation_meters * + P; + + Eigen::Map T_f(inst.transform); + T_f = composed.cast(); + + // World AABB from the composed transform + the mesh's local AABB. + if (inst.mesh_id < m.meshes.size()) { + const MeshInfo& mi = m.meshes[inst.mesh_id]; + worldAabbFromLocalVp(mi.local_aabb_min, mi.local_aabb_max, + inst.transform, + inst.world_aabb_min, inst.world_aabb_max); + } else { + // Mesh not yet uploaded — leave AABB at zero. The streamer's + // contract emits MeshChunk before InstanceChunk so this branch + // shouldn't fire under normal flow. + for (int a = 0; a < 3; ++a) { + inst.world_aabb_min[a] = 0.0f; + inst.world_aabb_max[a] = 0.0f; + } + } +} + +void ViewportWindow::recomposeAndUploadModel(uint32_t model_id) { + if (!gl_initialized_) return; + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end()) return; + ModelGpuData& m = it->second; + if (m.instances.empty()) return; + context_->makeCurrent(this); + + // Recompose every instance + refresh reflection flag. + m.instance_reflected.resize(m.instances.size()); + std::vector gpu(m.instances.size()); + for (size_t i = 0; i < m.instances.size(); ++i) { + InstanceCpu& inst = m.instances[i]; + composeInstanceFromPlacement(inst, m); + m.instance_reflected[i] = transformIsReflected(inst.transform) ? 1 : 0; + + InstanceGpu& dst = gpu[i]; + std::memcpy(dst.transform, inst.transform, sizeof(dst.transform)); + dst.object_id = inst.object_id; + dst.color_override_rgba8 = inst.color_override_rgba8; + dst.mesh_id = inst.mesh_id; + dst._pad1 = 0; + } + + // Re-upload the SSBO in one shot — its capacity already matches + // m.instances.size() since the SSBO grew incrementally during stream. + const size_t bytes = gpu.size() * sizeof(InstanceGpu); + if (bytes > m.ssbo_capacity) { + if (!growModelSsbo(m, bytes)) return; + } + if (bytes > 0) { + gl_->glNamedBufferSubData(m.ssbo, 0, bytes, gpu.data()); + } + m.ssbo_instance_count = static_cast(gpu.size()); + + // World AABBs changed, so the BVH is stale. + buildBvhForModel(m, model_id); + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters) { + if (federated_false_origin_meters_ == matrix_meters) return; + federated_false_origin_meters_ = matrix_meters; + for (auto& kv : models_gpu_) { + recomposeAndUploadModel(kv.first); + } +} + +void ViewportWindow::setModelCoordinateOperation(uint32_t model_id, + const Eigen::Matrix4d& matrix_meters) { + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end()) return; + if (it->second.coordinate_operation_meters == matrix_meters) return; + it->second.coordinate_operation_meters = matrix_meters; + recomposeAndUploadModel(model_id); +} + +void ViewportWindow::setModelTransformation(uint32_t model_id, + const Eigen::Matrix4d& matrix_meters) { + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end()) return; + if (it->second.model_transformation_meters == matrix_meters) return; + it->second.model_transformation_meters = matrix_meters; + recomposeAndUploadModel(model_id); +} diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index a64657fbc2..9c49d1ef98 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -41,6 +41,8 @@ QT_END_NAMESPACE #include #include +#include + #include "BvhAccel.h" #include "InstancedGeometry.h" #include "SidecarCache.h" @@ -124,6 +126,12 @@ struct ModelGpuData { bool finalized = false; bool hidden = false; + + // Per-model federation-pipeline matrices in metres. Default identity + // → no per-model contribution to the composed transform. See + // Federation.h for the full pipeline composition order. + Eigen::Matrix4d coordinate_operation_meters = Eigen::Matrix4d::Identity(); + Eigen::Matrix4d model_transformation_meters = Eigen::Matrix4d::Identity(); }; // Rendering is event-driven: render() runs only when QEvent::UpdateRequest @@ -169,6 +177,18 @@ public: void showModel(uint32_t model_id); void removeModel(uint32_t model_id); + // Federation pipeline: composed instance transform = + // FederatedFalseOrigin · ModelTransformation · CoordinateOperation + // · placement_transformation + // setFederatedFalseOrigin affects every model; the per-model setters + // affect a single model. Each setter rewrites the SSBO, recomputes + // world AABBs, rebuilds the BVH, and posts an update. Defaults are + // identity, so until a setter is called the composed transform equals + // placement_transformation. + void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters); + void setModelCoordinateOperation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters); + void setModelTransformation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters); + void setSelectedObjectId(uint32_t id); uint32_t pickObjectAt(int x, int y); @@ -364,6 +384,21 @@ private: // model's SSBO + indirect buffer, growing them if needed. void uploadCullResults(ModelGpuData& m); + // Compose + // FederatedFalseOrigin · ModelTransformation · CoordinateOperation + // · placement_transformation + // for one instance: writes inst.transform (composed, float) and + // recomputes inst.world_aabb_* from the composed transform + the + // mesh's local AABB. Maths runs in double; narrow to float at the end. + void composeInstanceFromPlacement(InstanceCpu& inst, const ModelGpuData& m) const; + + // Walk every instance of `model_id`, recompose `transform` from + // `placement_transformation` + current stage matrices, recompute world + // AABBs, re-upload the InstanceGpu SSBO, refresh instance_reflected, + // and rebuild the BVH. Posts an update. No-op if model_id is unknown + // or GL isn't initialised yet. + void recomposeAndUploadModel(uint32_t model_id); + // Mouse interaction void handleMousePress(QMouseEvent* event); void handleMouseRelease(QMouseEvent* event); @@ -413,6 +448,10 @@ private: // Per-model GPU data std::unordered_map models_gpu_; + // FederatedFalseOrigin matrix, in metres. Default identity → no + // contribution to the composed transform. See Federation.h. + Eigen::Matrix4d federated_false_origin_meters_ = Eigen::Matrix4d::Identity(); + // Pick framebuffer. Three color attachments: // 0: R32UI — object_id // 1: RGB32F — world position at hit From e9d577890e2a5cc8a1d8b8024b306281c60936f7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 2 May 2026 07:45:40 +1000 Subject: [PATCH 093/120] ifcviewer: gate CoordinateOperation on a settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppSettings.applyCoordinateOperation (default false, persisted via QSettings) controls whether each loaded model's IfcCoordinateOperation is applied at upload time. Off keeps models in their local engineering frame (current behaviour). On lifts each model into map coordinates via the stage-2 georef matrix cached on SceneLoader. MainWindow: - applyCoordinateOperationToViewport(mid) reads the toggle, fetches the model's ModelGeoref, and pushes either the coordinate_operation_meters matrix or identity to the viewport. - Called from onLoadedFromStream (streamer path) and onDataSourceReady (sidecar-hit path, where the IFC arrives asynchronously). - Subscribed to AppSettings::applyCoordinateOperationChanged: a runtime toggle walks every loaded model and re-applies, so users can flip georef on/off without reloading. SettingsWindow gains a "Apply Coordinate Operation" checkbox alongside the existing per-load toggles. Default-off so the change is opt-in — users with georeferenced models (UTM coords etc.) can flip the toggle to see them in their map frame once they're ready. Visual verification on a real georeferenced model still pending. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 33 +++++++++++++++++++++++++++ src/ifcviewer-full/MainWindow.h | 7 ++++++ src/ifcviewer-full/SettingsWindow.cpp | 12 ++++++++++ src/ifcviewer-full/SettingsWindow.h | 1 + src/ifcviewer/AppSettings.cpp | 15 ++++++++++++ src/ifcviewer/AppSettings.h | 10 ++++++++ 6 files changed, 78 insertions(+) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 3105af7cf5..98bcc9224c 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -89,6 +89,16 @@ MainWindow::MainWindow(QWidget* parent) if (!show) stats_label_->clear(); }); + // Toggling the CoordinateOperation setting walks every loaded model + // and pushes either its georef matrix or identity to the viewport. + connect(&AppSettings::instance(), + &AppSettings::applyCoordinateOperationChanged, + this, [this](bool /*enabled*/) { + for (const auto& kv : fed_id_to_model_id_) { + applyCoordinateOperationToViewport(kv.second); + } + }); + updateWindowTitle(); resize(1400, 900); } @@ -457,6 +467,13 @@ void MainWindow::onSidecarElementsReady(uint32_t mid, } void MainWindow::onDataSourceReady(uint32_t mid) { + // The IFC file is now available — push the model's CoordinateOperation + // (or identity) to the viewport. Sidecar-hit models get here for the + // first time; stream-loaded models also pass through here when a + // separate data source opens, but applyCoordinateOperationToViewport + // is idempotent so double-applying is harmless. + applyCoordinateOperationToViewport(mid); + // Re-populate if the current selection belongs to this model, since // populateProperties() now has an ifcFile() to query. auto items = element_tree_->selectedItems(); @@ -468,6 +485,18 @@ void MainWindow::onDataSourceReady(uint32_t mid) { } } +void MainWindow::applyCoordinateOperationToViewport(uint32_t mid) { + Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); + if (AppSettings::instance().applyCoordinateOperation()) { + if (const ModelGeoref* gr = loader_->modelGeoref(mid)) { + if (gr->has_coordinate_operation) { + M = gr->coordinate_operation_meters; + } + } + } + viewport_->setModelCoordinateOperation(mid, M); +} + void MainWindow::onLoadedFromSidecar(uint32_t /*mid*/, qint64 elapsed_ms) { progress_bar_->setVisible(false); status_label_->setText(QString("%1 elements across %2 model(s) — loaded from cache in %3") @@ -572,6 +601,10 @@ void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { .arg(loader_->modelCount()) .arg(formatElapsed(elapsed_ms))); + // Stream path: the IFC is owned by the streamer, so georef is + // computable now. (Sidecar-hit models defer to onDataSourceReady.) + applyCoordinateOperationToViewport(mid); + writeSidecarForModel(mid); } diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 13dfecf061..03dac8f4f2 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -96,6 +96,13 @@ private: void writeSidecarForModel(uint32_t mid); void removeModelUi(uint32_t mid); void applyPendingBenchmark(); + + // Push a model's CoordinateOperation matrix to the viewport (or + // identity, when the AppSettings toggle is off or the model has no + // map conversion). No-op if the model isn't yet known to the loader + // or its IFC file isn't available (sidecar-hit before data-source + // load); the call retries on onDataSourceReady. + void applyCoordinateOperationToViewport(uint32_t mid); QString formatElapsed(qint64 ms) const; ViewportWindow* viewport_ = nullptr; diff --git a/src/ifcviewer-full/SettingsWindow.cpp b/src/ifcviewer-full/SettingsWindow.cpp index 8fd867b3ae..9a7032fd86 100644 --- a/src/ifcviewer-full/SettingsWindow.cpp +++ b/src/ifcviewer-full/SettingsWindow.cpp @@ -59,6 +59,14 @@ void SettingsWindow::setupUi() { "and, on sidecar hits, avoids a second file read."); form->addRow("Load Property Data Source", load_data_source_check_); + apply_coordinate_operation_check_ = new QCheckBox(this); + apply_coordinate_operation_check_->setToolTip( + "Apply each model's IfcCoordinateOperation (e.g. IfcMapConversion) " + "after load so it lands in georeferenced map coordinates. " + "Disable to keep models in their local engineering frame."); + form->addRow("Apply Coordinate Operation", + apply_coordinate_operation_check_); + void_limit_spin_ = new QSpinBox(this); void_limit_spin_->setRange(0, 100000); void_limit_spin_->setToolTip( @@ -109,6 +117,8 @@ void SettingsWindow::syncFromSettings() { show_stats_check_->setChecked(AppSettings::instance().showStats()); backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling()); load_data_source_check_->setChecked(AppSettings::instance().loadDataSource()); + apply_coordinate_operation_check_->setChecked( + AppSettings::instance().applyCoordinateOperation()); void_limit_spin_->setValue(AppSettings::instance().voidLimit()); deflection_tolerance_spin_->setValue(AppSettings::instance().deflectionTolerance()); angular_tolerance_spin_->setValue(AppSettings::instance().angularTolerance()); @@ -119,6 +129,8 @@ void SettingsWindow::onAccepted() { AppSettings::instance().setShowStats(show_stats_check_->isChecked()); AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked()); AppSettings::instance().setLoadDataSource(load_data_source_check_->isChecked()); + AppSettings::instance().setApplyCoordinateOperation( + apply_coordinate_operation_check_->isChecked()); AppSettings::instance().setVoidLimit(void_limit_spin_->value()); AppSettings::instance().setDeflectionTolerance(deflection_tolerance_spin_->value()); AppSettings::instance().setAngularTolerance(angular_tolerance_spin_->value()); diff --git a/src/ifcviewer-full/SettingsWindow.h b/src/ifcviewer-full/SettingsWindow.h index e9a996e94d..070e90867e 100644 --- a/src/ifcviewer-full/SettingsWindow.h +++ b/src/ifcviewer-full/SettingsWindow.h @@ -47,6 +47,7 @@ private: QCheckBox* show_stats_check_ = nullptr; QCheckBox* backface_culling_check_ = nullptr; QCheckBox* load_data_source_check_ = nullptr; + QCheckBox* apply_coordinate_operation_check_ = nullptr; QSpinBox* void_limit_spin_ = nullptr; QDoubleSpinBox* deflection_tolerance_spin_ = nullptr; QDoubleSpinBox* angular_tolerance_spin_ = nullptr; diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index 3f7dfea9e0..d6383ee4c5 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -27,6 +27,7 @@ constexpr const char* kGeometryLibraryDefault = "hybrid-cgal-simple-opencascade" constexpr const char* kShowStatsKey = "viewport/show_stats"; constexpr const char* kBackfaceCullingKey = "viewport/backface_culling"; constexpr const char* kLoadDataSourceKey = "loading/load_data_source"; +constexpr const char* kApplyCoordinateOperationKey = "loading/apply_coordinate_operation"; constexpr const char* kVoidLimitKey = "loading/void_limit"; constexpr int kVoidLimitDefault = 30; constexpr const char* kDeflectionToleranceKey = "loading/deflection_tolerance"; @@ -88,6 +89,17 @@ void AppSettings::setLoadDataSource(bool value) { emit loadDataSourceChanged(value); } +bool AppSettings::applyCoordinateOperation() const { + return apply_coordinate_operation_; +} + +void AppSettings::setApplyCoordinateOperation(bool value) { + if (apply_coordinate_operation_ == value) return; + apply_coordinate_operation_ = value; + persist(); + emit applyCoordinateOperationChanged(value); +} + int AppSettings::voidLimit() const { return void_limit_; } @@ -130,6 +142,8 @@ void AppSettings::load() { show_stats_ = settings.value(kShowStatsKey, false).toBool(); backface_culling_ = settings.value(kBackfaceCullingKey, true).toBool(); load_data_source_ = settings.value(kLoadDataSourceKey, true).toBool(); + apply_coordinate_operation_ = + settings.value(kApplyCoordinateOperationKey, false).toBool(); void_limit_ = settings.value(kVoidLimitKey, kVoidLimitDefault).toInt(); if (void_limit_ < 0) void_limit_ = 0; deflection_tolerance_ = settings.value(kDeflectionToleranceKey, kDeflectionToleranceDefault).toDouble(); @@ -144,6 +158,7 @@ void AppSettings::persist() { settings.setValue(kShowStatsKey, show_stats_); settings.setValue(kBackfaceCullingKey, backface_culling_); settings.setValue(kLoadDataSourceKey, load_data_source_); + settings.setValue(kApplyCoordinateOperationKey, apply_coordinate_operation_); settings.setValue(kVoidLimitKey, void_limit_); settings.setValue(kDeflectionToleranceKey, deflection_tolerance_); settings.setValue(kAngularToleranceKey, angular_tolerance_); diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index d435158f70..ac96ab3392 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -47,6 +47,14 @@ public: bool loadDataSource() const; void setLoadDataSource(bool value); + // When true, each loaded model's IfcCoordinateOperation (e.g. + // IfcMapConversion) is applied to the per-instance transform after + // load, lifting the model into map (georeferenced) coordinates. + // When false, models render in their local engineering frame — + // useful for previewing geometry without translating to e.g. UTM. + bool applyCoordinateOperation() const; + void setApplyCoordinateOperation(bool value); + // Skip elements with more than this many voids (HasOpenings inverse). // Boolean subtraction of many openings is the dominant cost in some // pathological exports; dropping those elements keeps load times sane. @@ -69,6 +77,7 @@ signals: void showStatsChanged(bool value); void backfaceCullingChanged(bool value); void loadDataSourceChanged(bool value); + void applyCoordinateOperationChanged(bool value); void voidLimitChanged(int value); void deflectionToleranceChanged(double value); void angularToleranceChanged(double value); @@ -82,6 +91,7 @@ private: bool show_stats_ = false; bool backface_culling_ = true; bool load_data_source_ = true; + bool apply_coordinate_operation_ = false; int void_limit_ = 30; double deflection_tolerance_ = 0.001; double angular_tolerance_ = 0.5; From 4a9af1362676366930f411ee9b77f997e2f75564 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 2 May 2026 07:58:26 +1000 Subject: [PATCH 094/120] ifcviewer: wire FederatedFalseOrigin / ModelTransformation to viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation grows three granular signals so consumers can recompose only what's affected: - configChanged() — federation unit changed - federatedFalseOriginChanged() — stage 3 changed - modelTransformationChanged(fed_id) — stage 4 changed for one model Emitted from setConfig / setFederatedFalseOrigin / setModelTransformation in addition to the existing dirtyChanged. MainWindow gains applyFederatedFalseOriginToViewport and applyModelTransformationToViewport helpers. Each composes the matrix from the current federation state (using composeFederatedFalseOrigin / composeModelTransformation, which already exist on Federation.h) and pushes to the viewport's setFederatedFalseOrigin / setModelTransformation. ModelTransformation reads ModelUnits and the active CoordinateOperation matrix from SceneLoader::modelGeoref so ModelLocal-frame `a` lifts correctly through stage 2 when authored. Wiring: - federation.federatedFalseOriginChanged -> applyFederatedFalseOriginToViewport - federation.configChanged -> stage 3 + walk all models for stage 4 - federation.modelTransformationChanged -> stage 4 for that one model - applyCoordinateOperationToViewport now also re-pushes stage 4 (the compose result depends on the active stage 2 when a_frame is ModelLocal) - openFederation() pushes the loaded FederatedFalseOrigin once load completes; per-model stage 4 falls out of the existing onLoadedFromStream / onDataSourceReady path. End-to-end pipeline is now active under the AppSettings toggle: edit the federation in memory and the viewport recomposes immediately. UI for editing (form-based dialog) still pending. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 62 +++++++++++++++++++++++++++++++ src/ifcviewer-full/MainWindow.h | 10 +++++ src/ifcviewer/Federation.cpp | 3 ++ src/ifcviewer/Federation.h | 7 ++++ 4 files changed, 82 insertions(+) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 98bcc9224c..a07ad82db8 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -43,6 +43,28 @@ MainWindow::MainWindow(QWidget* parent) setupMenus(); federation_ = new Federation(this); + + // Federation -> viewport: granular signals so we recompose only what's + // affected. Each handler reads the current federation state, composes + // the matrix, and pushes to the viewport. + connect(federation_, &Federation::federatedFalseOriginChanged, + this, &MainWindow::applyFederatedFalseOriginToViewport); + connect(federation_, &Federation::configChanged, this, [this]() { + // Federation unit changed — both stage 3 (uses fed unit) and every + // model's stage 4 (b/pivot are in fed units) need recomposing. + applyFederatedFalseOriginToViewport(); + for (const auto& kv : fed_id_to_model_id_) { + applyModelTransformationToViewport(kv.second); + } + }); + connect(federation_, &Federation::modelTransformationChanged, + this, [this](const QString& fed_id) { + auto it = fed_id_to_model_id_.find(fed_id); + if (it != fed_id_to_model_id_.end()) { + applyModelTransformationToViewport(it->second); + } + }); + connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { setWindowModified(dirty); }); @@ -276,6 +298,11 @@ bool MainWindow::openFederation(const QString& path) { federation_->markClean(); updateWindowTitle(); + // Push the federation's loaded FederatedFalseOrigin to the viewport. + // Per-model ModelTransformations get pushed as each model finishes + // loading, via applyCoordinateOperationToViewport. + applyFederatedFalseOriginToViewport(); + if (federation_->hasHomeView()) { const auto& hv = federation_->homeView(); viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), @@ -495,6 +522,41 @@ void MainWindow::applyCoordinateOperationToViewport(uint32_t mid) { } } viewport_->setModelCoordinateOperation(mid, M); + // ModelTransformation's compose can depend on the active + // CoordinateOperation (when ModelTransformation.a_frame == ModelLocal, + // a is lifted through stage 2), so re-push it whenever stage 2 changes. + applyModelTransformationToViewport(mid); +} + +void MainWindow::applyModelTransformationToViewport(uint32_t mid) { + Eigen::Matrix4d M = Eigen::Matrix4d::Identity(); + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it != model_id_to_fed_id_.end()) { + if (const Federation::Model* m = federation_->findById(fed_it->second)) { + // ModelUnits + the active CoordinateOperation come from the + // ModelGeoref cache; defaults are safe (1.0, 1.0, identity) + // when the IFC file isn't yet available. + ModelUnits units; + Eigen::Matrix4d coord_op = Eigen::Matrix4d::Identity(); + if (const ModelGeoref* gr = loader_->modelGeoref(mid)) { + units = gr->units; + if (AppSettings::instance().applyCoordinateOperation() && + gr->has_coordinate_operation) { + coord_op = gr->coordinate_operation_meters; + } + } + M = composeModelTransformation( + m->model_transformation, federation_->config(), + units, coord_op); + } + } + viewport_->setModelTransformation(mid, M); +} + +void MainWindow::applyFederatedFalseOriginToViewport() { + const Eigen::Matrix4d M = composeFederatedFalseOrigin( + federation_->federatedFalseOrigin(), federation_->config()); + viewport_->setFederatedFalseOrigin(M); } void MainWindow::onLoadedFromSidecar(uint32_t /*mid*/, qint64 elapsed_ms) { diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 03dac8f4f2..86bfc3c00c 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -103,6 +103,16 @@ private: // or its IFC file isn't available (sidecar-hit before data-source // load); the call retries on onDataSourceReady. void applyCoordinateOperationToViewport(uint32_t mid); + + // Push a model's ModelTransformation (stage 4) matrix to the viewport, + // composed from the federation's authoring intent + the model's units + // + the active CoordinateOperation matrix. Identity when the model is + // not in the federation. + void applyModelTransformationToViewport(uint32_t mid); + + // Push the federation-wide FederatedFalseOrigin (stage 3) matrix to + // the viewport. Affects every loaded model. + void applyFederatedFalseOriginToViewport(); QString formatElapsed(qint64 ms) const; ViewportWindow* viewport_ = nullptr; diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 9dc500cbd1..ba4b33dddf 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -202,6 +202,7 @@ void Federation::setConfig(const FederationConfig& c) { return; config_ = c; setDirty(true); + emit configChanged(); } void Federation::setFederatedFalseOrigin(const FederatedFalseOrigin& o) { @@ -209,6 +210,7 @@ void Federation::setFederatedFalseOrigin(const FederatedFalseOrigin& o) { federated_false_origin_.rz_deg == o.rz_deg) return; federated_false_origin_ = o; setDirty(true); + emit federatedFalseOriginChanged(); } void Federation::setModelTransformation(const QString& fed_id, @@ -217,6 +219,7 @@ void Federation::setModelTransformation(const QString& fed_id, if (m.id != fed_id) continue; m.model_transformation = xf; setDirty(true); + emit modelTransformationChanged(fed_id); return; } } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 97fe072cff..87562168c1 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -204,6 +204,13 @@ public: signals: void dirtyChanged(bool dirty); + // Granular signals so consumers (notably the viewport-pushing layer in + // the host app) can recompose only what's needed. Emitted in addition + // to dirtyChanged from the corresponding setters. + void configChanged(); + void federatedFalseOriginChanged(); + void modelTransformationChanged(const QString& fed_id); + private: void setDirty(bool d); static QString generateId(); From 81b1efdfd6cf3a527977aff9178c8ebb24ca7309 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 2 May 2026 08:02:59 +1000 Subject: [PATCH 095/120] ifcviewer-full: federation settings dialog (unit + false origin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New FederationSettingsDialog edits the federation-wide unit and the FederatedFalseOrigin (XYZ + Z-rotation in that unit). On Ok it calls Federation::setConfig + setFederatedFalseOrigin, which fire the granular Federation signals MainWindow listens to → viewport recomposes immediately. Reachable from File > Federation Settings. Unit picker is a fixed combobox of common length units (metres / mm / cm / km / ft / in / yd / mi); each item carries (prefix, name) in itemData so saving round-trips correctly. Per-model ModelTransformation editor still to come — that's a per-model dialog reachable from the model entry, not the federation-wide settings. Co-Authored-By: Claude Opus 4.7 --- .../FederationSettingsDialog.cpp | 174 ++++++++++++++++++ src/ifcviewer-full/FederationSettingsDialog.h | 61 ++++++ src/ifcviewer-full/MainWindow.cpp | 12 ++ src/ifcviewer-full/MainWindow.h | 3 + 4 files changed, 250 insertions(+) create mode 100644 src/ifcviewer-full/FederationSettingsDialog.cpp create mode 100644 src/ifcviewer-full/FederationSettingsDialog.h diff --git a/src/ifcviewer-full/FederationSettingsDialog.cpp b/src/ifcviewer-full/FederationSettingsDialog.cpp new file mode 100644 index 0000000000..aa55eddeba --- /dev/null +++ b/src/ifcviewer-full/FederationSettingsDialog.cpp @@ -0,0 +1,174 @@ +/******************************************************************************** + * * + * 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 "FederationSettingsDialog.h" +#include "Federation.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Common length units users will pick. itemData() carries +// (prefix, name) — empty prefix for non-prefixed or conversion-based units. +struct UnitChoice { + const char* label; + const char* prefix; + const char* name; +}; +const UnitChoice kUnitChoices[] = { + { "Metres (m)", "", "METRE" }, + { "Millimetres (mm)", "MILLI", "METRE" }, + { "Centimetres (cm)", "CENTI", "METRE" }, + { "Kilometres (km)", "KILO", "METRE" }, + { "Feet (ft)", "", "foot" }, + { "Inches (in)", "", "inch" }, + { "Yards (yd)", "", "yard" }, + { "Miles (mi)", "", "mile" }, +}; + +} // namespace + +FederationSettingsDialog::FederationSettingsDialog(Federation* federation, + QWidget* parent) + : QDialog(parent), federation_(federation) +{ + setWindowTitle("Federation Settings"); + setupUi(); +} + +void FederationSettingsDialog::setupUi() { + auto* root = new QVBoxLayout(this); + + // Unit + { + auto* group = new QGroupBox("Federation Unit", this); + auto* form = new QFormLayout(group); + unit_combo_ = new QComboBox(group); + for (const auto& uc : kUnitChoices) { + QStringList data; + data << QString::fromUtf8(uc.prefix) << QString::fromUtf8(uc.name); + unit_combo_->addItem(uc.label, data); + } + unit_combo_->setToolTip( + "All Federated False Origin and Model Transformation numbers " + "are interpreted in this unit. Changing it after data has been " + "entered re-interprets the numbers — same physical location " + "expressed in the new unit."); + form->addRow("Unit", unit_combo_); + root->addWidget(group); + } + + // Federated False Origin + { + auto* group = new QGroupBox("Federated False Origin", this); + group->setToolTip( + "Nominate a point in the federation that becomes the new (0,0,0). " + "Optional Z-rotation aligns north for the federation."); + auto* form = new QFormLayout(group); + + auto build_xyz = [this](QDoubleSpinBox*& sb) { + sb = new QDoubleSpinBox(); + sb->setRange(-1e12, 1e12); + sb->setDecimals(6); + sb->setSingleStep(1.0); + }; + build_xyz(xyz_x_); + build_xyz(xyz_y_); + build_xyz(xyz_z_); + form->addRow("X", xyz_x_); + form->addRow("Y", xyz_y_); + form->addRow("Z", xyz_z_); + + rz_deg_ = new QDoubleSpinBox(); + rz_deg_->setRange(-360.0, 360.0); + rz_deg_->setDecimals(6); + rz_deg_->setSingleStep(1.0); + form->addRow("Z rotation (°)", rz_deg_); + root->addWidget(group); + } + + auto* button_box = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + root->addWidget(button_box); + connect(button_box, &QDialogButtonBox::accepted, this, + &FederationSettingsDialog::onAccepted); + connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); +} + +void FederationSettingsDialog::showEvent(QShowEvent* event) { + syncFromFederation(); + QDialog::showEvent(event); +} + +void FederationSettingsDialog::syncFromFederation() { + if (!federation_) return; + + const auto& cfg = federation_->config(); + int idx = -1; + for (int i = 0; i < unit_combo_->count(); ++i) { + const QStringList data = unit_combo_->itemData(i).toStringList(); + if (data.size() == 2 && + data[0].toStdString() == cfg.unit_prefix && + data[1].toStdString() == cfg.unit_name) { + idx = i; + break; + } + } + if (idx >= 0) { + unit_combo_->setCurrentIndex(idx); + } else { + // Federation has a unit that isn't in the common list — fall back to + // the first entry. Saving keeps whatever the user picks; the + // original federation unit is preserved unless they hit Ok. + unit_combo_->setCurrentIndex(0); + } + + const auto& origin = federation_->federatedFalseOrigin(); + xyz_x_->setValue(origin.xyz.x()); + xyz_y_->setValue(origin.xyz.y()); + xyz_z_->setValue(origin.xyz.z()); + rz_deg_->setValue(origin.rz_deg); +} + +void FederationSettingsDialog::onAccepted() { + if (!federation_) { accept(); return; } + + const QStringList data = unit_combo_->currentData().toStringList(); + FederationConfig cfg; + if (data.size() == 2) { + cfg.unit_prefix = data[0].toStdString(); + cfg.unit_name = data[1].toStdString(); + } + federation_->setConfig(cfg); + + FederatedFalseOrigin origin; + origin.xyz = Eigen::Vector3d(xyz_x_->value(), xyz_y_->value(), xyz_z_->value()); + origin.rz_deg = rz_deg_->value(); + federation_->setFederatedFalseOrigin(origin); + + accept(); +} diff --git a/src/ifcviewer-full/FederationSettingsDialog.h b/src/ifcviewer-full/FederationSettingsDialog.h new file mode 100644 index 0000000000..04cf8ebe3b --- /dev/null +++ b/src/ifcviewer-full/FederationSettingsDialog.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef FEDERATIONSETTINGSDIALOG_H +#define FEDERATIONSETTINGSDIALOG_H + +#include + +QT_BEGIN_NAMESPACE +class QComboBox; +class QDoubleSpinBox; +QT_END_NAMESPACE + +class Federation; + +// Edits federation-wide state: the display unit (used to interpret all +// FederatedFalseOrigin / ModelTransformation numeric inputs) and the +// FederatedFalseOrigin (XYZ + Z-axis rotation in that unit). On Ok, +// calls Federation::setConfig + setFederatedFalseOrigin, which fire the +// granular Federation signals and trigger the viewport to recompose. +class FederationSettingsDialog : public QDialog { + Q_OBJECT +public: + explicit FederationSettingsDialog(Federation* federation, QWidget* parent = nullptr); + +protected: + void showEvent(QShowEvent* event) override; + +private: + void setupUi(); + void syncFromFederation(); + void onAccepted(); + + Federation* federation_ = nullptr; + + // The combobox encodes (prefix, name) pairs in user data; common length + // units are listed. itemData(idx) returns a QStringList { prefix, name }. + QComboBox* unit_combo_ = nullptr; + QDoubleSpinBox* xyz_x_ = nullptr; + QDoubleSpinBox* xyz_y_ = nullptr; + QDoubleSpinBox* xyz_z_ = nullptr; + QDoubleSpinBox* rz_deg_ = nullptr; +}; + +#endif diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index a07ad82db8..0fd00530b1 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -20,6 +20,7 @@ #include "MainWindow.h" #include "AppSettings.h" #include "Federation.h" +#include "FederationSettingsDialog.h" #include "SettingsWindow.h" #include "LodBuilder.h" #include "SidecarCache.h" @@ -189,6 +190,8 @@ void MainWindow::setupMenus() { file_menu->addAction("Save Federation &As...", this, &MainWindow::onFederationSaveAs, QKeySequence::SaveAs); + file_menu->addAction("Federation Se&ttings...", + this, &MainWindow::onFederationSettings); file_menu->addSeparator(); file_menu->addAction("&Settings...", this, &MainWindow::onFileSettings); file_menu->addSeparator(); @@ -228,6 +231,15 @@ void MainWindow::onFileSettings() { settings_->raise(); } +void MainWindow::onFederationSettings() { + if (federation_settings_ == nullptr) { + federation_settings_ = new FederationSettingsDialog(federation_, this); + } + federation_settings_->open(); + federation_settings_->activateWindow(); + federation_settings_->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 86bfc3c00c..20a514c073 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -36,6 +36,7 @@ class Federation; class SettingsWindow; +class FederationSettingsDialog; class MainWindow : public QMainWindow { Q_OBJECT @@ -61,6 +62,7 @@ private slots: void onSetHomeView(); void onGoHomeView(); void onFileSettings(); + void onFederationSettings(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); @@ -119,6 +121,7 @@ private: SceneLoader* loader_ = nullptr; Federation* federation_ = nullptr; SettingsWindow* settings_ = nullptr; + FederationSettingsDialog* federation_settings_ = nullptr; QWidget* viewport_container_ = nullptr; QTreeWidget* element_tree_ = nullptr; QTableWidget* property_table_ = nullptr; From 8f3eaa35d7150c5de010a1bfe22ed5c9c0178d4a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 2 May 2026 08:06:25 +1000 Subject: [PATCH 096/120] ifcviewer-full: per-model ModelTransformation editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelTransformationDialog edits one federation model at a time. Top combobox picks the model; below it the form covers the four pieces of authoring intent: - AFrame radio: ModelLocal vs ModelGlobal - Point A: 3 doubles, label switches between "model project length unit" and "model map unit" with the radio - Point B: 3 doubles in federation units (label reflects current FederationConfig.unit_*) - Rotation: rx/ry/rz in degrees, intrinsic XYZ - Pivot: 3 doubles in federation units Switching models discards unsaved form edits — Ok saves the currently-visible model, Cancel discards. On Ok calls Federation::setModelTransformation, which fires modelTransformationChanged → MainWindow recomposes that model in the viewport. Reachable from File > Model Transformations. End-to-end is now editable: open a federation, edit federation unit / false origin from one dialog, edit any model's transformation from the other, watch the viewport recompose live. Visual verification on a real model still pending. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 12 + src/ifcviewer-full/MainWindow.h | 3 + .../ModelTransformationDialog.cpp | 248 ++++++++++++++++++ .../ModelTransformationDialog.h | 88 +++++++ 4 files changed, 351 insertions(+) create mode 100644 src/ifcviewer-full/ModelTransformationDialog.cpp create mode 100644 src/ifcviewer-full/ModelTransformationDialog.h 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 From e0e143bab5dc0dce86e836d1c65e542aadedb0ba Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 2 May 2026 19:01:50 +1000 Subject: [PATCH 097/120] ifcviewer: F to frame selection from tree, debug coord dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tree -> viewport selection was already wired (onTreeSelectionChanged calls setSelectedObjectId), but pressing F afterwards routed to the focused tree widget rather than the viewport, so framing didn't fire. Add a window-level View > Frame Selected QAction with Qt::Key_F that delegates to ViewportWindow::focusOnSelectedObject — works regardless of which child widget has focus. The viewport's own F handler stays in place for when the viewport itself owns focus. For debugging coordinate problems, add View > Print Selected Coords (Ctrl+Shift+P) -> ViewportWindow::printSelectedObjectCoords, which qInfo's: - a sample vertex (first vertex of the selected mesh, decoded on demand from the quantised VBO so no extra CPU storage is needed); - placement_transformation (the per-instance matrix that maps the sample vertex from mesh-local into the model's pre-georef frame); - global = CoordinateOperation . placement_transformation (where the IFC's own IfcCoordinateOperation has been folded in); - the sample vertex transformed through both matrices. The print is a no-op when nothing is selected or GL hasn't initialised. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 11 ++++ src/ifcviewer/ViewportWindow.cpp | 83 +++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 6 +++ 3 files changed, 100 insertions(+) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 8dda5cfd42..3503072e77 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -201,6 +201,17 @@ void MainWindow::setupMenus() { file_menu->addAction("&Quit", QKeySequence::Quit, qApp, &QApplication::quit); auto* view_menu = menuBar()->addMenu("&View"); + // F frames the current selection. The viewport already binds F in its + // own keyPressEvent for the case where it has focus; this duplicate at + // window level is so the shortcut still fires when the tree, property + // table, or any other child widget has keyboard focus. + view_menu->addAction("&Frame Selected", this, [this]() { + viewport_->focusOnSelectedObject(); + }, QKeySequence(Qt::Key_F)); + view_menu->addAction("Print Selected &Coords", this, [this]() { + viewport_->printSelectedObjectCoords(); + }, QKeySequence("Ctrl+Shift+P")); + view_menu->addSeparator(); view_menu->addAction("Set &Home View", this, &MainWindow::onSetHomeView); view_menu->addAction("&Go to Home View", this, &MainWindow::onGoHomeView); } diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 8aeb3300f3..8330ce16a7 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -3664,3 +3664,86 @@ void ViewportWindow::setModelTransformation(uint32_t model_id, it->second.model_transformation_meters = matrix_meters; recomposeAndUploadModel(model_id); } + +void ViewportWindow::printSelectedObjectCoords() { + if (selected_object_id_ == 0) { + qInfo("printSelectedObjectCoords: no object selected"); + return; + } + if (!gl_initialized_) { + qInfo("printSelectedObjectCoords: GL not initialised yet"); + return; + } + + for (const auto& kv : models_gpu_) { + const ModelGpuData& m = kv.second; + for (size_t i = 0; i < m.instances.size(); ++i) { + const InstanceCpu& inst = m.instances[i]; + if (inst.object_id != selected_object_id_) continue; + + qInfo("Selected object %u (model %u, mesh %u, instance %zu):", + inst.object_id, inst.model_id, inst.mesh_id, i); + + // Decode the first emitted vertex from the VBO. Per + // InstancedGeometry.h: pos is 3 x uint16 at offset 0, normalised + // to [0,1] and dequantised against the mesh's local AABB. + float vx = 0, vy = 0, vz = 0; + bool have_vert = false; + if (inst.mesh_id < m.meshes.size()) { + const MeshInfo& mi = m.meshes[inst.mesh_id]; + if (mi.vertex_count > 0) { + context_->makeCurrent(this); + uint16_t pos_u16[3] = {0, 0, 0}; + gl_->glGetNamedBufferSubData( + m.vbo, mi.vbo_byte_offset, sizeof(pos_u16), pos_u16); + auto lerp = [](float lo, float hi, float t) { + return lo + t * (hi - lo); + }; + vx = lerp(mi.local_aabb_min[0], mi.local_aabb_max[0], + pos_u16[0] / 65535.0f); + vy = lerp(mi.local_aabb_min[1], mi.local_aabb_max[1], + pos_u16[1] / 65535.0f); + vz = lerp(mi.local_aabb_min[2], mi.local_aabb_max[2], + pos_u16[2] / 65535.0f); + have_vert = true; + } + } + if (have_vert) { + qInfo(" vertex (mesh-local m): (%g, %g, %g)", vx, vy, vz); + } else { + qInfo(" vertex: (no vertex data)"); + } + + using Mat4f = Eigen::Matrix; + const Eigen::Matrix4d Pd = + Eigen::Map(inst.placement_transformation).cast(); + // global = CoordinateOperation · placement_transformation. + // (FederatedFalseOrigin and ModelTransformation are user-side + // tweaks; "global" here means the IFC's own georeferenced frame.) + const Eigen::Matrix4d Gd = m.coordinate_operation_meters * Pd; + + auto print_mat = [](const char* label, const Eigen::Matrix4d& M) { + qInfo(" %s (m):", label); + for (int r = 0; r < 4; ++r) { + qInfo(" [% .9g % .9g % .9g % .9g]", + M(r, 0), M(r, 1), M(r, 2), M(r, 3)); + } + }; + print_mat("placement_transformation", Pd); + print_mat("global (CoordinateOperation . placement)", Gd); + + if (have_vert) { + const Eigen::Vector4d vh(vx, vy, vz, 1.0); + const Eigen::Vector3d after_p = (Pd * vh).head<3>(); + const Eigen::Vector3d after_g = (Gd * vh).head<3>(); + qInfo(" vertex after placement (m): (% .9g, % .9g, % .9g)", + after_p.x(), after_p.y(), after_p.z()); + qInfo(" vertex after global (m): (% .9g, % .9g, % .9g)", + after_g.x(), after_g.y(), after_g.z()); + } + return; + } + } + qInfo("printSelectedObjectCoords: object_id %u not found in any model", + selected_object_id_); +} diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 9c49d1ef98..74cfdb36ad 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -177,6 +177,12 @@ public: void showModel(uint32_t model_id); void removeModel(uint32_t model_id); + // Debug helper: walk to the currently selected object's instance and + // qInfo a sample vertex (decoded from the VBO), the placement + // transformation matrix, and the global matrix + // (CoordinateOperation · placement_transformation), in metres. + void printSelectedObjectCoords(); + // Federation pipeline: composed instance transform = // FederatedFalseOrigin · ModelTransformation · CoordinateOperation // · placement_transformation From 8ffdb8f0b9923dcbfa53093fc29f000854def40b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 2 May 2026 19:08:45 +1000 Subject: [PATCH 098/120] ifcviewer: cache CoordinateOperation in sidecar (v10 -> v11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, applyCoordinateOperationToViewport — which pushes both CoordinateOperation and ModelTransformation — was only called on paths that required the IFC source to be loaded (onLoadedFromStream and onDataSourceReady). Sidecar-only loads (loadDataSource off, or no .ifc/.rdb sibling) silently lost both stages. Cache the per-model georef + unit scales in the sidecar itself so the IFC source isn't needed to apply them: SidecarData gains coordinate_operation_meters[16] // column-major project_length_to_meters map_unit_to_meters has_coordinate_operation 148 B fixed block written/read between instances and elements. SIDECAR_VERSION 10 -> 11; existing sidecars rebuild on next load. MainWindow::writeSidecarForModel populates the block from loader_->modelGeoref(mid) before writeSidecar. SceneLoader::applySidecarData restores it into the model's ModelGeoref + sets has_georef = true, so subsequent loader_->modelGeoref(mid) calls return the cached data without needing the IFC. MainWindow::onLoadedFromSidecar now calls applyCoordinateOperationToViewport(mid) directly — both CoordinateOperation and ModelTransformation land at sidecar-load time, no longer waiting on a possibly-never-arriving data source. Edits to the IFC's IfcMapConversion don't invalidate the cache — delete the .ifcview manually if the source's georef changes. This matches the existing cache-invalidation contract. Tests: round-trip the new fields through the existing sidecar fixture; assert SIDECAR_VERSION == 11. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 18 +++++++++++++- src/ifcviewer/SceneLoader.cpp | 15 +++++++++++ src/ifcviewer/SidecarCache.cpp | 29 ++++++++++++++++++++-- src/ifcviewer/SidecarCache.h | 21 +++++++++++++++- src/ifcviewer/tests/test_sidecar_cache.cpp | 22 ++++++++++++++-- 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 3503072e77..7e261eaf3d 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -594,12 +594,18 @@ void MainWindow::applyFederatedFalseOriginToViewport() { viewport_->setFederatedFalseOrigin(M); } -void MainWindow::onLoadedFromSidecar(uint32_t /*mid*/, qint64 elapsed_ms) { +void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { progress_bar_->setVisible(false); status_label_->setText(QString("%1 elements across %2 model(s) — loaded from cache in %3") .arg(element_map_.size()) .arg(loader_->modelCount()) .arg(formatElapsed(elapsed_ms))); + + // Sidecar v11+ caches the CoordinateOperation, so SceneLoader has + // already populated modelGeoref by now — push CoordinateOperation + + // ModelTransformation immediately rather than waiting for the + // (possibly never-arriving) data-source load. + applyCoordinateOperationToViewport(mid); } void MainWindow::onStreamedElementsReady(uint32_t /*mid*/, std::vector elements) { @@ -615,6 +621,16 @@ void MainWindow::writeSidecarForModel(uint32_t mid) { SidecarData sd; if (!viewport_->snapshotModel(mid, sd)) return; + // Cache the model's CoordinateOperation alongside the geometry so a + // sidecar load doesn't need the IFC source just to apply georef. + if (const ModelGeoref* gr = loader_->modelGeoref(mid)) { + sd.has_coordinate_operation = gr->has_coordinate_operation ? 1 : 0; + Eigen::Map>( + sd.coordinate_operation_meters) = gr->coordinate_operation_meters; + sd.project_length_to_meters = gr->units.project_length_to_meters; + sd.map_unit_to_meters = gr->units.map_unit_to_meters; + } + for (const auto& [oid, info] : element_map_) { if (info.model_id != mid) continue; PackedElementInfo pe; diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index d455615376..e8184ff7fd 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -199,6 +199,21 @@ void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) { inst.model_id = mid; } + // Restore the cached CoordinateOperation into the model so + // modelGeoref(mid) returns it without needing the IFC source. Prevents + // sidecar-loaded models from silently losing their georef when the + // .ifc/.rdb sibling is absent or AppSettings.loadDataSource is off. + { + ModelGeoref& gr = model.georef; + gr.has_coordinate_operation = data.has_coordinate_operation != 0; + Eigen::Map> M( + data.coordinate_operation_meters); + gr.coordinate_operation_meters = M; + gr.units.project_length_to_meters = data.project_length_to_meters; + gr.units.map_unit_to_meters = data.map_unit_to_meters; + model.has_georef = true; + } + std::vector elements = std::move(data.elements); std::string stbl = std::move(data.string_table); diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index b603dce354..86951f7395 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -17,7 +17,7 @@ * * ********************************************************************************/ -// v9 layout (all multi-byte fields native-endian; endianness marker in header). +// v11 layout (all multi-byte fields native-endian; endianness marker in header). // // SidecarHeader (12 bytes) // @@ -30,7 +30,12 @@ // MeshInfo[num_meshes] // // uint32_t num_instances -// InstanceCpu[num_instances] (already sorted by mesh_id) +// InstanceCpu[num_instances] (already sorted by mesh_id; v10 layout) +// +// uint32_t has_coordinate_operation (v11+) +// double[16] coordinate_operation_meters (v11+; column-major) +// double project_length_to_meters (v11+) +// double map_unit_to_meters (v11+) // // uint32_t num_elements // PackedElementInfo[num_elements] @@ -93,6 +98,16 @@ bool writeSidecar(const std::string& ifc_path, const SidecarData& data) { if (!writeVec(f, data.indices)) { fclose(f); return false; } if (!writeVec(f, data.meshes)) { fclose(f); return false; } if (!writeVec(f, data.instances)) { fclose(f); return false; } + + // v11 georef block (148 B). + if (fwrite(&data.has_coordinate_operation, 4, 1, f) != 1) { fclose(f); return false; } + if (fwrite(data.coordinate_operation_meters, + sizeof(double), 16, f) != 16) { fclose(f); return false; } + if (fwrite(&data.project_length_to_meters, + sizeof(double), 1, f) != 1) { fclose(f); return false; } + if (fwrite(&data.map_unit_to_meters, + sizeof(double), 1, f) != 1) { fclose(f); return false; } + if (!writeVec(f, data.elements)) { fclose(f); return false; } uint32_t stbl_len = static_cast(data.string_table.size()); @@ -123,6 +138,16 @@ std::optional readSidecar(const std::string& ifc_path) { if (!readVec(f, data.indices)) return fail(); if (!readVec(f, data.meshes)) return fail(); if (!readVec(f, data.instances)) return fail(); + + // v11 georef block. + if (fread(&data.has_coordinate_operation, 4, 1, f) != 1) return fail(); + if (fread(data.coordinate_operation_meters, + sizeof(double), 16, f) != 16) return fail(); + if (fread(&data.project_length_to_meters, + sizeof(double), 1, f) != 1) return fail(); + if (fread(&data.map_unit_to_meters, + sizeof(double), 1, f) != 1) return fail(); + if (!readVec(f, data.elements)) return fail(); uint32_t stbl_len; diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 916a42acb1..ac33e200de 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -53,7 +53,13 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW" // result. Sidecar serialises both; on load the transform is recomputed // from placement_transformation + the ViewportWindow's current stage // matrices, so v10 sidecars are reusable across .ifcfeds. -static constexpr uint32_t SIDECAR_VERSION = 10; +// v11 = SidecarData gains a per-model CoordinateOperation cache: +// coordinate_operation_meters[16] (column-major), unit scales, and a +// has_coordinate_operation flag. Lets sidecar-loaded models apply +// georef without re-parsing the IFC source. Edits to the IFC's +// IfcMapConversion do NOT invalidate the sidecar — delete the +// .ifcview manually if you change the source's georef parameters. +static constexpr uint32_t SIDECAR_VERSION = 11; static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304; // Fixed-size element record. Strings are stored as (offset, length) pairs @@ -83,6 +89,19 @@ struct SidecarData { std::vector meshes; // indexed by local_mesh_id std::vector instances; // sorted by mesh_id + // CoordinateOperation cache (v11+). Mirrors ModelGeoref so a sidecar + // load can apply georef without re-parsing the IFC source. + // has_coordinate_operation == 0 means the model has no + // IfcMapConversion; the matrix is then the identity placeholder. + double coordinate_operation_meters[16] = { + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 }; + double project_length_to_meters = 1.0; + double map_unit_to_meters = 1.0; + uint32_t has_coordinate_operation = 0; + // Element tree metadata. std::vector elements; std::string string_table; diff --git a/src/ifcviewer/tests/test_sidecar_cache.cpp b/src/ifcviewer/tests/test_sidecar_cache.cpp index 978305db53..f592a37772 100644 --- a/src/ifcviewer/tests/test_sidecar_cache.cpp +++ b/src/ifcviewer/tests/test_sidecar_cache.cpp @@ -88,7 +88,10 @@ SidecarData buildFixture() { inst.object_id = uint32_t(100 + i); inst.color_override_rgba8 = uint32_t(0xAA000000u | (i * 0x010203u)); inst.model_id = 1; - for (int k = 0; k < 16; ++k) inst.transform[k] = float(i) * 0.5f + float(k); + for (int k = 0; k < 16; ++k) { + inst.placement_transformation[k] = float(i) * 0.25f + float(k); + inst.transform[k] = float(i) * 0.5f + float(k); + } inst.world_aabb_min[0] = float(i); inst.world_aabb_min[1] = float(i + 1); inst.world_aabb_min[2] = float(i + 2); @@ -97,6 +100,12 @@ SidecarData buildFixture() { inst.world_aabb_max[2] = float(i + 2) + 10.0f; } + // Non-default georef block. + sd.has_coordinate_operation = 1; + for (int k = 0; k < 16; ++k) sd.coordinate_operation_meters[k] = 0.5 + 0.1 * k; + sd.project_length_to_meters = 0.001; // mm project + sd.map_unit_to_meters = 1.0; // metres map + sd.string_table = std::string("\0Wall\0Slab\0", 11); // includes embedded NULs sd.elements.resize(3); for (size_t i = 0; i < sd.elements.size(); ++i) { @@ -129,6 +138,15 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) { for (size_t i = 0; i < a.elements.size(); ++i) { if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(PackedElementInfo)) != 0) return false; } + + // v11 georef block. + if (a.has_coordinate_operation != b.has_coordinate_operation) return false; + if (a.project_length_to_meters != b.project_length_to_meters) return false; + if (a.map_unit_to_meters != b.map_unit_to_meters) return false; + for (int i = 0; i < 16; ++i) { + if (a.coordinate_operation_meters[i] != b.coordinate_operation_meters[i]) + return false; + } return true; } @@ -137,7 +155,7 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) { TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") { REQUIRE(sizeof(MeshInfo) == 56); REQUIRE(sizeof(InstanceGpu) == 80); - REQUIRE(SIDECAR_VERSION == 9); + REQUIRE(SIDECAR_VERSION == 11); REQUIRE(SIDECAR_MAGIC == 0x49465657u); } From f7add7f4126e8100b27553c4b627944ab4128812 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 3 May 2026 10:04:39 +1000 Subject: [PATCH 099/120] ifcviewer: auto-guess FederatedFalseOrigin on first model added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user adds a model into a fresh, untitled federation that still has the default (0,0,0, no rotation) FederatedFalseOrigin, derive an origin from the first instance's placement_transformation (lifted through CoordinateOperation when enabled) and the helmert grid-north baked into ModelGeoref::coordinate_operation_meters. Multi-file batches naturally settle: whichever load finishes first anchors the federation, the rest see a non-default origin and skip. Saved .ifcfeds keep their authoritative origin. Adds Placement.{h,cpp} (port of util/placement.py — a2p, get_axis2placement, get_local_placement) so Geolocation no longer needs its own anonymous getAxis2Placement, and xaxis2angleDeg in Geolocation mirroring util/geolocation.xaxis2angle. SceneLoader captures the first instance's placement_transformation from either the sidecar's InstanceCpu[0] or the streamer's first InstanceChunk, so the guess works on both load paths without re-reading the IFC. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 28 +++++- src/ifcviewer-full/MainWindow.h | 9 ++ src/ifcviewer/Federation.cpp | 30 +++++++ src/ifcviewer/Federation.h | 22 +++++ src/ifcviewer/Geolocation.cpp | 63 +++---------- src/ifcviewer/Geolocation.h | 5 ++ src/ifcviewer/Placement.cpp | 144 ++++++++++++++++++++++++++++++ src/ifcviewer/Placement.h | 50 +++++++++++ src/ifcviewer/SceneLoader.cpp | 23 +++++ src/ifcviewer/SceneLoader.h | 14 +++ 10 files changed, 333 insertions(+), 55 deletions(-) create mode 100644 src/ifcviewer/Placement.cpp create mode 100644 src/ifcviewer/Placement.h diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 7e261eaf3d..616ca0bb68 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -594,6 +594,30 @@ void MainWindow::applyFederatedFalseOriginToViewport() { viewport_->setFederatedFalseOrigin(M); } +void MainWindow::maybeGuessFederatedFalseOrigin(uint32_t mid) { + // Only auto-guess for untitled federations. A saved .ifcfed carries its + // authoritative origin (even if that happens to be the default), so we + // never silently overwrite it when the user re-adds a model. + if (!federation_->filePath().isEmpty()) return; + + // Skip once the origin is non-default — either the user edited it, a + // previous batch already guessed, or a load-from-file populated it. + // For a multi-file batch this means whichever model finishes first + // anchors the federation; the rest see a non-default origin and skip. + const FederatedFalseOrigin& cur = federation_->federatedFalseOrigin(); + const FederatedFalseOrigin def; + if (cur.xyz != def.xyz || cur.rz_deg != def.rz_deg) return; + + const Eigen::Matrix4d* placement = loader_->firstPlacement(mid); + const ModelGeoref* gr = loader_->modelGeoref(mid); + if (placement == nullptr || gr == nullptr) return; + + const FederatedFalseOrigin guess = guessFederatedFalseOrigin( + *placement, *gr, federation_->config(), + AppSettings::instance().applyCoordinateOperation()); + federation_->setFederatedFalseOrigin(guess); +} + void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { progress_bar_->setVisible(false); status_label_->setText(QString("%1 elements across %2 model(s) — loaded from cache in %3") @@ -606,6 +630,7 @@ void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { // ModelTransformation immediately rather than waiting for the // (possibly never-arriving) data-source load. applyCoordinateOperationToViewport(mid); + maybeGuessFederatedFalseOrigin(mid); } void MainWindow::onStreamedElementsReady(uint32_t /*mid*/, std::vector elements) { @@ -714,9 +739,8 @@ void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { .arg(loader_->modelCount()) .arg(formatElapsed(elapsed_ms))); - // Stream path: the IFC is owned by the streamer, so georef is - // computable now. (Sidecar-hit models defer to onDataSourceReady.) applyCoordinateOperationToViewport(mid); + maybeGuessFederatedFalseOrigin(mid); writeSidecarForModel(mid); } diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 85bd6b2473..3910f4fd62 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -117,6 +117,15 @@ private: // Push the federation-wide FederatedFalseOrigin (stage 3) matrix to // the viewport. Affects every loaded model. void applyFederatedFalseOriginToViewport(); + + // For an untitled federation whose FederatedFalseOrigin is still at + // its default, derive a sensible origin from `mid`'s first instance + // placement + georef and push it via Federation. Idempotent: a + // non-default origin (user-edited, already guessed by a sibling load + // in the same batch, or loaded from a saved .ifcfed) is left + // untouched, so multi-file batches naturally anchor on whichever + // model finishes first. + void maybeGuessFederatedFalseOrigin(uint32_t mid); QString formatElapsed(qint64 ms) const; ViewportWindow* viewport_ = nullptr; diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index ba4b33dddf..892266e3a7 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -135,6 +135,36 @@ ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file) { return out; } +FederatedFalseOrigin +guessFederatedFalseOrigin(const Eigen::Matrix4d& first_placement_meters, + const ModelGeoref& georef, + const FederationConfig& fed_cfg, + bool apply_coordinate_operation) { + Eigen::Vector3d t_m = first_placement_meters.block<3, 1>(0, 3); + + const bool use_coord_op = + apply_coordinate_operation && georef.has_coordinate_operation; + if (use_coord_op) { + const Eigen::Vector4d th(t_m.x(), t_m.y(), t_m.z(), 1.0); + t_m = (georef.coordinate_operation_meters * th).head<3>(); + } + + const double u_fed = federationUnitToMeters(fed_cfg); + const double u_fed_inv = (u_fed != 0.0) ? (1.0 / u_fed) : 1.0; + + FederatedFalseOrigin out; + out.xyz = t_m * u_fed_inv; + + // Rotation: helmert grid-north baked into coordinate_operation_meters. + // helmertMetersFromParameters built that block as R_z(theta)·diag(fx,fy,fz) + // with theta = atan2(xao, xaa); xaxis2angle is `-theta` in degrees. + if (use_coord_op) { + const Eigen::Matrix4d& M = georef.coordinate_operation_meters; + out.rz_deg = xaxis2angleDeg(M(0, 0), M(1, 0)); + } + return out; +} + Eigen::Matrix4d composeModelTransformation(const ModelTransformation& xf, const FederationConfig& fed_cfg, const ModelUnits& model_units, diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 87562168c1..ce0e715df0 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -28,6 +28,7 @@ #include #include +#include #include #include @@ -125,6 +126,27 @@ struct ModelGeoref { // caller doesn't want to cache. ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file); +// Build a FederatedFalseOrigin guess so that a model lands near the +// federation origin instead of out at its surveyor coordinates. Designed +// to work without an open IFC file so it's usable from sidecar-only loads +// (the inputs are all derivable from the InstanceCpu cache + ModelGeoref). +// +// Position: `first_placement_meters` is the model's "anchor" placement — +// typically the first instance's `placement_transformation`, which the +// iterator already produces in metres (its `convert-back-units` default +// is false). The translation is optionally lifted through +// `georef.coordinate_operation_meters` (controlled by +// `apply_coordinate_operation`), then expressed in the federation unit. +// +// Rotation: read directly from `georef.coordinate_operation_meters` when +// `apply_coordinate_operation && has_coordinate_operation` (this is the +// helmert grid-north angle). Otherwise zero. Anticlockwise positive. +FederatedFalseOrigin +guessFederatedFalseOrigin(const Eigen::Matrix4d& first_placement_meters, + const ModelGeoref& georef, + const FederationConfig& fed_cfg, + bool apply_coordinate_operation); + // 1 federation_unit -> N metres. double federationUnitToMeters(const FederationConfig&); diff --git a/src/ifcviewer/Geolocation.cpp b/src/ifcviewer/Geolocation.cpp index 80833ec062..e9b6889003 100644 --- a/src/ifcviewer/Geolocation.cpp +++ b/src/ifcviewer/Geolocation.cpp @@ -18,6 +18,7 @@ ********************************************************************************/ #include "Geolocation.h" +#include "Placement.h" #include "../ifcparse/express.h" #include "../ifcparse/file.h" @@ -30,59 +31,6 @@ namespace { -// IfcAxis2Placement3D / IfcAxis2PlacementLinear -> column-major 4x4 matrix. -// Mirrors ifcopenshell.util.placement.a2p + get_axis2placement, but only the -// branches needed for IfcGeometricRepresentationContext.WorldCoordinateSystem. -std::optional getAxis2Placement(express::Base placement) { - if (!placement) return std::nullopt; - const auto& decl = placement.declaration(); - if (!(decl.is("IfcAxis2Placement3D") || decl.is("IfcAxis2PlacementLinear"))) { - return std::nullopt; - } - - auto entity = placement.as(); - - Eigen::Vector3d z(0.0, 0.0, 1.0); - Eigen::Vector3d x(1.0, 0.0, 0.0); - - auto axis_attr = entity.get("Axis"); - if (!axis_attr.isNull()) { - express::Base axis = axis_attr; - std::vector dr = - axis.as().get("DirectionRatios"); - if (dr.size() >= 3) z = Eigen::Vector3d(dr[0], dr[1], dr[2]); - } - - auto refdir_attr = entity.get("RefDirection"); - if (!refdir_attr.isNull()) { - express::Base refdir = refdir_attr; - std::vector dr = - refdir.as().get("DirectionRatios"); - if (dr.size() >= 3) x = Eigen::Vector3d(dr[0], dr[1], dr[2]); - } - - auto loc_attr = entity.get("Location"); - if (loc_attr.isNull()) return std::nullopt; - express::Base location = loc_attr; - auto coords_attr = location.as().get("Coordinates"); - if (coords_attr.isNull()) return std::nullopt; - std::vector coords = coords_attr; - if (coords.size() < 3) return std::nullopt; - - Eigen::Vector3d xn = x.normalized(); - Eigen::Vector3d zn = z.normalized(); - Eigen::Vector3d yn = zn.cross(xn).normalized(); - - Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); - m.block<3, 1>(0, 0) = xn; - m.block<3, 1>(0, 1) = yn; - m.block<3, 1>(0, 2) = zn; - m(0, 3) = coords[0]; - m(1, 3) = coords[1]; - m(2, 3) = coords[2]; - return m; -} - // Read a numeric NominalValue out of an IfcPropertySingleValue. IFC2X3 // ePSet_MapConversion stores eastings/northings/scale as IfcLengthMeasure or // IfcReal wrapped inside IfcValue (a SELECT) — get_attribute_value(0) peels @@ -223,6 +171,10 @@ std::optional getWcs(ifcopenshell::file* ifc_file) { } } if (!found) return std::nullopt; + const auto& decl = wcs.declaration(); + if (!(decl.is("IfcAxis2Placement3D") || decl.is("IfcAxis2PlacementLinear"))) { + return std::nullopt; + } return getAxis2Placement(wcs); } @@ -311,3 +263,8 @@ std::optional getMapUnit(ifcopenshell::file* ifc_file) { if (mu_attr.isNull()) return std::nullopt; return (express::Base) mu_attr; } + +double xaxis2angleDeg(double xaa, double xao) { + constexpr double kPi = 3.14159265358979323846; + return -std::atan2(xao, xaa) * (180.0 / kPi); +} diff --git a/src/ifcviewer/Geolocation.h b/src/ifcviewer/Geolocation.h index d5f14c196e..13eaecd8b1 100644 --- a/src/ifcviewer/Geolocation.h +++ b/src/ifcviewer/Geolocation.h @@ -100,4 +100,9 @@ Eigen::Matrix4d helmertMetersFromParameters(const HelmertTransformation& params, // calculateUnitScale(file, "LENGTHUNIT") in that case. std::optional getMapUnit(ifcopenshell::file* ifc_file); +// "How do I rotate project east to get to grid east?" — i.e. -atan2(xao, xaa) +// converted to degrees, anticlockwise positive. Mirrors +// ifcopenshell.util.geolocation.xaxis2angle. +double xaxis2angleDeg(double xaa, double xao); + #endif // GEOLOCATION_H diff --git a/src/ifcviewer/Placement.cpp b/src/ifcviewer/Placement.cpp new file mode 100644 index 0000000000..6d0bd7ecb0 --- /dev/null +++ b/src/ifcviewer/Placement.cpp @@ -0,0 +1,144 @@ +/******************************************************************************** + * * + * 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 "Placement.h" + +#include "../ifcparse/instance_data.h" +#include "../ifcparse/schema.h" + +#include + +namespace { + +Eigen::Vector3d safeNormalize(const Eigen::Vector3d& v, + const Eigen::Vector3d& fallback) { + const double n = v.norm(); + return (n > 0.0) ? Eigen::Vector3d(v / n) : fallback; +} + +std::vector readDirectionRatios(const express::Base& dir) { + if (!dir) return {}; + auto attr = dir.as().get("DirectionRatios"); + if (attr.isNull()) return {}; + return attr; +} + +} // namespace + +Eigen::Matrix4d a2p(const Eigen::Vector3d& origin, + const Eigen::Vector3d& z, + const Eigen::Vector3d& x) { + const Eigen::Vector3d xn = safeNormalize(x, Eigen::Vector3d::UnitX()); + const Eigen::Vector3d zn = safeNormalize(z, Eigen::Vector3d::UnitZ()); + const Eigen::Vector3d yn = safeNormalize(zn.cross(xn), Eigen::Vector3d::UnitY()); + + Eigen::Matrix4d m = Eigen::Matrix4d::Identity(); + m.block<3, 1>(0, 0) = xn; + m.block<3, 1>(0, 1) = yn; + m.block<3, 1>(0, 2) = zn; + m.block<3, 1>(0, 3) = origin; + return m; +} + +Eigen::Matrix4d getAxis2Placement(const express::Base& placement) { + if (!placement) return Eigen::Matrix4d::Identity(); + const auto& decl = placement.declaration(); + auto entity = placement.as(); + + Eigen::Vector3d z(0.0, 0.0, 1.0); + Eigen::Vector3d x(1.0, 0.0, 0.0); + Eigen::Vector3d o(0.0, 0.0, 0.0); + + if (decl.is("IfcAxis2Placement3D") || decl.is("IfcAxis2PlacementLinear")) { + auto axis_attr = entity.get("Axis"); + if (!axis_attr.isNull()) { + auto dr = readDirectionRatios((express::Base) axis_attr); + if (dr.size() >= 3) z = Eigen::Vector3d(dr[0], dr[1], dr[2]); + } + auto refdir_attr = entity.get("RefDirection"); + if (!refdir_attr.isNull()) { + auto dr = readDirectionRatios((express::Base) refdir_attr); + if (dr.size() >= 3) x = Eigen::Vector3d(dr[0], dr[1], dr[2]); + } + auto loc_attr = entity.get("Location"); + if (loc_attr.isNull()) return Eigen::Matrix4d::Identity(); + express::Base location = loc_attr; + auto coords_attr = location.as().get("Coordinates"); + if (coords_attr.isNull()) return Eigen::Matrix4d::Identity(); + std::vector coords = coords_attr; + if (coords.size() >= 3) o = Eigen::Vector3d(coords[0], coords[1], coords[2]); + } else if (decl.is("IfcAxis2Placement2D")) { + auto refdir_attr = entity.get("RefDirection"); + if (!refdir_attr.isNull()) { + auto dr = readDirectionRatios((express::Base) refdir_attr); + if (dr.size() >= 1) { + x = Eigen::Vector3d(dr.size() > 0 ? dr[0] : 1.0, + dr.size() > 1 ? dr[1] : 0.0, + 0.0); + } + } + auto loc_attr = entity.get("Location"); + if (loc_attr.isNull()) return Eigen::Matrix4d::Identity(); + express::Base location = loc_attr; + auto coords_attr = location.as().get("Coordinates"); + if (coords_attr.isNull()) return Eigen::Matrix4d::Identity(); + std::vector coords = coords_attr; + if (coords.size() >= 2) { + o = Eigen::Vector3d(coords[0], coords[1], + coords.size() >= 3 ? coords[2] : 0.0); + } + } else if (decl.is("IfcAxis1Placement")) { + auto axis_attr = entity.get("Axis"); + if (!axis_attr.isNull()) { + auto dr = readDirectionRatios((express::Base) axis_attr); + if (dr.size() >= 3) z = Eigen::Vector3d(dr[0], dr[1], dr[2]); + } + auto loc_attr = entity.get("Location"); + if (loc_attr.isNull()) return Eigen::Matrix4d::Identity(); + express::Base location = loc_attr; + auto coords_attr = location.as().get("Coordinates"); + if (coords_attr.isNull()) return Eigen::Matrix4d::Identity(); + std::vector coords = coords_attr; + if (coords.size() >= 3) o = Eigen::Vector3d(coords[0], coords[1], coords[2]); + } else { + return Eigen::Matrix4d::Identity(); + } + + return a2p(o, z, x); +} + +Eigen::Matrix4d getLocalPlacement(const express::Base& placement) { + if (!placement) return Eigen::Matrix4d::Identity(); + const auto& decl = placement.declaration(); + + if (decl.is("IfcLocalPlacement")) { + auto entity = placement.as(); + Eigen::Matrix4d parent = Eigen::Matrix4d::Identity(); + auto rel_attr = entity.get("PlacementRelTo"); + if (!rel_attr.isNull()) { + parent = getLocalPlacement((express::Base) rel_attr); + } + auto rp_attr = entity.get("RelativePlacement"); + if (rp_attr.isNull()) return parent; + return parent * getAxis2Placement((express::Base) rp_attr); + } + + // IfcAxis2Placement* / IfcAxis1Placement passed in directly. + return getAxis2Placement(placement); +} diff --git a/src/ifcviewer/Placement.h b/src/ifcviewer/Placement.h new file mode 100644 index 0000000000..346e20651c --- /dev/null +++ b/src/ifcviewer/Placement.h @@ -0,0 +1,50 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +// Port of selected helpers from +// src/ifcopenshell-python/ifcopenshell/util/placement.py — entity-instance +// placement chains -> 4x4 matrices, used by callers that need to read +// IfcLocalPlacement / IfcAxis2Placement* outside of the geometry kernel. + +#ifndef PLACEMENT_H +#define PLACEMENT_H + +#include "../ifcparse/express.h" + +#include + +// Build a 4x4 placement matrix from an origin + Z + X axis triple, mirroring +// ifcopenshell.util.placement.a2p. The Y axis is derived as Z × X. Inputs +// don't need to be unit; vectors are renormalised internally. +Eigen::Matrix4d a2p(const Eigen::Vector3d& origin, + const Eigen::Vector3d& z, + const Eigen::Vector3d& x); + +// IfcAxis2Placement{2D,3D,Linear} / IfcAxis1Placement -> 4x4 matrix. Mirrors +// ifcopenshell.util.placement.get_axis2placement. Returns identity for null +// or unparseable inputs. Translation is in the IFC's project length unit. +Eigen::Matrix4d getAxis2Placement(const express::Base& placement); + +// Resolve an IfcLocalPlacement (or an IfcAxis2Placement* directly) into a +// 4x4 matrix in the IFC project's length unit, walking the PlacementRelTo +// chain. Mirrors ifcopenshell.util.placement.get_local_placement. Returns +// identity for a null input. +Eigen::Matrix4d getLocalPlacement(const express::Base& placement); + +#endif // PLACEMENT_H diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index e8184ff7fd..b37c4cb6f1 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -81,6 +81,12 @@ const ModelGeoref* SceneLoader::modelGeoref(uint32_t mid) { return &m.georef; } +const Eigen::Matrix4d* SceneLoader::firstPlacement(uint32_t mid) const { + auto it = models_.find(mid); + if (it == models_.end() || !it->second.has_first_placement) return nullptr; + return &it->second.first_placement; +} + std::vector SceneLoader::addFiles(const QStringList& paths) { std::vector assigned; assigned.reserve(paths.size()); @@ -214,6 +220,14 @@ void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) { model.has_georef = true; } + if (!data.instances.empty() && !model.has_first_placement) { + using Mat4fCol = Eigen::Matrix; + model.first_placement = + Eigen::Map(data.instances[0].placement_transformation) + .cast(); + model.has_first_placement = true; + } + std::vector elements = std::move(data.elements); std::string stbl = std::move(data.string_table); @@ -302,6 +316,15 @@ void SceneLoader::onStreamerMeshReady(MeshChunk chunk) { } void SceneLoader::onStreamerInstanceReady(InstanceChunk chunk) { + if (loading_model_id_ != 0) { + auto it = models_.find(loading_model_id_); + if (it != models_.end() && !it->second.has_first_placement) { + using Mat4fCol = Eigen::Matrix; + it->second.first_placement = + Eigen::Map(chunk.transform).cast(); + it->second.has_first_placement = true; + } + } viewport_->uploadInstanceChunk(chunk); } diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 6efca5a555..71459f8d6a 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -72,6 +72,14 @@ public: // sidecar-hit path before the data-source thread populates the streamer). const ModelGeoref* modelGeoref(uint32_t mid); + // The placement_transformation (in metres, column-major 4x4) of the + // first instance the loader saw for `mid` — captured from the streamer's + // first InstanceChunk during a stream load, or from the cached + // InstanceCpu[0] on a sidecar hit. Returns nullptr until at least one + // instance has been observed. Used by the federation false-origin + // auto-guess to anchor the model without re-parsing the IFC. + const Eigen::Matrix4d* firstPlacement(uint32_t mid) const; + signals: void progressChanged(int percent); void loadStarted(uint32_t mid, QString display_name); @@ -125,6 +133,12 @@ private: // streamer has its IFC file loaded. ModelGeoref georef; bool has_georef = false; + + // The first instance's placement_transformation (in metres) — set + // once per model from either the sidecar's InstanceCpu[0] or the + // streamer's first InstanceChunk. + Eigen::Matrix4d first_placement = Eigen::Matrix4d::Identity(); + bool has_first_placement = false; }; void startNextLoad(); From e84767c22b209d8d70075aa5a2413f6a698ec104 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 4 May 2026 07:14:52 +1000 Subject: [PATCH 100/120] ifcviewer-full: multi-select directories in Add Database dialog Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 616ca0bb68..84f13ea908 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -36,6 +36,9 @@ #include #include #include +#include +#include +#include #include MainWindow::MainWindow(QWidget* parent) @@ -228,11 +231,23 @@ void MainWindow::onFileOpen() { } void MainWindow::onDatabaseOpen() { - QString path = QFileDialog::getExistingDirectory( - this, "Add IFC Database", QString(), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!path.isEmpty()) { - addFiles({ path }); + QFileDialog dialog(this, "Add IFC Databases"); + dialog.setFileMode(QFileDialog::Directory); + dialog.setOption(QFileDialog::ShowDirsOnly, true); + dialog.setOption(QFileDialog::DontResolveSymlinks, true); + // Native dialogs only support single-directory selection — use Qt's + // dialog so we can flip the inner views into ExtendedSelection. + dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (auto* list = dialog.findChild("listView")) { + list->setSelectionMode(QAbstractItemView::ExtendedSelection); + } + if (auto* tree = dialog.findChild()) { + tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + } + if (dialog.exec() != QDialog::Accepted) return; + QStringList paths = dialog.selectedFiles(); + if (!paths.isEmpty()) { + addFiles(paths); } } From ca2e866c45c3f6b96ca05df025e329d05ded3eeb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 4 May 2026 07:28:12 +1000 Subject: [PATCH 101/120] serializers: skip_supertypes filter in RocksDbSerializer streaming write Plumbed through to the Python convert_path_to_rocksdb wrapper. Co-Authored-By: Claude Opus 4.7 --- .../ifcopenshell/__init__.py | 13 +++++++++++-- src/serializers/RocksDbSerializer.cpp | 18 +++++++++++++++++- src/serializers/RocksDbSerializer.h | 6 +++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index cdc82c9c74..07d1f570dd 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -378,15 +378,24 @@ def stream2_from_string(data: str) -> Generator[dict]: yield inst -def convert_path_to_rocksdb(ifcspf_path: Union[Path, str], rocksdb_path: Union[Path, str]) -> None: +def convert_path_to_rocksdb( + ifcspf_path: Union[Path, str], + rocksdb_path: Union[Path, str], + skip_supertypes: Optional[list[str]] = None, +) -> None: """Converts an IFC-SPF file on disk to the IfcOpenShell-specific RocksDB encoding. RocksDB is an embedded key-value store that allows partial reads and is therefore more memory efficient with larger files. :param ifcspf_path: Input file path - needs to exist :param rocksdb_path: RocksDB file path (directory) - may exist, but result may then be invalid + :param skip_supertypes: Optional list of entity type names. Any instance + whose declaration is-a (or subclasses) one of these names will be + omitted from the resulting RocksDB. Note: references to skipped + instances become dangling - reads that walk into them will fail. """ - ser = ifcopenshell_wrapper.RocksDbSerializer(str(ifcspf_path), str(rocksdb_path), True) + skip = list(skip_supertypes) if skip_supertypes else [] + ser = ifcopenshell_wrapper.RocksDbSerializer(str(ifcspf_path), str(rocksdb_path), True, skip) ser.finalize() diff --git a/src/serializers/RocksDbSerializer.cpp b/src/serializers/RocksDbSerializer.cpp index b55e1eadf8..bbc8e38057 100644 --- a/src/serializers/RocksDbSerializer.cpp +++ b/src/serializers/RocksDbSerializer.cpp @@ -23,9 +23,10 @@ RocksDbSerializer::RocksDbSerializer(ifcopenshell::file* file, const std::string output_file_->calculate_unit_factors = false; } -RocksDbSerializer::RocksDbSerializer(const std::string& input_filename, const std::string& rocksdb_filename, bool stream) +RocksDbSerializer::RocksDbSerializer(const std::string& input_filename, const std::string& rocksdb_filename, bool stream, const std::vector& skip_supertypes) : file_(input_filename) , rocksdb_filename_(rocksdb_filename) + , skip_supertypes_(skip_supertypes) { } @@ -144,6 +145,21 @@ void RocksDbSerializer::write_streaming_() { const bool is_header = decl->schema() == &Header_section_schema::get_schema(); + if (!is_header && decl->as_entity() && !skip_supertypes_.empty()) { + bool skip = false; + for (const auto& super : skip_supertypes_) { + if (decl->is(super)) { + skip = true; + break; + } + } + if (skip) { + streamer.references().clear(); + streamer.inverses().clear(); + continue; + } + } + std::vector simple_type_instances; for (size_t i = 0; i < data->storage_->size(); i++) { diff --git a/src/serializers/RocksDbSerializer.h b/src/serializers/RocksDbSerializer.h index 1ff8fec813..66caf3edb2 100644 --- a/src/serializers/RocksDbSerializer.h +++ b/src/serializers/RocksDbSerializer.h @@ -8,17 +8,21 @@ #include +#include +#include + class SERIALIZERS_API RocksDbSerializer : public Serializer { private: rocksdb::DB* db_; std::string rocksdb_filename_; std::variant file_; ifcopenshell::file* output_file_; + std::vector skip_supertypes_; void write_streaming_(); public: RocksDbSerializer(ifcopenshell::file* file, const std::string& rocksdb_filename); - RocksDbSerializer(const std::string& input_filename, const std::string& rocksdb_filename, bool stream); + RocksDbSerializer(const std::string& input_filename, const std::string& rocksdb_filename, bool stream, const std::vector& skip_supertypes = {}); virtual ~RocksDbSerializer() {} From 4597be7fdaa5e55fc2827c114be689238ebcd39d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 4 May 2026 12:21:50 +1000 Subject: [PATCH 102/120] ifcviewer: link Placement.cpp into test_federation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit f7add7f4 split getAxis2Placement out of an anonymous helper in Geolocation.cpp into a shared Placement.{h,cpp}, but the test_federation target's source list wasn't updated. The test binary failed to link with `undefined reference to getAxis2Placement(express::Base const&)` from Geolocation::getWcs. Add Placement.cpp to the explicit-source list — it has no Qt dependency, only ifcparse. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/tests/CMakeLists.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index c07512d66d..b32154f3aa 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -62,9 +62,11 @@ add_executable(test_federation # Geolocation helpers (helmertMetersFromParameters, getWcs, getMapUnit) # for computeModelGeoref; compile both directly so the test doesn't # have to link the whole IfcViewer library (which would drag in - # Qt6::OpenGL, OpenCASCADE, etc.). + # Qt6::OpenGL, OpenCASCADE, etc.). Placement.cpp provides + # getAxis2Placement, called from Geolocation::getWcs. ${IFCVIEWER_SRC}/Unit.cpp ${IFCVIEWER_SRC}/Geolocation.cpp + ${IFCVIEWER_SRC}/Placement.cpp ) set_target_properties(test_federation PROPERTIES AUTOMOC ON) target_include_directories(test_federation PRIVATE ${IFCVIEWER_SRC}) From de5eb9641f7fce78138d8edc766b16b500ec0643 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 4 May 2026 12:22:14 +1000 Subject: [PATCH 103/120] ifcviewer-full: hide and remove model actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a model root in the Elements tree to get Hide/Show and Remove. Hide flips the federation's per-model visible flag (already round-tripped to .ifcfed), pushes ViewportWindow::hideModel/showModel, and italicises + greys the tree root as a visual cue. Remove drops the model from the viewport, the SceneLoader (streamer + caches), the MainWindow UI maps and tree, and the Federation — disabled while the model is the active load. Visibility is reapplied on each model's load completion (sidecar or stream), so a federation saved with hidden models opens with them hidden. clearScene() now also drops SceneLoader state so streamers no longer leak across federation transitions. API additions: - Federation::setModelVisible + modelVisibilityChanged signal - SceneLoader::removeModel + isLoadingModel Tests cover the setter (dirty + signal + idempotence + unknown id); extends the existing round-trip test to actually exercise the visibility load/save it always claimed to. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 87 +++++++++++++++++++++++++ src/ifcviewer-full/MainWindow.h | 10 +++ src/ifcviewer/Federation.cpp | 11 ++++ src/ifcviewer/Federation.h | 2 + src/ifcviewer/SceneLoader.cpp | 19 ++++++ src/ifcviewer/SceneLoader.h | 7 ++ src/ifcviewer/tests/test_federation.cpp | 47 +++++++++++++ 7 files changed, 183 insertions(+) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 84f13ea908..bcff5b6890 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -28,9 +28,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -69,6 +71,13 @@ MainWindow::MainWindow(QWidget* parent) applyModelTransformationToViewport(it->second); } }); + connect(federation_, &Federation::modelVisibilityChanged, + this, [this](const QString& fed_id, bool /*visible*/) { + auto it = fed_id_to_model_id_.find(fed_id); + if (it != fed_id_to_model_id_.end()) { + applyModelVisibilityToViewport(it->second); + } + }); connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { setWindowModified(dirty); @@ -148,7 +157,10 @@ void MainWindow::setupUi() { element_tree_->setColumnWidth(0, 200); element_tree_->setColumnWidth(1, 120); element_tree_->setSelectionMode(QAbstractItemView::SingleSelection); + element_tree_->setContextMenuPolicy(Qt::CustomContextMenu); connect(element_tree_, &QTreeWidget::itemSelectionChanged, this, &MainWindow::onTreeSelectionChanged); + connect(element_tree_, &QTreeWidget::customContextMenuRequested, + this, &MainWindow::onTreeContextMenu); tree_dock->setWidget(element_tree_); addDockWidget(Qt::LeftDockWidgetArea, tree_dock); @@ -432,6 +444,7 @@ void MainWindow::clearScene() { while (!tree_roots_.empty()) { uint32_t mid = tree_roots_.begin()->first; viewport_->removeModel(mid); + loader_->removeModel(mid); removeModelUi(mid); } fed_id_to_model_id_.clear(); @@ -645,6 +658,7 @@ void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { // ModelTransformation immediately rather than waiting for the // (possibly never-arriving) data-source load. applyCoordinateOperationToViewport(mid); + applyModelVisibilityToViewport(mid); maybeGuessFederatedFalseOrigin(mid); } @@ -755,6 +769,7 @@ void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { .arg(formatElapsed(elapsed_ms))); applyCoordinateOperationToViewport(mid); + applyModelVisibilityToViewport(mid); maybeGuessFederatedFalseOrigin(mid); writeSidecarForModel(mid); @@ -882,3 +897,75 @@ QString MainWindow::formatElapsed(qint64 ms) const { ? QString::number(ms / 1000.0, 'f', 2) + " s" : QString::number(ms) + " ms"; } + +uint32_t MainWindow::modelIdForRoot(QTreeWidgetItem* item) const { + if (!item) return 0; + for (const auto& kv : tree_roots_) { + if (kv.second == item) return kv.first; + } + return 0; +} + +void MainWindow::applyModelVisibilityToViewport(uint32_t mid) { + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it == model_id_to_fed_id_.end()) return; + const Federation::Model* m = federation_->findById(fed_it->second); + if (!m) return; + if (m->visible) viewport_->showModel(mid); + else viewport_->hideModel(mid); + + // Tree-side cue: italicise + grey out the model root when hidden. + auto root_it = tree_roots_.find(mid); + if (root_it != tree_roots_.end()) { + QFont f = root_it->second->font(0); + f.setItalic(!m->visible); + for (int col = 0; col < element_tree_->columnCount(); ++col) { + root_it->second->setFont(col, f); + root_it->second->setForeground( + col, + m->visible ? element_tree_->palette().color(QPalette::Text) + : element_tree_->palette().color(QPalette::Disabled, + QPalette::Text)); + } + } +} + +void MainWindow::onTreeContextMenu(const QPoint& pos) { + QTreeWidgetItem* item = element_tree_->itemAt(pos); + uint32_t mid = modelIdForRoot(item); + if (mid == 0) return; // not a model root — only roots get the menu + + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it == model_id_to_fed_id_.end()) return; + const Federation::Model* m = federation_->findById(fed_it->second); + if (!m) return; + + const bool currently_loading = loader_->isLoadingModel(mid); + + QMenu menu(this); + QAction* hide_show = menu.addAction(m->visible ? "Hide" : "Show"); + QAction* remove = menu.addAction("Remove"); + remove->setEnabled(!currently_loading); + + QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); + if (!chosen) return; + if (chosen == hide_show) { + federation_->setModelVisible(fed_it->second, !m->visible); + } else if (chosen == remove) { + removeModel(mid); + } +} + +void MainWindow::removeModel(uint32_t mid) { + if (loader_->isLoadingModel(mid)) return; + + QString fed_id; + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it != model_id_to_fed_id_.end()) fed_id = fed_it->second; + + viewport_->removeModel(mid); + loader_->removeModel(mid); + removeModelUi(mid); + if (!fed_id.isEmpty()) federation_->removeModel(fed_id); + updateWindowTitle(); +} diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 3910f4fd62..99e694de53 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -67,6 +67,7 @@ private slots: void onModelTransformations(); void onObjectPicked(uint32_t object_id); void onTreeSelectionChanged(); + void onTreeContextMenu(const QPoint& pos); void onLoadStarted(uint32_t mid, QString display_name); void onLoadProgressChanged(int percent); @@ -99,6 +100,15 @@ private: const std::string& type); void writeSidecarForModel(uint32_t mid); void removeModelUi(uint32_t mid); + void removeModel(uint32_t mid); + // Returns the model_id whose tree root is `item`, or 0 if `item` is not + // a model root (i.e. an element row, or null). + uint32_t modelIdForRoot(QTreeWidgetItem* item) const; + // Push the federation's `visible` flag for `mid` onto the viewport. + // No-op if `mid` is not in the federation map. Idempotent — safe to + // call before the model is finalised on the viewport (hideModel is a + // lookup-and-set on models_gpu_; missing entries are skipped). + void applyModelVisibilityToViewport(uint32_t mid); void applyPendingBenchmark(); // Push a model's CoordinateOperation matrix to the viewport (or diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 892266e3a7..d32e066617 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -254,6 +254,17 @@ void Federation::setModelTransformation(const QString& fed_id, } } +void Federation::setModelVisible(const QString& fed_id, bool visible) { + for (auto& m : models_) { + if (m.id != fed_id) continue; + if (m.visible == visible) return; + m.visible = visible; + setDirty(true); + emit modelVisibilityChanged(fed_id, visible); + return; + } +} + void Federation::markClean() { setDirty(false); } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index ce0e715df0..ddc5173e3f 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -210,6 +210,7 @@ public: void setConfig(const FederationConfig&); void setFederatedFalseOrigin(const FederatedFalseOrigin&); void setModelTransformation(const QString& fed_id, const ModelTransformation&); + void setModelVisible(const QString& fed_id, bool visible); // Accessors const std::vector& models() const { return models_; } @@ -232,6 +233,7 @@ signals: void configChanged(); void federatedFalseOriginChanged(); void modelTransformationChanged(const QString& fed_id); + void modelVisibilityChanged(const QString& fed_id, bool visible); private: void setDirty(bool d); diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index b37c4cb6f1..62ae291513 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -123,6 +123,25 @@ void SceneLoader::connectStreamer(GeometryStreamer* streamer) { this, &SceneLoader::onStreamerError, Qt::QueuedConnection); } +void SceneLoader::removeModel(uint32_t mid) { + // Refuse while the model is the active load: the streamer thread is still + // running and would race with the deleteLater(). UI gates Remove on + // isLoading(), but guard here too. + if (loading_model_id_ == mid) return; + + for (auto it = load_queue_.begin(); it != load_queue_.end();) { + if (*it == mid) it = load_queue_.erase(it); + else ++it; + } + + auto it = models_.find(mid); + if (it == models_.end()) return; + if (it->second.streamer) { + it->second.streamer->deleteLater(); + } + models_.erase(it); +} + void SceneLoader::cancelCurrentLoad() { if (loading_model_id_ == 0) return; auto it = models_.find(loading_model_id_); diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 71459f8d6a..992b7def3f 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -60,8 +60,15 @@ public: std::vector addFiles(const QStringList& paths); void cancelCurrentLoad(); bool isLoading() const { return loading_model_id_ != 0 || !load_queue_.empty(); } + bool isLoadingModel(uint32_t mid) const { return loading_model_id_ == mid; } size_t modelCount() const { return models_.size(); } + // Drop the loader's tracking for `mid` — its streamer, file path, georef + // cache, and queue slot if still pending. Caller is responsible for the + // viewport / UI cleanup; this only releases the loader's own state. + // Refuses while the model is the active load (use cancelCurrentLoad first). + void removeModel(uint32_t mid); + QString filePath(uint32_t mid) const; QString displayName(uint32_t mid) const; ifcopenshell::file* ifcFile(uint32_t mid) const; diff --git a/src/ifcviewer/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp index 223075c66f..0f90eb36e2 100644 --- a/src/ifcviewer/tests/test_federation.cpp +++ b/src/ifcviewer/tests/test_federation.cpp @@ -145,6 +145,48 @@ TEST_CASE("setHomeView / clearHomeView toggle dirty + has_home_view", "[federati REQUIRE(spy.count() == 0); } +TEST_CASE("setModelVisible toggles flag, dirty, and signal; idempotent", "[federation]") { + ensureQApp(); + QTemporaryDir tmp; + REQUIRE(tmp.isValid()); + + Federation fed; + QString id = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + REQUIRE_FALSE(id.isEmpty()); + REQUIRE(fed.findById(id)->visible); // visible by default + fed.markClean(); + + QSignalSpy dirty_spy(&fed, &Federation::dirtyChanged); + QSignalSpy vis_spy(&fed, &Federation::modelVisibilityChanged); + + fed.setModelVisible(id, false); + REQUIRE_FALSE(fed.findById(id)->visible); + REQUIRE(fed.isDirty()); + REQUIRE(dirty_spy.count() == 1); + REQUIRE(vis_spy.count() == 1); + REQUIRE(vis_spy.takeFirst().at(0).toString() == id); + + // Idempotent: same value, no signal, dirty unchanged. + fed.markClean(); + dirty_spy.clear(); + vis_spy.clear(); + fed.setModelVisible(id, false); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE(dirty_spy.count() == 0); + REQUIRE(vis_spy.count() == 0); + + // Unknown fed_id is a no-op (no crash, no signal). + fed.setModelVisible("not-a-real-id", false); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE(vis_spy.count() == 0); + + // Toggle back on. + fed.setModelVisible(id, true); + REQUIRE(fed.findById(id)->visible); + REQUIRE(fed.isDirty()); + REQUIRE(vis_spy.count() == 1); +} + TEST_CASE("save then load round-trips models, transform, visibility, home view", "[federation]") { ensureQApp(); QTemporaryDir tmp; @@ -160,6 +202,9 @@ TEST_CASE("save then load round-trips models, transform, visibility, home view", REQUIRE_FALSE(id1.isEmpty()); REQUIRE_FALSE(id2.isEmpty()); + // Hide the second model — exercises the visibility round-trip. + src.setModelVisible(id2, false); + Federation::HomeView hv; hv.target = QVector3D(10, 20, 30); hv.distance = 77.0f; @@ -183,9 +228,11 @@ TEST_CASE("save then load round-trips models, transform, visibility, home view", REQUIRE(dst.models()[0].id == id1); REQUIRE(dst.models()[0].display_name == "Wall"); REQUIRE(dst.models()[0].source_path == src1); + REQUIRE(dst.models()[0].visible); REQUIRE(dst.models()[1].id == id2); REQUIRE(dst.models()[1].display_name == "slab.ifc"); REQUIRE(dst.models()[1].source_path == src2); + REQUIRE_FALSE(dst.models()[1].visible); REQUIRE(dst.hasHomeView()); REQUIRE(dst.homeView().target == QVector3D(10, 20, 30)); From 095e4a1677ced4eb8b0defa61b905b4bbe6f9a5b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 4 May 2026 17:39:28 +1000 Subject: [PATCH 104/120] ifcviewer: nested groups in federation, with cascading visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Federation gains a nested Group tree (id, display_name, visible, children); models reference a single group via Model::group_id. Visibility cascades: a model is effectively visible only when its own flag is on and every ancestor group is visible. Persistence nests groups directly in the JSON — no parent_id field. ifcviewer-full surfaces this in the element tree with right-click menus to create / rename / move / remove groups, move models between groups, and toggle group visibility. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 375 ++++++++++++++++++++++-- src/ifcviewer-full/MainWindow.h | 31 +- src/ifcviewer/Federation.cpp | 251 ++++++++++++++++ src/ifcviewer/Federation.h | 76 +++++ src/ifcviewer/tests/test_federation.cpp | 231 +++++++++++++++ 5 files changed, 936 insertions(+), 28 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index bcff5b6890..c1d1220b15 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -28,11 +28,14 @@ #include #include + +#include #include #include #include #include #include +#include #include #include #include @@ -79,6 +82,59 @@ MainWindow::MainWindow(QWidget* parent) } }); + connect(federation_, &Federation::modelGroupChanged, + this, [this](const QString& fed_id, const QString& /*group_id*/) { + auto it = fed_id_to_model_id_.find(fed_id); + if (it == fed_id_to_model_id_.end()) return; + reparentModelTreeRoot(it->second); + // Effective visibility may have flipped because the new group's + // chain visibility differs from the old one. + applyModelVisibilityToViewport(it->second); + }); + + connect(federation_, &Federation::groupAdded, + this, [this](const QString& group_id) { + ensureGroupTreeItem(group_id); + reparentGroupTreeItem(group_id); + refreshGroupRowAppearance(group_id); + }); + + connect(federation_, &Federation::groupChanged, + this, [this](const QString& group_id) { + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return; + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + it->second->setText(0, g->display_name); + reparentGroupTreeItem(group_id); + // Reparenting a group changes the chain visibility for the moved + // group + its descendants — refresh both rows and viewport. + for (const QString& gid : descendantGroupIds(group_id)) { + refreshGroupRowAppearance(gid); + } + applyVisibilityCascadeFromGroup(group_id); + }); + + connect(federation_, &Federation::groupVisibilityChanged, + this, [this](const QString& group_id, bool /*visible*/) { + for (const QString& gid : descendantGroupIds(group_id)) { + refreshGroupRowAppearance(gid); + } + applyVisibilityCascadeFromGroup(group_id); + }); + + connect(federation_, &Federation::groupRemoved, + this, [this](const QString& group_id) { + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + // By now, the federation has fired groupChanged / modelGroupChanged + // for every direct child, and our slots have moved them out from + // under this item, so deleting it just removes the (now empty) + // group row. + delete it->second; + group_tree_items_.erase(it); + }); + connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { setWindowModified(dirty); }); @@ -314,11 +370,12 @@ void MainWindow::loadModelsFromPaths(const QStringList& paths, model_id_to_fed_id_[mid] = fed_id; QString display = QFileInfo(paths[i]).fileName(); - auto* root = new QTreeWidgetItem(element_tree_); + auto* root = new QTreeWidgetItem(); root->setText(0, display); root->setText(1, "IFC Model"); root->setData(0, Qt::UserRole, static_cast(0)); tree_roots_[mid] = root; + reparentModelTreeRoot(mid); } } @@ -335,6 +392,15 @@ bool MainWindow::openFederation(const QString& path) { clearScene(); + // Materialise group tree items. allGroups() walks parents-before- + // children, so reparenting in the same pass always finds the parent's + // tree item ready. + for (const Federation::Group* g : federation_->allGroups()) { + ensureGroupTreeItem(g->id); + reparentGroupTreeItem(g->id); + refreshGroupRowAppearance(g->id); + } + QStringList paths; QStringList fed_ids; QStringList missing; @@ -449,6 +515,8 @@ void MainWindow::clearScene() { } fed_id_to_model_id_.clear(); model_id_to_fed_id_.clear(); + for (auto& kv : group_tree_items_) delete kv.second; + group_tree_items_.clear(); } bool MainWindow::confirmDiscardIfDirty() { @@ -909,50 +977,160 @@ uint32_t MainWindow::modelIdForRoot(QTreeWidgetItem* item) const { void MainWindow::applyModelVisibilityToViewport(uint32_t mid) { auto fed_it = model_id_to_fed_id_.find(mid); if (fed_it == model_id_to_fed_id_.end()) return; - const Federation::Model* m = federation_->findById(fed_it->second); - if (!m) return; - if (m->visible) viewport_->showModel(mid); - else viewport_->hideModel(mid); + const QString& fed_id = fed_it->second; + const bool effective = federation_->isModelEffectivelyVisible(fed_id); + if (effective) viewport_->showModel(mid); + else viewport_->hideModel(mid); - // Tree-side cue: italicise + grey out the model root when hidden. + // Tree-side cue: italicise + grey out the model root when not + // effectively visible (own toggle off, or any ancestor group hidden). auto root_it = tree_roots_.find(mid); if (root_it != tree_roots_.end()) { QFont f = root_it->second->font(0); - f.setItalic(!m->visible); + f.setItalic(!effective); for (int col = 0; col < element_tree_->columnCount(); ++col) { root_it->second->setFont(col, f); root_it->second->setForeground( col, - m->visible ? element_tree_->palette().color(QPalette::Text) - : element_tree_->palette().color(QPalette::Disabled, - QPalette::Text)); + effective ? element_tree_->palette().color(QPalette::Text) + : element_tree_->palette().color(QPalette::Disabled, + QPalette::Text)); } } } void MainWindow::onTreeContextMenu(const QPoint& pos) { QTreeWidgetItem* item = element_tree_->itemAt(pos); - uint32_t mid = modelIdForRoot(item); - if (mid == 0) return; // not a model root — only roots get the menu - - auto fed_it = model_id_to_fed_id_.find(mid); - if (fed_it == model_id_to_fed_id_.end()) return; - const Federation::Model* m = federation_->findById(fed_it->second); - if (!m) return; - - const bool currently_loading = loader_->isLoadingModel(mid); + const uint32_t mid = modelIdForRoot(item); + const QString group_id = groupIdForItem(item); + // Element rows (children of a model root) are excluded — only model + // roots, group rows, and the empty area get a menu. + const bool is_element_row = + item != nullptr && mid == 0 && group_id.isEmpty(); + if (is_element_row) return; QMenu menu(this); - QAction* hide_show = menu.addAction(m->visible ? "Hide" : "Show"); - QAction* remove = menu.addAction("Remove"); - remove->setEnabled(!currently_loading); + if (mid != 0) { + // === Model row === + auto fed_it = model_id_to_fed_id_.find(mid); + if (fed_it == model_id_to_fed_id_.end()) return; + const Federation::Model* m = federation_->findById(fed_it->second); + if (!m) return; + + const bool currently_loading = loader_->isLoadingModel(mid); + + QAction* hide_show = menu.addAction(m->visible ? "Hide" : "Show"); + + QMenu* move_menu = menu.addMenu("Move to Group"); + QAction* move_to_root = move_menu->addAction("(Root)"); + move_to_root->setEnabled(!m->group_id.isEmpty()); + move_menu->addSeparator(); + std::vector> move_targets; + for (const Federation::Group* g : federation_->allGroups()) { + QAction* a = move_menu->addAction(g->display_name); + a->setEnabled(g->id != m->group_id); + move_targets.emplace_back(a, g->id); + } + if (federation_->allGroups().empty()) { + QAction* none = move_menu->addAction("(no groups)"); + none->setEnabled(false); + } + + QAction* remove = menu.addAction("Remove"); + remove->setEnabled(!currently_loading); + + QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); + if (!chosen) return; + if (chosen == hide_show) { + federation_->setModelVisible(fed_it->second, !m->visible); + } else if (chosen == move_to_root) { + federation_->setModelGroup(fed_it->second, QString()); + } else if (chosen == remove) { + removeModel(mid); + } else { + for (const auto& [a, gid] : move_targets) { + if (chosen == a) { + federation_->setModelGroup(fed_it->second, gid); + break; + } + } + } + return; + } + + if (!group_id.isEmpty()) { + // === Group row === + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return; + + QAction* hide_show = menu.addAction(g->visible ? "Hide" : "Show"); + QAction* rename = menu.addAction("Rename..."); + QAction* new_sub = menu.addAction("New Subgroup"); + + QMenu* move_menu = menu.addMenu("Move to Parent"); + QAction* move_to_root = move_menu->addAction("(Root)"); + move_to_root->setEnabled(g->parent != nullptr); + move_menu->addSeparator(); + std::vector> move_targets; + for (const Federation::Group* og : federation_->allGroups()) { + QAction* a = move_menu->addAction(og->display_name); + // Disable self, current parent, and any descendant of g (the + // latter would create a cycle). Walk og's ancestor chain to + // detect descendants. + bool would_cycle = false; + for (const Federation::Group* cur = og; cur; cur = cur->parent) { + if (cur == g) { would_cycle = true; break; } + } + const bool is_current_parent = + g->parent != nullptr && og == g->parent; + a->setEnabled(!would_cycle && !is_current_parent); + move_targets.emplace_back(a, og->id); + } + + QAction* remove = menu.addAction("Remove Group"); + + QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); + if (!chosen) return; + if (chosen == hide_show) { + federation_->setGroupVisible(group_id, !g->visible); + } else if (chosen == rename) { + bool ok = false; + QString name = QInputDialog::getText( + this, "Rename Group", "Group name:", QLineEdit::Normal, + g->display_name, &ok); + if (ok && !name.isEmpty()) federation_->setGroupName(group_id, name); + } else if (chosen == new_sub) { + bool ok = false; + QString name = QInputDialog::getText( + this, "New Subgroup", "Group name:", QLineEdit::Normal, + "Group", &ok); + if (ok && !name.isEmpty()) federation_->addGroup(name, group_id); + } else if (chosen == move_to_root) { + federation_->setGroupParent(group_id, QString()); + } else if (chosen == remove) { + federation_->removeGroup(group_id); + } else { + for (const auto& [a, gid] : move_targets) { + if (chosen == a) { + federation_->setGroupParent(group_id, gid); + break; + } + } + } + return; + } + + // === Empty area === + QAction* new_group = menu.addAction("New Group"); QAction* chosen = menu.exec(element_tree_->viewport()->mapToGlobal(pos)); if (!chosen) return; - if (chosen == hide_show) { - federation_->setModelVisible(fed_it->second, !m->visible); - } else if (chosen == remove) { - removeModel(mid); + if (chosen == new_group) { + bool ok = false; + QString name = QInputDialog::getText( + this, "New Group", "Group name:", QLineEdit::Normal, + "Group", &ok); + if (ok && !name.isEmpty()) federation_->addGroup(name, QString()); } } @@ -969,3 +1147,148 @@ void MainWindow::removeModel(uint32_t mid) { if (!fed_id.isEmpty()) federation_->removeModel(fed_id); updateWindowTitle(); } + +QString MainWindow::groupIdForItem(QTreeWidgetItem* item) const { + if (!item) return {}; + for (const auto& kv : group_tree_items_) { + if (kv.second == item) return kv.first; + } + return {}; +} + +QTreeWidgetItem* MainWindow::ensureGroupTreeItem(const QString& group_id) { + if (group_id.isEmpty()) return nullptr; + auto it = group_tree_items_.find(group_id); + if (it != group_tree_items_.end()) return it->second; + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return nullptr; + + auto* item = new QTreeWidgetItem(); + item->setText(0, g->display_name); + item->setText(1, "Group"); + group_tree_items_[group_id] = item; + return item; +} + +void MainWindow::reparentGroupTreeItem(const QString& group_id) { + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + QTreeWidgetItem* item = it->second; + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return; + + QTreeWidgetItem* desired_parent = nullptr; + if (g->parent != nullptr) { + auto pit = group_tree_items_.find(g->parent->id); + if (pit != group_tree_items_.end()) desired_parent = pit->second; + } + + QTreeWidgetItem* current_parent = item->parent(); + if (current_parent == desired_parent && + (current_parent != nullptr || + element_tree_->indexOfTopLevelItem(item) >= 0)) { + return; + } + + // Detach from current location. + if (current_parent) { + current_parent->removeChild(item); + } else { + int idx = element_tree_->indexOfTopLevelItem(item); + if (idx >= 0) element_tree_->takeTopLevelItem(idx); + } + // Attach to desired location. + if (desired_parent) desired_parent->addChild(item); + else element_tree_->addTopLevelItem(item); +} + +void MainWindow::reparentModelTreeRoot(uint32_t mid) { + auto root_it = tree_roots_.find(mid); + if (root_it == tree_roots_.end()) return; + QTreeWidgetItem* item = root_it->second; + + auto fed_it = model_id_to_fed_id_.find(mid); + QString group_id; + if (fed_it != model_id_to_fed_id_.end()) { + if (const Federation::Model* m = federation_->findById(fed_it->second)) { + group_id = m->group_id; + } + } + + QTreeWidgetItem* desired_parent = nullptr; + if (!group_id.isEmpty()) { + auto pit = group_tree_items_.find(group_id); + if (pit != group_tree_items_.end()) desired_parent = pit->second; + } + + QTreeWidgetItem* current_parent = item->parent(); + if (current_parent == desired_parent && + (current_parent != nullptr || + element_tree_->indexOfTopLevelItem(item) >= 0)) { + return; + } + + if (current_parent) { + current_parent->removeChild(item); + } else { + int idx = element_tree_->indexOfTopLevelItem(item); + if (idx >= 0) element_tree_->takeTopLevelItem(idx); + } + if (desired_parent) desired_parent->addChild(item); + else element_tree_->addTopLevelItem(item); +} + +void MainWindow::refreshGroupRowAppearance(const QString& group_id) { + auto it = group_tree_items_.find(group_id); + if (it == group_tree_items_.end()) return; + QTreeWidgetItem* item = it->second; + const bool effective = federation_->isGroupChainVisible(group_id); + + QFont f = item->font(0); + f.setItalic(!effective); + f.setBold(true); + for (int col = 0; col < element_tree_->columnCount(); ++col) { + item->setFont(col, f); + item->setForeground( + col, + effective ? element_tree_->palette().color(QPalette::Text) + : element_tree_->palette().color(QPalette::Disabled, + QPalette::Text)); + } +} + +std::vector MainWindow::descendantGroupIds(const QString& group_id) const { + std::vector out; + auto walk = [&](auto&& self, + const std::vector>& src) -> void { + for (const auto& g : src) { + out.push_back(g->id); + self(self, g->children); + } + }; + if (group_id.isEmpty()) { + walk(walk, federation_->rootGroups()); + } else { + const Federation::Group* g = federation_->findGroupById(group_id); + if (!g) return out; + out.push_back(g->id); + walk(walk, g->children); + } + return out; +} + +void MainWindow::applyVisibilityCascadeFromGroup(const QString& group_id) { + const std::vector gids = descendantGroupIds(group_id); + // Models directly assigned to one of these groups need their viewport + // visibility re-pushed because chain visibility may have flipped. + for (const auto& m : federation_->models()) { + const bool affected = + (group_id.isEmpty()) || + std::find(gids.begin(), gids.end(), m.group_id) != gids.end(); + if (!affected) continue; + auto it = fed_id_to_model_id_.find(m.id); + if (it != fed_id_to_model_id_.end()) { + applyModelVisibilityToViewport(it->second); + } + } +} diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index 99e694de53..ff395fe51f 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -30,6 +30,7 @@ #include #include +#include #include "ViewportWindow.h" #include "SceneLoader.h" @@ -102,8 +103,31 @@ private: void removeModelUi(uint32_t mid); void removeModel(uint32_t mid); // Returns the model_id whose tree root is `item`, or 0 if `item` is not - // a model root (i.e. an element row, or null). + // a model root (i.e. an element row, group, or null). uint32_t modelIdForRoot(QTreeWidgetItem* item) const; + // Returns the group_id whose tree item is `item`, or empty string when + // `item` is null or is not a group item. + QString groupIdForItem(QTreeWidgetItem* item) const; + // Place / reparent a model root under its group (or at top level when + // group_id is empty / unknown). Idempotent. No-op if there's no tree + // root yet for `mid`. + void reparentModelTreeRoot(uint32_t mid); + // Place / reparent a group item under its parent group (or at top + // level). Idempotent. No-op if there's no tree item for `group_id`. + void reparentGroupTreeItem(const QString& group_id); + // Lazily create the QTreeWidgetItem for `group_id` if not already in + // group_tree_items_. Returns the item. Sets text + flags but does + // not place it under a parent — call reparentGroupTreeItem afterwards. + QTreeWidgetItem* ensureGroupTreeItem(const QString& group_id); + // Recompute italic/grey on a group row from current effective visibility. + void refreshGroupRowAppearance(const QString& group_id); + // Walk descendants of `group_id` and re-push effective visibility to + // the viewport for every model under it. When `group_id` is empty, + // re-pushes every model in the federation. + void applyVisibilityCascadeFromGroup(const QString& group_id); + // Walk every group_id whose ancestor chain currently includes + // `group_id` (inclusive). + std::vector descendantGroupIds(const QString& group_id) const; // Push the federation's `visible` flag for `mid` onto the viewport. // No-op if `mid` is not in the federation map. Idempotent — safe to // call before the model is finalised on the viewport (hideModel is a @@ -151,8 +175,11 @@ private: QLabel* status_label_ = nullptr; QLabel* stats_label_ = nullptr; - // Per-model tree roots, keyed by model_id. + // Per-model tree roots, keyed by model_id. May live at the top level + // of element_tree_ or as a child of a group tree item. std::map tree_roots_; + // Per-group tree items, keyed by Federation group id. + std::unordered_map group_tree_items_; // Bidirectional federation_id <-> model_id map. Federation owns the // persistent ids; SceneLoader owns the runtime model_ids. diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index d32e066617..c6183adcf3 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -31,7 +31,9 @@ #include #include +#include #include +#include namespace { constexpr const char* kSchema = "ifcfed/1"; @@ -220,6 +222,7 @@ void Federation::clear() { created_ = QDateTime(); modified_ = QDateTime(); models_.clear(); + root_groups_.clear(); config_ = FederationConfig{}; federated_false_origin_ = FederatedFalseOrigin{}; has_home_view_ = false; @@ -265,6 +268,193 @@ void Federation::setModelVisible(const QString& fed_id, bool visible) { } } +void Federation::setModelGroup(const QString& fed_id, const QString& group_id) { + if (!group_id.isEmpty() && findGroupById(group_id) == nullptr) return; + for (auto& m : models_) { + if (m.id != fed_id) continue; + if (m.group_id == group_id) return; + m.group_id = group_id; + setDirty(true); + emit modelGroupChanged(fed_id, group_id); + return; + } +} + +QString Federation::addGroup(const QString& display_name, + const QString& parent_id) { + Group* parent = nullptr; + if (!parent_id.isEmpty()) { + parent = findGroupByIdMutable(parent_id); + if (!parent) return {}; + } + + auto g = std::make_unique(); + g->id = generateId(); + g->display_name = display_name.isEmpty() ? QString("Group") : display_name; + g->parent = parent; + const QString new_id = g->id; + + if (parent) parent->children.push_back(std::move(g)); + else root_groups_.push_back(std::move(g)); + + setDirty(true); + emit groupAdded(new_id); + return new_id; +} + +void Federation::removeGroup(const QString& group_id) { + Group* group = findGroupByIdMutable(group_id); + if (!group) return; + + Group* new_parent = group->parent; + const QString new_parent_id = new_parent ? new_parent->id : QString(); + auto& target_children = new_parent ? new_parent->children : root_groups_; + + // Move out of the to-be-removed group. Splice into target_children + // *before* the removed group's slot when possible to keep stable + // visual order. We don't bother with the precise position — append + // is fine and simpler. + std::vector moved_child_ids; + moved_child_ids.reserve(group->children.size()); + for (auto& child : group->children) { + moved_child_ids.push_back(child->id); + child->parent = new_parent; + target_children.push_back(std::move(child)); + } + group->children.clear(); + + // Reparent direct child models up one level. + std::vector moved_model_ids; + for (auto& m : models_) { + if (m.group_id == group_id) { + m.group_id = new_parent_id; + moved_model_ids.push_back(m.id); + } + } + + // Detach + drop the now-empty group. + auto owned = detachGroup(group); + owned.reset(); + + setDirty(true); + for (const auto& cid : moved_child_ids) emit groupChanged(cid); + for (const auto& mid : moved_model_ids) emit modelGroupChanged(mid, new_parent_id); + emit groupRemoved(group_id); +} + +void Federation::setGroupName(const QString& group_id, + const QString& display_name) { + Group* g = findGroupByIdMutable(group_id); + if (!g) return; + if (g->display_name == display_name) return; + g->display_name = display_name; + setDirty(true); + emit groupChanged(group_id); +} + +void Federation::setGroupParent(const QString& group_id, + const QString& parent_id) { + if (group_id.isEmpty()) return; + if (parent_id == group_id) return; + + Group* group = findGroupByIdMutable(group_id); + if (!group) return; + + Group* new_parent = nullptr; + if (!parent_id.isEmpty()) { + new_parent = findGroupByIdMutable(parent_id); + if (!new_parent) return; + if (isDescendantOrSelf(group, new_parent)) return; + } + + if (group->parent == new_parent) return; + + auto owned = detachGroup(group); + if (!owned) return; + owned->parent = new_parent; + if (new_parent) new_parent->children.push_back(std::move(owned)); + else root_groups_.push_back(std::move(owned)); + + setDirty(true); + emit groupChanged(group_id); +} + +void Federation::setGroupVisible(const QString& group_id, bool visible) { + Group* g = findGroupByIdMutable(group_id); + if (!g) return; + if (g->visible == visible) return; + g->visible = visible; + setDirty(true); + emit groupVisibilityChanged(group_id, visible); +} + +const Federation::Group* Federation::findGroupById(const QString& group_id) const { + return const_cast(this)->findGroupByIdMutable(group_id); +} + +Federation::Group* Federation::findGroupByIdMutable(const QString& group_id) { + if (group_id.isEmpty()) return nullptr; + std::vector stack; + for (auto& g : root_groups_) stack.push_back(g.get()); + while (!stack.empty()) { + Group* g = stack.back(); + stack.pop_back(); + if (g->id == group_id) return g; + for (auto& c : g->children) stack.push_back(c.get()); + } + return nullptr; +} + +std::vector Federation::allGroups() const { + std::vector out; + for (const auto& g : root_groups_) appendDfs(g.get(), out); + return out; +} + +void Federation::appendDfs(const Group* g, std::vector& out) { + if (!g) return; + out.push_back(g); + for (const auto& c : g->children) appendDfs(c.get(), out); +} + +std::unique_ptr Federation::detachGroup(Group* group) { + if (!group) return nullptr; + auto& siblings = group->parent ? group->parent->children : root_groups_; + auto it = std::find_if(siblings.begin(), siblings.end(), + [group](const std::unique_ptr& up) { return up.get() == group; }); + if (it == siblings.end()) return nullptr; + std::unique_ptr owned = std::move(*it); + siblings.erase(it); + return owned; +} + +bool Federation::isDescendantOrSelf(const Group* group, + const Group* candidate_descendant) { + if (!group || !candidate_descendant) return false; + if (group == candidate_descendant) return true; + for (const auto& c : group->children) { + if (isDescendantOrSelf(c.get(), candidate_descendant)) return true; + } + return false; +} + +bool Federation::isGroupChainVisible(const QString& group_id) const { + if (group_id.isEmpty()) return true; + const Group* g = findGroupById(group_id); + while (g != nullptr) { + if (!g->visible) return false; + g = g->parent; + } + return true; +} + +bool Federation::isModelEffectivelyVisible(const QString& fed_id) const { + const Model* m = findById(fed_id); + if (!m) return false; + if (!m->visible) return false; + return isGroupChainVisible(m->group_id); +} + void Federation::markClean() { setDirty(false); } @@ -372,6 +562,41 @@ bool Federation::load(const QString& path, federated_false_origin_.rz_deg = oo.value("rz_deg").toDouble(0.0); } + // Groups load before models so model.group_id can be validated. + { + // Recursive descend over the nested "groups" array. Each entry is + // {id, display_name, visible?, groups?: [...]}. Children inherit + // their parent pointer at construction time. + std::function>&, + Group*)> load_groups; + load_groups = [&](const QJsonArray& arr, + std::vector>& sink, + Group* parent) { + for (int i = 0; i < arr.size(); ++i) { + if (!arr[i].isObject()) { + if (warnings) + *warnings << QString("groups: entry %1 is not an object; skipping.").arg(i); + continue; + } + QJsonObject go = arr[i].toObject(); + auto g = std::make_unique(); + g->id = go.value("id").toString(); + if (g->id.isEmpty()) g->id = generateId(); + g->display_name = go.value("display_name").toString(); + if (QJsonValue vv = go.value("visible"); vv.isBool()) + g->visible = vv.toBool(); + g->parent = parent; + + if (QJsonValue cv = go.value("groups"); cv.isArray()) { + load_groups(cv.toArray(), g->children, g.get()); + } + sink.push_back(std::move(g)); + } + }; + load_groups(root.value("groups").toArray(), root_groups_, nullptr); + } + QJsonArray arr = root.value("models").toArray(); for (int i = 0; i < arr.size(); ++i) { if (!arr[i].isObject()) { @@ -426,6 +651,14 @@ bool Federation::load(const QString& path, QJsonValue vv = mo.value("visible"); if (vv.isBool()) m.visible = vv.toBool(); + m.group_id = mo.value("group_id").toString(); + if (!m.group_id.isEmpty() && findGroupById(m.group_id) == nullptr) { + if (warnings) + *warnings << QString("models[%1]: unknown group_id '%2'; moved to root.") + .arg(i).arg(m.group_id); + m.group_id.clear(); + } + models_.push_back(std::move(m)); } @@ -481,6 +714,23 @@ bool Federation::save(const QString& path, QString* err) { root["federated_false_origin"] = oo; } + if (!root_groups_.empty()) { + std::function>&)> dump; + dump = [&](const std::vector>& src) { + QJsonArray out; + for (const auto& g : src) { + QJsonObject go; + go["id"] = g->id; + go["display_name"] = g->display_name; + if (!g->visible) go["visible"] = false; + if (!g->children.empty()) go["groups"] = dump(g->children); + out.append(go); + } + return out; + }; + root["groups"] = dump(root_groups_); + } + QJsonArray arr; for (const auto& m : models_) { QJsonObject mo; @@ -520,6 +770,7 @@ bool Federation::save(const QString& path, QString* err) { } if (!m.visible) mo["visible"] = false; + if (!m.group_id.isEmpty()) mo["group_id"] = m.group_id; arr.append(mo); } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index ddc5173e3f..2377e337bf 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -191,6 +192,29 @@ public: QString source_path; // resolved absolute when kind == "local" ModelTransformation model_transformation; bool visible = true; + QString group_id; // empty = root level + }; + + // Group — a named container for sub-groups and models. Models are + // assigned via Model::group_id (one-to-one); sub-groups live in + // `children` (owning). Visibility is per-group and cascades: a + // model is effectively visible only when its `visible` is true and + // every ancestor group's `visible` is true. + // + // `parent` is a non-owning back pointer, kept in sync by Federation + // mutations. Group ownership tree is rooted at Federation::root_groups_. + struct Group { + QString id; // stable, persisted + QString display_name; + bool visible = true; + std::vector> children; + Group* parent = nullptr; // not owned; nullptr at root + + Group() = default; + Group(const Group&) = delete; + Group& operator=(const Group&) = delete; + Group(Group&&) = default; + Group& operator=(Group&&) = default; }; explicit Federation(QObject* parent = nullptr); @@ -211,10 +235,39 @@ public: void setFederatedFalseOrigin(const FederatedFalseOrigin&); void setModelTransformation(const QString& fed_id, const ModelTransformation&); void setModelVisible(const QString& fed_id, bool visible); + // Reassign a model to a group (or to root, when group_id is empty). + // No-op when fed_id is unknown or group_id is unknown-and-non-empty. + void setModelGroup(const QString& fed_id, const QString& group_id); + + // Group mutations. All return / accept stable group ids. + QString addGroup(const QString& display_name = QString(), + const QString& parent_id = QString()); + // Removes the group; child sub-groups + child models are reparented + // to the removed group's parent (i.e. up one level). No-op when + // group_id is unknown. + void removeGroup(const QString& group_id); + void setGroupName(const QString& group_id, const QString& display_name); + // Reparents a group. No-op if the move would create a cycle (new + // parent is the group itself or one of its descendants) or if either + // id is unknown. + void setGroupParent(const QString& group_id, const QString& parent_id); + void setGroupVisible(const QString& group_id, bool visible); // Accessors const std::vector& models() const { return models_; } const Model* findById(const QString& fed_id) const; + // Top-level groups in insertion order; descend via Group::children. + const std::vector>& rootGroups() const { return root_groups_; } + const Group* findGroupById(const QString& group_id) const; + // Depth-first flatten: every group in the tree, parents before + // children. Cheap, intended for UI iteration. + std::vector allGroups() const; + // True iff every ancestor of `group_id` (inclusive of `group_id` + // itself) has visible == true. Returns true for empty group_id (root). + bool isGroupChainVisible(const QString& group_id) const; + // True iff the model exists, its own `visible` is true, and every + // ancestor group is visible. + bool isModelEffectivelyVisible(const QString& fed_id) const; bool isDirty() const { return dirty_; } void markClean(); QString filePath() const { return file_path_; } @@ -234,17 +287,40 @@ signals: void federatedFalseOriginChanged(); void modelTransformationChanged(const QString& fed_id); void modelVisibilityChanged(const QString& fed_id, bool visible); + void modelGroupChanged(const QString& fed_id, const QString& group_id); + + void groupAdded(const QString& group_id); + void groupRemoved(const QString& group_id); + // Emitted on rename or reparent. + void groupChanged(const QString& group_id); + // Visibility flip on this group only. Effective visibility of + // descendant models also changes; consumers that care should walk + // descendants themselves. + void groupVisibilityChanged(const QString& group_id, bool visible); private: void setDirty(bool d); static QString generateId(); static bool isFederationPath(const QString& path); + Group* findGroupByIdMutable(const QString& group_id); + // Detach a group from its current parent's children vector, returning + // ownership. group->parent is left set to its former parent — the + // caller must update it before reattachment. Returns nullptr if the + // group can't be found in the expected parent. + std::unique_ptr detachGroup(Group* group); + // True iff `candidate_descendant` is `group` itself or any descendant. + static bool isDescendantOrSelf(const Group* group, + const Group* candidate_descendant); + // DFS append for allGroups() and similar walks. + static void appendDfs(const Group* g, std::vector& out); + QString file_path_; QString name_; QDateTime created_; QDateTime modified_; std::vector models_; + std::vector> root_groups_; FederationConfig config_; FederatedFalseOrigin federated_false_origin_; bool has_home_view_ = false; diff --git a/src/ifcviewer/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp index 0f90eb36e2..a93ddec56b 100644 --- a/src/ifcviewer/tests/test_federation.cpp +++ b/src/ifcviewer/tests/test_federation.cpp @@ -457,6 +457,237 @@ TEST_CASE("composeFederatedFalseOrigin scales by federation unit", REQUIRE(std::abs(M(0, 3) - (-0.3048)) < 1e-9); } +TEST_CASE("addGroup creates a top-level group; addGroup with parent nests it", + "[federation][groups]") { + ensureQApp(); + Federation fed; + QSignalSpy added_spy(&fed, &Federation::groupAdded); + + QString a = fed.addGroup("Site A"); + REQUIRE_FALSE(a.isEmpty()); + REQUIRE(fed.rootGroups().size() == 1); + REQUIRE(fed.rootGroups()[0]->id == a); + REQUIRE(fed.findGroupById(a)->parent == nullptr); + REQUIRE(fed.isDirty()); + REQUIRE(added_spy.count() == 1); + + QString sub = fed.addGroup("Building 1", a); + REQUIRE_FALSE(sub.isEmpty()); + REQUIRE(fed.rootGroups().size() == 1); // still one root + REQUIRE(fed.rootGroups()[0]->children.size() == 1); + REQUIRE(fed.rootGroups()[0]->children[0]->id == sub); + REQUIRE(fed.findGroupById(sub)->parent == fed.findGroupById(a)); + + // Unknown parent_id is rejected. + QString bad = fed.addGroup("Orphan", "no-such-id"); + REQUIRE(bad.isEmpty()); + + // allGroups walks parents-before-children. + auto all = fed.allGroups(); + REQUIRE(all.size() == 2); + REQUIRE(all[0]->id == a); + REQUIRE(all[1]->id == sub); +} + +TEST_CASE("setModelGroup assigns and reassigns; rejects unknown group", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + Federation fed; + QString mid = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString gid = fed.addGroup("G"); + fed.markClean(); + + QSignalSpy spy(&fed, &Federation::modelGroupChanged); + fed.setModelGroup(mid, gid); + REQUIRE(fed.findById(mid)->group_id == gid); + REQUIRE(fed.isDirty()); + REQUIRE(spy.count() == 1); + + // Idempotent. + fed.markClean(); + spy.clear(); + fed.setModelGroup(mid, gid); + REQUIRE_FALSE(fed.isDirty()); + REQUIRE(spy.count() == 0); + + // Unknown group is rejected. + fed.setModelGroup(mid, "no-such-group"); + REQUIRE(fed.findById(mid)->group_id == gid); + REQUIRE_FALSE(fed.isDirty()); + + // Reassign back to root. + fed.setModelGroup(mid, QString()); + REQUIRE(fed.findById(mid)->group_id.isEmpty()); + REQUIRE(spy.count() == 1); +} + +TEST_CASE("setGroupVisible affects effective visibility cascade", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + Federation fed; + QString mid = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString outer = fed.addGroup("Outer"); + QString inner = fed.addGroup("Inner", outer); + fed.setModelGroup(mid, inner); + + REQUIRE(fed.isModelEffectivelyVisible(mid)); + REQUIRE(fed.isGroupChainVisible(inner)); + + // Hide the outer group: inner chain visibility flips, model effective + // visibility flips, but the model's own visible flag is untouched. + fed.setGroupVisible(outer, false); + REQUIRE_FALSE(fed.isGroupChainVisible(outer)); + REQUIRE_FALSE(fed.isGroupChainVisible(inner)); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + REQUIRE(fed.findById(mid)->visible); + + // Hiding a model directly while its group is also hidden — still + // effectively hidden. + fed.setModelVisible(mid, false); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + + // Re-show the outer group; model is still hidden by its own flag. + fed.setGroupVisible(outer, true); + REQUIRE(fed.isGroupChainVisible(inner)); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + + fed.setModelVisible(mid, true); + REQUIRE(fed.isModelEffectivelyVisible(mid)); +} + +TEST_CASE("setGroupParent rejects cycles and self-parenting", + "[federation][groups]") { + ensureQApp(); + Federation fed; + QString a = fed.addGroup("A"); + QString b = fed.addGroup("B", a); + QString c = fed.addGroup("C", b); + + // Self-parent: rejected. + fed.setGroupParent(a, a); + REQUIRE(fed.findGroupById(a)->parent == nullptr); + + // Parenting an ancestor under its descendant: rejected. + fed.setGroupParent(a, c); + REQUIRE(fed.findGroupById(a)->parent == nullptr); + REQUIRE(fed.findGroupById(c)->parent->id == b); + + // Valid reparent: move b up to root. + fed.setGroupParent(b, QString()); + REQUIRE(fed.findGroupById(b)->parent == nullptr); + REQUIRE(fed.findGroupById(c)->parent->id == b); // c stays under b + REQUIRE(fed.rootGroups().size() == 2); // a + b at root +} + +TEST_CASE("removeGroup reparents direct children + models up one level", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + Federation fed; + QString outer = fed.addGroup("Outer"); + QString mid_outer = fed.addGroup("MidOuter", outer); + QString inner = fed.addGroup("Inner", mid_outer); + + QString m_outer = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString m_mid = fed.addModel(writeStubFile(tmp.filePath("b.ifc"))); + QString m_inner = fed.addModel(writeStubFile(tmp.filePath("c.ifc"))); + fed.setModelGroup(m_outer, outer); + fed.setModelGroup(m_mid, mid_outer); + fed.setModelGroup(m_inner, inner); + fed.markClean(); + + QSignalSpy gc_spy(&fed, &Federation::groupChanged); + QSignalSpy mg_spy(&fed, &Federation::modelGroupChanged); + QSignalSpy gr_spy(&fed, &Federation::groupRemoved); + + // Remove the middle group: its child group (inner) and child model + // (m_mid) should both move up to `outer`. + fed.removeGroup(mid_outer); + + REQUIRE(fed.findGroupById(mid_outer) == nullptr); + REQUIRE(fed.findGroupById(inner)->parent->id == outer); + REQUIRE(fed.findById(m_mid)->group_id == outer); + // Untouched siblings. + REQUIRE(fed.findById(m_outer)->group_id == outer); + REQUIRE(fed.findById(m_inner)->group_id == inner); + REQUIRE(fed.isDirty()); + + REQUIRE(gc_spy.count() == 1); + REQUIRE(gc_spy.takeFirst().at(0).toString() == inner); + REQUIRE(mg_spy.count() == 1); + REQUIRE(mg_spy.takeFirst().at(0).toString() == m_mid); + REQUIRE(gr_spy.count() == 1); + REQUIRE(gr_spy.takeFirst().at(0).toString() == mid_outer); +} + +TEST_CASE("groups + model.group_id round-trip through nested JSON save/load", + "[federation][groups]") { + ensureQApp(); + QTemporaryDir tmp; + QString fed_path = tmp.filePath("p.ifcfed"); + + QString site_id, bldg_id, m_root, m_bldg; + { + Federation src; + site_id = src.addGroup("Site"); + bldg_id = src.addGroup("Building 1", site_id); + m_root = src.addModel(writeStubFile(tmp.filePath("root.ifc"))); + m_bldg = src.addModel(writeStubFile(tmp.filePath("bldg.ifc"))); + src.setModelGroup(m_bldg, bldg_id); + src.setGroupVisible(bldg_id, false); + + QString err; + REQUIRE(src.save(fed_path, &err)); + } + + // Inspect raw JSON: groups should be nested under "groups" with no + // parent_id field anywhere, and model.group_id is present only when set. + { + QJsonObject root = readJsonFile(fed_path); + REQUIRE(root.contains("groups")); + QJsonArray grps = root.value("groups").toArray(); + REQUIRE(grps.size() == 1); + QJsonObject site = grps[0].toObject(); + REQUIRE(site.value("display_name").toString() == "Site"); + REQUIRE_FALSE(site.contains("parent_id")); + + QJsonArray site_children = site.value("groups").toArray(); + REQUIRE(site_children.size() == 1); + QJsonObject bldg = site_children[0].toObject(); + REQUIRE(bldg.value("display_name").toString() == "Building 1"); + REQUIRE(bldg.value("visible").toBool() == false); + REQUIRE_FALSE(bldg.contains("parent_id")); + + QJsonArray models = root.value("models").toArray(); + REQUIRE(models.size() == 2); + // m_root is at root: no group_id key. + REQUIRE_FALSE(models[0].toObject().contains("group_id")); + // m_bldg is inside the building. + REQUIRE(models[1].toObject().value("group_id").toString() == bldg_id); + } + + { + Federation dst; + QStringList warnings; + QString err; + REQUIRE(dst.load(fed_path, &warnings, &err)); + REQUIRE(warnings.isEmpty()); + REQUIRE(dst.rootGroups().size() == 1); + REQUIRE(dst.rootGroups()[0]->id == site_id); + REQUIRE(dst.rootGroups()[0]->children.size() == 1); + REQUIRE(dst.rootGroups()[0]->children[0]->id == bldg_id); + REQUIRE_FALSE(dst.rootGroups()[0]->children[0]->visible); + REQUIRE(dst.rootGroups()[0]->children[0]->parent != nullptr); + REQUIRE(dst.rootGroups()[0]->children[0]->parent->id == site_id); + + REQUIRE(dst.findById(m_root)->group_id.isEmpty()); + REQUIRE(dst.findById(m_bldg)->group_id == bldg_id); + REQUIRE_FALSE(dst.isModelEffectivelyVisible(m_bldg)); + } +} + TEST_CASE("composeModelTransformation with pivot=B keeps A landing on B", "[federation][compose]") { // A in ModelGlobal frame, federation in metres, identity CoordinateOperation. From 278c1e506891525d7bf3866ffa2f80052283b91e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 5 May 2026 07:29:32 +1000 Subject: [PATCH 105/120] Interface mockup --- src/interface/CMakeLists.txt | 50 + src/interface/MockMainWindow.cpp | 1292 ++++++++++++++++++ src/interface/MockMainWindow.h | 88 ++ src/interface/icons/box-3d-center.svg | 8 + src/interface/icons/box-3d-three-points.svg | 8 + src/interface/icons/building.svg | 9 + src/interface/icons/cellar.svg | 6 + src/interface/icons/city.svg | 9 + src/interface/icons/clock-rotate-right.svg | 5 + src/interface/icons/cloud-square.svg | 4 + src/interface/icons/cube-dots.svg | 8 + src/interface/icons/cube-scan-solid.svg | 7 + src/interface/icons/cube-scan.svg | 8 + src/interface/icons/cube.svg | 5 + src/interface/icons/cursor-pointer.svg | 3 + src/interface/icons/download-square.svg | 5 + src/interface/icons/drone.svg | 11 + src/interface/icons/eye-closed.svg | 6 + src/interface/icons/eye-solid.svg | 4 + src/interface/icons/eye.svg | 4 + src/interface/icons/face-3d-draft.svg | 7 + src/interface/icons/filter.svg | 3 + src/interface/icons/floppy-disk-arrow-in.svg | 8 + src/interface/icons/floppy-disk.svg | 5 + src/interface/icons/folder-minus.svg | 5 + src/interface/icons/folder-plus.svg | 5 + src/interface/icons/folder.svg | 3 + src/interface/icons/frame-alt.svg | 7 + src/interface/icons/home-alt.svg | 5 + src/interface/icons/home.svg | 4 + src/interface/icons/intersect.svg | 12 + src/interface/icons/minus-square.svg | 4 + src/interface/icons/perspective-view.svg | 9 + src/interface/icons/planimetry.svg | 10 + src/interface/icons/plus-square.svg | 4 + src/interface/icons/refresh-double.svg | 6 + src/interface/icons/rotate-camera-right.svg | 6 + src/interface/icons/select-edge3d.svg | 1 + src/interface/icons/select-face3d.svg | 1 + src/interface/icons/select-point3d.svg | 1 + src/interface/icons/settings.svg | 4 + src/interface/icons/square3d-from-center.svg | 4 + src/interface/interface_resources.qrc | 59 + src/interface/main.cpp | 77 ++ 44 files changed, 1790 insertions(+) create mode 100644 src/interface/CMakeLists.txt create mode 100644 src/interface/MockMainWindow.cpp create mode 100644 src/interface/MockMainWindow.h create mode 100644 src/interface/icons/box-3d-center.svg create mode 100644 src/interface/icons/box-3d-three-points.svg create mode 100644 src/interface/icons/building.svg create mode 100644 src/interface/icons/cellar.svg create mode 100644 src/interface/icons/city.svg create mode 100644 src/interface/icons/clock-rotate-right.svg create mode 100644 src/interface/icons/cloud-square.svg create mode 100644 src/interface/icons/cube-dots.svg create mode 100644 src/interface/icons/cube-scan-solid.svg create mode 100644 src/interface/icons/cube-scan.svg create mode 100644 src/interface/icons/cube.svg create mode 100644 src/interface/icons/cursor-pointer.svg create mode 100644 src/interface/icons/download-square.svg create mode 100644 src/interface/icons/drone.svg create mode 100644 src/interface/icons/eye-closed.svg create mode 100644 src/interface/icons/eye-solid.svg create mode 100644 src/interface/icons/eye.svg create mode 100644 src/interface/icons/face-3d-draft.svg create mode 100644 src/interface/icons/filter.svg create mode 100644 src/interface/icons/floppy-disk-arrow-in.svg create mode 100644 src/interface/icons/floppy-disk.svg create mode 100644 src/interface/icons/folder-minus.svg create mode 100644 src/interface/icons/folder-plus.svg create mode 100644 src/interface/icons/folder.svg create mode 100644 src/interface/icons/frame-alt.svg create mode 100644 src/interface/icons/home-alt.svg create mode 100644 src/interface/icons/home.svg create mode 100644 src/interface/icons/intersect.svg create mode 100644 src/interface/icons/minus-square.svg create mode 100644 src/interface/icons/perspective-view.svg create mode 100644 src/interface/icons/planimetry.svg create mode 100644 src/interface/icons/plus-square.svg create mode 100644 src/interface/icons/refresh-double.svg create mode 100644 src/interface/icons/rotate-camera-right.svg create mode 100644 src/interface/icons/select-edge3d.svg create mode 100644 src/interface/icons/select-face3d.svg create mode 100644 src/interface/icons/select-point3d.svg create mode 100644 src/interface/icons/settings.svg create mode 100644 src/interface/icons/square3d-from-center.svg create mode 100644 src/interface/interface_resources.qrc create mode 100644 src/interface/main.cpp diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt new file mode 100644 index 0000000000..5e43a3171b --- /dev/null +++ b/src/interface/CMakeLists.txt @@ -0,0 +1,50 @@ +# This file was generated with the assistance of an AI coding tool. +################################################################################ +# # +# 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/interface") + +set(QT_VERSION 6 CACHE STRING "Qt version") +find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets Svg REQUIRED PATHS ${QT_DIR}) + +set(INTERFACE_FILES + ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MockMainWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MockMainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/interface_resources.qrc +) + +add_executable(IfcInterfaceMockup ${INTERFACE_FILES}) + +set_target_properties(IfcInterfaceMockup PROPERTIES + AUTOMOC ON + AUTORCC ON + WIN32_EXECUTABLE ON + MACOSX_BUNDLE ON +) + +target_link_libraries(IfcInterfaceMockup PRIVATE + IfcViewer + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Gui + Qt${QT_VERSION}::Svg + Qt${QT_VERSION}::Widgets +) + +install(TARGETS IfcInterfaceMockup EXPORT ${IFCOPENSHELL_EXPORT_TARGETS}) diff --git a/src/interface/MockMainWindow.cpp b/src/interface/MockMainWindow.cpp new file mode 100644 index 0000000000..85cefdd4ed --- /dev/null +++ b/src/interface/MockMainWindow.cpp @@ -0,0 +1,1292 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "MockMainWindow.h" + +#include "AppSettings.h" +#include "SceneLoader.h" +#include "ViewportWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QPixmap renderTintedSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { + QFile file(icon_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QIcon(icon_path).pixmap(size); + } + + QString svg = QString::fromUtf8(file.readAll()); + QString tinted = svg; + tinted.replace("currentColor", color, Qt::CaseSensitive); + tinted.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); + tinted.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); + QByteArray data = tinted.toUtf8(); + QSvgRenderer renderer(data); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +QIcon makeTintedSvgIcon(const QString& icon_path, const QString& normal = "#39b54a", + const QString& active = "#53c763", const QString& disabled = "#6f7988") { + QFile file(icon_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QIcon(icon_path); + } + + QIcon icon; + icon.addPixmap(renderTintedSvgPixmap(icon_path, normal, QSize(20, 20)), QIcon::Normal, QIcon::Off); + icon.addPixmap(renderTintedSvgPixmap(icon_path, active, QSize(20, 20)), QIcon::Active, QIcon::Off); + icon.addPixmap(renderTintedSvgPixmap(icon_path, active, QSize(20, 20)), QIcon::Selected, QIcon::Off); + icon.addPixmap(renderTintedSvgPixmap(icon_path, disabled, QSize(20, 20)), QIcon::Disabled, QIcon::Off); + return icon; +} + +QIcon makePanelSvgIcon(const QString& icon_path) { + return makeTintedSvgIcon(icon_path, "#e7ebf2", "#ffffff", "#6f7988"); +} + +QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size) { + return renderTintedSvgPixmap(icon_path, "#e7ebf2", size); +} + +class DockTitleBar : public QWidget { +public: + explicit DockTitleBar(const QString& title, bool has_settings = false, QWidget* parent = nullptr) + : QWidget(parent) + { + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(10, 6, 6, 6); + layout->setSpacing(6); + + auto* text = new QLabel(title.toUpper(), this); + text->setObjectName("dockTitleText"); + + layout->addWidget(text); + layout->addStretch(1); + if (has_settings) { + auto* settings = new QToolButton(this); + settings->setIcon(makePanelSvgIcon(":/icons/settings.svg")); + settings->setAutoRaise(true); + settings->setCursor(Qt::ArrowCursor); + settings->setFixedSize(18, 18); + settings->setObjectName("dockTitleButton"); + settings->setToolTip(QString("%1 settings").arg(title)); + connect(settings, &QToolButton::clicked, this, [this, title]() { + auto* anchor = parentWidget(); + QMenu menu(anchor); + menu.addAction(QString("%1 settings coming soon").arg(title)); + menu.exec(QCursor::pos()); + }); + layout->addWidget(settings); + } + } +}; + +QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings = false) { + auto* dock = new QDockWidget(title, parent); + dock->setObjectName(title); + dock->setFeatures(QDockWidget::DockWidgetMovable | + QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetClosable); + dock->setTitleBarWidget(new DockTitleBar(title, has_settings, dock)); + dock->setWidget(content); + return dock; +} + +QFrame* wrapPanel(QWidget* inner) { + auto* outer = new QFrame(); + auto* outer_layout = new QVBoxLayout(outer); + outer_layout->setContentsMargins(6, 6, 6, 6); + outer_layout->setSpacing(0); + + auto* frame = new QFrame(outer); + frame->setObjectName("panelFrame"); + auto* layout = new QVBoxLayout(frame); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(0); + layout->addWidget(inner); + + outer_layout->addWidget(frame); + return outer; +} + +QFrame* wrapInspectorPanel(QWidget* inner) { + auto* outer = new QFrame(); + auto* outer_layout = new QVBoxLayout(outer); + outer_layout->setContentsMargins(6, 6, 6, 6); + outer_layout->setSpacing(0); + + auto* frame = new QFrame(outer); + frame->setObjectName("panelFrame"); + auto* layout = new QVBoxLayout(frame); + layout->setContentsMargins(0, 8, 0, 8); + layout->setSpacing(0); + layout->addWidget(inner); + + outer_layout->addWidget(frame); + return outer; +} + +QWidget* makeInspectorFilterField(const QString& placeholder, QWidget* parent = nullptr) { + auto* field = new QLineEdit(parent); + field->setPlaceholderText(placeholder); + field->setClearButtonEnabled(true); + field->addAction(makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); + return field; +} + +QWidget* makePropertySetPanel(const QString& title, + const QList>& rows, + QWidget* parent = nullptr) { + auto* group = new QGroupBox(title, parent); + group->setObjectName("propertySetCard"); + auto* form = new QFormLayout(group); + form->setContentsMargins(10, 10, 10, 10); + form->setHorizontalSpacing(16); + form->setVerticalSpacing(6); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + for (const auto& [name, value] : rows) { + auto* key = new QLabel(name, group); + key->setObjectName("propertyKeyLabel"); + auto* val = new QLabel(value, group); + val->setObjectName("propertyValueLabel"); + val->setWordWrap(true); + form->addRow(key, val); + } + + return group; +} + +QWidget* makeInspectorSection(const QString& title, + const QString& filter_placeholder, + const QList& groups, + QWidget* parent = nullptr) { + auto* section = new QWidget(parent); + section->setObjectName("inspectorSection"); + auto* layout = new QVBoxLayout(section); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + auto* header = new QFrame(section); + header->setObjectName("inspectorSectionHeader"); + auto* header_layout = new QHBoxLayout(header); + header_layout->setContentsMargins(0, 0, 0, 0); + header_layout->setSpacing(6); + + auto* toggle = new QToolButton(header); + toggle->setObjectName("inspectorSectionButton"); + toggle->setText(title); + toggle->setCheckable(true); + toggle->setChecked(true); + toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toggle->setArrowType(Qt::DownArrow); + header_layout->addWidget(toggle); + header_layout->addStretch(1); + + QLineEdit* filter_field = nullptr; + if (!filter_placeholder.isEmpty()) { + auto* filter_toggle = new QToolButton(header); + filter_toggle->setObjectName("inspectorFilterToggle"); + filter_toggle->setCheckable(true); + filter_toggle->setChecked(false); + filter_toggle->setIcon(makePanelSvgIcon(":/icons/filter.svg")); + filter_toggle->setAutoRaise(true); + filter_toggle->setToolTip(QString("Filter %1").arg(title.toLower())); + header_layout->addWidget(filter_toggle); + + filter_field = qobject_cast(makeInspectorFilterField(filter_placeholder, section)); + filter_field->setVisible(false); + QObject::connect(filter_toggle, &QToolButton::toggled, filter_field, [filter_field](bool visible) { + filter_field->setVisible(visible); + if (visible) filter_field->setFocus(); + }); + } + + auto* body = new QWidget(section); + body->setObjectName("inspectorSectionBody"); + auto* body_layout = new QVBoxLayout(body); + body_layout->setContentsMargins(10, 6, 10, 0); + body_layout->setSpacing(6); + for (auto* group : groups) body_layout->addWidget(group); + + QObject::connect(toggle, &QToolButton::toggled, body, [toggle, body](bool expanded) { + toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + body->setVisible(expanded); + }); + + layout->addWidget(header); + if (filter_field) { + auto* filter_wrapper = new QWidget(section); + filter_wrapper->setObjectName("inspectorFilterWrapper"); + auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); + filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); + filter_wrapper_layout->setSpacing(0); + filter_wrapper_layout->addWidget(filter_field); + layout->addWidget(filter_wrapper); + } + layout->addWidget(body); + return section; +} + +QWidget* makeAttributeList(const QList>& rows, QWidget* parent = nullptr) { + auto* panel = new QWidget(parent); + panel->setObjectName("attributeList"); + auto* form = new QFormLayout(panel); + form->setContentsMargins(0, 0, 0, 0); + form->setHorizontalSpacing(16); + form->setVerticalSpacing(6); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + for (const auto& [name, value] : rows) { + auto* key = new QLabel(name, panel); + key->setObjectName("propertyKeyLabel"); + auto* val = new QLabel(value, panel); + val->setObjectName("propertyValueLabel"); + val->setWordWrap(true); + form->addRow(key, val); + } + + return panel; +} + +QWidget* makeRelationshipList(const QList>& rows, QWidget* parent = nullptr) { + auto* panel = new QWidget(parent); + panel->setObjectName("attributeList"); + auto* layout = new QVBoxLayout(panel); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + for (const auto& [name, value] : rows) { + auto* row = new QWidget(panel); + row->setObjectName("relationshipRow"); + auto* row_layout = new QHBoxLayout(row); + row_layout->setContentsMargins(0, 0, 0, 0); + row_layout->setSpacing(12); + + auto* key = new QLabel(name, row); + key->setObjectName("propertyKeyLabel"); + key->setMinimumWidth(72); + + auto* target = new QLabel(value, row); + target->setObjectName("relationshipValueLabel"); + target->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + auto* icon = new QLabel(row); + icon->setObjectName("relationshipIconLabel"); + icon->setPixmap(makePanelSvgPixmap(":/icons/cursor-pointer.svg", QSize(14, 14))); + icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + row_layout->addWidget(key); + row_layout->addWidget(target, 1); + row_layout->addWidget(icon, 0, Qt::AlignRight | Qt::AlignVCenter); + layout->addWidget(row); + } + + return panel; +} + +} // namespace + +MockMainWindow::MockMainWindow(QWidget* parent) + : QMainWindow(parent) +{ + setupChrome(); + setupViewport(); + setupDocks(); + setupStatus(); + setupLoader(); + setupRibbon(); + resize(1720, 980); +} + +void MockMainWindow::setupChrome() { + setWindowTitle("IfcOpenShell Interface"); + setDockOptions(QMainWindow::AllowNestedDocks | + QMainWindow::AllowTabbedDocks | + QMainWindow::GroupedDragging); + + setStyleSheet(R"( + QMainWindow { + background: #26292f; + } + QWidget { + color: #d0d5dd; + background: #26292f; + selection-background-color: #39b54a; + selection-color: #14161a; + } + QFrame#ribbonShell { + background: #2d3138; + border-bottom: 1px solid #1b1d22; + } + QTabBar::tab { + background: transparent; + color: #8d97a7; + padding: 8px 14px; + margin-right: 2px; + border-bottom: 2px solid transparent; + } + QTabBar::tab:selected { + color: #f2f5fa; + border-bottom: 2px solid #39b54a; + } + QTabBar::tab:hover { + color: #ffffff; + } + QFrame#ribbonBand { + background: #31353d; + border-top: 1px solid #3b4048; + } + QFrame#ribbonPage { + background: transparent; + } + QFrame#ribbonGroup { + background: transparent; + border-right: 1px solid #434852; + } + QLabel#ribbonGroupLabel { + color: #7f8796; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + } + QToolButton#ribbonButton { + background: transparent; + border: none; + padding: 6px 4px 4px 4px; + font-size: 11px; + color: #d6dce6; + } + QToolButton#ribbonButton:hover { + background: #3a3f48; + } + QToolButton#ribbonButton:pressed { + background: #24282f; + } + QFrame#viewportShell { + background: #202329; + border-top: 1px solid #1d2025; + } + QFrame#viewportFrame { + background: #1a1d22; + border: 1px solid #333942; + } + QDockWidget { + color: #d0d5dd; + } + QLabel#dockTitleText { + color: #dfe4ec; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + } + QToolButton#dockTitleButton { + color: #8e97a5; + border: none; + background: transparent; + } + QToolButton#dockTitleButton:hover { + color: #ffffff; + background: #353a42; + } + QFrame#panelFrame { + background: #2b2f36; + border: 1px solid #3e444e; + border-radius: 3px; + } + QTreeWidget, QListWidget, QTableWidget, QAbstractScrollArea { + background: #2b2f36; + border: none; + outline: none; + gridline-color: #333842; + } + QTreeWidget::viewport, QListWidget::viewport, QTableWidget::viewport { + background: #2b2f36; + } + QHeaderView::section { + background: #31353d; + color: #b5becc; + border: none; + border-bottom: 1px solid #434a55; + padding: 7px 8px; + font-weight: 600; + } + QTableCornerButton::section { + background: #31353d; + border: none; + } + QScrollArea { + background: #2b2f36; + border: none; + } + QScrollArea > QWidget > QWidget { + background: #2b2f36; + } + QLineEdit { + background: #31353d; + border: 1px solid #434a55; + border-radius: 3px; + padding: 6px 8px; + color: #d9dfeb; + } + QLineEdit:focus { + border: 1px solid #5b6472; + } + QFrame#entityClassCard { + background: #26292f; + border: 1px solid #404650; + border-radius: 3px; + } + QLabel#entityClassLabel { + color: #eef2f8; + font-weight: 700; + background: transparent; + } + QLabel#entityTypeLabel { + color: #9aa4b3; + background: transparent; + } + QWidget#attributeList { + background: transparent; + } + QTreeView::item, QListView::item, QTableView::item { + padding: 4px; + } + QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 2px 2px 2px 0; + } + QScrollBar:horizontal { + background: transparent; + height: 10px; + margin: 0 2px 2px 2px; + } + QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: #525a67; + border-radius: 3px; + min-height: 24px; + min-width: 24px; + } + QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover { + background: #697385; + } + QScrollBar::add-line, QScrollBar::sub-line, + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + border: none; + } + QStatusBar { + background: #24272c; + border-top: 1px solid #1a1c20; + } + QStatusBar QLabel { + color: #97a1af; + background: transparent; + border: none; + padding: 2px 8px; + } + QGroupBox { + background: transparent; + border: 1px solid #404650; + border-radius: 3px; + margin-top: 10px; + padding-top: 10px; + } + QGroupBox#propertySetCard { + background: #26292f; + border: 1px solid #404650; + border-radius: 3px; + } + QGroupBox#propertySetCard::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #d5dbe5; + } + QGroupBox#propertySetCard > QWidget { + background: #26292f; + } + QWidget#inspectorSection { + background: transparent; + } + QWidget#inspectorFilterWrapper { + background: transparent; + } + QWidget#relationshipRow { + background: transparent; + } + QLabel#relationshipIconLabel { + background: transparent; + } + QWidget#inspectorPanel { + background: #2b2f36; + } + QToolButton#inspectorSectionButton { + background: transparent; + border: none; + color: #e1e7f0; + font-weight: 700; + text-align: left; + padding: 2px; + margin: 0; + } + QToolButton#inspectorSectionButton:hover { + color: #ffffff; + } + QToolButton#inspectorSectionButton::menu-indicator { + image: none; + width: 0; + } + QFrame#inspectorSectionHeader { + background: #26292f; + } + QToolButton#inspectorFilterToggle { + background: transparent; + border: none; + padding: 2px; + } + QToolButton#inspectorFilterToggle:hover { + background: #353a42; + } + QWidget#inspectorSectionBody { + background: transparent; + } + QLabel#propertyKeyLabel { + color: #9aa4b3; + background: transparent; + } + QLabel#propertyValueLabel { + color: #dce2eb; + background: transparent; + } + QLabel#relationshipValueLabel { + color: #dce2eb; + background: transparent; + } + )"); +} + +QToolButton* MockMainWindow::makeRibbonAction(const QString& text, const QString& icon_path) { + auto* button = new QToolButton(this); + button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + button->setIcon(icon_path.endsWith(".svg") + ? makeTintedSvgIcon(icon_path) + : QIcon(icon_path)); + button->setIconSize(QSize(20, 20)); + button->setText(text); + button->setMinimumSize(QSize(68, 54)); + button->setObjectName("ribbonButton"); + button->setAutoRaise(false); + return button; +} + +QWidget* MockMainWindow::makeRibbonGroup(const QString& title, const QList& buttons) { + auto* group = new QFrame(this); + group->setObjectName("ribbonGroup"); + auto* group_layout = new QVBoxLayout(group); + group_layout->setContentsMargins(8, 6, 8, 4); + group_layout->setSpacing(4); + auto* button_row = new QHBoxLayout(); + button_row->setContentsMargins(0, 0, 0, 0); + button_row->setSpacing(4); + for (auto* button : buttons) { + button_row->addWidget(button); + } + auto* label = new QLabel(title, group); + label->setObjectName("ribbonGroupLabel"); + label->setAlignment(Qt::AlignCenter); + group_layout->addLayout(button_row); + group_layout->addWidget(label); + return group; +} + +QWidget* MockMainWindow::makeComingSoonPanel(const QString& title) { + auto* widget = new QWidget(this); + auto* layout = new QVBoxLayout(widget); + layout->setContentsMargins(12, 12, 12, 12); + auto* heading = new QLabel(title, widget); + heading->setStyleSheet("font-size:14px; font-weight:600; color:#e1e6ee;"); + auto* body = new QLabel("Coming soon", widget); + body->setAlignment(Qt::AlignCenter); + body->setStyleSheet("color:#8f98a6;"); + layout->addWidget(heading); + layout->addStretch(1); + layout->addWidget(body); + layout->addStretch(1); + return widget; +} + +void MockMainWindow::setStatusMessage(const QString& mode, const QString& detail) { + status_mode_label_->setText(mode); + status_selection_label_->setText(detail); +} + +QToolButton* MockMainWindow::makePanelToggle(const QString& text, QDockWidget* dock) { + auto* button = makeRibbonAction(text, ":/icons/dm_toggle_openings.png"); + button->setCheckable(true); + button->setChecked(dock->isVisible()); + connect(button, &QToolButton::toggled, dock, [dock](bool checked) { + dock->setVisible(checked); + if (checked) dock->raise(); + }); + connect(dock, &QDockWidget::visibilityChanged, button, [button](bool visible) { + const QSignalBlocker blocker(button); + button->setChecked(visible); + }); + return button; +} + +QWidget* MockMainWindow::buildHomeRibbonPage() { + auto* page = new QFrame(this); + page->setObjectName("ribbonPage"); + auto* row = new QHBoxLayout(page); + row->setContentsMargins(6, 4, 6, 4); + row->setSpacing(0); + + auto* new_project = makeRibbonAction("New Project", ":/icons/plus-square.svg"); + connect(new_project, &QToolButton::clicked, this, [this]() { + setStatusMessage("Project", "New Project coming soon"); + }); + auto* open_project = makeRibbonAction("Open Project", ":/icons/download-square.svg"); + connect(open_project, &QToolButton::clicked, this, [this]() { + setStatusMessage("Project", "Open Project coming soon"); + }); + auto* open_cloud = makeRibbonAction("Open Cloud", ":/icons/cloud-square.svg"); + connect(open_cloud, &QToolButton::clicked, this, [this]() { + setStatusMessage("Project", "Open Cloud Project coming soon"); + }); + auto* open_recent = makeRibbonAction("Open Recent", ":/icons/clock-rotate-right.svg"); + connect(open_recent, &QToolButton::clicked, this, [this]() { + setStatusMessage("Project", "Open Recent coming soon"); + }); + auto* save_project = makeRibbonAction("Save Project", ":/icons/floppy-disk.svg"); + connect(save_project, &QToolButton::clicked, this, [this]() { + setStatusMessage("Project", "Save Project coming soon"); + }); + auto* save_project_as = makeRibbonAction("Save As", ":/icons/floppy-disk-arrow-in.svg"); + connect(save_project_as, &QToolButton::clicked, this, [this]() { + setStatusMessage("Project", "Save Project As coming soon"); + }); + + auto* add_model = makeRibbonAction("Add Model", ":/icons/cube.svg"); + connect(add_model, &QToolButton::clicked, this, &MockMainWindow::onAddFiles); + auto* sync_models = makeRibbonAction("Sync Models", ":/icons/refresh-double.svg"); + connect(sync_models, &QToolButton::clicked, this, [this]() { + setStatusMessage("Models", "Sync models coming soon"); + }); + + auto* settings_button = makeRibbonAction("Settings", ":/icons/settings.svg"); + connect(settings_button, &QToolButton::clicked, this, [this]() { + setStatusMessage("Settings", "Settings coming soon"); + }); + + row->addWidget(makeRibbonGroup("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project, save_project_as})); + row->addWidget(makeRibbonGroup("MODELS", {add_model, sync_models})); + row->addWidget(makeRibbonGroup("SETTINGS", {settings_button})); + row->addStretch(1); + return page; +} + +QWidget* MockMainWindow::buildNavigateRibbonPage() { + auto* page = new QFrame(this); + page->setObjectName("ribbonPage"); + auto* row = new QHBoxLayout(page); + row->setContentsMargins(2, 4, 2, 4); + row->setSpacing(0); + + auto* set_home = makeRibbonAction("Set Home", ":/icons/home.svg"); + connect(set_home, &QToolButton::clicked, this, [this]() { + setStatusMessage("Camera", "Set home view coming soon"); + }); + auto* go_home = makeRibbonAction("Go Home", ":/icons/home-alt.svg"); + connect(go_home, &QToolButton::clicked, this, [this]() { + setStatusMessage("Camera", "Go to home view coming soon"); + }); + auto* view_all = makeRibbonAction("View All", ":/icons/cube-scan.svg"); + connect(view_all, &QToolButton::clicked, this, [this]() { + if (viewport_) viewport_->viewAll(); + }); + auto* view_selected = makeRibbonAction("View Selected", ":/icons/cube-scan-solid.svg"); + connect(view_selected, &QToolButton::clicked, this, [this]() { + if (viewport_) viewport_->focusOnSelectedObject(); + }); + + auto* plan_view = makeRibbonAction("Plan", ":/icons/planimetry.svg"); + connect(plan_view, &QToolButton::clicked, this, [this]() { + if (viewport_) viewport_->setStandardView(90.0f, 90.0f); + }); + auto* front_view = makeRibbonAction("Front", ":/icons/city.svg"); + connect(front_view, &QToolButton::clicked, this, [this]() { + if (viewport_) viewport_->setStandardView(0.0f, 0.0f); + }); + auto* side_view = makeRibbonAction("Side", ":/icons/building.svg"); + connect(side_view, &QToolButton::clicked, this, [this]() { + if (viewport_) viewport_->setStandardView(90.0f, 0.0f); + }); + auto* align_object = makeRibbonAction("Align Object", ":/icons/cellar.svg"); + connect(align_object, &QToolButton::clicked, this, [this]() { + setStatusMessage("Orientation", "Align to object coming soon"); + }); + auto* projection_button = makeRibbonAction("Perspective", ":/icons/perspective-view.svg"); + connect(projection_button, &QToolButton::clicked, this, [this, projection_button]() { + if (!viewport_) return; + viewport_->toggleProjection(); + projection_button->setText(viewport_->projectionOrtho() ? "Ortho" : "Perspective"); + }); + + auto* orbit_mode = makeRibbonAction("Orbit", ":/icons/rotate-camera-right.svg"); + connect(orbit_mode, &QToolButton::clicked, this, [this]() { + setStatusMessage("Mode", "Orbit mode active"); + }); + auto* fly_mode = makeRibbonAction("Fly", ":/icons/drone.svg"); + connect(fly_mode, &QToolButton::clicked, this, [this]() { + setStatusMessage("Mode", "Fly mode coming soon"); + }); + + row->addWidget(makeRibbonGroup("CAMERA", {set_home, go_home, view_all, view_selected})); + row->addWidget(makeRibbonGroup("ORIENTATION", {plan_view, front_view, side_view, align_object, projection_button})); + row->addWidget(makeRibbonGroup("MODE", {orbit_mode, fly_mode})); + row->addStretch(1); + return page; +} + +QWidget* MockMainWindow::buildInspectRibbonPage() { + auto* page = new QFrame(this); + page->setObjectName("ribbonPage"); + auto* row = new QHBoxLayout(page); + row->setContentsMargins(2, 4, 2, 4); + row->setSpacing(0); + + auto* hide_selected = makeRibbonAction("Hide", ":/icons/eye-closed.svg"); + connect(hide_selected, &QToolButton::clicked, this, [this]() { + setStatusMessage("Selection", "Hide selected coming soon"); + }); + auto* isolate_selected = makeRibbonAction("Isolate", ":/icons/eye-solid.svg"); + connect(isolate_selected, &QToolButton::clicked, this, [this]() { + setStatusMessage("Selection", "Isolate selected coming soon"); + }); + auto* show_all = makeRibbonAction("Show All", ":/icons/eye.svg"); + connect(show_all, &QToolButton::clicked, this, [this]() { + setStatusMessage("Selection", "Show all coming soon"); + }); + auto* invert_selection = makeRibbonAction("Invert", ":/icons/intersect.svg"); + connect(invert_selection, &QToolButton::clicked, this, [this]() { + setStatusMessage("Selection", "Invert selection coming soon"); + }); + + auto* distance = makeRibbonAction("Distance", ":/icons/select-edge3d.svg"); + connect(distance, &QToolButton::clicked, this, [this]() { + setStatusMessage("Measure", "Distance coming soon"); + }); + auto* area = makeRibbonAction("Area", ":/icons/select-face3d.svg"); + connect(area, &QToolButton::clicked, this, [this]() { + setStatusMessage("Measure", "Area coming soon"); + }); + auto* volume = makeRibbonAction("Volume", ":/icons/select-point3d.svg"); + connect(volume, &QToolButton::clicked, this, [this]() { + setStatusMessage("Measure", "Volume coming soon"); + }); + + row->addWidget(makeRibbonGroup("SELECTION", {hide_selected, isolate_selected, show_all, invert_selection})); + row->addWidget(makeRibbonGroup("MEASURE", {distance, area, volume})); + row->addStretch(1); + return page; +} + +QWidget* MockMainWindow::buildPanelsRibbonPage() { + auto* page = new QFrame(this); + page->setObjectName("ribbonPage"); + auto* row = new QHBoxLayout(page); + row->setContentsMargins(2, 4, 2, 4); + row->setSpacing(0); + + row->addWidget(makeRibbonGroup("DATA", { + makePanelToggle("Models", models_dock_), + makePanelToggle("Spatial", spatial_dock_), + makePanelToggle("Layers", layers_dock_), + makePanelToggle("Properties", properties_dock_) + })); + row->addWidget(makeRibbonGroup("QUERY", { + makePanelToggle("Views", stored_views_dock_), + makePanelToggle("Search", search_dock_), + makePanelToggle("Sheets", spreadsheet_dock_) + })); + row->addWidget(makeRibbonGroup("COLLABORATE", { + makePanelToggle("Clash", clash_dock_), + makePanelToggle("Issues", issues_dock_) + })); + row->addStretch(1); + return page; +} + +void MockMainWindow::setupRibbon() { + auto* shell = new QFrame(this); + shell->setObjectName("ribbonShell"); + + auto* shell_layout = new QVBoxLayout(shell); + shell_layout->setContentsMargins(0, 0, 0, 0); + shell_layout->setSpacing(0); + + ribbon_tabs_ = new QTabBar(shell); + ribbon_tabs_->addTab("Home"); + ribbon_tabs_->addTab("Navigate"); + ribbon_tabs_->addTab("Inspect"); + ribbon_tabs_->addTab("Panels"); + ribbon_tabs_->setCurrentIndex(0); + ribbon_tabs_->setExpanding(false); + ribbon_tabs_->setDrawBase(false); + + auto* ribbon_band = new QFrame(shell); + ribbon_band->setObjectName("ribbonBand"); + auto* band_layout = new QVBoxLayout(ribbon_band); + band_layout->setContentsMargins(0, 0, 0, 0); + band_layout->setSpacing(0); + + ribbon_pages_ = new QStackedWidget(ribbon_band); + ribbon_pages_->addWidget(buildHomeRibbonPage()); + ribbon_pages_->addWidget(buildNavigateRibbonPage()); + ribbon_pages_->addWidget(buildInspectRibbonPage()); + ribbon_pages_->addWidget(buildPanelsRibbonPage()); + + band_layout->addWidget(ribbon_pages_); + shell_layout->addWidget(ribbon_tabs_); + shell_layout->addWidget(ribbon_band); + + connect(ribbon_tabs_, &QTabBar::currentChanged, + ribbon_pages_, &QStackedWidget::setCurrentIndex); + + setMenuWidget(shell); +} + +void MockMainWindow::setupViewport() { + viewport_ = new ViewportWindow(); + viewport_container_ = QWidget::createWindowContainer(viewport_, this); + viewport_container_->setMinimumSize(400, 300); + viewport_container_->setFocusPolicy(Qt::StrongFocus); + + auto* shell = new QFrame(this); + shell->setObjectName("viewportShell"); + auto* root = new QVBoxLayout(shell); + root->setContentsMargins(10, 10, 10, 10); + root->setSpacing(0); + + auto* frame = new QFrame(shell); + frame->setObjectName("viewportFrame"); + auto* frame_layout = new QVBoxLayout(frame); + frame_layout->setContentsMargins(0, 0, 0, 0); + frame_layout->addWidget(viewport_container_); + + root->addWidget(frame); + setCentralWidget(shell); +} + +void MockMainWindow::setupDocks() { + auto* models_panel = new QWidget(this); + auto* models_panel_layout = new QVBoxLayout(models_panel); + models_panel_layout->setContentsMargins(0, 0, 0, 0); + models_panel_layout->setSpacing(0); + + auto* models_tree = new QTreeWidget(this); + models_tree->setColumnCount(2); + models_tree->setHeaderLabels({"Model", ""}); + models_tree->setIconSize(QSize(16, 16)); + models_tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + models_tree->setContextMenuPolicy(Qt::CustomContextMenu); + models_tree->setUniformRowHeights(true); + models_tree->header()->setStretchLastSection(false); + models_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + models_tree->header()->setSectionResizeMode(1, QHeaderView::Fixed); + models_tree->header()->resizeSection(1, 28); + models_tree->header()->hide(); + + auto* local_group = new QTreeWidgetItem(models_tree, {"Local Models", ""}); + local_group->setIcon(0, makePanelSvgIcon(":/icons/folder.svg")); + local_group->setIcon(1, makePanelSvgIcon(":/icons/eye.svg")); + local_group->setData(1, Qt::UserRole, true); + local_group->setSizeHint(0, QSize(0, 24)); + auto* linked_group = new QTreeWidgetItem(models_tree, {"Linked Models", ""}); + linked_group->setIcon(0, makePanelSvgIcon(":/icons/folder.svg")); + linked_group->setIcon(1, makePanelSvgIcon(":/icons/eye.svg")); + linked_group->setData(1, Qt::UserRole, true); + linked_group->setSizeHint(0, QSize(0, 24)); + + auto make_model_item = [this](QTreeWidgetItem* parent, const QString& name, bool visible) { + auto* item = new QTreeWidgetItem(parent, {name, ""}); + item->setIcon(0, makePanelSvgIcon(":/icons/cube.svg")); + item->setIcon(1, makePanelSvgIcon(visible ? ":/icons/eye-solid.svg" : ":/icons/eye.svg")); + item->setData(1, Qt::UserRole, visible); + item->setSizeHint(0, QSize(0, 24)); + return item; + }; + + make_model_item(local_group, "Architecture.ifc", true); + make_model_item(local_group, "Structure.ifc", true); + make_model_item(linked_group, "MEP.ifc", false); + models_tree->expandAll(); + + models_panel_layout->addWidget(models_tree); + + connect(models_tree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { + if (!item || column != 1) return; + const bool visible = item->data(1, Qt::UserRole).toBool(); + const bool next_visible = !visible; + item->setData(1, Qt::UserRole, next_visible); + if (item->childCount() > 0) { + item->setIcon(1, makePanelSvgIcon(next_visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); + } else { + item->setIcon(1, makePanelSvgIcon(next_visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg")); + } + setStatusMessage("Models", next_visible ? "Item shown" : "Item hidden"); + }); + + connect(models_tree, &QTreeWidget::customContextMenuRequested, this, [this, models_tree](const QPoint& pos) { + auto* item = models_tree->itemAt(pos); + QMenu menu(models_tree); + if (item && item->childCount() > 0) { + menu.addAction(makePanelSvgIcon(":/icons/folder-plus.svg"), "Add Group", [this]() { + setStatusMessage("Models", "Add Group coming soon"); + }); + menu.addAction(makePanelSvgIcon(":/icons/folder-minus.svg"), "Remove Group", [this]() { + setStatusMessage("Models", "Remove Group coming soon"); + }); + } + if (item && item->childCount() == 0) { + menu.addAction(makePanelSvgIcon(":/icons/minus-square.svg"), "Remove Model", [this]() { + setStatusMessage("Models", "Remove Model coming soon"); + }); + } + menu.addAction(makePanelSvgIcon(":/icons/intersect.svg"), "Invert Visibility", [this]() { + setStatusMessage("Models", "Invert visibility coming soon"); + }); + if (!menu.actions().isEmpty()) menu.exec(models_tree->viewport()->mapToGlobal(pos)); + }); + + auto* spatial_tree = new QTreeWidget(this); + spatial_tree->setColumnCount(2); + spatial_tree->setHeaderLabels({"Spatial Item", ""}); + spatial_tree->setIconSize(QSize(16, 16)); + spatial_tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + spatial_tree->setUniformRowHeights(true); + spatial_tree->header()->setStretchLastSection(false); + spatial_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + spatial_tree->header()->setSectionResizeMode(1, QHeaderView::Fixed); + spatial_tree->header()->resizeSection(1, 28); + spatial_tree->header()->hide(); + + auto make_spatial_item = [this](QTreeWidgetItem* parent, const QString& name, const QString& icon_path) { + auto* item = new QTreeWidgetItem(parent, {name, ""}); + item->setIcon(0, makePanelSvgIcon(icon_path)); + item->setIcon(1, makePanelSvgIcon(":/icons/eye.svg")); + item->setData(1, Qt::UserRole, true); + item->setSizeHint(0, QSize(0, 24)); + return item; + }; + + auto* site = make_spatial_item(spatial_tree->invisibleRootItem(), "Site A", ":/icons/frame-alt.svg"); + auto* building = make_spatial_item(site, "Building 01", ":/icons/city.svg"); + auto* storey = make_spatial_item(building, "Level 02", ":/icons/planimetry.svg"); + make_spatial_item(storey, "Lobby", ":/icons/square3d-from-center.svg"); + make_spatial_item(storey, "Core", ":/icons/square3d-from-center.svg"); + spatial_tree->expandAll(); + + connect(spatial_tree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { + if (!item || column != 1) return; + const bool visible = item->data(1, Qt::UserRole).toBool(); + const bool next_visible = !visible; + item->setData(1, Qt::UserRole, next_visible); + item->setIcon(1, makePanelSvgIcon(next_visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); + setStatusMessage("Spatial", next_visible ? "Item shown" : "Item hidden"); + }); + + auto* properties_content = new QWidget(this); + properties_content->setObjectName("inspectorPanel"); + auto* properties_layout = new QVBoxLayout(properties_content); + properties_layout->setContentsMargins(0, 0, 0, 0); + properties_layout->setSpacing(12); + + auto* entity_card = new QFrame(properties_content); + entity_card->setObjectName("entityClassCard"); + auto* entity_layout = new QHBoxLayout(entity_card); + entity_layout->setContentsMargins(10, 8, 10, 8); + entity_layout->setSpacing(10); + auto* entity_icon = new QLabel(entity_card); + entity_icon->setPixmap(makePanelSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); + entity_icon->setAlignment(Qt::AlignCenter); + auto* entity_text = new QWidget(entity_card); + auto* entity_text_layout = new QVBoxLayout(entity_text); + entity_text_layout->setContentsMargins(0, 0, 0, 0); + entity_text_layout->setSpacing(2); + auto* entity_class = new QLabel("IfcWall", entity_text); + entity_class->setObjectName("entityClassLabel"); + auto* entity_type = new QLabel("SOLIDWALL", entity_text); + entity_type->setObjectName("entityTypeLabel"); + entity_text_layout->addWidget(entity_class); + entity_text_layout->addWidget(entity_type); + entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); + entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); + + auto* attributes_section = makeInspectorSection( + "Attributes", "", + { + makeAttributeList({{"GlobalId", "2Q$n5SLPP9Q8B7wQKjKfUQ"}, + {"Name", "Core-EXT-204"}, + {"Description", "External load-bearing wall"}}, properties_content) + }, + properties_content); + + auto* properties_section = makeInspectorSection( + "Properties", "Filter properties or sets", + { + makePropertySetPanel("Pset_WallCommon", + {{"Reference", "Core-EXT-204"}, + {"Status", "Reviewed"}, + {"Fire Rating", "120 min"}, + {"LoadBearing", "True"}}, + properties_content), + makePropertySetPanel("Identity Data", + {{"Type", "IfcWall"}, + {"Name", "Core-EXT-204"}, + {"Owner", "Architecture"}, + {"Phase", "Construction"}}, + properties_content), + makePropertySetPanel("BIM Collaboration", + {{"Issue Count", "2 open"}, + {"Last Review", "2026-04-30"}, + {"Assigned To", "Design Coordination"}}, + properties_content) + }, + properties_content); + + qobject_cast(attributes_section->layout())->setSpacing(8); + + auto* relationships_section = makeInspectorSection( + "Relationships", "", + { + makeRelationshipList({{"Type", "Basic Wall: Exterior - 200mm"}, + {"Container", "Level 02"}}, properties_content) + }, + properties_content); + + auto* quantities_section = makeInspectorSection( + "Quantities", "Filter quantities or sets", + { + makePropertySetPanel("BaseQuantities", + {{"Length", "6.20 m"}, + {"Height", "3.45 m"}, + {"Width", "0.30 m"}, + {"Volume", "6.42 m3"}}, + properties_content), + makePropertySetPanel("Finish Quantities", + {{"NetSideArea", "21.39 m2"}, + {"GrossArea", "22.10 m2"}, + {"Paint Coverage", "42.78 m2"}}, + properties_content) + }, + properties_content); + + auto* entity_wrapper = new QWidget(properties_content); + entity_wrapper->setObjectName("inspectorSectionBody"); + auto* entity_wrapper_layout = new QVBoxLayout(entity_wrapper); + entity_wrapper_layout->setContentsMargins(10, 0, 10, 0); + entity_wrapper_layout->setSpacing(0); + entity_wrapper_layout->addWidget(entity_card); + + properties_layout->addWidget(entity_wrapper); + properties_layout->addWidget(attributes_section); + properties_layout->addWidget(relationships_section); + properties_layout->addWidget(properties_section); + properties_layout->addWidget(quantities_section); + properties_layout->addStretch(1); + + auto* properties_scroll = new QScrollArea(this); + properties_scroll->setWidgetResizable(true); + properties_scroll->setFrameShape(QFrame::NoFrame); + properties_scroll->setWidget(properties_content); + + models_dock_ = makeDock("Models", wrapPanel(models_panel), this, true); + spatial_dock_ = makeDock("Spatial Hierarchy", wrapPanel(spatial_tree), this); + properties_dock_ = makeDock("Properties", wrapInspectorPanel(properties_scroll), this); + layers_dock_ = makeDock("Layers", wrapPanel(makeComingSoonPanel("Layers")), this); + stored_views_dock_ = makeDock("Stored Views", wrapPanel(makeComingSoonPanel("Stored Views")), this); + search_dock_ = makeDock("Search and Query", wrapPanel(makeComingSoonPanel("Search and Query")), this); + spreadsheet_dock_ = makeDock("Spreadsheet", wrapPanel(makeComingSoonPanel("Spreadsheet")), this); + clash_dock_ = makeDock("Clash", wrapPanel(makeComingSoonPanel("Clash")), this); + issues_dock_ = makeDock("Issues", wrapPanel(makeComingSoonPanel("Issues")), this); + + addDockWidget(Qt::LeftDockWidgetArea, models_dock_); + addDockWidget(Qt::LeftDockWidgetArea, spatial_dock_); + splitDockWidget(models_dock_, spatial_dock_, Qt::Vertical); + + addDockWidget(Qt::RightDockWidgetArea, properties_dock_); + addDockWidget(Qt::RightDockWidgetArea, layers_dock_); + addDockWidget(Qt::RightDockWidgetArea, stored_views_dock_); + addDockWidget(Qt::RightDockWidgetArea, search_dock_); + addDockWidget(Qt::RightDockWidgetArea, spreadsheet_dock_); + addDockWidget(Qt::RightDockWidgetArea, clash_dock_); + addDockWidget(Qt::RightDockWidgetArea, issues_dock_); + + tabifyDockWidget(properties_dock_, layers_dock_); + tabifyDockWidget(layers_dock_, stored_views_dock_); + tabifyDockWidget(stored_views_dock_, search_dock_); + tabifyDockWidget(search_dock_, spreadsheet_dock_); + tabifyDockWidget(spreadsheet_dock_, clash_dock_); + tabifyDockWidget(clash_dock_, issues_dock_); + properties_dock_->raise(); + + layers_dock_->hide(); + stored_views_dock_->hide(); + search_dock_->hide(); + spreadsheet_dock_->hide(); + clash_dock_->hide(); + issues_dock_->hide(); + + resizeDocks({models_dock_, properties_dock_}, {290, 330}, Qt::Horizontal); + resizeDocks({models_dock_, spatial_dock_}, {280, 240}, Qt::Vertical); +} + +void MockMainWindow::setupStatus() { + status_mode_label_ = new QLabel("Ready", this); + status_selection_label_ = new QLabel("No selection", this); + status_perf_label_ = new QLabel(this); + status_perf_label_->setVisible(AppSettings::instance().showStats()); + + statusBar()->setSizeGripEnabled(false); + statusBar()->addWidget(status_mode_label_); + statusBar()->addWidget(status_selection_label_, 1); + statusBar()->addPermanentWidget(status_perf_label_); + + connect(&AppSettings::instance(), &AppSettings::showStatsChanged, this, [this](bool show) { + status_perf_label_->setVisible(show); + if (!show) status_perf_label_->clear(); + }); +} + +void MockMainWindow::setupLoader() { + AppSettings::instance().setLoadDataSource(false); + loader_ = new SceneLoader(viewport_, this); + connect(loader_, &SceneLoader::loadStarted, this, &MockMainWindow::onLoadStarted); + connect(loader_, &SceneLoader::loadedFromSidecar, this, &MockMainWindow::onLoadedFromSidecar); + connect(loader_, &SceneLoader::loadedFromStream, this, &MockMainWindow::onLoadedFromStream); + connect(loader_, &SceneLoader::loadCancelled, this, &MockMainWindow::onLoadCancelled); + connect(loader_, &SceneLoader::loadError, this, &MockMainWindow::onLoadError); + connect(loader_, &SceneLoader::allLoadsFinished, this, &MockMainWindow::onAllLoadsFinished); + + connect(viewport_, &ViewportWindow::frameStatsUpdated, this, + [this](const ViewportWindow::FrameStats& s) { + if (!status_perf_label_->isVisible()) return; + status_perf_label_->setText( + QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws") + .arg(s.fps, 0, 'f', 1) + .arg(s.frame_time_ms, 0, 'f', 1) + .arg(s.visible_objects) + .arg(s.total_objects) + .arg(s.visible_triangles) + .arg(s.total_triangles) + .arg(s.gl_draw_calls)); + }); +} + +void MockMainWindow::addFiles(const QStringList& paths) { + if (paths.isEmpty()) return; + loader_->addFiles(paths); +} + +QString MockMainWindow::formatElapsed(qint64 ms) const { + return (ms >= 1000) + ? QString::number(ms / 1000.0, 'f', 2) + " s" + : QString::number(ms) + " ms"; +} + +void MockMainWindow::onAddFiles() { + const QStringList paths = QFileDialog::getOpenFileNames( + this, "Add IFC Files", QString(), + "IFC Viewer Cache (*.ifcview)"); + addFiles(paths); +} + +void MockMainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { + status_mode_label_->setText("Loading"); + status_selection_label_->setText(display_name); +} + +void MockMainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { + status_mode_label_->setText("Loaded"); + status_selection_label_->setText( + QString("%1 from cache in %2") + .arg(loader_->displayName(mid)) + .arg(formatElapsed(elapsed_ms))); +} + +void MockMainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { + status_mode_label_->setText("Loaded"); + status_selection_label_->setText( + QString("%1 streamed in %2") + .arg(loader_->displayName(mid)) + .arg(formatElapsed(elapsed_ms))); +} + +void MockMainWindow::onLoadCancelled(uint32_t mid) { + status_mode_label_->setText("Cancelled"); + status_selection_label_->setText(loader_->displayName(mid)); +} + +void MockMainWindow::onLoadError(uint32_t /*mid*/, QString message) { + status_mode_label_->setText("Error"); + status_selection_label_->setText(message); + QMessageBox::warning(this, "IfcInterfaceMockup", message); +} + +void MockMainWindow::onAllLoadsFinished() { + status_mode_label_->setText("Loaded"); + status_selection_label_->setText(QString("%1 model(s)").arg(loader_->modelCount())); +} diff --git a/src/interface/MockMainWindow.h b/src/interface/MockMainWindow.h new file mode 100644 index 0000000000..81da53db90 --- /dev/null +++ b/src/interface/MockMainWindow.h @@ -0,0 +1,88 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_MOCKMAINWINDOW_H +#define IFCINTERFACE_MOCKMAINWINDOW_H + +#include +#include + +class QLabel; +class QDockWidget; +class QStackedWidget; +class QTabBar; +class QToolButton; +class ViewportWindow; +class SceneLoader; + +class MockMainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MockMainWindow(QWidget* parent = nullptr); + +private: + void setupChrome(); + void setupRibbon(); + void setupViewport(); + void setupDocks(); + void setupStatus(); + void setupLoader(); + QWidget* buildHomeRibbonPage(); + QWidget* buildNavigateRibbonPage(); + QWidget* buildInspectRibbonPage(); + QWidget* buildPanelsRibbonPage(); + QToolButton* makeRibbonAction(const QString& text, const QString& icon_path); + QWidget* makeRibbonGroup(const QString& title, const QList& buttons); + QWidget* makeComingSoonPanel(const QString& title); + QToolButton* makePanelToggle(const QString& text, QDockWidget* dock); + void setStatusMessage(const QString& mode, const QString& detail); + void addFiles(const QStringList& paths); + QString formatElapsed(qint64 ms) const; + +private slots: + void onAddFiles(); + void onLoadStarted(uint32_t mid, QString display_name); + void onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + void onLoadedFromStream(uint32_t mid, qint64 elapsed_ms); + void onLoadCancelled(uint32_t mid); + void onLoadError(uint32_t mid, QString message); + void onAllLoadsFinished(); + +private: + QLabel* status_mode_label_ = nullptr; + QLabel* status_selection_label_ = nullptr; + QLabel* status_perf_label_ = nullptr; + QTabBar* ribbon_tabs_ = nullptr; + QStackedWidget* ribbon_pages_ = nullptr; + ViewportWindow* viewport_ = nullptr; + SceneLoader* loader_ = nullptr; + QWidget* viewport_container_ = nullptr; + QDockWidget* models_dock_ = nullptr; + QDockWidget* spatial_dock_ = nullptr; + QDockWidget* layers_dock_ = nullptr; + QDockWidget* properties_dock_ = nullptr; + QDockWidget* stored_views_dock_ = nullptr; + QDockWidget* search_dock_ = nullptr; + QDockWidget* spreadsheet_dock_ = nullptr; + QDockWidget* clash_dock_ = nullptr; + QDockWidget* issues_dock_ = nullptr; +}; + +#endif diff --git a/src/interface/icons/box-3d-center.svg b/src/interface/icons/box-3d-center.svg new file mode 100644 index 0000000000..2821094abd --- /dev/null +++ b/src/interface/icons/box-3d-center.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/interface/icons/box-3d-three-points.svg b/src/interface/icons/box-3d-three-points.svg new file mode 100644 index 0000000000..a925b676b1 --- /dev/null +++ b/src/interface/icons/box-3d-three-points.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/interface/icons/building.svg b/src/interface/icons/building.svg new file mode 100644 index 0000000000..3c1cbcd15a --- /dev/null +++ b/src/interface/icons/building.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/interface/icons/cellar.svg b/src/interface/icons/cellar.svg new file mode 100644 index 0000000000..a7548be21d --- /dev/null +++ b/src/interface/icons/cellar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/icons/city.svg b/src/interface/icons/city.svg new file mode 100644 index 0000000000..d7ea85ade2 --- /dev/null +++ b/src/interface/icons/city.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/interface/icons/clock-rotate-right.svg b/src/interface/icons/clock-rotate-right.svg new file mode 100644 index 0000000000..38216101ec --- /dev/null +++ b/src/interface/icons/clock-rotate-right.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/cloud-square.svg b/src/interface/icons/cloud-square.svg new file mode 100644 index 0000000000..03be85183d --- /dev/null +++ b/src/interface/icons/cloud-square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/cube-dots.svg b/src/interface/icons/cube-dots.svg new file mode 100644 index 0000000000..f42a21121a --- /dev/null +++ b/src/interface/icons/cube-dots.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/interface/icons/cube-scan-solid.svg b/src/interface/icons/cube-scan-solid.svg new file mode 100644 index 0000000000..ba150bae1a --- /dev/null +++ b/src/interface/icons/cube-scan-solid.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/interface/icons/cube-scan.svg b/src/interface/icons/cube-scan.svg new file mode 100644 index 0000000000..286cb4d9b4 --- /dev/null +++ b/src/interface/icons/cube-scan.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/interface/icons/cube.svg b/src/interface/icons/cube.svg new file mode 100644 index 0000000000..85290d7c18 --- /dev/null +++ b/src/interface/icons/cube.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/cursor-pointer.svg b/src/interface/icons/cursor-pointer.svg new file mode 100644 index 0000000000..c05a6ab739 --- /dev/null +++ b/src/interface/icons/cursor-pointer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/interface/icons/download-square.svg b/src/interface/icons/download-square.svg new file mode 100644 index 0000000000..9fa31e236f --- /dev/null +++ b/src/interface/icons/download-square.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/drone.svg b/src/interface/icons/drone.svg new file mode 100644 index 0000000000..278a3a9d35 --- /dev/null +++ b/src/interface/icons/drone.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/interface/icons/eye-closed.svg b/src/interface/icons/eye-closed.svg new file mode 100644 index 0000000000..1854ee3f67 --- /dev/null +++ b/src/interface/icons/eye-closed.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/icons/eye-solid.svg b/src/interface/icons/eye-solid.svg new file mode 100644 index 0000000000..030e83b5ac --- /dev/null +++ b/src/interface/icons/eye-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/eye.svg b/src/interface/icons/eye.svg new file mode 100644 index 0000000000..9db8457784 --- /dev/null +++ b/src/interface/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/face-3d-draft.svg b/src/interface/icons/face-3d-draft.svg new file mode 100644 index 0000000000..f1f458a5e2 --- /dev/null +++ b/src/interface/icons/face-3d-draft.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/interface/icons/filter.svg b/src/interface/icons/filter.svg new file mode 100644 index 0000000000..7f01001139 --- /dev/null +++ b/src/interface/icons/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/interface/icons/floppy-disk-arrow-in.svg b/src/interface/icons/floppy-disk-arrow-in.svg new file mode 100644 index 0000000000..49811f93a0 --- /dev/null +++ b/src/interface/icons/floppy-disk-arrow-in.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/interface/icons/floppy-disk.svg b/src/interface/icons/floppy-disk.svg new file mode 100644 index 0000000000..bb7828aab1 --- /dev/null +++ b/src/interface/icons/floppy-disk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/folder-minus.svg b/src/interface/icons/folder-minus.svg new file mode 100644 index 0000000000..4173fc6dba --- /dev/null +++ b/src/interface/icons/folder-minus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/folder-plus.svg b/src/interface/icons/folder-plus.svg new file mode 100644 index 0000000000..b39508e411 --- /dev/null +++ b/src/interface/icons/folder-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/folder.svg b/src/interface/icons/folder.svg new file mode 100644 index 0000000000..ccc42cb6f8 --- /dev/null +++ b/src/interface/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/interface/icons/frame-alt.svg b/src/interface/icons/frame-alt.svg new file mode 100644 index 0000000000..45d917240a --- /dev/null +++ b/src/interface/icons/frame-alt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/interface/icons/home-alt.svg b/src/interface/icons/home-alt.svg new file mode 100644 index 0000000000..9278f10f59 --- /dev/null +++ b/src/interface/icons/home-alt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/icons/home.svg b/src/interface/icons/home.svg new file mode 100644 index 0000000000..d8f1e2262a --- /dev/null +++ b/src/interface/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/intersect.svg b/src/interface/icons/intersect.svg new file mode 100644 index 0000000000..64cc346750 --- /dev/null +++ b/src/interface/icons/intersect.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/interface/icons/minus-square.svg b/src/interface/icons/minus-square.svg new file mode 100644 index 0000000000..cf268693b8 --- /dev/null +++ b/src/interface/icons/minus-square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/perspective-view.svg b/src/interface/icons/perspective-view.svg new file mode 100644 index 0000000000..fd980d30ff --- /dev/null +++ b/src/interface/icons/perspective-view.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/interface/icons/planimetry.svg b/src/interface/icons/planimetry.svg new file mode 100644 index 0000000000..dca2cf94c3 --- /dev/null +++ b/src/interface/icons/planimetry.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/interface/icons/plus-square.svg b/src/interface/icons/plus-square.svg new file mode 100644 index 0000000000..d347206808 --- /dev/null +++ b/src/interface/icons/plus-square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/refresh-double.svg b/src/interface/icons/refresh-double.svg new file mode 100644 index 0000000000..6d70aaef0b --- /dev/null +++ b/src/interface/icons/refresh-double.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/icons/rotate-camera-right.svg b/src/interface/icons/rotate-camera-right.svg new file mode 100644 index 0000000000..fca1c4e747 --- /dev/null +++ b/src/interface/icons/rotate-camera-right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/icons/select-edge3d.svg b/src/interface/icons/select-edge3d.svg new file mode 100644 index 0000000000..39700d70fe --- /dev/null +++ b/src/interface/icons/select-edge3d.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/interface/icons/select-face3d.svg b/src/interface/icons/select-face3d.svg new file mode 100644 index 0000000000..f7b04f3d6a --- /dev/null +++ b/src/interface/icons/select-face3d.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/interface/icons/select-point3d.svg b/src/interface/icons/select-point3d.svg new file mode 100644 index 0000000000..3a6bbd6fd1 --- /dev/null +++ b/src/interface/icons/select-point3d.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/interface/icons/settings.svg b/src/interface/icons/settings.svg new file mode 100644 index 0000000000..177771faee --- /dev/null +++ b/src/interface/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/icons/square3d-from-center.svg b/src/interface/icons/square3d-from-center.svg new file mode 100644 index 0000000000..531d99bd76 --- /dev/null +++ b/src/interface/icons/square3d-from-center.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/interface/interface_resources.qrc b/src/interface/interface_resources.qrc new file mode 100644 index 0000000000..9be3928f7d --- /dev/null +++ b/src/interface/interface_resources.qrc @@ -0,0 +1,59 @@ + + + + ../ifctester/webapp/public/fonts/dmsans/DMSans-VariableFont_opsz,wght.ttf + + + ../bonsai/bonsai/bim/data/icons/IFC.png + ../bonsai/bonsai/bim/data/icons/dm_add.png + ../bonsai/bonsai/bim/data/icons/dm_add_type.png + ../bonsai/bonsai/bim/data/icons/dm_assign.png + ../bonsai/bonsai/bim/data/icons/dm_centerline.png + ../bonsai/bonsai/bim/data/icons/dm_connect_mep_elements.png + ../bonsai/bonsai/bim/data/icons/dm_decomposition.png + ../bonsai/bonsai/bim/data/icons/dm_edit_profile.png + ../bonsai/bonsai/bim/data/icons/dm_ifc.png + ../bonsai/bonsai/bim/data/icons/dm_perform_quantity_take-off.png + ../bonsai/bonsai/bim/data/icons/dm_rectangle.png + ../bonsai/bonsai/bim/data/icons/dm_refresh.png + ../bonsai/bonsai/bim/data/icons/dm_rotate_90.png + ../bonsai/bonsai/bim/data/icons/dm_split.png + ../bonsai/bonsai/bim/data/icons/dm_toggle_openings.png + icons/plus-square.svg + icons/download-square.svg + icons/cloud-square.svg + icons/clock-rotate-right.svg + icons/floppy-disk.svg + icons/floppy-disk-arrow-in.svg + icons/cube.svg + icons/refresh-double.svg + icons/settings.svg + icons/home.svg + icons/home-alt.svg + icons/cube-scan.svg + icons/cube-scan-solid.svg + icons/planimetry.svg + icons/city.svg + icons/building.svg + icons/cellar.svg + icons/perspective-view.svg + icons/rotate-camera-right.svg + icons/drone.svg + icons/folder-plus.svg + icons/folder-minus.svg + icons/folder.svg + icons/minus-square.svg + icons/eye-closed.svg + icons/eye.svg + icons/eye-solid.svg + icons/intersect.svg + icons/select-edge3d.svg + icons/select-face3d.svg + icons/select-point3d.svg + icons/frame-alt.svg + icons/square3d-from-center.svg + icons/filter.svg + icons/cube-dots.svg + icons/cursor-pointer.svg + + diff --git a/src/interface/main.cpp b/src/interface/main.cpp new file mode 100644 index 0000000000..7a1e1d1970 --- /dev/null +++ b/src/interface/main.cpp @@ -0,0 +1,77 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "MockMainWindow.h" + +#include +#include +#include +#include +#include + +namespace { + +void installUiFont() { + const QString blender_font = + "/home/dion/drive/blender/blender-5.1.0-linux-x64/5.1/datafiles/fonts/Inter.woff2"; + + int font_id = QFontDatabase::addApplicationFont(blender_font); + if (font_id < 0) { + font_id = QFontDatabase::addApplicationFont( + ":/fonts/DMSans-VariableFont_opsz,wght.ttf"); + } + QString family; + if (font_id >= 0) { + const QStringList families = QFontDatabase::applicationFontFamilies(font_id); + if (!families.isEmpty()) { + family = families.front(); + } + } + if (!family.isEmpty()) { + QApplication::setFont(QFont(family, 10)); + } +} + +} // namespace + +int main(int argc, char* argv[]) { + QApplication app(argc, argv); + app.setApplicationName("IfcInterfaceMockup"); + app.setOrganizationName("IfcOpenShell"); + + 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 interface mockup with live viewport"); + parser.addHelpOption(); + parser.process(app); + + installUiFont(); + + MockMainWindow window; + window.show(); + return app.exec(); +} From 5ccb655e1086f3bc082eeb39b5e722de268fb755 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 5 May 2026 09:40:58 +1000 Subject: [PATCH 106/120] Interface mockup 2 --- src/interface/CMakeLists.txt | 15 + src/interface/MockMainWindow.cpp | 402 +----------------- src/interface/MockMainWindow.h | 6 + .../panels/properties/PropertiesPanelTypes.h | 60 +++ .../panels/properties/PropertiesPanelView.cpp | 71 ++++ .../panels/properties/PropertiesPanelView.h | 44 ++ .../properties/PropertiesPanelWidget.cpp | 313 ++++++++++++++ .../panels/properties/PropertiesPanelWidget.h | 40 ++ .../SpatialHierarchyPanelTypes.h | 48 +++ .../SpatialHierarchyPanelView.cpp | 71 ++++ .../SpatialHierarchyPanelView.h | 50 +++ .../SpatialHierarchyPanelWidget.cpp | 131 ++++++ .../SpatialHierarchyPanelWidget.h | 53 +++ 13 files changed, 920 insertions(+), 384 deletions(-) create mode 100644 src/interface/panels/properties/PropertiesPanelTypes.h create mode 100644 src/interface/panels/properties/PropertiesPanelView.cpp create mode 100644 src/interface/panels/properties/PropertiesPanelView.h create mode 100644 src/interface/panels/properties/PropertiesPanelWidget.cpp create mode 100644 src/interface/panels/properties/PropertiesPanelWidget.h create mode 100644 src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h create mode 100644 src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp create mode 100644 src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h create mode 100644 src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp create mode 100644 src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index 5e43a3171b..4bbf886756 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -27,6 +27,21 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MockMainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MockMainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelTypes.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelView.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelView.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelTypes.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelWidget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelWidget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelView.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelView.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelView.h ${CMAKE_CURRENT_SOURCE_DIR}/interface_resources.qrc ) diff --git a/src/interface/MockMainWindow.cpp b/src/interface/MockMainWindow.cpp index 85cefdd4ed..70864390b9 100644 --- a/src/interface/MockMainWindow.cpp +++ b/src/interface/MockMainWindow.cpp @@ -23,6 +23,12 @@ #include "AppSettings.h" #include "SceneLoader.h" #include "ViewportWindow.h" +#include "panels/models/ModelsPanelView.h" +#include "panels/models/ModelsPanelWidget.h" +#include "panels/properties/PropertiesPanelView.h" +#include "panels/properties/PropertiesPanelWidget.h" +#include "panels/spatial_hierarchy/SpatialHierarchyPanelView.h" +#include "panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h" #include #include @@ -93,10 +99,6 @@ QIcon makePanelSvgIcon(const QString& icon_path) { return makeTintedSvgIcon(icon_path, "#e7ebf2", "#ffffff", "#6f7988"); } -QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size) { - return renderTintedSvgPixmap(icon_path, "#e7ebf2", size); -} - class DockTitleBar : public QWidget { public: explicit DockTitleBar(const QString& title, bool has_settings = false, QWidget* parent = nullptr) @@ -175,165 +177,6 @@ QFrame* wrapInspectorPanel(QWidget* inner) { return outer; } -QWidget* makeInspectorFilterField(const QString& placeholder, QWidget* parent = nullptr) { - auto* field = new QLineEdit(parent); - field->setPlaceholderText(placeholder); - field->setClearButtonEnabled(true); - field->addAction(makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); - return field; -} - -QWidget* makePropertySetPanel(const QString& title, - const QList>& rows, - QWidget* parent = nullptr) { - auto* group = new QGroupBox(title, parent); - group->setObjectName("propertySetCard"); - auto* form = new QFormLayout(group); - form->setContentsMargins(10, 10, 10, 10); - form->setHorizontalSpacing(16); - form->setVerticalSpacing(6); - form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - - for (const auto& [name, value] : rows) { - auto* key = new QLabel(name, group); - key->setObjectName("propertyKeyLabel"); - auto* val = new QLabel(value, group); - val->setObjectName("propertyValueLabel"); - val->setWordWrap(true); - form->addRow(key, val); - } - - return group; -} - -QWidget* makeInspectorSection(const QString& title, - const QString& filter_placeholder, - const QList& groups, - QWidget* parent = nullptr) { - auto* section = new QWidget(parent); - section->setObjectName("inspectorSection"); - auto* layout = new QVBoxLayout(section); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - - auto* header = new QFrame(section); - header->setObjectName("inspectorSectionHeader"); - auto* header_layout = new QHBoxLayout(header); - header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(6); - - auto* toggle = new QToolButton(header); - toggle->setObjectName("inspectorSectionButton"); - toggle->setText(title); - toggle->setCheckable(true); - toggle->setChecked(true); - toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - toggle->setArrowType(Qt::DownArrow); - header_layout->addWidget(toggle); - header_layout->addStretch(1); - - QLineEdit* filter_field = nullptr; - if (!filter_placeholder.isEmpty()) { - auto* filter_toggle = new QToolButton(header); - filter_toggle->setObjectName("inspectorFilterToggle"); - filter_toggle->setCheckable(true); - filter_toggle->setChecked(false); - filter_toggle->setIcon(makePanelSvgIcon(":/icons/filter.svg")); - filter_toggle->setAutoRaise(true); - filter_toggle->setToolTip(QString("Filter %1").arg(title.toLower())); - header_layout->addWidget(filter_toggle); - - filter_field = qobject_cast(makeInspectorFilterField(filter_placeholder, section)); - filter_field->setVisible(false); - QObject::connect(filter_toggle, &QToolButton::toggled, filter_field, [filter_field](bool visible) { - filter_field->setVisible(visible); - if (visible) filter_field->setFocus(); - }); - } - - auto* body = new QWidget(section); - body->setObjectName("inspectorSectionBody"); - auto* body_layout = new QVBoxLayout(body); - body_layout->setContentsMargins(10, 6, 10, 0); - body_layout->setSpacing(6); - for (auto* group : groups) body_layout->addWidget(group); - - QObject::connect(toggle, &QToolButton::toggled, body, [toggle, body](bool expanded) { - toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); - body->setVisible(expanded); - }); - - layout->addWidget(header); - if (filter_field) { - auto* filter_wrapper = new QWidget(section); - filter_wrapper->setObjectName("inspectorFilterWrapper"); - auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); - filter_wrapper_layout->setSpacing(0); - filter_wrapper_layout->addWidget(filter_field); - layout->addWidget(filter_wrapper); - } - layout->addWidget(body); - return section; -} - -QWidget* makeAttributeList(const QList>& rows, QWidget* parent = nullptr) { - auto* panel = new QWidget(parent); - panel->setObjectName("attributeList"); - auto* form = new QFormLayout(panel); - form->setContentsMargins(0, 0, 0, 0); - form->setHorizontalSpacing(16); - form->setVerticalSpacing(6); - form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - - for (const auto& [name, value] : rows) { - auto* key = new QLabel(name, panel); - key->setObjectName("propertyKeyLabel"); - auto* val = new QLabel(value, panel); - val->setObjectName("propertyValueLabel"); - val->setWordWrap(true); - form->addRow(key, val); - } - - return panel; -} - -QWidget* makeRelationshipList(const QList>& rows, QWidget* parent = nullptr) { - auto* panel = new QWidget(parent); - panel->setObjectName("attributeList"); - auto* layout = new QVBoxLayout(panel); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - - for (const auto& [name, value] : rows) { - auto* row = new QWidget(panel); - row->setObjectName("relationshipRow"); - auto* row_layout = new QHBoxLayout(row); - row_layout->setContentsMargins(0, 0, 0, 0); - row_layout->setSpacing(12); - - auto* key = new QLabel(name, row); - key->setObjectName("propertyKeyLabel"); - key->setMinimumWidth(72); - - auto* target = new QLabel(value, row); - target->setObjectName("relationshipValueLabel"); - target->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - - auto* icon = new QLabel(row); - icon->setObjectName("relationshipIconLabel"); - icon->setPixmap(makePanelSvgPixmap(":/icons/cursor-pointer.svg", QSize(14, 14))); - icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - row_layout->addWidget(key); - row_layout->addWidget(target, 1); - row_layout->addWidget(icon, 0, Qt::AlignRight | Qt::AlignVCenter); - layout->addWidget(row); - } - - return panel; -} - } // namespace MockMainWindow::MockMainWindow(QWidget* parent) @@ -931,231 +774,22 @@ void MockMainWindow::setupViewport() { } void MockMainWindow::setupDocks() { - auto* models_panel = new QWidget(this); - auto* models_panel_layout = new QVBoxLayout(models_panel); - models_panel_layout->setContentsMargins(0, 0, 0, 0); - models_panel_layout->setSpacing(0); + auto* models_panel = new ifcinterface::panels::models::ModelsPanelWidget(this); + auto* spatial_panel = new ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); + auto* properties_panel = new ifcinterface::panels::properties::PropertiesPanelWidget(this); - auto* models_tree = new QTreeWidget(this); - models_tree->setColumnCount(2); - models_tree->setHeaderLabels({"Model", ""}); - models_tree->setIconSize(QSize(16, 16)); - models_tree->setSelectionMode(QAbstractItemView::ExtendedSelection); - models_tree->setContextMenuPolicy(Qt::CustomContextMenu); - models_tree->setUniformRowHeights(true); - models_tree->header()->setStretchLastSection(false); - models_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); - models_tree->header()->setSectionResizeMode(1, QHeaderView::Fixed); - models_tree->header()->resizeSection(1, 28); - models_tree->header()->hide(); + models_panel_view_ = new ifcinterface::panels::models::ModelsPanelView(models_panel, this); + spatial_panel_view_ = new ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel, this); + properties_panel_view_ = new ifcinterface::panels::properties::PropertiesPanelView(properties_panel, this); - auto* local_group = new QTreeWidgetItem(models_tree, {"Local Models", ""}); - local_group->setIcon(0, makePanelSvgIcon(":/icons/folder.svg")); - local_group->setIcon(1, makePanelSvgIcon(":/icons/eye.svg")); - local_group->setData(1, Qt::UserRole, true); - local_group->setSizeHint(0, QSize(0, 24)); - auto* linked_group = new QTreeWidgetItem(models_tree, {"Linked Models", ""}); - linked_group->setIcon(0, makePanelSvgIcon(":/icons/folder.svg")); - linked_group->setIcon(1, makePanelSvgIcon(":/icons/eye.svg")); - linked_group->setData(1, Qt::UserRole, true); - linked_group->setSizeHint(0, QSize(0, 24)); - - auto make_model_item = [this](QTreeWidgetItem* parent, const QString& name, bool visible) { - auto* item = new QTreeWidgetItem(parent, {name, ""}); - item->setIcon(0, makePanelSvgIcon(":/icons/cube.svg")); - item->setIcon(1, makePanelSvgIcon(visible ? ":/icons/eye-solid.svg" : ":/icons/eye.svg")); - item->setData(1, Qt::UserRole, visible); - item->setSizeHint(0, QSize(0, 24)); - return item; - }; - - make_model_item(local_group, "Architecture.ifc", true); - make_model_item(local_group, "Structure.ifc", true); - make_model_item(linked_group, "MEP.ifc", false); - models_tree->expandAll(); - - models_panel_layout->addWidget(models_tree); - - connect(models_tree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { - if (!item || column != 1) return; - const bool visible = item->data(1, Qt::UserRole).toBool(); - const bool next_visible = !visible; - item->setData(1, Qt::UserRole, next_visible); - if (item->childCount() > 0) { - item->setIcon(1, makePanelSvgIcon(next_visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); - } else { - item->setIcon(1, makePanelSvgIcon(next_visible ? ":/icons/eye-solid.svg" : ":/icons/eye-closed.svg")); - } - setStatusMessage("Models", next_visible ? "Item shown" : "Item hidden"); - }); - - connect(models_tree, &QTreeWidget::customContextMenuRequested, this, [this, models_tree](const QPoint& pos) { - auto* item = models_tree->itemAt(pos); - QMenu menu(models_tree); - if (item && item->childCount() > 0) { - menu.addAction(makePanelSvgIcon(":/icons/folder-plus.svg"), "Add Group", [this]() { - setStatusMessage("Models", "Add Group coming soon"); - }); - menu.addAction(makePanelSvgIcon(":/icons/folder-minus.svg"), "Remove Group", [this]() { - setStatusMessage("Models", "Remove Group coming soon"); - }); - } - if (item && item->childCount() == 0) { - menu.addAction(makePanelSvgIcon(":/icons/minus-square.svg"), "Remove Model", [this]() { - setStatusMessage("Models", "Remove Model coming soon"); - }); - } - menu.addAction(makePanelSvgIcon(":/icons/intersect.svg"), "Invert Visibility", [this]() { - setStatusMessage("Models", "Invert visibility coming soon"); - }); - if (!menu.actions().isEmpty()) menu.exec(models_tree->viewport()->mapToGlobal(pos)); - }); - - auto* spatial_tree = new QTreeWidget(this); - spatial_tree->setColumnCount(2); - spatial_tree->setHeaderLabels({"Spatial Item", ""}); - spatial_tree->setIconSize(QSize(16, 16)); - spatial_tree->setSelectionMode(QAbstractItemView::ExtendedSelection); - spatial_tree->setUniformRowHeights(true); - spatial_tree->header()->setStretchLastSection(false); - spatial_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); - spatial_tree->header()->setSectionResizeMode(1, QHeaderView::Fixed); - spatial_tree->header()->resizeSection(1, 28); - spatial_tree->header()->hide(); - - auto make_spatial_item = [this](QTreeWidgetItem* parent, const QString& name, const QString& icon_path) { - auto* item = new QTreeWidgetItem(parent, {name, ""}); - item->setIcon(0, makePanelSvgIcon(icon_path)); - item->setIcon(1, makePanelSvgIcon(":/icons/eye.svg")); - item->setData(1, Qt::UserRole, true); - item->setSizeHint(0, QSize(0, 24)); - return item; - }; - - auto* site = make_spatial_item(spatial_tree->invisibleRootItem(), "Site A", ":/icons/frame-alt.svg"); - auto* building = make_spatial_item(site, "Building 01", ":/icons/city.svg"); - auto* storey = make_spatial_item(building, "Level 02", ":/icons/planimetry.svg"); - make_spatial_item(storey, "Lobby", ":/icons/square3d-from-center.svg"); - make_spatial_item(storey, "Core", ":/icons/square3d-from-center.svg"); - spatial_tree->expandAll(); - - connect(spatial_tree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { - if (!item || column != 1) return; - const bool visible = item->data(1, Qt::UserRole).toBool(); - const bool next_visible = !visible; - item->setData(1, Qt::UserRole, next_visible); - item->setIcon(1, makePanelSvgIcon(next_visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); - setStatusMessage("Spatial", next_visible ? "Item shown" : "Item hidden"); - }); - - auto* properties_content = new QWidget(this); - properties_content->setObjectName("inspectorPanel"); - auto* properties_layout = new QVBoxLayout(properties_content); - properties_layout->setContentsMargins(0, 0, 0, 0); - properties_layout->setSpacing(12); - - auto* entity_card = new QFrame(properties_content); - entity_card->setObjectName("entityClassCard"); - auto* entity_layout = new QHBoxLayout(entity_card); - entity_layout->setContentsMargins(10, 8, 10, 8); - entity_layout->setSpacing(10); - auto* entity_icon = new QLabel(entity_card); - entity_icon->setPixmap(makePanelSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); - entity_icon->setAlignment(Qt::AlignCenter); - auto* entity_text = new QWidget(entity_card); - auto* entity_text_layout = new QVBoxLayout(entity_text); - entity_text_layout->setContentsMargins(0, 0, 0, 0); - entity_text_layout->setSpacing(2); - auto* entity_class = new QLabel("IfcWall", entity_text); - entity_class->setObjectName("entityClassLabel"); - auto* entity_type = new QLabel("SOLIDWALL", entity_text); - entity_type->setObjectName("entityTypeLabel"); - entity_text_layout->addWidget(entity_class); - entity_text_layout->addWidget(entity_type); - entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); - entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); - - auto* attributes_section = makeInspectorSection( - "Attributes", "", - { - makeAttributeList({{"GlobalId", "2Q$n5SLPP9Q8B7wQKjKfUQ"}, - {"Name", "Core-EXT-204"}, - {"Description", "External load-bearing wall"}}, properties_content) - }, - properties_content); - - auto* properties_section = makeInspectorSection( - "Properties", "Filter properties or sets", - { - makePropertySetPanel("Pset_WallCommon", - {{"Reference", "Core-EXT-204"}, - {"Status", "Reviewed"}, - {"Fire Rating", "120 min"}, - {"LoadBearing", "True"}}, - properties_content), - makePropertySetPanel("Identity Data", - {{"Type", "IfcWall"}, - {"Name", "Core-EXT-204"}, - {"Owner", "Architecture"}, - {"Phase", "Construction"}}, - properties_content), - makePropertySetPanel("BIM Collaboration", - {{"Issue Count", "2 open"}, - {"Last Review", "2026-04-30"}, - {"Assigned To", "Design Coordination"}}, - properties_content) - }, - properties_content); - - qobject_cast(attributes_section->layout())->setSpacing(8); - - auto* relationships_section = makeInspectorSection( - "Relationships", "", - { - makeRelationshipList({{"Type", "Basic Wall: Exterior - 200mm"}, - {"Container", "Level 02"}}, properties_content) - }, - properties_content); - - auto* quantities_section = makeInspectorSection( - "Quantities", "Filter quantities or sets", - { - makePropertySetPanel("BaseQuantities", - {{"Length", "6.20 m"}, - {"Height", "3.45 m"}, - {"Width", "0.30 m"}, - {"Volume", "6.42 m3"}}, - properties_content), - makePropertySetPanel("Finish Quantities", - {{"NetSideArea", "21.39 m2"}, - {"GrossArea", "22.10 m2"}, - {"Paint Coverage", "42.78 m2"}}, - properties_content) - }, - properties_content); - - auto* entity_wrapper = new QWidget(properties_content); - entity_wrapper->setObjectName("inspectorSectionBody"); - auto* entity_wrapper_layout = new QVBoxLayout(entity_wrapper); - entity_wrapper_layout->setContentsMargins(10, 0, 10, 0); - entity_wrapper_layout->setSpacing(0); - entity_wrapper_layout->addWidget(entity_card); - - properties_layout->addWidget(entity_wrapper); - properties_layout->addWidget(attributes_section); - properties_layout->addWidget(relationships_section); - properties_layout->addWidget(properties_section); - properties_layout->addWidget(quantities_section); - properties_layout->addStretch(1); - - auto* properties_scroll = new QScrollArea(this); - properties_scroll->setWidgetResizable(true); - properties_scroll->setFrameShape(QFrame::NoFrame); - properties_scroll->setWidget(properties_content); + connect(models_panel_view_, &ifcinterface::panels::models::ModelsPanelView::statusMessageRequested, + this, &MockMainWindow::setStatusMessage); + connect(spatial_panel_view_, &ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, + this, &MockMainWindow::setStatusMessage); models_dock_ = makeDock("Models", wrapPanel(models_panel), this, true); - spatial_dock_ = makeDock("Spatial Hierarchy", wrapPanel(spatial_tree), this); - properties_dock_ = makeDock("Properties", wrapInspectorPanel(properties_scroll), this); + spatial_dock_ = makeDock("Spatial Hierarchy", wrapPanel(spatial_panel), this); + properties_dock_ = makeDock("Properties", wrapInspectorPanel(properties_panel), this); layers_dock_ = makeDock("Layers", wrapPanel(makeComingSoonPanel("Layers")), this); stored_views_dock_ = makeDock("Stored Views", wrapPanel(makeComingSoonPanel("Stored Views")), this); search_dock_ = makeDock("Search and Query", wrapPanel(makeComingSoonPanel("Search and Query")), this); diff --git a/src/interface/MockMainWindow.h b/src/interface/MockMainWindow.h index 81da53db90..a038cb68b7 100644 --- a/src/interface/MockMainWindow.h +++ b/src/interface/MockMainWindow.h @@ -31,6 +31,9 @@ class QTabBar; class QToolButton; class ViewportWindow; class SceneLoader; +namespace ifcinterface::panels::models { class ModelsPanelView; } +namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelView; } +namespace ifcinterface::panels::properties { class PropertiesPanelView; } class MockMainWindow : public QMainWindow { Q_OBJECT @@ -83,6 +86,9 @@ private: QDockWidget* spreadsheet_dock_ = nullptr; QDockWidget* clash_dock_ = nullptr; QDockWidget* issues_dock_ = nullptr; + ifcinterface::panels::models::ModelsPanelView* models_panel_view_ = nullptr; + ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView* spatial_panel_view_ = nullptr; + ifcinterface::panels::properties::PropertiesPanelView* properties_panel_view_ = nullptr; }; #endif diff --git a/src/interface/panels/properties/PropertiesPanelTypes.h b/src/interface/panels/properties/PropertiesPanelTypes.h new file mode 100644 index 0000000000..9a1f8da9e7 --- /dev/null +++ b/src/interface/panels/properties/PropertiesPanelTypes.h @@ -0,0 +1,60 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_PROPERTIESPANELTYPES_H +#define IFCINTERFACE_PANELS_PROPERTIESPANELTYPES_H + +#include +#include +#include + +namespace ifcinterface::panels::properties { + +struct KeyValueRow { + QString key; + QString value; +}; + +struct RelationshipRow { + QString key; + QString value; +}; + +struct PropertySet { + QString title; + QList rows; +}; + +struct EntitySummary { + QString entity_class; + QString predefined_type; +}; + +struct PropertiesPanelState { + EntitySummary entity; + QList attributes; + QList relationships; + QList property_sets; + QList quantity_sets; +}; + +} // namespace ifcinterface::panels::properties + +#endif diff --git a/src/interface/panels/properties/PropertiesPanelView.cpp b/src/interface/panels/properties/PropertiesPanelView.cpp new file mode 100644 index 0000000000..1b74f40fff --- /dev/null +++ b/src/interface/panels/properties/PropertiesPanelView.cpp @@ -0,0 +1,71 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "PropertiesPanelView.h" + +#include "PropertiesPanelWidget.h" + +namespace ifcinterface::panels::properties { + +PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, QObject* parent) + : QObject(parent), widget_(widget) +{ + state_.entity = {"IfcWall", "SOLIDWALL"}; + state_.attributes = { + {"GlobalId", "2Q$n5SLPP9Q8B7wQKjKfUQ"}, + {"Name", "Core-EXT-204"}, + {"Description", "External load-bearing wall"}, + }; + state_.relationships = { + {"Type", "Basic Wall: Exterior - 200mm"}, + {"Container", "Level 02"}, + }; + state_.property_sets = { + {"Pset_WallCommon", + {{"Reference", "Core-EXT-204"}, + {"Status", "Reviewed"}, + {"Fire Rating", "120 min"}, + {"LoadBearing", "True"}}}, + {"Identity Data", + {{"Type", "IfcWall"}, + {"Name", "Core-EXT-204"}, + {"Owner", "Architecture"}, + {"Phase", "Construction"}}}, + {"BIM Collaboration", + {{"Issue Count", "2 open"}, + {"Last Review", "2026-04-30"}, + {"Assigned To", "Design Coordination"}}}, + }; + state_.quantity_sets = { + {"BaseQuantities", + {{"Length", "6.20 m"}, + {"Height", "3.45 m"}, + {"Width", "0.30 m"}, + {"Volume", "6.42 m3"}}}, + {"Finish Quantities", + {{"NetSideArea", "21.39 m2"}, + {"GrossArea", "22.10 m2"}, + {"Paint Coverage", "42.78 m2"}}}, + }; + + widget_->setState(state_); +} + +} // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelView.h b/src/interface/panels/properties/PropertiesPanelView.h new file mode 100644 index 0000000000..a61c393d31 --- /dev/null +++ b/src/interface/panels/properties/PropertiesPanelView.h @@ -0,0 +1,44 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H +#define IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H + +#include "PropertiesPanelTypes.h" + +#include + +namespace ifcinterface::panels::properties { + +class PropertiesPanelWidget; + +class PropertiesPanelView : public QObject { + Q_OBJECT +public: + explicit PropertiesPanelView(PropertiesPanelWidget* widget, QObject* parent = nullptr); + +private: + PropertiesPanelWidget* widget_ = nullptr; + PropertiesPanelState state_; +}; + +} // namespace ifcinterface::panels::properties + +#endif diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp new file mode 100644 index 0000000000..4459faa5dd --- /dev/null +++ b/src/interface/panels/properties/PropertiesPanelWidget.cpp @@ -0,0 +1,313 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "PropertiesPanelWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QPixmap renderPanelSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { + QFile file(icon_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QIcon(icon_path).pixmap(size); + } + + QString svg = QString::fromUtf8(file.readAll()); + svg.replace("currentColor", color, Qt::CaseSensitive); + svg.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); + svg.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); + + QSvgRenderer renderer(svg.toUtf8()); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +QIcon makePanelSvgIcon(const QString& icon_path) { + QIcon icon; + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#e7ebf2", QSize(20, 20)), QIcon::Normal, QIcon::Off); + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Active, QIcon::Off); + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Selected, QIcon::Off); + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#6f7988", QSize(20, 20)), QIcon::Disabled, QIcon::Off); + return icon; +} + +QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size) { + return renderPanelSvgPixmap(icon_path, "#e7ebf2", size); +} + +QWidget* makeInspectorFilterField(const QString& placeholder, QWidget* parent = nullptr) { + auto* field = new QLineEdit(parent); + field->setPlaceholderText(placeholder); + field->setClearButtonEnabled(true); + field->addAction(makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); + return field; +} + +QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { + auto* group = new QGroupBox(property_set.title, parent); + group->setObjectName("propertySetCard"); + auto* form = new QFormLayout(group); + form->setContentsMargins(10, 10, 10, 10); + form->setHorizontalSpacing(16); + form->setVerticalSpacing(6); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + for (const auto& row : property_set.rows) { + auto* key = new QLabel(row.key, group); + key->setObjectName("propertyKeyLabel"); + auto* value = new QLabel(row.value, group); + value->setObjectName("propertyValueLabel"); + value->setWordWrap(true); + form->addRow(key, value); + } + + return group; +} + +QWidget* makeInspectorSection(const QString& title, + const QString& filter_placeholder, + const QList& groups, + QWidget* parent = nullptr) { + auto* section = new QWidget(parent); + section->setObjectName("inspectorSection"); + auto* layout = new QVBoxLayout(section); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + auto* header = new QFrame(section); + header->setObjectName("inspectorSectionHeader"); + auto* header_layout = new QHBoxLayout(header); + header_layout->setContentsMargins(0, 0, 0, 0); + header_layout->setSpacing(6); + + auto* toggle = new QToolButton(header); + toggle->setObjectName("inspectorSectionButton"); + toggle->setText(title); + toggle->setCheckable(true); + toggle->setChecked(true); + toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toggle->setArrowType(Qt::DownArrow); + header_layout->addWidget(toggle); + header_layout->addStretch(1); + + QLineEdit* filter_field = nullptr; + if (!filter_placeholder.isEmpty()) { + auto* filter_toggle = new QToolButton(header); + filter_toggle->setObjectName("inspectorFilterToggle"); + filter_toggle->setCheckable(true); + filter_toggle->setIcon(makePanelSvgIcon(":/icons/filter.svg")); + filter_toggle->setAutoRaise(true); + header_layout->addWidget(filter_toggle); + + filter_field = qobject_cast(makeInspectorFilterField(filter_placeholder, section)); + filter_field->setVisible(false); + QObject::connect(filter_toggle, &QToolButton::toggled, filter_field, [filter_field](bool visible) { + filter_field->setVisible(visible); + if (visible) filter_field->setFocus(); + }); + } + + auto* body = new QWidget(section); + body->setObjectName("inspectorSectionBody"); + auto* body_layout = new QVBoxLayout(body); + body_layout->setContentsMargins(10, 6, 10, 0); + body_layout->setSpacing(6); + for (auto* group : groups) body_layout->addWidget(group); + + QObject::connect(toggle, &QToolButton::toggled, body, [toggle, body](bool expanded) { + toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + body->setVisible(expanded); + }); + + layout->addWidget(header); + if (filter_field) { + auto* filter_wrapper = new QWidget(section); + filter_wrapper->setObjectName("inspectorFilterWrapper"); + auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); + filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); + filter_wrapper_layout->setSpacing(0); + filter_wrapper_layout->addWidget(filter_field); + layout->addWidget(filter_wrapper); + } + layout->addWidget(body); + return section; +} + +QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { + auto* panel = new QWidget(parent); + panel->setObjectName("attributeList"); + auto* form = new QFormLayout(panel); + form->setContentsMargins(0, 0, 0, 0); + form->setHorizontalSpacing(16); + form->setVerticalSpacing(6); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + for (const auto& row : rows) { + auto* key = new QLabel(row.key, panel); + key->setObjectName("propertyKeyLabel"); + auto* value = new QLabel(row.value, panel); + value->setObjectName("propertyValueLabel"); + value->setWordWrap(true); + form->addRow(key, value); + } + + return panel; +} + +QWidget* makeRelationshipList(const QList& rows, QWidget* parent = nullptr) { + auto* panel = new QWidget(parent); + panel->setObjectName("attributeList"); + auto* layout = new QVBoxLayout(panel); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + for (const auto& row_data : rows) { + auto* row = new QWidget(panel); + row->setObjectName("relationshipRow"); + auto* row_layout = new QHBoxLayout(row); + row_layout->setContentsMargins(0, 0, 0, 0); + row_layout->setSpacing(12); + + auto* key = new QLabel(row_data.key, row); + key->setObjectName("propertyKeyLabel"); + key->setMinimumWidth(72); + + auto* target = new QLabel(row_data.value, row); + target->setObjectName("relationshipValueLabel"); + target->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + auto* icon = new QLabel(row); + icon->setObjectName("relationshipIconLabel"); + icon->setPixmap(makePanelSvgPixmap(":/icons/cursor-pointer.svg", QSize(14, 14))); + icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + row_layout->addWidget(key); + row_layout->addWidget(target, 1); + row_layout->addWidget(icon, 0, Qt::AlignRight | Qt::AlignVCenter); + layout->addWidget(row); + } + + return panel; +} + +} // namespace + +namespace ifcinterface::panels::properties { + +PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) + : QWidget(parent) +{ + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); +} + +void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { + auto* root = qobject_cast(layout()); + while (auto* item = root->takeAt(0)) { + if (auto* widget = item->widget()) widget->deleteLater(); + delete item; + } + + auto* content = new QWidget(this); + content->setObjectName("inspectorPanel"); + auto* content_layout = new QVBoxLayout(content); + content_layout->setContentsMargins(0, 0, 0, 0); + content_layout->setSpacing(12); + + auto* entity_card = new QFrame(content); + entity_card->setObjectName("entityClassCard"); + auto* entity_layout = new QHBoxLayout(entity_card); + entity_layout->setContentsMargins(10, 8, 10, 8); + entity_layout->setSpacing(10); + auto* entity_icon = new QLabel(entity_card); + entity_icon->setPixmap(makePanelSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); + entity_icon->setAlignment(Qt::AlignCenter); + auto* entity_text = new QWidget(entity_card); + auto* entity_text_layout = new QVBoxLayout(entity_text); + entity_text_layout->setContentsMargins(0, 0, 0, 0); + entity_text_layout->setSpacing(2); + auto* entity_class = new QLabel(state.entity.entity_class, entity_text); + entity_class->setObjectName("entityClassLabel"); + auto* entity_type = new QLabel(state.entity.predefined_type, entity_text); + entity_type->setObjectName("entityTypeLabel"); + entity_text_layout->addWidget(entity_class); + entity_text_layout->addWidget(entity_type); + entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); + entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); + + auto* entity_wrapper = new QWidget(content); + entity_wrapper->setObjectName("inspectorSectionBody"); + auto* entity_wrapper_layout = new QVBoxLayout(entity_wrapper); + entity_wrapper_layout->setContentsMargins(10, 0, 10, 0); + entity_wrapper_layout->setSpacing(0); + entity_wrapper_layout->addWidget(entity_card); + + QList property_set_widgets; + for (const auto& property_set : state.property_sets) { + property_set_widgets.append(makePropertySetPanel(property_set, content)); + } + + QList quantity_set_widgets; + for (const auto& property_set : state.quantity_sets) { + quantity_set_widgets.append(makePropertySetPanel(property_set, content)); + } + + auto* attributes_section = makeInspectorSection( + "Attributes", "", {makeAttributeList(state.attributes, content)}, content); + auto* relationships_section = makeInspectorSection( + "Relationships", "", {makeRelationshipList(state.relationships, content)}, content); + auto* properties_section = makeInspectorSection( + "Properties", "Filter properties or sets", property_set_widgets, content); + auto* quantities_section = makeInspectorSection( + "Quantities", "Filter quantities or sets", quantity_set_widgets, content); + + content_layout->addWidget(entity_wrapper); + content_layout->addWidget(attributes_section); + content_layout->addWidget(relationships_section); + content_layout->addWidget(properties_section); + content_layout->addWidget(quantities_section); + content_layout->addStretch(1); + + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidget(content); + root->addWidget(scroll); +} + +} // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelWidget.h b/src/interface/panels/properties/PropertiesPanelWidget.h new file mode 100644 index 0000000000..dcd600cbb7 --- /dev/null +++ b/src/interface/panels/properties/PropertiesPanelWidget.h @@ -0,0 +1,40 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_PROPERTIESPANELWIDGET_H +#define IFCINTERFACE_PANELS_PROPERTIESPANELWIDGET_H + +#include "PropertiesPanelTypes.h" + +#include + +namespace ifcinterface::panels::properties { + +class PropertiesPanelWidget : public QWidget { + Q_OBJECT +public: + explicit PropertiesPanelWidget(QWidget* parent = nullptr); + + void setState(const PropertiesPanelState& state); +}; + +} // namespace ifcinterface::panels::properties + +#endif diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h new file mode 100644 index 0000000000..90dd67b8fd --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h @@ -0,0 +1,48 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELTYPES_H +#define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELTYPES_H + +#include +#include +#include + +namespace ifcinterface::panels::spatial_hierarchy { + +enum class ItemKind { + Site, + Building, + Storey, + Space, +}; + +struct TreeNode { + QString name; + ItemKind kind = ItemKind::Space; + bool visible = true; + QList children; +}; + +using NodePath = QStringList; + +} // namespace ifcinterface::panels::spatial_hierarchy + +#endif diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp new file mode 100644 index 0000000000..a3948558b8 --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp @@ -0,0 +1,71 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "SpatialHierarchyPanelView.h" + +#include "SpatialHierarchyPanelWidget.h" + +namespace ifcinterface::panels::spatial_hierarchy { + +namespace { + +TreeNode* findNodeRecursive(QList& nodes, const NodePath& path, int depth) { + for (auto& node : nodes) { + if (node.name != path.at(depth)) continue; + if (depth == path.size() - 1) return &node; + return findNodeRecursive(node.children, path, depth + 1); + } + return nullptr; +} + +} // namespace + +SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanelWidget* widget, QObject* parent) + : QObject(parent), widget_(widget) +{ + nodes_ = { + {"Site A", ItemKind::Site, true, + {{"Building 01", ItemKind::Building, true, + {{"Level 02", ItemKind::Storey, true, + {{"Lobby", ItemKind::Space, true, {}}, + {"Core", ItemKind::Space, true, {}}}}}}}}, + }; + + connect(widget_, &SpatialHierarchyPanelWidget::visibilityToggleRequested, this, [this](const NodePath& path) { + if (auto* node = findNode(path)) { + node->visible = !node->visible; + reload(); + emit statusMessageRequested("Spatial", node->visible ? "Item shown" : "Item hidden"); + } + }); + + reload(); +} + +void SpatialHierarchyPanelView::reload() { + widget_->setNodes(nodes_); +} + +TreeNode* SpatialHierarchyPanelView::findNode(const NodePath& path) { + if (path.isEmpty()) return nullptr; + return findNodeRecursive(nodes_, path, 0); +} + +} // namespace ifcinterface::panels::spatial_hierarchy diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h new file mode 100644 index 0000000000..1924b7f9f3 --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h @@ -0,0 +1,50 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELVIEW_H +#define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELVIEW_H + +#include "SpatialHierarchyPanelTypes.h" + +#include + +namespace ifcinterface::panels::spatial_hierarchy { + +class SpatialHierarchyPanelWidget; + +class SpatialHierarchyPanelView : public QObject { + Q_OBJECT +public: + explicit SpatialHierarchyPanelView(SpatialHierarchyPanelWidget* widget, QObject* parent = nullptr); + +signals: + void statusMessageRequested(const QString& mode, const QString& detail); + +private: + void reload(); + TreeNode* findNode(const NodePath& path); + + SpatialHierarchyPanelWidget* widget_ = nullptr; + QList nodes_; +}; + +} // namespace ifcinterface::panels::spatial_hierarchy + +#endif diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp new file mode 100644 index 0000000000..efb312cd1e --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp @@ -0,0 +1,131 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "SpatialHierarchyPanelWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QPixmap renderPanelSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { + QFile file(icon_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QIcon(icon_path).pixmap(size); + } + + QString svg = QString::fromUtf8(file.readAll()); + svg.replace("currentColor", color, Qt::CaseSensitive); + svg.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); + svg.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); + + QSvgRenderer renderer(svg.toUtf8()); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +QIcon makePanelSvgIcon(const QString& icon_path) { + QIcon icon; + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#e7ebf2", QSize(20, 20)), QIcon::Normal, QIcon::Off); + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Active, QIcon::Off); + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Selected, QIcon::Off); + icon.addPixmap(renderPanelSvgPixmap(icon_path, "#6f7988", QSize(20, 20)), QIcon::Disabled, QIcon::Off); + return icon; +} + +} // namespace + +namespace ifcinterface::panels::spatial_hierarchy { + +SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + tree_ = new QTreeWidget(this); + tree_->setColumnCount(2); + tree_->setHeaderLabels({"Spatial Item", ""}); + tree_->setIconSize(QSize(16, 16)); + tree_->setSelectionMode(QAbstractItemView::ExtendedSelection); + tree_->setUniformRowHeights(true); + tree_->header()->setStretchLastSection(false); + tree_->header()->setSectionResizeMode(0, QHeaderView::Stretch); + tree_->header()->setSectionResizeMode(1, QHeaderView::Fixed); + tree_->header()->resizeSection(1, 28); + tree_->header()->hide(); + layout->addWidget(tree_); + + connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { + if (!item || column != 1) return; + emit visibilityToggleRequested(itemPath(item)); + }); +} + +void SpatialHierarchyPanelWidget::setNodes(const QList& nodes) { + tree_->clear(); + for (const auto& node : nodes) { + addNode(tree_->invisibleRootItem(), node); + } + tree_->expandAll(); +} + +void SpatialHierarchyPanelWidget::addNode(QTreeWidgetItem* parent, const TreeNode& node) { + auto* item = new QTreeWidgetItem(parent, {node.name, ""}); + item->setData(1, Qt::UserRole, node.visible); + item->setSizeHint(0, QSize(0, 24)); + item->setIcon(0, makePanelSvgIcon(iconPath(node.kind))); + item->setIcon(1, makePanelSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); + for (const auto& child : node.children) { + addNode(item, child); + } +} + +NodePath SpatialHierarchyPanelWidget::itemPath(QTreeWidgetItem* item) const { + NodePath path; + while (item) { + path.prepend(item->text(0)); + item = item->parent(); + } + return path; +} + +QString SpatialHierarchyPanelWidget::iconPath(ItemKind kind) const { + switch (kind) { + case ItemKind::Site: return ":/icons/frame-alt.svg"; + case ItemKind::Building: return ":/icons/city.svg"; + case ItemKind::Storey: return ":/icons/planimetry.svg"; + case ItemKind::Space: return ":/icons/square3d-from-center.svg"; + } + return ":/icons/frame-alt.svg"; +} + +} // namespace ifcinterface::panels::spatial_hierarchy diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h new file mode 100644 index 0000000000..b5e15720ce --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h @@ -0,0 +1,53 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELWIDGET_H +#define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELWIDGET_H + +#include "SpatialHierarchyPanelTypes.h" + +#include + +class QTreeWidget; +class QTreeWidgetItem; + +namespace ifcinterface::panels::spatial_hierarchy { + +class SpatialHierarchyPanelWidget : public QWidget { + Q_OBJECT +public: + explicit SpatialHierarchyPanelWidget(QWidget* parent = nullptr); + + void setNodes(const QList& nodes); + +signals: + void visibilityToggleRequested(const NodePath& path); + +private: + void addNode(QTreeWidgetItem* parent, const TreeNode& node); + NodePath itemPath(QTreeWidgetItem* item) const; + QString iconPath(ItemKind kind) const; + + QTreeWidget* tree_ = nullptr; +}; + +} // namespace ifcinterface::panels::spatial_hierarchy + +#endif From ae66550cae5295fc30c391d29cfc7a1376a227e2 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 5 May 2026 18:02:02 +1000 Subject: [PATCH 107/120] Interface mockup 3 --- src/interface/CMakeLists.txt | 10 +- .../{MockMainWindow.cpp => MainWindow.cpp} | 262 +++++------------- .../{MockMainWindow.h => MainWindow.h} | 12 +- .../components/CollapsibleSection.cpp | 113 ++++++++ src/interface/components/CollapsibleSection.h | 48 ++++ src/interface/components/PanelChrome.cpp | 117 ++++++++ src/interface/components/PanelChrome.h | 38 +++ src/interface/components/SvgIcon.cpp | 72 +++++ src/interface/components/SvgIcon.h | 41 +++ src/interface/main.cpp | 4 +- .../properties/PropertiesPanelWidget.cpp | 139 +--------- .../SpatialHierarchyPanelWidget.cpp | 43 +-- 12 files changed, 535 insertions(+), 364 deletions(-) rename src/interface/{MockMainWindow.cpp => MainWindow.cpp} (74%) rename src/interface/{MockMainWindow.h => MainWindow.h} (94%) create mode 100644 src/interface/components/CollapsibleSection.cpp create mode 100644 src/interface/components/CollapsibleSection.h create mode 100644 src/interface/components/PanelChrome.cpp create mode 100644 src/interface/components/PanelChrome.h create mode 100644 src/interface/components/SvgIcon.cpp create mode 100644 src/interface/components/SvgIcon.h diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index 4bbf886756..db55d16807 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -25,8 +25,14 @@ find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets Svg REQUIRED PATHS ${QT set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/MockMainWindow.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/MockMainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/CollapsibleSection.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/CollapsibleSection.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/PanelChrome.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/PanelChrome.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelTypes.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.h diff --git a/src/interface/MockMainWindow.cpp b/src/interface/MainWindow.cpp similarity index 74% rename from src/interface/MockMainWindow.cpp rename to src/interface/MainWindow.cpp index 70864390b9..212b552a49 100644 --- a/src/interface/MockMainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -18,11 +18,13 @@ * * ********************************************************************************/ -#include "MockMainWindow.h" +#include "MainWindow.h" -#include "AppSettings.h" -#include "SceneLoader.h" -#include "ViewportWindow.h" +#include "../ifcviewer/AppSettings.h" +#include "../ifcviewer/SceneLoader.h" +#include "../ifcviewer/ViewportWindow.h" +#include "components/PanelChrome.h" +#include "components/SvgIcon.h" #include "panels/models/ModelsPanelView.h" #include "panels/models/ModelsPanelWidget.h" #include "panels/properties/PropertiesPanelView.h" @@ -32,154 +34,21 @@ #include #include -#include -#include #include -#include -#include #include #include #include -#include -#include -#include #include -#include -#include -#include -#include -#include #include #include #include -#include #include #include -#include #include -namespace { +namespace ifcinterface::shell { -QPixmap renderTintedSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { - QFile file(icon_path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QIcon(icon_path).pixmap(size); - } - - QString svg = QString::fromUtf8(file.readAll()); - QString tinted = svg; - tinted.replace("currentColor", color, Qt::CaseSensitive); - tinted.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); - tinted.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); - QByteArray data = tinted.toUtf8(); - QSvgRenderer renderer(data); - QPixmap pixmap(size); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - renderer.render(&painter); - return pixmap; -} - -QIcon makeTintedSvgIcon(const QString& icon_path, const QString& normal = "#39b54a", - const QString& active = "#53c763", const QString& disabled = "#6f7988") { - QFile file(icon_path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QIcon(icon_path); - } - - QIcon icon; - icon.addPixmap(renderTintedSvgPixmap(icon_path, normal, QSize(20, 20)), QIcon::Normal, QIcon::Off); - icon.addPixmap(renderTintedSvgPixmap(icon_path, active, QSize(20, 20)), QIcon::Active, QIcon::Off); - icon.addPixmap(renderTintedSvgPixmap(icon_path, active, QSize(20, 20)), QIcon::Selected, QIcon::Off); - icon.addPixmap(renderTintedSvgPixmap(icon_path, disabled, QSize(20, 20)), QIcon::Disabled, QIcon::Off); - return icon; -} - -QIcon makePanelSvgIcon(const QString& icon_path) { - return makeTintedSvgIcon(icon_path, "#e7ebf2", "#ffffff", "#6f7988"); -} - -class DockTitleBar : public QWidget { -public: - explicit DockTitleBar(const QString& title, bool has_settings = false, QWidget* parent = nullptr) - : QWidget(parent) - { - auto* layout = new QHBoxLayout(this); - layout->setContentsMargins(10, 6, 6, 6); - layout->setSpacing(6); - - auto* text = new QLabel(title.toUpper(), this); - text->setObjectName("dockTitleText"); - - layout->addWidget(text); - layout->addStretch(1); - if (has_settings) { - auto* settings = new QToolButton(this); - settings->setIcon(makePanelSvgIcon(":/icons/settings.svg")); - settings->setAutoRaise(true); - settings->setCursor(Qt::ArrowCursor); - settings->setFixedSize(18, 18); - settings->setObjectName("dockTitleButton"); - settings->setToolTip(QString("%1 settings").arg(title)); - connect(settings, &QToolButton::clicked, this, [this, title]() { - auto* anchor = parentWidget(); - QMenu menu(anchor); - menu.addAction(QString("%1 settings coming soon").arg(title)); - menu.exec(QCursor::pos()); - }); - layout->addWidget(settings); - } - } -}; - -QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings = false) { - auto* dock = new QDockWidget(title, parent); - dock->setObjectName(title); - dock->setFeatures(QDockWidget::DockWidgetMovable | - QDockWidget::DockWidgetFloatable | - QDockWidget::DockWidgetClosable); - dock->setTitleBarWidget(new DockTitleBar(title, has_settings, dock)); - dock->setWidget(content); - return dock; -} - -QFrame* wrapPanel(QWidget* inner) { - auto* outer = new QFrame(); - auto* outer_layout = new QVBoxLayout(outer); - outer_layout->setContentsMargins(6, 6, 6, 6); - outer_layout->setSpacing(0); - - auto* frame = new QFrame(outer); - frame->setObjectName("panelFrame"); - auto* layout = new QVBoxLayout(frame); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(0); - layout->addWidget(inner); - - outer_layout->addWidget(frame); - return outer; -} - -QFrame* wrapInspectorPanel(QWidget* inner) { - auto* outer = new QFrame(); - auto* outer_layout = new QVBoxLayout(outer); - outer_layout->setContentsMargins(6, 6, 6, 6); - outer_layout->setSpacing(0); - - auto* frame = new QFrame(outer); - frame->setObjectName("panelFrame"); - auto* layout = new QVBoxLayout(frame); - layout->setContentsMargins(0, 8, 0, 8); - layout->setSpacing(0); - layout->addWidget(inner); - - outer_layout->addWidget(frame); - return outer; -} - -} // namespace - -MockMainWindow::MockMainWindow(QWidget* parent) +MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { setupChrome(); @@ -191,7 +60,7 @@ MockMainWindow::MockMainWindow(QWidget* parent) resize(1720, 980); } -void MockMainWindow::setupChrome() { +void MainWindow::setupChrome() { setWindowTitle("IfcOpenShell Interface"); setDockOptions(QMainWindow::AllowNestedDocks | QMainWindow::AllowTabbedDocks | @@ -459,12 +328,12 @@ void MockMainWindow::setupChrome() { )"); } -QToolButton* MockMainWindow::makeRibbonAction(const QString& text, const QString& icon_path) { +QToolButton* MainWindow::makeRibbonAction(const QString& text, const QString& icon_path) { auto* button = new QToolButton(this); button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); button->setIcon(icon_path.endsWith(".svg") - ? makeTintedSvgIcon(icon_path) - : QIcon(icon_path)); + ? components::icons::makeTintedSvgIcon(icon_path) + : QIcon(icon_path)); button->setIconSize(QSize(20, 20)); button->setText(text); button->setMinimumSize(QSize(68, 54)); @@ -473,7 +342,7 @@ QToolButton* MockMainWindow::makeRibbonAction(const QString& text, const QString return button; } -QWidget* MockMainWindow::makeRibbonGroup(const QString& title, const QList& buttons) { +QWidget* MainWindow::makeRibbonGroup(const QString& title, const QList& buttons) { auto* group = new QFrame(this); group->setObjectName("ribbonGroup"); auto* group_layout = new QVBoxLayout(group); @@ -493,7 +362,7 @@ QWidget* MockMainWindow::makeRibbonGroup(const QString& title, const QListsetContentsMargins(12, 12, 12, 12); @@ -509,12 +378,12 @@ QWidget* MockMainWindow::makeComingSoonPanel(const QString& title) { return widget; } -void MockMainWindow::setStatusMessage(const QString& mode, const QString& detail) { +void MainWindow::setStatusMessage(const QString& mode, const QString& detail) { status_mode_label_->setText(mode); status_selection_label_->setText(detail); } -QToolButton* MockMainWindow::makePanelToggle(const QString& text, QDockWidget* dock) { +QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) { auto* button = makeRibbonAction(text, ":/icons/dm_toggle_openings.png"); button->setCheckable(true); button->setChecked(dock->isVisible()); @@ -529,7 +398,7 @@ QToolButton* MockMainWindow::makePanelToggle(const QString& text, QDockWidget* d return button; } -QWidget* MockMainWindow::buildHomeRibbonPage() { +QWidget* MainWindow::buildHomeRibbonPage() { auto* page = new QFrame(this); page->setObjectName("ribbonPage"); auto* row = new QHBoxLayout(page); @@ -562,7 +431,7 @@ QWidget* MockMainWindow::buildHomeRibbonPage() { }); auto* add_model = makeRibbonAction("Add Model", ":/icons/cube.svg"); - connect(add_model, &QToolButton::clicked, this, &MockMainWindow::onAddFiles); + connect(add_model, &QToolButton::clicked, this, &MainWindow::onAddFiles); auto* sync_models = makeRibbonAction("Sync Models", ":/icons/refresh-double.svg"); connect(sync_models, &QToolButton::clicked, this, [this]() { setStatusMessage("Models", "Sync models coming soon"); @@ -580,7 +449,7 @@ QWidget* MockMainWindow::buildHomeRibbonPage() { return page; } -QWidget* MockMainWindow::buildNavigateRibbonPage() { +QWidget* MainWindow::buildNavigateRibbonPage() { auto* page = new QFrame(this); page->setObjectName("ribbonPage"); auto* row = new QHBoxLayout(page); @@ -643,7 +512,7 @@ QWidget* MockMainWindow::buildNavigateRibbonPage() { return page; } -QWidget* MockMainWindow::buildInspectRibbonPage() { +QWidget* MainWindow::buildInspectRibbonPage() { auto* page = new QFrame(this); page->setObjectName("ribbonPage"); auto* row = new QHBoxLayout(page); @@ -686,7 +555,7 @@ QWidget* MockMainWindow::buildInspectRibbonPage() { return page; } -QWidget* MockMainWindow::buildPanelsRibbonPage() { +QWidget* MainWindow::buildPanelsRibbonPage() { auto* page = new QFrame(this); page->setObjectName("ribbonPage"); auto* row = new QHBoxLayout(page); @@ -712,7 +581,7 @@ QWidget* MockMainWindow::buildPanelsRibbonPage() { return page; } -void MockMainWindow::setupRibbon() { +void MainWindow::setupRibbon() { auto* shell = new QFrame(this); shell->setObjectName("ribbonShell"); @@ -751,7 +620,7 @@ void MockMainWindow::setupRibbon() { setMenuWidget(shell); } -void MockMainWindow::setupViewport() { +void MainWindow::setupViewport() { viewport_ = new ViewportWindow(); viewport_container_ = QWidget::createWindowContainer(viewport_, this); viewport_container_->setMinimumSize(400, 300); @@ -773,29 +642,38 @@ void MockMainWindow::setupViewport() { setCentralWidget(shell); } -void MockMainWindow::setupDocks() { - auto* models_panel = new ifcinterface::panels::models::ModelsPanelWidget(this); - auto* spatial_panel = new ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); - auto* properties_panel = new ifcinterface::panels::properties::PropertiesPanelWidget(this); +void MainWindow::setupDocks() { + auto* models_panel = new panels::models::ModelsPanelWidget(this); + auto* spatial_panel = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); + auto* properties_panel = new panels::properties::PropertiesPanelWidget(this); - models_panel_view_ = new ifcinterface::panels::models::ModelsPanelView(models_panel, this); - spatial_panel_view_ = new ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel, this); - properties_panel_view_ = new ifcinterface::panels::properties::PropertiesPanelView(properties_panel, this); + models_panel_view_ = new panels::models::ModelsPanelView(models_panel, this); + spatial_panel_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel, this); + properties_panel_view_ = new panels::properties::PropertiesPanelView(properties_panel, this); - connect(models_panel_view_, &ifcinterface::panels::models::ModelsPanelView::statusMessageRequested, - this, &MockMainWindow::setStatusMessage); - connect(spatial_panel_view_, &ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, - this, &MockMainWindow::setStatusMessage); + connect(models_panel_view_, &panels::models::ModelsPanelView::statusMessageRequested, + this, &MainWindow::setStatusMessage); + connect(spatial_panel_view_, &panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, + this, &MainWindow::setStatusMessage); - models_dock_ = makeDock("Models", wrapPanel(models_panel), this, true); - spatial_dock_ = makeDock("Spatial Hierarchy", wrapPanel(spatial_panel), this); - properties_dock_ = makeDock("Properties", wrapInspectorPanel(properties_panel), this); - layers_dock_ = makeDock("Layers", wrapPanel(makeComingSoonPanel("Layers")), this); - stored_views_dock_ = makeDock("Stored Views", wrapPanel(makeComingSoonPanel("Stored Views")), this); - search_dock_ = makeDock("Search and Query", wrapPanel(makeComingSoonPanel("Search and Query")), this); - spreadsheet_dock_ = makeDock("Spreadsheet", wrapPanel(makeComingSoonPanel("Spreadsheet")), this); - clash_dock_ = makeDock("Clash", wrapPanel(makeComingSoonPanel("Clash")), this); - issues_dock_ = makeDock("Issues", wrapPanel(makeComingSoonPanel("Issues")), this); + models_dock_ = components::panel::makeDock( + "Models", components::panel::wrapPanel(models_panel), this, true); + spatial_dock_ = components::panel::makeDock( + "Spatial Hierarchy", components::panel::wrapPanel(spatial_panel), this); + properties_dock_ = components::panel::makeDock( + "Properties", components::panel::wrapInspectorPanel(properties_panel), this); + layers_dock_ = components::panel::makeDock( + "Layers", components::panel::wrapPanel(makeComingSoonPanel("Layers")), this); + stored_views_dock_ = components::panel::makeDock( + "Stored Views", components::panel::wrapPanel(makeComingSoonPanel("Stored Views")), this); + search_dock_ = components::panel::makeDock( + "Search and Query", components::panel::wrapPanel(makeComingSoonPanel("Search and Query")), this); + spreadsheet_dock_ = components::panel::makeDock( + "Spreadsheet", components::panel::wrapPanel(makeComingSoonPanel("Spreadsheet")), this); + clash_dock_ = components::panel::makeDock( + "Clash", components::panel::wrapPanel(makeComingSoonPanel("Clash")), this); + issues_dock_ = components::panel::makeDock( + "Issues", components::panel::wrapPanel(makeComingSoonPanel("Issues")), this); addDockWidget(Qt::LeftDockWidgetArea, models_dock_); addDockWidget(Qt::LeftDockWidgetArea, spatial_dock_); @@ -828,7 +706,7 @@ void MockMainWindow::setupDocks() { resizeDocks({models_dock_, spatial_dock_}, {280, 240}, Qt::Vertical); } -void MockMainWindow::setupStatus() { +void MainWindow::setupStatus() { status_mode_label_ = new QLabel("Ready", this); status_selection_label_ = new QLabel("No selection", this); status_perf_label_ = new QLabel(this); @@ -845,15 +723,15 @@ void MockMainWindow::setupStatus() { }); } -void MockMainWindow::setupLoader() { +void MainWindow::setupLoader() { AppSettings::instance().setLoadDataSource(false); loader_ = new SceneLoader(viewport_, this); - connect(loader_, &SceneLoader::loadStarted, this, &MockMainWindow::onLoadStarted); - connect(loader_, &SceneLoader::loadedFromSidecar, this, &MockMainWindow::onLoadedFromSidecar); - connect(loader_, &SceneLoader::loadedFromStream, this, &MockMainWindow::onLoadedFromStream); - connect(loader_, &SceneLoader::loadCancelled, this, &MockMainWindow::onLoadCancelled); - connect(loader_, &SceneLoader::loadError, this, &MockMainWindow::onLoadError); - connect(loader_, &SceneLoader::allLoadsFinished, this, &MockMainWindow::onAllLoadsFinished); + connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); + connect(loader_, &SceneLoader::loadedFromSidecar, this, &MainWindow::onLoadedFromSidecar); + connect(loader_, &SceneLoader::loadedFromStream, this, &MainWindow::onLoadedFromStream); + connect(loader_, &SceneLoader::loadCancelled, this, &MainWindow::onLoadCancelled); + connect(loader_, &SceneLoader::loadError, this, &MainWindow::onLoadError); + connect(loader_, &SceneLoader::allLoadsFinished, this, &MainWindow::onAllLoadsFinished); connect(viewport_, &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& s) { @@ -870,30 +748,30 @@ void MockMainWindow::setupLoader() { }); } -void MockMainWindow::addFiles(const QStringList& paths) { +void MainWindow::addFiles(const QStringList& paths) { if (paths.isEmpty()) return; loader_->addFiles(paths); } -QString MockMainWindow::formatElapsed(qint64 ms) const { +QString MainWindow::formatElapsed(qint64 ms) const { return (ms >= 1000) ? QString::number(ms / 1000.0, 'f', 2) + " s" : QString::number(ms) + " ms"; } -void MockMainWindow::onAddFiles() { +void MainWindow::onAddFiles() { const QStringList paths = QFileDialog::getOpenFileNames( this, "Add IFC Files", QString(), "IFC Viewer Cache (*.ifcview)"); addFiles(paths); } -void MockMainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { +void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { status_mode_label_->setText("Loading"); status_selection_label_->setText(display_name); } -void MockMainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { +void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { status_mode_label_->setText("Loaded"); status_selection_label_->setText( QString("%1 from cache in %2") @@ -901,7 +779,7 @@ void MockMainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { .arg(formatElapsed(elapsed_ms))); } -void MockMainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { +void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { status_mode_label_->setText("Loaded"); status_selection_label_->setText( QString("%1 streamed in %2") @@ -909,18 +787,20 @@ void MockMainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { .arg(formatElapsed(elapsed_ms))); } -void MockMainWindow::onLoadCancelled(uint32_t mid) { +void MainWindow::onLoadCancelled(uint32_t mid) { status_mode_label_->setText("Cancelled"); status_selection_label_->setText(loader_->displayName(mid)); } -void MockMainWindow::onLoadError(uint32_t /*mid*/, QString message) { +void MainWindow::onLoadError(uint32_t /*mid*/, QString message) { status_mode_label_->setText("Error"); status_selection_label_->setText(message); QMessageBox::warning(this, "IfcInterfaceMockup", message); } -void MockMainWindow::onAllLoadsFinished() { +void MainWindow::onAllLoadsFinished() { status_mode_label_->setText("Loaded"); status_selection_label_->setText(QString("%1 model(s)").arg(loader_->modelCount())); } + +} // namespace ifcinterface::shell diff --git a/src/interface/MockMainWindow.h b/src/interface/MainWindow.h similarity index 94% rename from src/interface/MockMainWindow.h rename to src/interface/MainWindow.h index a038cb68b7..e4a491a403 100644 --- a/src/interface/MockMainWindow.h +++ b/src/interface/MainWindow.h @@ -18,8 +18,8 @@ * * ********************************************************************************/ -#ifndef IFCINTERFACE_MOCKMAINWINDOW_H -#define IFCINTERFACE_MOCKMAINWINDOW_H +#ifndef IFCINTERFACE_SHELL_MAINWINDOW_H +#define IFCINTERFACE_SHELL_MAINWINDOW_H #include #include @@ -35,10 +35,12 @@ namespace ifcinterface::panels::models { class ModelsPanelView; } namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelView; } namespace ifcinterface::panels::properties { class PropertiesPanelView; } -class MockMainWindow : public QMainWindow { +namespace ifcinterface::shell { + +class MainWindow : public QMainWindow { Q_OBJECT public: - explicit MockMainWindow(QWidget* parent = nullptr); + explicit MainWindow(QWidget* parent = nullptr); private: void setupChrome(); @@ -91,4 +93,6 @@ private: ifcinterface::panels::properties::PropertiesPanelView* properties_panel_view_ = nullptr; }; +} // namespace ifcinterface::shell + #endif diff --git a/src/interface/components/CollapsibleSection.cpp b/src/interface/components/CollapsibleSection.cpp new file mode 100644 index 0000000000..3d21d00c99 --- /dev/null +++ b/src/interface/components/CollapsibleSection.cpp @@ -0,0 +1,113 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "CollapsibleSection.h" + +#include "SvgIcon.h" + +#include +#include +#include +#include +#include + +namespace ifcinterface::components::inspector { + +namespace { + +QWidget* makeInspectorFilterField(const QString& placeholder, QWidget* parent = nullptr) { + auto* field = new QLineEdit(parent); + field->setPlaceholderText(placeholder); + field->setClearButtonEnabled(true); + field->addAction(icons::makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); + return field; +} + +} // namespace + +CollapsibleSection::CollapsibleSection(const QString& title, const QString& filter_placeholder, QWidget* parent) + : QWidget(parent) +{ + setObjectName("inspectorSection"); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + auto* header = new QFrame(this); + header->setObjectName("inspectorSectionHeader"); + auto* header_layout = new QHBoxLayout(header); + header_layout->setContentsMargins(0, 0, 0, 0); + header_layout->setSpacing(6); + + auto* toggle = new QToolButton(header); + toggle->setObjectName("inspectorSectionButton"); + toggle->setText(title); + toggle->setCheckable(true); + toggle->setChecked(true); + toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toggle->setArrowType(Qt::DownArrow); + header_layout->addWidget(toggle); + header_layout->addStretch(1); + + if (!filter_placeholder.isEmpty()) { + auto* filter_toggle = new QToolButton(header); + filter_toggle->setObjectName("inspectorFilterToggle"); + filter_toggle->setCheckable(true); + filter_toggle->setIcon(icons::makePanelSvgIcon(":/icons/filter.svg")); + filter_toggle->setAutoRaise(true); + header_layout->addWidget(filter_toggle); + + filter_field_ = qobject_cast(makeInspectorFilterField(filter_placeholder, this)); + filter_field_->setVisible(false); + connect(filter_toggle, &QToolButton::toggled, filter_field_, [this](bool visible) { + filter_field_->setVisible(visible); + if (visible) filter_field_->setFocus(); + }); + } + + body_ = new QWidget(this); + body_->setObjectName("inspectorSectionBody"); + body_layout_ = new QVBoxLayout(body_); + body_layout_->setContentsMargins(10, 6, 10, 0); + body_layout_->setSpacing(6); + + connect(toggle, &QToolButton::toggled, body_, [toggle, this](bool expanded) { + toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + body_->setVisible(expanded); + }); + + layout->addWidget(header); + if (filter_field_) { + auto* filter_wrapper = new QWidget(this); + filter_wrapper->setObjectName("inspectorFilterWrapper"); + auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); + filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); + filter_wrapper_layout->setSpacing(0); + filter_wrapper_layout->addWidget(filter_field_); + layout->addWidget(filter_wrapper); + } + layout->addWidget(body_); +} + +void CollapsibleSection::addBodyWidget(QWidget* widget) { + body_layout_->addWidget(widget); +} + +} // namespace ifcinterface::components::inspector diff --git a/src/interface/components/CollapsibleSection.h b/src/interface/components/CollapsibleSection.h new file mode 100644 index 0000000000..7e9431f7fb --- /dev/null +++ b/src/interface/components/CollapsibleSection.h @@ -0,0 +1,48 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_INSPECTOR_COLLAPSIBLESECTION_H +#define IFCINTERFACE_COMPONENTS_INSPECTOR_COLLAPSIBLESECTION_H + +#include + +class QLineEdit; +class QVBoxLayout; + +namespace ifcinterface::components::inspector { + +class CollapsibleSection : public QWidget { + Q_OBJECT +public: + explicit CollapsibleSection(const QString& title, + const QString& filter_placeholder = {}, + QWidget* parent = nullptr); + + void addBodyWidget(QWidget* widget); + +private: + QWidget* body_ = nullptr; + QVBoxLayout* body_layout_ = nullptr; + QLineEdit* filter_field_ = nullptr; +}; + +} // namespace ifcinterface::components::inspector + +#endif diff --git a/src/interface/components/PanelChrome.cpp b/src/interface/components/PanelChrome.cpp new file mode 100644 index 0000000000..4c970096ea --- /dev/null +++ b/src/interface/components/PanelChrome.cpp @@ -0,0 +1,117 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "PanelChrome.h" + +#include "SvgIcon.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace ifcinterface::components::panel { + +namespace { + +class DockTitleBar : public QWidget { +public: + explicit DockTitleBar(const QString& title, bool has_settings = false, QWidget* parent = nullptr) + : QWidget(parent) + { + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(10, 6, 6, 6); + layout->setSpacing(6); + + auto* text = new QLabel(title.toUpper(), this); + text->setObjectName("dockTitleText"); + + layout->addWidget(text); + layout->addStretch(1); + if (has_settings) { + auto* settings = new QToolButton(this); + settings->setIcon(icons::makePanelSvgIcon(":/icons/settings.svg")); + settings->setAutoRaise(true); + settings->setCursor(Qt::ArrowCursor); + settings->setFixedSize(18, 18); + settings->setObjectName("dockTitleButton"); + settings->setToolTip(QString("%1 settings").arg(title)); + connect(settings, &QToolButton::clicked, this, [this, title]() { + auto* anchor = parentWidget(); + QMenu menu(anchor); + menu.addAction(QString("%1 settings coming soon").arg(title)); + menu.exec(QCursor::pos()); + }); + layout->addWidget(settings); + } + } +}; + +} // namespace + +QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings) { + auto* dock = new QDockWidget(title, parent); + dock->setObjectName(title); + dock->setFeatures(QDockWidget::DockWidgetMovable | + QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetClosable); + dock->setTitleBarWidget(new DockTitleBar(title, has_settings, dock)); + dock->setWidget(content); + return dock; +} + +QFrame* wrapPanel(QWidget* inner) { + auto* outer = new QFrame(); + auto* outer_layout = new QVBoxLayout(outer); + outer_layout->setContentsMargins(6, 6, 6, 6); + outer_layout->setSpacing(0); + + auto* frame = new QFrame(outer); + frame->setObjectName("panelFrame"); + auto* layout = new QVBoxLayout(frame); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(0); + layout->addWidget(inner); + + outer_layout->addWidget(frame); + return outer; +} + +QFrame* wrapInspectorPanel(QWidget* inner) { + auto* outer = new QFrame(); + auto* outer_layout = new QVBoxLayout(outer); + outer_layout->setContentsMargins(6, 6, 6, 6); + outer_layout->setSpacing(0); + + auto* frame = new QFrame(outer); + frame->setObjectName("panelFrame"); + auto* layout = new QVBoxLayout(frame); + layout->setContentsMargins(0, 8, 0, 8); + layout->setSpacing(0); + layout->addWidget(inner); + + outer_layout->addWidget(frame); + return outer; +} + +} // namespace ifcinterface::components::panel diff --git a/src/interface/components/PanelChrome.h b/src/interface/components/PanelChrome.h new file mode 100644 index 0000000000..cb1a405e16 --- /dev/null +++ b/src/interface/components/PanelChrome.h @@ -0,0 +1,38 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H +#define IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H + +#include + +class QDockWidget; +class QFrame; +class QWidget; + +namespace ifcinterface::components::panel { + +QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings = false); +QFrame* wrapPanel(QWidget* inner); +QFrame* wrapInspectorPanel(QWidget* inner); + +} // namespace ifcinterface::components::panel + +#endif diff --git a/src/interface/components/SvgIcon.cpp b/src/interface/components/SvgIcon.cpp new file mode 100644 index 0000000000..8c6dfd8765 --- /dev/null +++ b/src/interface/components/SvgIcon.cpp @@ -0,0 +1,72 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "SvgIcon.h" + +#include +#include +#include +#include + +namespace ifcinterface::components::icons { + +QPixmap renderTintedSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { + QFile file(icon_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QIcon(icon_path).pixmap(size); + } + + QString svg = QString::fromUtf8(file.readAll()); + svg.replace("currentColor", color, Qt::CaseSensitive); + svg.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); + svg.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); + + QSvgRenderer renderer(svg.toUtf8()); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +QIcon makeTintedSvgIcon(const QString& icon_path, const QString& normal, + const QString& active, const QString& disabled) { + QFile file(icon_path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + return QIcon(icon_path); + } + + QIcon icon; + icon.addPixmap(renderTintedSvgPixmap(icon_path, normal, QSize(20, 20)), QIcon::Normal, QIcon::Off); + icon.addPixmap(renderTintedSvgPixmap(icon_path, active, QSize(20, 20)), QIcon::Active, QIcon::Off); + icon.addPixmap(renderTintedSvgPixmap(icon_path, active, QSize(20, 20)), QIcon::Selected, QIcon::Off); + icon.addPixmap(renderTintedSvgPixmap(icon_path, disabled, QSize(20, 20)), QIcon::Disabled, QIcon::Off); + return icon; +} + +QIcon makePanelSvgIcon(const QString& icon_path) { + return makeTintedSvgIcon(icon_path, "#e7ebf2", "#ffffff", "#6f7988"); +} + +QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size) { + return renderTintedSvgPixmap(icon_path, "#e7ebf2", size); +} + +} // namespace ifcinterface::components::icons diff --git a/src/interface/components/SvgIcon.h b/src/interface/components/SvgIcon.h new file mode 100644 index 0000000000..d1c7e7417d --- /dev/null +++ b/src/interface/components/SvgIcon.h @@ -0,0 +1,41 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_ICONS_SVGICON_H +#define IFCINTERFACE_COMPONENTS_ICONS_SVGICON_H + +#include +#include +#include +#include + +namespace ifcinterface::components::icons { + +QPixmap renderTintedSvgPixmap(const QString& icon_path, const QString& color, const QSize& size); +QIcon makeTintedSvgIcon(const QString& icon_path, + const QString& normal = "#39b54a", + const QString& active = "#53c763", + const QString& disabled = "#6f7988"); +QIcon makePanelSvgIcon(const QString& icon_path); +QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size); + +} // namespace ifcinterface::components::icons + +#endif diff --git a/src/interface/main.cpp b/src/interface/main.cpp index 7a1e1d1970..57e40f679c 100644 --- a/src/interface/main.cpp +++ b/src/interface/main.cpp @@ -18,7 +18,7 @@ * * ********************************************************************************/ -#include "MockMainWindow.h" +#include "MainWindow.h" #include #include @@ -71,7 +71,7 @@ int main(int argc, char* argv[]) { installUiFont(); - MockMainWindow window; + ifcinterface::shell::MainWindow window; window.show(); return app.exec(); } diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp index 4459faa5dd..c6ef61355b 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.cpp +++ b/src/interface/panels/properties/PropertiesPanelWidget.cpp @@ -20,63 +20,19 @@ #include "PropertiesPanelWidget.h" -#include +#include "../../components/CollapsibleSection.h" +#include "../../components/SvgIcon.h" + #include #include #include #include #include -#include -#include -#include -#include #include -#include -#include #include namespace { -QPixmap renderPanelSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { - QFile file(icon_path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QIcon(icon_path).pixmap(size); - } - - QString svg = QString::fromUtf8(file.readAll()); - svg.replace("currentColor", color, Qt::CaseSensitive); - svg.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); - svg.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); - - QSvgRenderer renderer(svg.toUtf8()); - QPixmap pixmap(size); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - renderer.render(&painter); - return pixmap; -} - -QIcon makePanelSvgIcon(const QString& icon_path) { - QIcon icon; - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#e7ebf2", QSize(20, 20)), QIcon::Normal, QIcon::Off); - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Active, QIcon::Off); - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Selected, QIcon::Off); - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#6f7988", QSize(20, 20)), QIcon::Disabled, QIcon::Off); - return icon; -} - -QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size) { - return renderPanelSvgPixmap(icon_path, "#e7ebf2", size); -} - -QWidget* makeInspectorFilterField(const QString& placeholder, QWidget* parent = nullptr) { - auto* field = new QLineEdit(parent); - field->setPlaceholderText(placeholder); - field->setClearButtonEnabled(true); - field->addAction(makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); - return field; -} - QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { auto* group = new QGroupBox(property_set.title, parent); group->setObjectName("propertySetCard"); @@ -98,75 +54,6 @@ QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySe return group; } -QWidget* makeInspectorSection(const QString& title, - const QString& filter_placeholder, - const QList& groups, - QWidget* parent = nullptr) { - auto* section = new QWidget(parent); - section->setObjectName("inspectorSection"); - auto* layout = new QVBoxLayout(section); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - - auto* header = new QFrame(section); - header->setObjectName("inspectorSectionHeader"); - auto* header_layout = new QHBoxLayout(header); - header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(6); - - auto* toggle = new QToolButton(header); - toggle->setObjectName("inspectorSectionButton"); - toggle->setText(title); - toggle->setCheckable(true); - toggle->setChecked(true); - toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - toggle->setArrowType(Qt::DownArrow); - header_layout->addWidget(toggle); - header_layout->addStretch(1); - - QLineEdit* filter_field = nullptr; - if (!filter_placeholder.isEmpty()) { - auto* filter_toggle = new QToolButton(header); - filter_toggle->setObjectName("inspectorFilterToggle"); - filter_toggle->setCheckable(true); - filter_toggle->setIcon(makePanelSvgIcon(":/icons/filter.svg")); - filter_toggle->setAutoRaise(true); - header_layout->addWidget(filter_toggle); - - filter_field = qobject_cast(makeInspectorFilterField(filter_placeholder, section)); - filter_field->setVisible(false); - QObject::connect(filter_toggle, &QToolButton::toggled, filter_field, [filter_field](bool visible) { - filter_field->setVisible(visible); - if (visible) filter_field->setFocus(); - }); - } - - auto* body = new QWidget(section); - body->setObjectName("inspectorSectionBody"); - auto* body_layout = new QVBoxLayout(body); - body_layout->setContentsMargins(10, 6, 10, 0); - body_layout->setSpacing(6); - for (auto* group : groups) body_layout->addWidget(group); - - QObject::connect(toggle, &QToolButton::toggled, body, [toggle, body](bool expanded) { - toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); - body->setVisible(expanded); - }); - - layout->addWidget(header); - if (filter_field) { - auto* filter_wrapper = new QWidget(section); - filter_wrapper->setObjectName("inspectorFilterWrapper"); - auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); - filter_wrapper_layout->setSpacing(0); - filter_wrapper_layout->addWidget(filter_field); - layout->addWidget(filter_wrapper); - } - layout->addWidget(body); - return section; -} - QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { auto* panel = new QWidget(parent); panel->setObjectName("attributeList"); @@ -212,7 +99,7 @@ QWidget* makeRelationshipList(const QListsetObjectName("relationshipIconLabel"); - icon->setPixmap(makePanelSvgPixmap(":/icons/cursor-pointer.svg", QSize(14, 14))); + icon->setPixmap(ifcinterface::components::icons::makePanelSvgPixmap(":/icons/cursor-pointer.svg", QSize(14, 14))); icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); row_layout->addWidget(key); @@ -255,7 +142,7 @@ void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { entity_layout->setContentsMargins(10, 8, 10, 8); entity_layout->setSpacing(10); auto* entity_icon = new QLabel(entity_card); - entity_icon->setPixmap(makePanelSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); + entity_icon->setPixmap(components::icons::makePanelSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); entity_icon->setAlignment(Qt::AlignCenter); auto* entity_text = new QWidget(entity_card); auto* entity_text_layout = new QVBoxLayout(entity_text); @@ -287,14 +174,14 @@ void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { quantity_set_widgets.append(makePropertySetPanel(property_set, content)); } - auto* attributes_section = makeInspectorSection( - "Attributes", "", {makeAttributeList(state.attributes, content)}, content); - auto* relationships_section = makeInspectorSection( - "Relationships", "", {makeRelationshipList(state.relationships, content)}, content); - auto* properties_section = makeInspectorSection( - "Properties", "Filter properties or sets", property_set_widgets, content); - auto* quantities_section = makeInspectorSection( - "Quantities", "Filter quantities or sets", quantity_set_widgets, content); + auto* attributes_section = new components::inspector::CollapsibleSection("Attributes", "", content); + attributes_section->addBodyWidget(makeAttributeList(state.attributes, content)); + auto* relationships_section = new components::inspector::CollapsibleSection("Relationships", "", content); + relationships_section->addBodyWidget(makeRelationshipList(state.relationships, content)); + auto* properties_section = new components::inspector::CollapsibleSection("Properties", "Filter properties or sets", content); + for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); + auto* quantities_section = new components::inspector::CollapsibleSection("Quantities", "Filter quantities or sets", content); + for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); content_layout->addWidget(entity_wrapper); content_layout->addWidget(attributes_section); diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp index efb312cd1e..d682c56500 100644 --- a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp @@ -20,48 +20,13 @@ #include "SpatialHierarchyPanelWidget.h" -#include +#include "../../components/SvgIcon.h" + #include -#include -#include -#include -#include #include #include #include -namespace { - -QPixmap renderPanelSvgPixmap(const QString& icon_path, const QString& color, const QSize& size) { - QFile file(icon_path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - return QIcon(icon_path).pixmap(size); - } - - QString svg = QString::fromUtf8(file.readAll()); - svg.replace("currentColor", color, Qt::CaseSensitive); - svg.replace(QRegularExpression(R"(stroke="[^"]*")"), QString("stroke=\"%1\"").arg(color)); - svg.replace(QRegularExpression(R"(fill="none")"), "fill=\"none\""); - - QSvgRenderer renderer(svg.toUtf8()); - QPixmap pixmap(size); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - renderer.render(&painter); - return pixmap; -} - -QIcon makePanelSvgIcon(const QString& icon_path) { - QIcon icon; - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#e7ebf2", QSize(20, 20)), QIcon::Normal, QIcon::Off); - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Active, QIcon::Off); - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#ffffff", QSize(20, 20)), QIcon::Selected, QIcon::Off); - icon.addPixmap(renderPanelSvgPixmap(icon_path, "#6f7988", QSize(20, 20)), QIcon::Disabled, QIcon::Off); - return icon; -} - -} // namespace - namespace ifcinterface::panels::spatial_hierarchy { SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) @@ -102,8 +67,8 @@ void SpatialHierarchyPanelWidget::addNode(QTreeWidgetItem* parent, const TreeNod auto* item = new QTreeWidgetItem(parent, {node.name, ""}); item->setData(1, Qt::UserRole, node.visible); item->setSizeHint(0, QSize(0, 24)); - item->setIcon(0, makePanelSvgIcon(iconPath(node.kind))); - item->setIcon(1, makePanelSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); + item->setIcon(0, components::icons::makePanelSvgIcon(iconPath(node.kind))); + item->setIcon(1, components::icons::makePanelSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); for (const auto& child : node.children) { addNode(item, child); } From e20cea221b3171966636a4ae0431e9fab044ff90 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 5 May 2026 19:59:51 +1000 Subject: [PATCH 108/120] Interface mockup 4 --- src/interface/CMakeLists.txt | 6 +- src/interface/MainWindow.cpp | 52 +++---- .../components/CollapsibleSection.cpp | 113 --------------- src/interface/components/PanelChrome.cpp | 17 --- src/interface/components/PanelChrome.h | 1 - src/interface/components/Section.cpp | 133 ++++++++++++++++++ .../{CollapsibleSection.h => Section.h} | 22 +-- .../properties/PropertiesPanelWidget.cpp | 21 ++- .../SpatialHierarchyPanelWidget.cpp | 8 +- src/interface/panels/todo/TodoPanelWidget.cpp | 60 ++++++++ src/interface/panels/todo/TodoPanelWidget.h | 36 +++++ 11 files changed, 288 insertions(+), 181 deletions(-) delete mode 100644 src/interface/components/CollapsibleSection.cpp create mode 100644 src/interface/components/Section.cpp rename src/interface/components/{CollapsibleSection.h => Section.h} (78%) create mode 100644 src/interface/panels/todo/TodoPanelWidget.cpp create mode 100644 src/interface/panels/todo/TodoPanelWidget.h diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index db55d16807..015aac0da6 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -29,8 +29,8 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.h - ${CMAKE_CURRENT_SOURCE_DIR}/components/CollapsibleSection.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/components/CollapsibleSection.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h ${CMAKE_CURRENT_SOURCE_DIR}/components/PanelChrome.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/PanelChrome.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelTypes.h @@ -38,6 +38,8 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelView.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/todo/TodoPanelWidget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/todo/TodoPanelWidget.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelTypes.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelWidget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelWidget.h diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 212b552a49..1f0fb7b021 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -25,6 +25,7 @@ #include "../ifcviewer/ViewportWindow.h" #include "components/PanelChrome.h" #include "components/SvgIcon.h" +#include "panels/todo/TodoPanelWidget.h" #include "panels/models/ModelsPanelView.h" #include "panels/models/ModelsPanelWidget.h" #include "panels/properties/PropertiesPanelView.h" @@ -155,6 +156,9 @@ void MainWindow::setupChrome() { border: 1px solid #3e444e; border-radius: 3px; } + QWidget#panelBody { + background: #2b2f36; + } QTreeWidget, QListWidget, QTableWidget, QAbstractScrollArea { background: #2b2f36; border: none; @@ -268,10 +272,10 @@ void MainWindow::setupChrome() { QGroupBox#propertySetCard > QWidget { background: #26292f; } - QWidget#inspectorSection { + QWidget#panelSection { background: transparent; } - QWidget#inspectorFilterWrapper { + QWidget#panelSectionFilterWrapper { background: transparent; } QWidget#relationshipRow { @@ -283,7 +287,10 @@ void MainWindow::setupChrome() { QWidget#inspectorPanel { background: #2b2f36; } - QToolButton#inspectorSectionButton { + QFrame#panelSectionHeader { + background: #26292f; + } + QToolButton#panelSectionHeaderButton { background: transparent; border: none; color: #e1e7f0; @@ -292,25 +299,22 @@ void MainWindow::setupChrome() { padding: 2px; margin: 0; } - QToolButton#inspectorSectionButton:hover { + QToolButton#panelSectionHeaderButton:hover { color: #ffffff; } - QToolButton#inspectorSectionButton::menu-indicator { + QToolButton#panelSectionHeaderButton::menu-indicator { image: none; width: 0; } - QFrame#inspectorSectionHeader { - background: #26292f; - } - QToolButton#inspectorFilterToggle { + QToolButton#panelSectionFilterToggle { background: transparent; border: none; padding: 2px; } - QToolButton#inspectorFilterToggle:hover { + QToolButton#panelSectionFilterToggle:hover { background: #353a42; } - QWidget#inspectorSectionBody { + QWidget#panelSectionBody { background: transparent; } QLabel#propertyKeyLabel { @@ -325,6 +329,16 @@ void MainWindow::setupChrome() { color: #dce2eb; background: transparent; } + QLabel#todoPanelTitle { + font-size: 14px; + font-weight: 600; + color: #e1e6ee; + background: transparent; + } + QLabel#todoPanelBody { + color: #8f98a6; + background: transparent; + } )"); } @@ -363,19 +377,7 @@ QWidget* MainWindow::makeRibbonGroup(const QString& title, const QListsetContentsMargins(12, 12, 12, 12); - auto* heading = new QLabel(title, widget); - heading->setStyleSheet("font-size:14px; font-weight:600; color:#e1e6ee;"); - auto* body = new QLabel("Coming soon", widget); - body->setAlignment(Qt::AlignCenter); - body->setStyleSheet("color:#8f98a6;"); - layout->addWidget(heading); - layout->addStretch(1); - layout->addWidget(body); - layout->addStretch(1); - return widget; + return new panels::todo::TodoPanelWidget(title, this); } void MainWindow::setStatusMessage(const QString& mode, const QString& detail) { @@ -661,7 +663,7 @@ void MainWindow::setupDocks() { spatial_dock_ = components::panel::makeDock( "Spatial Hierarchy", components::panel::wrapPanel(spatial_panel), this); properties_dock_ = components::panel::makeDock( - "Properties", components::panel::wrapInspectorPanel(properties_panel), this); + "Properties", components::panel::wrapPanel(properties_panel), this); layers_dock_ = components::panel::makeDock( "Layers", components::panel::wrapPanel(makeComingSoonPanel("Layers")), this); stored_views_dock_ = components::panel::makeDock( diff --git a/src/interface/components/CollapsibleSection.cpp b/src/interface/components/CollapsibleSection.cpp deleted file mode 100644 index 3d21d00c99..0000000000 --- a/src/interface/components/CollapsibleSection.cpp +++ /dev/null @@ -1,113 +0,0 @@ -// This file was generated with the assistance of an AI coding tool. -/******************************************************************************** - * * - * 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 "CollapsibleSection.h" - -#include "SvgIcon.h" - -#include -#include -#include -#include -#include - -namespace ifcinterface::components::inspector { - -namespace { - -QWidget* makeInspectorFilterField(const QString& placeholder, QWidget* parent = nullptr) { - auto* field = new QLineEdit(parent); - field->setPlaceholderText(placeholder); - field->setClearButtonEnabled(true); - field->addAction(icons::makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); - return field; -} - -} // namespace - -CollapsibleSection::CollapsibleSection(const QString& title, const QString& filter_placeholder, QWidget* parent) - : QWidget(parent) -{ - setObjectName("inspectorSection"); - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - - auto* header = new QFrame(this); - header->setObjectName("inspectorSectionHeader"); - auto* header_layout = new QHBoxLayout(header); - header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(6); - - auto* toggle = new QToolButton(header); - toggle->setObjectName("inspectorSectionButton"); - toggle->setText(title); - toggle->setCheckable(true); - toggle->setChecked(true); - toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - toggle->setArrowType(Qt::DownArrow); - header_layout->addWidget(toggle); - header_layout->addStretch(1); - - if (!filter_placeholder.isEmpty()) { - auto* filter_toggle = new QToolButton(header); - filter_toggle->setObjectName("inspectorFilterToggle"); - filter_toggle->setCheckable(true); - filter_toggle->setIcon(icons::makePanelSvgIcon(":/icons/filter.svg")); - filter_toggle->setAutoRaise(true); - header_layout->addWidget(filter_toggle); - - filter_field_ = qobject_cast(makeInspectorFilterField(filter_placeholder, this)); - filter_field_->setVisible(false); - connect(filter_toggle, &QToolButton::toggled, filter_field_, [this](bool visible) { - filter_field_->setVisible(visible); - if (visible) filter_field_->setFocus(); - }); - } - - body_ = new QWidget(this); - body_->setObjectName("inspectorSectionBody"); - body_layout_ = new QVBoxLayout(body_); - body_layout_->setContentsMargins(10, 6, 10, 0); - body_layout_->setSpacing(6); - - connect(toggle, &QToolButton::toggled, body_, [toggle, this](bool expanded) { - toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); - body_->setVisible(expanded); - }); - - layout->addWidget(header); - if (filter_field_) { - auto* filter_wrapper = new QWidget(this); - filter_wrapper->setObjectName("inspectorFilterWrapper"); - auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); - filter_wrapper_layout->setSpacing(0); - filter_wrapper_layout->addWidget(filter_field_); - layout->addWidget(filter_wrapper); - } - layout->addWidget(body_); -} - -void CollapsibleSection::addBodyWidget(QWidget* widget) { - body_layout_->addWidget(widget); -} - -} // namespace ifcinterface::components::inspector diff --git a/src/interface/components/PanelChrome.cpp b/src/interface/components/PanelChrome.cpp index 4c970096ea..4e49219788 100644 --- a/src/interface/components/PanelChrome.cpp +++ b/src/interface/components/PanelChrome.cpp @@ -86,23 +86,6 @@ QFrame* wrapPanel(QWidget* inner) { outer_layout->setContentsMargins(6, 6, 6, 6); outer_layout->setSpacing(0); - auto* frame = new QFrame(outer); - frame->setObjectName("panelFrame"); - auto* layout = new QVBoxLayout(frame); - layout->setContentsMargins(8, 8, 8, 8); - layout->setSpacing(0); - layout->addWidget(inner); - - outer_layout->addWidget(frame); - return outer; -} - -QFrame* wrapInspectorPanel(QWidget* inner) { - auto* outer = new QFrame(); - auto* outer_layout = new QVBoxLayout(outer); - outer_layout->setContentsMargins(6, 6, 6, 6); - outer_layout->setSpacing(0); - auto* frame = new QFrame(outer); frame->setObjectName("panelFrame"); auto* layout = new QVBoxLayout(frame); diff --git a/src/interface/components/PanelChrome.h b/src/interface/components/PanelChrome.h index cb1a405e16..806dfeefcd 100644 --- a/src/interface/components/PanelChrome.h +++ b/src/interface/components/PanelChrome.h @@ -31,7 +31,6 @@ namespace ifcinterface::components::panel { QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings = false); QFrame* wrapPanel(QWidget* inner); -QFrame* wrapInspectorPanel(QWidget* inner); } // namespace ifcinterface::components::panel diff --git a/src/interface/components/Section.cpp b/src/interface/components/Section.cpp new file mode 100644 index 0000000000..34790e7960 --- /dev/null +++ b/src/interface/components/Section.cpp @@ -0,0 +1,133 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Section.h" + +#include "SvgIcon.h" + +#include +#include +#include +#include +#include + +namespace ifcinterface::components { + +namespace { + +QWidget* makeSectionFilterField(const QString& placeholder, QWidget* parent = nullptr) { + auto* field = new QLineEdit(parent); + field->setPlaceholderText(placeholder); + field->setClearButtonEnabled(true); + field->addAction(icons::makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); + return field; +} + +} // namespace + +Section::Section(const QString& title, SectionHeaderMode header_mode, + const QString& filter_placeholder, QWidget* parent) + : QWidget(parent) +{ + setObjectName("panelSection"); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + if (header_mode == SectionHeaderMode::Visible) { + auto* header = new QFrame(this); + header->setObjectName("panelSectionHeader"); + auto* header_layout = new QHBoxLayout(header); + header_layout->setContentsMargins(0, 0, 0, 0); + header_layout->setSpacing(6); + + auto* toggle = new QToolButton(header); + toggle->setObjectName("panelSectionHeaderButton"); + toggle->setText(title); + toggle->setCheckable(true); + toggle->setChecked(true); + toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toggle->setArrowType(Qt::DownArrow); + header_layout->addWidget(toggle); + header_layout->addStretch(1); + + if (!filter_placeholder.isEmpty()) { + auto* filter_toggle = new QToolButton(header); + filter_toggle->setObjectName("panelSectionFilterToggle"); + filter_toggle->setCheckable(true); + filter_toggle->setIcon(icons::makePanelSvgIcon(":/icons/filter.svg")); + filter_toggle->setAutoRaise(true); + header_layout->addWidget(filter_toggle); + + filter_field_ = qobject_cast(makeSectionFilterField(filter_placeholder, this)); + filter_field_->setVisible(false); + connect(filter_toggle, &QToolButton::toggled, filter_field_, [this](bool visible) { + filter_field_->setVisible(visible); + if (visible) filter_field_->setFocus(); + }); + } + + layout->addWidget(header); + if (filter_field_) { + auto* filter_wrapper = new QWidget(this); + filter_wrapper->setObjectName("panelSectionFilterWrapper"); + auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); + filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); + filter_wrapper_layout->setSpacing(0); + filter_wrapper_layout->addWidget(filter_field_); + layout->addWidget(filter_wrapper); + } + + connect(toggle, &QToolButton::toggled, this, [this, toggle](bool expanded) { + toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + body_->setVisible(expanded); + if (filter_field_) { + auto* wrapper = filter_field_->parentWidget(); + if (wrapper) wrapper->setVisible(expanded && filter_field_->isVisible()); + } + }); + } else if (!filter_placeholder.isEmpty()) { + filter_field_ = qobject_cast(makeSectionFilterField(filter_placeholder, this)); + auto* filter_wrapper = new QWidget(this); + filter_wrapper->setObjectName("panelSectionFilterWrapper"); + auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); + filter_wrapper_layout->setContentsMargins(8, 0, 8, 0); + filter_wrapper_layout->setSpacing(0); + filter_wrapper_layout->addWidget(filter_field_); + layout->addWidget(filter_wrapper); + } + + body_ = new QWidget(this); + body_->setObjectName("panelSectionBody"); + body_layout_ = new QVBoxLayout(body_); + if (header_mode == SectionHeaderMode::Visible) { + body_layout_->setContentsMargins(10, 6, 10, 0); + } else { + body_layout_->setContentsMargins(8, 0, 8, 0); + } + body_layout_->setSpacing(6); + layout->addWidget(body_); +} + +void Section::addBodyWidget(QWidget* widget) { + body_layout_->addWidget(widget); +} + +} // namespace ifcinterface::components diff --git a/src/interface/components/CollapsibleSection.h b/src/interface/components/Section.h similarity index 78% rename from src/interface/components/CollapsibleSection.h rename to src/interface/components/Section.h index 7e9431f7fb..4dacae514d 100644 --- a/src/interface/components/CollapsibleSection.h +++ b/src/interface/components/Section.h @@ -18,22 +18,28 @@ * * ********************************************************************************/ -#ifndef IFCINTERFACE_COMPONENTS_INSPECTOR_COLLAPSIBLESECTION_H -#define IFCINTERFACE_COMPONENTS_INSPECTOR_COLLAPSIBLESECTION_H +#ifndef IFCINTERFACE_COMPONENTS_SECTION_H +#define IFCINTERFACE_COMPONENTS_SECTION_H #include class QLineEdit; class QVBoxLayout; -namespace ifcinterface::components::inspector { +namespace ifcinterface::components { -class CollapsibleSection : public QWidget { +enum class SectionHeaderMode { + Visible, + Hidden, +}; + +class Section : public QWidget { Q_OBJECT public: - explicit CollapsibleSection(const QString& title, - const QString& filter_placeholder = {}, - QWidget* parent = nullptr); + explicit Section(const QString& title, + SectionHeaderMode header_mode = SectionHeaderMode::Visible, + const QString& filter_placeholder = {}, + QWidget* parent = nullptr); void addBodyWidget(QWidget* widget); @@ -43,6 +49,6 @@ private: QLineEdit* filter_field_ = nullptr; }; -} // namespace ifcinterface::components::inspector +} // namespace ifcinterface::components #endif diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp index c6ef61355b..2c17e4c176 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.cpp +++ b/src/interface/panels/properties/PropertiesPanelWidget.cpp @@ -20,7 +20,7 @@ #include "PropertiesPanelWidget.h" -#include "../../components/CollapsibleSection.h" +#include "../../components/Section.h" #include "../../components/SvgIcon.h" #include @@ -157,13 +157,6 @@ void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); - auto* entity_wrapper = new QWidget(content); - entity_wrapper->setObjectName("inspectorSectionBody"); - auto* entity_wrapper_layout = new QVBoxLayout(entity_wrapper); - entity_wrapper_layout->setContentsMargins(10, 0, 10, 0); - entity_wrapper_layout->setSpacing(0); - entity_wrapper_layout->addWidget(entity_card); - QList property_set_widgets; for (const auto& property_set : state.property_sets) { property_set_widgets.append(makePropertySetPanel(property_set, content)); @@ -174,16 +167,18 @@ void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { quantity_set_widgets.append(makePropertySetPanel(property_set, content)); } - auto* attributes_section = new components::inspector::CollapsibleSection("Attributes", "", content); + auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, "", content); + entity_section->addBodyWidget(entity_card); + auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, "", content); attributes_section->addBodyWidget(makeAttributeList(state.attributes, content)); - auto* relationships_section = new components::inspector::CollapsibleSection("Relationships", "", content); + auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, "", content); relationships_section->addBodyWidget(makeRelationshipList(state.relationships, content)); - auto* properties_section = new components::inspector::CollapsibleSection("Properties", "Filter properties or sets", content); + auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, "Filter properties or sets", content); for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); - auto* quantities_section = new components::inspector::CollapsibleSection("Quantities", "Filter quantities or sets", content); + auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, "Filter quantities or sets", content); for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); - content_layout->addWidget(entity_wrapper); + content_layout->addWidget(entity_section); content_layout->addWidget(attributes_section); content_layout->addWidget(relationships_section); content_layout->addWidget(properties_section); diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp index d682c56500..5c48a1b24a 100644 --- a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp +++ b/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp @@ -20,6 +20,7 @@ #include "SpatialHierarchyPanelWidget.h" +#include "../../components/Section.h" #include "../../components/SvgIcon.h" #include @@ -36,7 +37,9 @@ SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - tree_ = new QTreeWidget(this); + auto* section = new components::Section("", components::SectionHeaderMode::Hidden, "", this); + + tree_ = new QTreeWidget(section); tree_->setColumnCount(2); tree_->setHeaderLabels({"Spatial Item", ""}); tree_->setIconSize(QSize(16, 16)); @@ -47,7 +50,8 @@ SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) tree_->header()->setSectionResizeMode(1, QHeaderView::Fixed); tree_->header()->resizeSection(1, 28); tree_->header()->hide(); - layout->addWidget(tree_); + section->addBodyWidget(tree_); + layout->addWidget(section); connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { if (!item || column != 1) return; diff --git a/src/interface/panels/todo/TodoPanelWidget.cpp b/src/interface/panels/todo/TodoPanelWidget.cpp new file mode 100644 index 0000000000..36104e7b6d --- /dev/null +++ b/src/interface/panels/todo/TodoPanelWidget.cpp @@ -0,0 +1,60 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "TodoPanelWidget.h" + +#include "../../components/Section.h" + +#include +#include + +namespace ifcinterface::panels::todo { + +TodoPanelWidget::TodoPanelWidget(const QString& title, QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto* section = new components::Section("", components::SectionHeaderMode::Hidden, "", this); + + auto* body = new QWidget(section); + auto* body_layout = new QVBoxLayout(body); + body_layout->setContentsMargins(0, 12, 0, 12); + body_layout->setSpacing(12); + + auto* heading = new QLabel(title, body); + heading->setObjectName("todoPanelTitle"); + + auto* content = new QLabel("Coming soon", body); + content->setObjectName("todoPanelBody"); + content->setAlignment(Qt::AlignCenter); + + body_layout->addWidget(heading); + body_layout->addStretch(1); + body_layout->addWidget(content); + body_layout->addStretch(1); + + section->addBodyWidget(body); + layout->addWidget(section); +} + +} // namespace ifcinterface::panels::todo diff --git a/src/interface/panels/todo/TodoPanelWidget.h b/src/interface/panels/todo/TodoPanelWidget.h new file mode 100644 index 0000000000..7ab3bba29b --- /dev/null +++ b/src/interface/panels/todo/TodoPanelWidget.h @@ -0,0 +1,36 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_TODO_TODOPANELWIDGET_H +#define IFCINTERFACE_PANELS_TODO_TODOPANELWIDGET_H + +#include + +namespace ifcinterface::panels::todo { + +class TodoPanelWidget : public QWidget { + Q_OBJECT +public: + explicit TodoPanelWidget(const QString& title, QWidget* parent = nullptr); +}; + +} // namespace ifcinterface::panels::todo + +#endif From 802ddf8ff99f5a45ace6b444a4b59650c3c11a8f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 6 May 2026 10:47:03 +1000 Subject: [PATCH 109/120] Interface mockup 5 --- src/interface/CMakeLists.txt | 10 +- src/interface/ElementRegistry.cpp | 82 ++++ src/interface/ElementRegistry.h | 66 +++ src/interface/MainWindow.cpp | 411 +++--------------- src/interface/MainWindow.h | 30 +- src/interface/components/KeyValueTable.cpp | 82 ++++ src/interface/components/KeyValueTable.h | 47 ++ .../components/{PanelChrome.cpp => Panel.cpp} | 46 +- .../components/{PanelChrome.h => Panel.h} | 16 +- src/interface/components/Section.cpp | 20 +- src/interface/components/Style.cpp | 303 +++++++++++++ src/interface/components/Style.h | 49 +++ src/interface/icons/sidebar-expand.svg | 5 + src/interface/interface_resources.qrc | 16 +- .../panels/properties/PropertiesPanelView.cpp | 48 +- .../panels/properties/PropertiesPanelView.h | 11 +- .../properties/PropertiesPanelWidget.cpp | 140 +++--- .../panels/properties/PropertiesPanelWidget.h | 10 +- 18 files changed, 880 insertions(+), 512 deletions(-) create mode 100644 src/interface/ElementRegistry.cpp create mode 100644 src/interface/ElementRegistry.h create mode 100644 src/interface/components/KeyValueTable.cpp create mode 100644 src/interface/components/KeyValueTable.h rename src/interface/components/{PanelChrome.cpp => Panel.cpp} (76%) rename src/interface/components/{PanelChrome.h => Panel.h} (85%) create mode 100644 src/interface/components/Style.cpp create mode 100644 src/interface/components/Style.h create mode 100644 src/interface/icons/sidebar-expand.svg diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index 015aac0da6..370dabb600 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -25,14 +25,20 @@ find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets Svg REQUIRED PATHS ${QT set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/Style.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/Style.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.h ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h - ${CMAKE_CURRENT_SOURCE_DIR}/components/PanelChrome.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/components/PanelChrome.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelTypes.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.h diff --git a/src/interface/ElementRegistry.cpp b/src/interface/ElementRegistry.cpp new file mode 100644 index 0000000000..1e41385d33 --- /dev/null +++ b/src/interface/ElementRegistry.cpp @@ -0,0 +1,82 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "ElementRegistry.h" + +#include "../ifcviewer/GeometryStreamer.h" +#include "../ifcviewer/SceneLoader.h" +#include "../ifcviewer/SidecarCache.h" + +namespace ifcinterface { + +ElementRegistry::ElementRegistry(QObject* parent) + : QObject(parent) +{ +} + +void ElementRegistry::bindLoader(SceneLoader* loader) { + connect(loader, &SceneLoader::sidecarElementsReady, + this, &ElementRegistry::onSidecarElementsReady); + connect(loader, &SceneLoader::streamedElementsReady, + this, &ElementRegistry::onStreamedElementsReady); +} + +std::optional ElementRegistry::find(uint32_t object_id) const { + auto it = elements_.find(object_id); + if (it == elements_.end()) return std::nullopt; + return it->second; +} + +void ElementRegistry::onSidecarElementsReady(uint32_t /*mid*/, + std::vector elements, + std::string string_table) { + auto str = [&](uint32_t offset, uint32_t length) -> QString { + if (length == 0 || offset + length > string_table.size()) return {}; + return QString::fromStdString(string_table.substr(offset, length)); + }; + + for (const auto& pe : elements) { + BasicElementInfo info; + info.object_id = pe.object_id; + info.model_id = pe.model_id; + info.ifc_id = pe.ifc_id; + info.parent_id = pe.parent_id; + info.guid = str(pe.guid_offset, pe.guid_length); + info.name = str(pe.name_offset, pe.name_length); + info.type = str(pe.type_offset, pe.type_length); + elements_[info.object_id] = info; + } +} + +void ElementRegistry::onStreamedElementsReady(uint32_t /*mid*/, std::vector elements) { + for (const auto& e : elements) { + BasicElementInfo info; + info.object_id = e.object_id; + info.model_id = e.model_id; + info.ifc_id = e.ifc_id; + info.parent_id = e.parent_id; + info.guid = QString::fromStdString(e.guid); + info.name = QString::fromStdString(e.name); + info.type = QString::fromStdString(e.type); + elements_[info.object_id] = info; + } +} + +} // namespace ifcinterface diff --git a/src/interface/ElementRegistry.h b/src/interface/ElementRegistry.h new file mode 100644 index 0000000000..471e53f31f --- /dev/null +++ b/src/interface/ElementRegistry.h @@ -0,0 +1,66 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_ELEMENTREGISTRY_H +#define IFCINTERFACE_ELEMENTREGISTRY_H + +#include +#include +#include +#include +#include +#include + +class SceneLoader; +struct PackedElementInfo; +struct ElementInfo; + +namespace ifcinterface { + +struct BasicElementInfo { + uint32_t object_id = 0; + uint32_t model_id = 0; + int ifc_id = 0; + int parent_id = 0; + QString guid; + QString name; + QString type; +}; + +class ElementRegistry : public QObject { + Q_OBJECT +public: + explicit ElementRegistry(QObject* parent = nullptr); + + void bindLoader(SceneLoader* loader); + std::optional find(uint32_t object_id) const; + +private: + void onSidecarElementsReady(uint32_t mid, + std::vector elements, + std::string string_table); + void onStreamedElementsReady(uint32_t mid, std::vector elements); + + std::unordered_map elements_; +}; + +} // namespace ifcinterface + +#endif diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 1f0fb7b021..28907cc495 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -23,7 +23,9 @@ #include "../ifcviewer/AppSettings.h" #include "../ifcviewer/SceneLoader.h" #include "../ifcviewer/ViewportWindow.h" -#include "components/PanelChrome.h" +#include "ElementRegistry.h" +#include "components/Panel.h" +#include "components/Style.h" #include "components/SvgIcon.h" #include "panels/todo/TodoPanelWidget.h" #include "panels/models/ModelsPanelView.h" @@ -52,9 +54,10 @@ namespace ifcinterface::shell { MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { + element_registry_ = new ifcinterface::ElementRegistry(this); setupChrome(); setupViewport(); - setupDocks(); + setupPanels(); setupStatus(); setupLoader(); setupRibbon(); @@ -66,280 +69,7 @@ void MainWindow::setupChrome() { setDockOptions(QMainWindow::AllowNestedDocks | QMainWindow::AllowTabbedDocks | QMainWindow::GroupedDragging); - - setStyleSheet(R"( - QMainWindow { - background: #26292f; - } - QWidget { - color: #d0d5dd; - background: #26292f; - selection-background-color: #39b54a; - selection-color: #14161a; - } - QFrame#ribbonShell { - background: #2d3138; - border-bottom: 1px solid #1b1d22; - } - QTabBar::tab { - background: transparent; - color: #8d97a7; - padding: 8px 14px; - margin-right: 2px; - border-bottom: 2px solid transparent; - } - QTabBar::tab:selected { - color: #f2f5fa; - border-bottom: 2px solid #39b54a; - } - QTabBar::tab:hover { - color: #ffffff; - } - QFrame#ribbonBand { - background: #31353d; - border-top: 1px solid #3b4048; - } - QFrame#ribbonPage { - background: transparent; - } - QFrame#ribbonGroup { - background: transparent; - border-right: 1px solid #434852; - } - QLabel#ribbonGroupLabel { - color: #7f8796; - font-size: 9px; - font-weight: 600; - letter-spacing: 0.08em; - } - QToolButton#ribbonButton { - background: transparent; - border: none; - padding: 6px 4px 4px 4px; - font-size: 11px; - color: #d6dce6; - } - QToolButton#ribbonButton:hover { - background: #3a3f48; - } - QToolButton#ribbonButton:pressed { - background: #24282f; - } - QFrame#viewportShell { - background: #202329; - border-top: 1px solid #1d2025; - } - QFrame#viewportFrame { - background: #1a1d22; - border: 1px solid #333942; - } - QDockWidget { - color: #d0d5dd; - } - QLabel#dockTitleText { - color: #dfe4ec; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.08em; - } - QToolButton#dockTitleButton { - color: #8e97a5; - border: none; - background: transparent; - } - QToolButton#dockTitleButton:hover { - color: #ffffff; - background: #353a42; - } - QFrame#panelFrame { - background: #2b2f36; - border: 1px solid #3e444e; - border-radius: 3px; - } - QWidget#panelBody { - background: #2b2f36; - } - QTreeWidget, QListWidget, QTableWidget, QAbstractScrollArea { - background: #2b2f36; - border: none; - outline: none; - gridline-color: #333842; - } - QTreeWidget::viewport, QListWidget::viewport, QTableWidget::viewport { - background: #2b2f36; - } - QHeaderView::section { - background: #31353d; - color: #b5becc; - border: none; - border-bottom: 1px solid #434a55; - padding: 7px 8px; - font-weight: 600; - } - QTableCornerButton::section { - background: #31353d; - border: none; - } - QScrollArea { - background: #2b2f36; - border: none; - } - QScrollArea > QWidget > QWidget { - background: #2b2f36; - } - QLineEdit { - background: #31353d; - border: 1px solid #434a55; - border-radius: 3px; - padding: 6px 8px; - color: #d9dfeb; - } - QLineEdit:focus { - border: 1px solid #5b6472; - } - QFrame#entityClassCard { - background: #26292f; - border: 1px solid #404650; - border-radius: 3px; - } - QLabel#entityClassLabel { - color: #eef2f8; - font-weight: 700; - background: transparent; - } - QLabel#entityTypeLabel { - color: #9aa4b3; - background: transparent; - } - QWidget#attributeList { - background: transparent; - } - QTreeView::item, QListView::item, QTableView::item { - padding: 4px; - } - QScrollBar:vertical { - background: transparent; - width: 10px; - margin: 2px 2px 2px 0; - } - QScrollBar:horizontal { - background: transparent; - height: 10px; - margin: 0 2px 2px 2px; - } - QScrollBar::handle:vertical, QScrollBar::handle:horizontal { - background: #525a67; - border-radius: 3px; - min-height: 24px; - min-width: 24px; - } - QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover { - background: #697385; - } - QScrollBar::add-line, QScrollBar::sub-line, - QScrollBar::add-page, QScrollBar::sub-page { - background: transparent; - border: none; - } - QStatusBar { - background: #24272c; - border-top: 1px solid #1a1c20; - } - QStatusBar QLabel { - color: #97a1af; - background: transparent; - border: none; - padding: 2px 8px; - } - QGroupBox { - background: transparent; - border: 1px solid #404650; - border-radius: 3px; - margin-top: 10px; - padding-top: 10px; - } - QGroupBox#propertySetCard { - background: #26292f; - border: 1px solid #404650; - border-radius: 3px; - } - QGroupBox#propertySetCard::title { - subcontrol-origin: margin; - left: 10px; - padding: 0 4px; - color: #d5dbe5; - } - QGroupBox#propertySetCard > QWidget { - background: #26292f; - } - QWidget#panelSection { - background: transparent; - } - QWidget#panelSectionFilterWrapper { - background: transparent; - } - QWidget#relationshipRow { - background: transparent; - } - QLabel#relationshipIconLabel { - background: transparent; - } - QWidget#inspectorPanel { - background: #2b2f36; - } - QFrame#panelSectionHeader { - background: #26292f; - } - QToolButton#panelSectionHeaderButton { - background: transparent; - border: none; - color: #e1e7f0; - font-weight: 700; - text-align: left; - padding: 2px; - margin: 0; - } - QToolButton#panelSectionHeaderButton:hover { - color: #ffffff; - } - QToolButton#panelSectionHeaderButton::menu-indicator { - image: none; - width: 0; - } - QToolButton#panelSectionFilterToggle { - background: transparent; - border: none; - padding: 2px; - } - QToolButton#panelSectionFilterToggle:hover { - background: #353a42; - } - QWidget#panelSectionBody { - background: transparent; - } - QLabel#propertyKeyLabel { - color: #9aa4b3; - background: transparent; - } - QLabel#propertyValueLabel { - color: #dce2eb; - background: transparent; - } - QLabel#relationshipValueLabel { - color: #dce2eb; - background: transparent; - } - QLabel#todoPanelTitle { - font-size: 14px; - font-weight: 600; - color: #e1e6ee; - background: transparent; - } - QLabel#todoPanelBody { - color: #8f98a6; - background: transparent; - } - )"); + setStyleSheet(components::style::buildAppStyleSheet()); } QToolButton* MainWindow::makeRibbonAction(const QString& text, const QString& icon_path) { @@ -376,17 +106,13 @@ QWidget* MainWindow::makeRibbonGroup(const QString& title, const QListsetText(mode); status_selection_label_->setText(detail); } QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) { - auto* button = makeRibbonAction(text, ":/icons/dm_toggle_openings.png"); + auto* button = makeRibbonAction(text, ":/icons/sidebar-expand.svg"); button->setCheckable(true); button->setChecked(dock->isVisible()); connect(button, &QToolButton::toggled, dock, [dock](bool checked) { @@ -565,19 +291,20 @@ QWidget* MainWindow::buildPanelsRibbonPage() { row->setSpacing(0); row->addWidget(makeRibbonGroup("DATA", { - makePanelToggle("Models", models_dock_), - makePanelToggle("Spatial", spatial_dock_), - makePanelToggle("Layers", layers_dock_), - makePanelToggle("Properties", properties_dock_) + makePanelToggle("Models", models_panel_), + makePanelToggle("Spatial", spatial_panel_), + makePanelToggle("Layers", layers_panel_), + makePanelToggle("Properties", properties_panel_) })); row->addWidget(makeRibbonGroup("QUERY", { - makePanelToggle("Views", stored_views_dock_), - makePanelToggle("Search", search_dock_), - makePanelToggle("Sheets", spreadsheet_dock_) + makePanelToggle("Views", stored_views_panel_), + makePanelToggle("Search", search_panel_), + makePanelToggle("Sheets", spreadsheet_panel_), + makePanelToggle("Audit", audit_panel_) })); row->addWidget(makeRibbonGroup("COLLABORATE", { - makePanelToggle("Clash", clash_dock_), - makePanelToggle("Issues", issues_dock_) + makePanelToggle("Clash", clash_panel_), + makePanelToggle("Issues", issues_panel_) })); row->addStretch(1); return page; @@ -644,68 +371,67 @@ void MainWindow::setupViewport() { setCentralWidget(shell); } -void MainWindow::setupDocks() { - auto* models_panel = new panels::models::ModelsPanelWidget(this); - auto* spatial_panel = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); - auto* properties_panel = new panels::properties::PropertiesPanelWidget(this); +void MainWindow::setupPanels() { + auto* models_widget = new panels::models::ModelsPanelWidget(this); + auto* spatial_widget = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); + auto* properties_widget = new panels::properties::PropertiesPanelWidget(this); - models_panel_view_ = new panels::models::ModelsPanelView(models_panel, this); - spatial_panel_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel, this); - properties_panel_view_ = new panels::properties::PropertiesPanelView(properties_panel, this); + models_view_ = new panels::models::ModelsPanelView(models_widget, this); + spatial_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_widget, this); + properties_view_ = new panels::properties::PropertiesPanelView( + properties_widget, viewport_, element_registry_, this); - connect(models_panel_view_, &panels::models::ModelsPanelView::statusMessageRequested, + connect(models_view_, &panels::models::ModelsPanelView::statusMessageRequested, this, &MainWindow::setStatusMessage); - connect(spatial_panel_view_, &panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, + connect(spatial_view_, &panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, this, &MainWindow::setStatusMessage); - models_dock_ = components::panel::makeDock( - "Models", components::panel::wrapPanel(models_panel), this, true); - spatial_dock_ = components::panel::makeDock( - "Spatial Hierarchy", components::panel::wrapPanel(spatial_panel), this); - properties_dock_ = components::panel::makeDock( - "Properties", components::panel::wrapPanel(properties_panel), this); - layers_dock_ = components::panel::makeDock( - "Layers", components::panel::wrapPanel(makeComingSoonPanel("Layers")), this); - stored_views_dock_ = components::panel::makeDock( - "Stored Views", components::panel::wrapPanel(makeComingSoonPanel("Stored Views")), this); - search_dock_ = components::panel::makeDock( - "Search and Query", components::panel::wrapPanel(makeComingSoonPanel("Search and Query")), this); - spreadsheet_dock_ = components::panel::makeDock( - "Spreadsheet", components::panel::wrapPanel(makeComingSoonPanel("Spreadsheet")), this); - clash_dock_ = components::panel::makeDock( - "Clash", components::panel::wrapPanel(makeComingSoonPanel("Clash")), this); - issues_dock_ = components::panel::makeDock( - "Issues", components::panel::wrapPanel(makeComingSoonPanel("Issues")), this); + models_panel_ = new components::Panel("Models", models_widget, this, true); + spatial_panel_ = new components::Panel("Spatial Hierarchy", spatial_widget, this); + properties_panel_ = new components::Panel("Properties", properties_widget, this); + layers_panel_ = new components::Panel("Layers", new panels::todo::TodoPanelWidget("Layers", this), this); + stored_views_panel_ = new components::Panel( + "Stored Views", new panels::todo::TodoPanelWidget("Stored Views", this), this); + search_panel_ = new components::Panel( + "Search and Query", new panels::todo::TodoPanelWidget("Search and Query", this), this); + spreadsheet_panel_ = new components::Panel( + "Spreadsheet", new panels::todo::TodoPanelWidget("Spreadsheet", this), this); + audit_panel_ = new components::Panel("Audit", new panels::todo::TodoPanelWidget("Audit", this), this); + clash_panel_ = new components::Panel("Clash", new panels::todo::TodoPanelWidget("Clash", this), this); + issues_panel_ = new components::Panel("Issues", new panels::todo::TodoPanelWidget("Issues", this), this); - addDockWidget(Qt::LeftDockWidgetArea, models_dock_); - addDockWidget(Qt::LeftDockWidgetArea, spatial_dock_); - splitDockWidget(models_dock_, spatial_dock_, Qt::Vertical); + addDockWidget(Qt::LeftDockWidgetArea, models_panel_); + addDockWidget(Qt::LeftDockWidgetArea, spatial_panel_); + splitDockWidget(models_panel_, spatial_panel_, Qt::Vertical); - addDockWidget(Qt::RightDockWidgetArea, properties_dock_); - addDockWidget(Qt::RightDockWidgetArea, layers_dock_); - addDockWidget(Qt::RightDockWidgetArea, stored_views_dock_); - addDockWidget(Qt::RightDockWidgetArea, search_dock_); - addDockWidget(Qt::RightDockWidgetArea, spreadsheet_dock_); - addDockWidget(Qt::RightDockWidgetArea, clash_dock_); - addDockWidget(Qt::RightDockWidgetArea, issues_dock_); + addDockWidget(Qt::RightDockWidgetArea, properties_panel_); + addDockWidget(Qt::RightDockWidgetArea, layers_panel_); + addDockWidget(Qt::RightDockWidgetArea, stored_views_panel_); + addDockWidget(Qt::RightDockWidgetArea, search_panel_); + addDockWidget(Qt::RightDockWidgetArea, spreadsheet_panel_); + addDockWidget(Qt::RightDockWidgetArea, audit_panel_); + addDockWidget(Qt::RightDockWidgetArea, clash_panel_); + addDockWidget(Qt::RightDockWidgetArea, issues_panel_); - tabifyDockWidget(properties_dock_, layers_dock_); - tabifyDockWidget(layers_dock_, stored_views_dock_); - tabifyDockWidget(stored_views_dock_, search_dock_); - tabifyDockWidget(search_dock_, spreadsheet_dock_); - tabifyDockWidget(spreadsheet_dock_, clash_dock_); - tabifyDockWidget(clash_dock_, issues_dock_); - properties_dock_->raise(); + tabifyDockWidget(properties_panel_, layers_panel_); + tabifyDockWidget(layers_panel_, stored_views_panel_); + tabifyDockWidget(stored_views_panel_, search_panel_); + tabifyDockWidget(search_panel_, spreadsheet_panel_); + tabifyDockWidget(spreadsheet_panel_, audit_panel_); + tabifyDockWidget(audit_panel_, clash_panel_); + tabifyDockWidget(clash_panel_, issues_panel_); + properties_panel_->raise(); - layers_dock_->hide(); - stored_views_dock_->hide(); - search_dock_->hide(); - spreadsheet_dock_->hide(); - clash_dock_->hide(); - issues_dock_->hide(); + layers_panel_->hide(); + stored_views_panel_->hide(); + search_panel_->hide(); + spreadsheet_panel_->hide(); + audit_panel_->hide(); + clash_panel_->hide(); + issues_panel_->hide(); - resizeDocks({models_dock_, properties_dock_}, {290, 330}, Qt::Horizontal); - resizeDocks({models_dock_, spatial_dock_}, {280, 240}, Qt::Vertical); + resizeDocks({models_panel_, properties_panel_}, {290, 330}, Qt::Horizontal); + resizeDocks({models_panel_, spatial_panel_}, {280, 240}, Qt::Vertical); } void MainWindow::setupStatus() { @@ -728,6 +454,7 @@ void MainWindow::setupStatus() { void MainWindow::setupLoader() { AppSettings::instance().setLoadDataSource(false); loader_ = new SceneLoader(viewport_, this); + element_registry_->bindLoader(loader_); connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); connect(loader_, &SceneLoader::loadedFromSidecar, this, &MainWindow::onLoadedFromSidecar); connect(loader_, &SceneLoader::loadedFromStream, this, &MainWindow::onLoadedFromStream); diff --git a/src/interface/MainWindow.h b/src/interface/MainWindow.h index e4a491a403..0ca4fc317a 100644 --- a/src/interface/MainWindow.h +++ b/src/interface/MainWindow.h @@ -31,6 +31,7 @@ class QTabBar; class QToolButton; class ViewportWindow; class SceneLoader; +namespace ifcinterface { class ElementRegistry; } namespace ifcinterface::panels::models { class ModelsPanelView; } namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelView; } namespace ifcinterface::panels::properties { class PropertiesPanelView; } @@ -46,7 +47,7 @@ private: void setupChrome(); void setupRibbon(); void setupViewport(); - void setupDocks(); + void setupPanels(); void setupStatus(); void setupLoader(); QWidget* buildHomeRibbonPage(); @@ -55,7 +56,6 @@ private: QWidget* buildPanelsRibbonPage(); QToolButton* makeRibbonAction(const QString& text, const QString& icon_path); QWidget* makeRibbonGroup(const QString& title, const QList& buttons); - QWidget* makeComingSoonPanel(const QString& title); QToolButton* makePanelToggle(const QString& text, QDockWidget* dock); void setStatusMessage(const QString& mode, const QString& detail); void addFiles(const QStringList& paths); @@ -78,19 +78,21 @@ private: QStackedWidget* ribbon_pages_ = nullptr; ViewportWindow* viewport_ = nullptr; SceneLoader* loader_ = nullptr; + ifcinterface::ElementRegistry* element_registry_ = nullptr; QWidget* viewport_container_ = nullptr; - QDockWidget* models_dock_ = nullptr; - QDockWidget* spatial_dock_ = nullptr; - QDockWidget* layers_dock_ = nullptr; - QDockWidget* properties_dock_ = nullptr; - QDockWidget* stored_views_dock_ = nullptr; - QDockWidget* search_dock_ = nullptr; - QDockWidget* spreadsheet_dock_ = nullptr; - QDockWidget* clash_dock_ = nullptr; - QDockWidget* issues_dock_ = nullptr; - ifcinterface::panels::models::ModelsPanelView* models_panel_view_ = nullptr; - ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView* spatial_panel_view_ = nullptr; - ifcinterface::panels::properties::PropertiesPanelView* properties_panel_view_ = nullptr; + QDockWidget* models_panel_ = nullptr; + QDockWidget* spatial_panel_ = nullptr; + QDockWidget* layers_panel_ = nullptr; + QDockWidget* properties_panel_ = nullptr; + QDockWidget* stored_views_panel_ = nullptr; + QDockWidget* search_panel_ = nullptr; + QDockWidget* spreadsheet_panel_ = nullptr; + QDockWidget* audit_panel_ = nullptr; + QDockWidget* clash_panel_ = nullptr; + QDockWidget* issues_panel_ = nullptr; + ifcinterface::panels::models::ModelsPanelView* models_view_ = nullptr; + ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView* spatial_view_ = nullptr; + ifcinterface::panels::properties::PropertiesPanelView* properties_view_ = nullptr; }; } // namespace ifcinterface::shell diff --git a/src/interface/components/KeyValueTable.cpp b/src/interface/components/KeyValueTable.cpp new file mode 100644 index 0000000000..0fbc436b3d --- /dev/null +++ b/src/interface/components/KeyValueTable.cpp @@ -0,0 +1,82 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "KeyValueTable.h" + +#include "SvgIcon.h" + +#include +#include +#include + +namespace ifcinterface::components { + +KeyValueTable::KeyValueTable(const QList& rows, QWidget* parent) + : QWidget(parent) +{ + setObjectName("attributeList"); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + for (const auto& row_data : rows) { + auto* row = new QWidget(this); + if (!row_data.trailing_icon_path.isEmpty()) { + row->setObjectName("relationshipRow"); + } else { + row->setObjectName("keyValueRow"); + } + + auto* row_layout = new QHBoxLayout(row); + row_layout->setContentsMargins(0, 0, 0, 0); + row_layout->setSpacing(12); + + auto* key = new QLabel(row_data.key, row); + key->setObjectName("propertyKeyLabel"); + if (row_data.key_minimum_width > 0) { + key->setMinimumWidth(row_data.key_minimum_width); + } + + auto* value = new QLabel(row_data.value, row); + value->setObjectName(row_data.value_object_name.isEmpty() + ? "propertyValueLabel" + : row_data.value_object_name); + value->setWordWrap(true); + value->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + row_layout->addWidget(key); + row_layout->addWidget(value, 1); + + if (!row_data.trailing_icon_path.isEmpty()) { + auto* icon = new QLabel(row); + icon->setObjectName(row_data.trailing_icon_object_name.isEmpty() + ? "relationshipIconLabel" + : row_data.trailing_icon_object_name); + icon->setPixmap(icons::makePanelSvgPixmap(row_data.trailing_icon_path, QSize(14, 14))); + icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + row_layout->addWidget(icon, 0, Qt::AlignRight | Qt::AlignVCenter); + } + + layout->addWidget(row); + } +} + +} // namespace ifcinterface::components diff --git a/src/interface/components/KeyValueTable.h b/src/interface/components/KeyValueTable.h new file mode 100644 index 0000000000..3e1ba10f73 --- /dev/null +++ b/src/interface/components/KeyValueTable.h @@ -0,0 +1,47 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H +#define IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H + +#include +#include +#include + +namespace ifcinterface::components { + +struct KeyValueTableRow { + QString key; + QString value; + QString value_object_name = "propertyValueLabel"; + QString trailing_icon_path; + QString trailing_icon_object_name; + int key_minimum_width = 0; +}; + +class KeyValueTable : public QWidget { + Q_OBJECT +public: + explicit KeyValueTable(const QList& rows, QWidget* parent = nullptr); +}; + +} // namespace ifcinterface::components + +#endif diff --git a/src/interface/components/PanelChrome.cpp b/src/interface/components/Panel.cpp similarity index 76% rename from src/interface/components/PanelChrome.cpp rename to src/interface/components/Panel.cpp index 4e49219788..3d73c98f6c 100644 --- a/src/interface/components/PanelChrome.cpp +++ b/src/interface/components/Panel.cpp @@ -18,8 +18,9 @@ * * ********************************************************************************/ -#include "PanelChrome.h" +#include "Panel.h" +#include "Style.h" #include "SvgIcon.h" #include @@ -30,7 +31,7 @@ #include #include -namespace ifcinterface::components::panel { +namespace ifcinterface::components { namespace { @@ -69,32 +70,31 @@ public: } // namespace -QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings) { - auto* dock = new QDockWidget(title, parent); - dock->setObjectName(title); - dock->setFeatures(QDockWidget::DockWidgetMovable | - QDockWidget::DockWidgetFloatable | - QDockWidget::DockWidgetClosable); - dock->setTitleBarWidget(new DockTitleBar(title, has_settings, dock)); - dock->setWidget(content); - return dock; -} - -QFrame* wrapPanel(QWidget* inner) { +Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_settings) + : QDockWidget(title, parent) +{ auto* outer = new QFrame(); auto* outer_layout = new QVBoxLayout(outer); - outer_layout->setContentsMargins(6, 6, 6, 6); + outer_layout->setContentsMargins(style::metrics::padding, + style::metrics::padding, + style::metrics::padding, + style::metrics::padding); outer_layout->setSpacing(0); auto* frame = new QFrame(outer); - frame->setObjectName("panelFrame"); - auto* layout = new QVBoxLayout(frame); - layout->setContentsMargins(0, 8, 0, 8); - layout->setSpacing(0); - layout->addWidget(inner); - + frame->setObjectName("panel"); + auto* frame_layout = new QVBoxLayout(frame); + frame_layout->setContentsMargins(0, style::metrics::padding, 0, style::metrics::padding); + frame_layout->setSpacing(0); + frame_layout->addWidget(content); outer_layout->addWidget(frame); - return outer; + + setObjectName(title); + setFeatures(QDockWidget::DockWidgetMovable | + QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetClosable); + setTitleBarWidget(new DockTitleBar(title, has_settings, this)); + setWidget(outer); } -} // namespace ifcinterface::components::panel +} // namespace ifcinterface::components diff --git a/src/interface/components/PanelChrome.h b/src/interface/components/Panel.h similarity index 85% rename from src/interface/components/PanelChrome.h rename to src/interface/components/Panel.h index 806dfeefcd..9739d53552 100644 --- a/src/interface/components/PanelChrome.h +++ b/src/interface/components/Panel.h @@ -21,17 +21,19 @@ #ifndef IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H #define IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H -#include +#include -class QDockWidget; -class QFrame; class QWidget; -namespace ifcinterface::components::panel { +namespace ifcinterface::components { -QDockWidget* makeDock(const QString& title, QWidget* content, QWidget* parent, bool has_settings = false); -QFrame* wrapPanel(QWidget* inner); +class Panel : public QDockWidget { + Q_OBJECT -} // namespace ifcinterface::components::panel +public: + explicit Panel(const QString& title, QWidget* content, QWidget* parent = nullptr, bool has_settings = false); +}; + +} // namespace ifcinterface::components #endif diff --git a/src/interface/components/Section.cpp b/src/interface/components/Section.cpp index 34790e7960..a7899b9ce6 100644 --- a/src/interface/components/Section.cpp +++ b/src/interface/components/Section.cpp @@ -20,6 +20,7 @@ #include "Section.h" +#include "Style.h" #include "SvgIcon.h" #include @@ -49,14 +50,14 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, setObjectName("panelSection"); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); + layout->setSpacing(style::metrics::section_spacing); if (header_mode == SectionHeaderMode::Visible) { auto* header = new QFrame(this); header->setObjectName("panelSectionHeader"); auto* header_layout = new QHBoxLayout(header); header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(6); + header_layout->setSpacing(style::metrics::section_spacing); auto* toggle = new QToolButton(header); toggle->setObjectName("panelSectionHeaderButton"); @@ -89,7 +90,8 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, auto* filter_wrapper = new QWidget(this); filter_wrapper->setObjectName("panelSectionFilterWrapper"); auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(10, 0, 10, 0); + filter_wrapper_layout->setContentsMargins(style::metrics::filter_body_padding_x, 0, + style::metrics::filter_body_padding_x, 0); filter_wrapper_layout->setSpacing(0); filter_wrapper_layout->addWidget(filter_field_); layout->addWidget(filter_wrapper); @@ -108,7 +110,8 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, auto* filter_wrapper = new QWidget(this); filter_wrapper->setObjectName("panelSectionFilterWrapper"); auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(8, 0, 8, 0); + filter_wrapper_layout->setContentsMargins(style::metrics::hidden_filter_body_padding_x, 0, + style::metrics::hidden_filter_body_padding_x, 0); filter_wrapper_layout->setSpacing(0); filter_wrapper_layout->addWidget(filter_field_); layout->addWidget(filter_wrapper); @@ -118,11 +121,14 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, body_->setObjectName("panelSectionBody"); body_layout_ = new QVBoxLayout(body_); if (header_mode == SectionHeaderMode::Visible) { - body_layout_->setContentsMargins(10, 6, 10, 0); + body_layout_->setContentsMargins(style::metrics::section_body_padding_x, + style::metrics::section_body_padding_top, + style::metrics::section_body_padding_x, 0); } else { - body_layout_->setContentsMargins(8, 0, 8, 0); + body_layout_->setContentsMargins(style::metrics::hidden_section_body_padding_x, 0, + style::metrics::hidden_section_body_padding_x, 0); } - body_layout_->setSpacing(6); + body_layout_->setSpacing(style::metrics::section_spacing); layout->addWidget(body_); } diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp new file mode 100644 index 0000000000..9c0b14a924 --- /dev/null +++ b/src/interface/components/Style.cpp @@ -0,0 +1,303 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Style.h" + +namespace ifcinterface::components::style { + +QString buildAppStyleSheet() { + return QString(R"( + QMainWindow { + background: #26292f; + } + QWidget { + color: #d0d5dd; + background: #26292f; + selection-background-color: #39b54a; + selection-color: #14161a; + } + QFrame#ribbonShell { + background: #2d3138; + border-bottom: 1px solid #1b1d22; + } + QTabBar::tab { + background: transparent; + color: #8d97a7; + padding: 8px 14px; + margin-right: 2px; + border-bottom: 2px solid transparent; + } + QTabBar::tab:selected { + color: #f2f5fa; + border-bottom: 2px solid #39b54a; + } + QTabBar::tab:hover { + color: #ffffff; + } + QFrame#ribbonBand { + background: #31353d; + border-top: 1px solid #3b4048; + } + QFrame#ribbonPage { + background: transparent; + } + QFrame#ribbonGroup { + background: transparent; + border-right: 1px solid #434852; + } + QLabel#ribbonGroupLabel { + color: #7f8796; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.08em; + } + QToolButton#ribbonButton { + background: transparent; + border: none; + padding: 6px 4px 4px 4px; + font-size: 11px; + color: #d6dce6; + } + QToolButton#ribbonButton:hover { + background: #3a3f48; + } + QToolButton#ribbonButton:pressed { + background: #24282f; + } + QFrame#viewportShell { + background: #202329; + border-top: 1px solid #1d2025; + } + QFrame#viewportFrame { + background: #1a1d22; + border: 1px solid #333942; + } + QDockWidget { + color: #d0d5dd; + } + QLabel#dockTitleText { + color: #dfe4ec; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + } + QToolButton#dockTitleButton { + color: #8e97a5; + border: none; + background: transparent; + } + QToolButton#dockTitleButton:hover { + color: #ffffff; + background: #353a42; + } + QFrame#panel { + background: #2b2f36; + border: 1px solid #3e444e; + border-radius: %1px; + } + QTreeWidget, QListWidget, QTableWidget, QAbstractScrollArea { + background: #2b2f36; + border: none; + outline: none; + gridline-color: #333842; + } + QTreeWidget::viewport, QListWidget::viewport, QTableWidget::viewport { + background: #2b2f36; + } + QHeaderView::section { + background: #31353d; + color: #b5becc; + border: none; + border-bottom: 1px solid #434a55; + padding: 7px 8px; + font-weight: 600; + } + QTableCornerButton::section { + background: #31353d; + border: none; + } + QScrollArea { + background: #2b2f36; + border: none; + } + QScrollArea > QWidget > QWidget { + background: #2b2f36; + } + QLineEdit { + background: #31353d; + border: 1px solid #434a55; + border-radius: %1px; + padding: %2px %3px; + color: #d9dfeb; + } + QLineEdit:focus { + border: 1px solid #5b6472; + } + QFrame#entityClassCard { + background: #26292f; + border: 1px solid #404650; + border-radius: %1px; + } + QLabel#entityClassLabel { + color: #eef2f8; + font-weight: 700; + background: transparent; + } + QLabel#entityTypeLabel { + color: #9aa4b3; + background: transparent; + } + QWidget#attributeList { + background: transparent; + } + QTreeView::item, QListView::item, QTableView::item { + padding: 4px; + } + QScrollBar:vertical { + background: transparent; + width: 10px; + margin: 2px 2px 2px 0; + } + QScrollBar:horizontal { + background: transparent; + height: 10px; + margin: 0 2px 2px 2px; + } + QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: #525a67; + border-radius: %1px; + min-height: 24px; + min-width: 24px; + } + QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover { + background: #697385; + } + QScrollBar::add-line, QScrollBar::sub-line, + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + border: none; + } + QStatusBar { + background: #24272c; + border-top: 1px solid #1a1c20; + } + QStatusBar QLabel { + color: #97a1af; + background: transparent; + border: none; + padding: 2px 8px; + } + QGroupBox { + background: transparent; + border: 1px solid #404650; + border-radius: %1px; + margin-top: 10px; + padding-top: 10px; + } + QGroupBox#propertySetCard { + background: #26292f; + border: 1px solid #404650; + border-radius: %1px; + } + QGroupBox#propertySetCard::title { + subcontrol-origin: margin; + left: %4px; + padding: 0 4px; + color: #d5dbe5; + } + QGroupBox#propertySetCard > QWidget { + background: #26292f; + } + QWidget#panelSection { + background: transparent; + } + QWidget#panelSectionFilterWrapper { + background: transparent; + } + QWidget#relationshipRow { + background: transparent; + } + QLabel#relationshipIconLabel { + background: transparent; + } + QWidget#inspectorPanel { + background: #2b2f36; + } + QFrame#panelSectionHeader { + background: #26292f; + } + QToolButton#panelSectionHeaderButton { + background: transparent; + border: none; + color: #e1e7f0; + font-weight: 700; + text-align: left; + padding: %5px; + margin: 0; + } + QToolButton#panelSectionHeaderButton:hover { + color: #ffffff; + } + QToolButton#panelSectionHeaderButton::menu-indicator { + image: none; + width: 0; + } + QToolButton#panelSectionFilterToggle { + background: transparent; + border: none; + padding: %5px; + } + QToolButton#panelSectionFilterToggle:hover { + background: #353a42; + } + QWidget#panelSectionBody { + background: transparent; + } + QLabel#propertyKeyLabel { + color: #9aa4b3; + background: transparent; + } + QLabel#propertyValueLabel { + color: #dce2eb; + background: transparent; + } + QLabel#relationshipValueLabel { + color: #dce2eb; + background: transparent; + } + QLabel#todoPanelTitle { + font-size: 14px; + font-weight: 600; + color: #e1e6ee; + background: transparent; + } + QLabel#todoPanelBody { + color: #8f98a6; + background: transparent; + } + )") + .arg(metrics::panel_radius) + .arg(metrics::control_padding_y) + .arg(metrics::control_padding_x) + .arg(metrics::card_padding) + .arg(metrics::section_header_padding); +} + +} // namespace ifcinterface::components::style diff --git a/src/interface/components/Style.h b/src/interface/components/Style.h new file mode 100644 index 0000000000..8c382fa804 --- /dev/null +++ b/src/interface/components/Style.h @@ -0,0 +1,49 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_STYLE_H +#define IFCINTERFACE_COMPONENTS_STYLE_H + +#include + +namespace ifcinterface::components::style::metrics { + +inline constexpr int padding = 6; +inline constexpr int section_spacing = 6; +inline constexpr int section_body_padding_x = 10; +inline constexpr int section_body_padding_top = 6; +inline constexpr int hidden_section_body_padding_x = 8; +inline constexpr int section_header_padding = 2; +inline constexpr int filter_body_padding_x = 10; +inline constexpr int hidden_filter_body_padding_x = 8; +inline constexpr int card_padding = 10; +inline constexpr int control_padding_y = 6; +inline constexpr int control_padding_x = 8; +inline constexpr int panel_radius = 3; + +} // namespace ifcinterface::components::style::metrics + +namespace ifcinterface::components::style { + +QString buildAppStyleSheet(); + +} // namespace ifcinterface::components::style + +#endif diff --git a/src/interface/icons/sidebar-expand.svg b/src/interface/icons/sidebar-expand.svg new file mode 100644 index 0000000000..6d7695253c --- /dev/null +++ b/src/interface/icons/sidebar-expand.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/interface_resources.qrc b/src/interface/interface_resources.qrc index 9be3928f7d..453e64d9e7 100644 --- a/src/interface/interface_resources.qrc +++ b/src/interface/interface_resources.qrc @@ -4,21 +4,6 @@ ../ifctester/webapp/public/fonts/dmsans/DMSans-VariableFont_opsz,wght.ttf - ../bonsai/bonsai/bim/data/icons/IFC.png - ../bonsai/bonsai/bim/data/icons/dm_add.png - ../bonsai/bonsai/bim/data/icons/dm_add_type.png - ../bonsai/bonsai/bim/data/icons/dm_assign.png - ../bonsai/bonsai/bim/data/icons/dm_centerline.png - ../bonsai/bonsai/bim/data/icons/dm_connect_mep_elements.png - ../bonsai/bonsai/bim/data/icons/dm_decomposition.png - ../bonsai/bonsai/bim/data/icons/dm_edit_profile.png - ../bonsai/bonsai/bim/data/icons/dm_ifc.png - ../bonsai/bonsai/bim/data/icons/dm_perform_quantity_take-off.png - ../bonsai/bonsai/bim/data/icons/dm_rectangle.png - ../bonsai/bonsai/bim/data/icons/dm_refresh.png - ../bonsai/bonsai/bim/data/icons/dm_rotate_90.png - ../bonsai/bonsai/bim/data/icons/dm_split.png - ../bonsai/bonsai/bim/data/icons/dm_toggle_openings.png icons/plus-square.svg icons/download-square.svg icons/cloud-square.svg @@ -55,5 +40,6 @@ icons/filter.svg icons/cube-dots.svg icons/cursor-pointer.svg + icons/sidebar-expand.svg diff --git a/src/interface/panels/properties/PropertiesPanelView.cpp b/src/interface/panels/properties/PropertiesPanelView.cpp index 1b74f40fff..291589762a 100644 --- a/src/interface/panels/properties/PropertiesPanelView.cpp +++ b/src/interface/panels/properties/PropertiesPanelView.cpp @@ -22,22 +22,36 @@ #include "PropertiesPanelWidget.h" +#include "../../ElementRegistry.h" +#include "../../../ifcviewer/ViewportWindow.h" + namespace ifcinterface::panels::properties { -PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, QObject* parent) - : QObject(parent), widget_(widget) +PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, + ViewportWindow* viewport, + ifcinterface::ElementRegistry* registry, + QObject* parent) + : QObject(parent), widget_(widget), registry_(registry) { - state_.entity = {"IfcWall", "SOLIDWALL"}; - state_.attributes = { + connect(viewport, &ViewportWindow::objectPicked, this, [this](uint32_t object_id) { + refresh(object_id); + }); + refresh(0); +} + +void PropertiesPanelView::refresh(uint32_t object_id) { + PropertiesPanelState state; + state.entity = {"IfcWall", "SOLIDWALL"}; + state.attributes = { {"GlobalId", "2Q$n5SLPP9Q8B7wQKjKfUQ"}, {"Name", "Core-EXT-204"}, {"Description", "External load-bearing wall"}, }; - state_.relationships = { + state.relationships = { {"Type", "Basic Wall: Exterior - 200mm"}, {"Container", "Level 02"}, }; - state_.property_sets = { + state.property_sets = { {"Pset_WallCommon", {{"Reference", "Core-EXT-204"}, {"Status", "Reviewed"}, @@ -53,7 +67,7 @@ PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, QObject* {"Last Review", "2026-04-30"}, {"Assigned To", "Design Coordination"}}}, }; - state_.quantity_sets = { + state.quantity_sets = { {"BaseQuantities", {{"Length", "6.20 m"}, {"Height", "3.45 m"}, @@ -65,7 +79,25 @@ PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, QObject* {"Paint Coverage", "42.78 m2"}}}, }; - widget_->setState(state_); + if (registry_) { + auto info = registry_->find(object_id); + if (info && !info->type.isEmpty()) { + state.entity.entity_class = info->type; + if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { + state.property_sets[1].rows[0].value = info->type; + } + } + if (info && !info->name.isEmpty()) { + state.attributes[1].value = info->name; + if (state.property_sets.size() > 1 && state.property_sets[1].rows.size() > 1) { + state.property_sets[1].rows[1].value = info->name; + } + } + if (info && !info->guid.isEmpty()) { + state.attributes[0].value = info->guid; + } + } + widget_->render(state); } } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelView.h b/src/interface/panels/properties/PropertiesPanelView.h index a61c393d31..65abe82f4f 100644 --- a/src/interface/panels/properties/PropertiesPanelView.h +++ b/src/interface/panels/properties/PropertiesPanelView.h @@ -25,6 +25,8 @@ #include +namespace ifcinterface { class ElementRegistry; } +class ViewportWindow; namespace ifcinterface::panels::properties { class PropertiesPanelWidget; @@ -32,11 +34,16 @@ class PropertiesPanelWidget; class PropertiesPanelView : public QObject { Q_OBJECT public: - explicit PropertiesPanelView(PropertiesPanelWidget* widget, QObject* parent = nullptr); + explicit PropertiesPanelView(PropertiesPanelWidget* widget, + ViewportWindow* viewport, + ifcinterface::ElementRegistry* registry, + QObject* parent = nullptr); private: + void refresh(uint32_t object_id); + PropertiesPanelWidget* widget_ = nullptr; - PropertiesPanelState state_; + ifcinterface::ElementRegistry* registry_ = nullptr; }; } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp index 2c17e4c176..33db92fbb4 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.cpp +++ b/src/interface/panels/properties/PropertiesPanelWidget.cpp @@ -20,10 +20,10 @@ #include "PropertiesPanelWidget.h" +#include "../../components/KeyValueTable.h" #include "../../components/Section.h" #include "../../components/SvgIcon.h" -#include #include #include #include @@ -36,79 +36,37 @@ namespace { QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { auto* group = new QGroupBox(property_set.title, parent); group->setObjectName("propertySetCard"); - auto* form = new QFormLayout(group); - form->setContentsMargins(10, 10, 10, 10); - form->setHorizontalSpacing(16); - form->setVerticalSpacing(6); - form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + auto* layout = new QVBoxLayout(group); + layout->setContentsMargins(10, 10, 10, 10); + layout->setSpacing(0); + QList rows; for (const auto& row : property_set.rows) { - auto* key = new QLabel(row.key, group); - key->setObjectName("propertyKeyLabel"); - auto* value = new QLabel(row.value, group); - value->setObjectName("propertyValueLabel"); - value->setWordWrap(true); - form->addRow(key, value); + rows.append({row.key, row.value, "propertyValueLabel", "", "", 0}); } - + layout->addWidget(new ifcinterface::components::KeyValueTable(rows, group)); return group; } QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { - auto* panel = new QWidget(parent); - panel->setObjectName("attributeList"); - auto* form = new QFormLayout(panel); - form->setContentsMargins(0, 0, 0, 0); - form->setHorizontalSpacing(16); - form->setVerticalSpacing(6); - form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - + QList table_rows; for (const auto& row : rows) { - auto* key = new QLabel(row.key, panel); - key->setObjectName("propertyKeyLabel"); - auto* value = new QLabel(row.value, panel); - value->setObjectName("propertyValueLabel"); - value->setWordWrap(true); - form->addRow(key, value); + table_rows.append({row.key, row.value, "propertyValueLabel", "", "", 0}); } - - return panel; + return new ifcinterface::components::KeyValueTable(table_rows, parent); } QWidget* makeRelationshipList(const QList& rows, QWidget* parent = nullptr) { - auto* panel = new QWidget(parent); - panel->setObjectName("attributeList"); - auto* layout = new QVBoxLayout(panel); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - + QList table_rows; for (const auto& row_data : rows) { - auto* row = new QWidget(panel); - row->setObjectName("relationshipRow"); - auto* row_layout = new QHBoxLayout(row); - row_layout->setContentsMargins(0, 0, 0, 0); - row_layout->setSpacing(12); - - auto* key = new QLabel(row_data.key, row); - key->setObjectName("propertyKeyLabel"); - key->setMinimumWidth(72); - - auto* target = new QLabel(row_data.value, row); - target->setObjectName("relationshipValueLabel"); - target->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - - auto* icon = new QLabel(row); - icon->setObjectName("relationshipIconLabel"); - icon->setPixmap(ifcinterface::components::icons::makePanelSvgPixmap(":/icons/cursor-pointer.svg", QSize(14, 14))); - icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - - row_layout->addWidget(key); - row_layout->addWidget(target, 1); - row_layout->addWidget(icon, 0, Qt::AlignRight | Qt::AlignVCenter); - layout->addWidget(row); + table_rows.append({row_data.key, + row_data.value, + "relationshipValueLabel", + ":/icons/cursor-pointer.svg", + "relationshipIconLabel", + 72}); } - - return panel; + return new ifcinterface::components::KeyValueTable(table_rows, parent); } } // namespace @@ -121,22 +79,28 @@ PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) auto* root = new QVBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); + + scroll_ = new QScrollArea(this); + scroll_->setWidgetResizable(true); + scroll_->setFrameShape(QFrame::NoFrame); + + content_ = new QWidget(scroll_); + content_->setObjectName("inspectorPanel"); + content_layout_ = new QVBoxLayout(content_); + content_layout_->setContentsMargins(0, 0, 0, 0); + content_layout_->setSpacing(12); + + scroll_->setWidget(content_); + root->addWidget(scroll_); } -void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { - auto* root = qobject_cast(layout()); - while (auto* item = root->takeAt(0)) { +void PropertiesPanelWidget::render(const PropertiesPanelState& state) { + while (auto* item = content_layout_->takeAt(0)) { if (auto* widget = item->widget()) widget->deleteLater(); delete item; } - auto* content = new QWidget(this); - content->setObjectName("inspectorPanel"); - auto* content_layout = new QVBoxLayout(content); - content_layout->setContentsMargins(0, 0, 0, 0); - content_layout->setSpacing(12); - - auto* entity_card = new QFrame(content); + auto* entity_card = new QFrame(content_); entity_card->setObjectName("entityClassCard"); auto* entity_layout = new QHBoxLayout(entity_card); entity_layout->setContentsMargins(10, 8, 10, 8); @@ -159,37 +123,31 @@ void PropertiesPanelWidget::setState(const PropertiesPanelState& state) { QList property_set_widgets; for (const auto& property_set : state.property_sets) { - property_set_widgets.append(makePropertySetPanel(property_set, content)); + property_set_widgets.append(makePropertySetPanel(property_set, content_)); } QList quantity_set_widgets; for (const auto& property_set : state.quantity_sets) { - quantity_set_widgets.append(makePropertySetPanel(property_set, content)); + quantity_set_widgets.append(makePropertySetPanel(property_set, content_)); } - auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, "", content); + auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, "", content_); entity_section->addBodyWidget(entity_card); - auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, "", content); - attributes_section->addBodyWidget(makeAttributeList(state.attributes, content)); - auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, "", content); - relationships_section->addBodyWidget(makeRelationshipList(state.relationships, content)); - auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, "Filter properties or sets", content); + auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, "", content_); + attributes_section->addBodyWidget(makeAttributeList(state.attributes, content_)); + auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, "", content_); + relationships_section->addBodyWidget(makeRelationshipList(state.relationships, content_)); + auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, "Filter properties or sets", content_); for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); - auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, "Filter quantities or sets", content); + auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, "Filter quantities or sets", content_); for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); - content_layout->addWidget(entity_section); - content_layout->addWidget(attributes_section); - content_layout->addWidget(relationships_section); - content_layout->addWidget(properties_section); - content_layout->addWidget(quantities_section); - content_layout->addStretch(1); - - auto* scroll = new QScrollArea(this); - scroll->setWidgetResizable(true); - scroll->setFrameShape(QFrame::NoFrame); - scroll->setWidget(content); - root->addWidget(scroll); + content_layout_->addWidget(entity_section); + content_layout_->addWidget(attributes_section); + content_layout_->addWidget(relationships_section); + content_layout_->addWidget(properties_section); + content_layout_->addWidget(quantities_section); + content_layout_->addStretch(1); } } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelWidget.h b/src/interface/panels/properties/PropertiesPanelWidget.h index dcd600cbb7..ea929c22de 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.h +++ b/src/interface/panels/properties/PropertiesPanelWidget.h @@ -25,6 +25,9 @@ #include +class QScrollArea; +class QVBoxLayout; + namespace ifcinterface::panels::properties { class PropertiesPanelWidget : public QWidget { @@ -32,7 +35,12 @@ class PropertiesPanelWidget : public QWidget { public: explicit PropertiesPanelWidget(QWidget* parent = nullptr); - void setState(const PropertiesPanelState& state); + void render(const PropertiesPanelState& state); + +private: + QScrollArea* scroll_ = nullptr; + QWidget* content_ = nullptr; + QVBoxLayout* content_layout_ = nullptr; }; } // namespace ifcinterface::panels::properties From 0d5fa0c20c577b9b935be675939702caee9116a7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 6 May 2026 12:36:28 +1000 Subject: [PATCH 110/120] Interface mockup 6 --- src/interface/ElementRegistry.cpp | 21 +++++++++- src/interface/ElementRegistry.h | 7 +++- src/interface/MainWindow.cpp | 2 +- src/interface/components/KeyValueTable.cpp | 4 +- src/interface/components/KeyValueTable.h | 2 +- src/interface/components/Panel.cpp | 22 +++++++++- src/interface/components/Panel.h | 6 ++- src/interface/components/Style.cpp | 20 ++------- .../panels/properties/PropertiesPanelView.cpp | 21 +++++++++- .../properties/PropertiesPanelWidget.cpp | 42 +++++++------------ .../panels/properties/PropertiesPanelWidget.h | 3 -- src/interface/panels/todo/TodoPanelWidget.cpp | 2 - 12 files changed, 90 insertions(+), 62 deletions(-) diff --git a/src/interface/ElementRegistry.cpp b/src/interface/ElementRegistry.cpp index 1e41385d33..7583ff3dd6 100644 --- a/src/interface/ElementRegistry.cpp +++ b/src/interface/ElementRegistry.cpp @@ -32,18 +32,37 @@ ElementRegistry::ElementRegistry(QObject* parent) } void ElementRegistry::bindLoader(SceneLoader* loader) { + loader_ = loader; connect(loader, &SceneLoader::sidecarElementsReady, this, &ElementRegistry::onSidecarElementsReady); connect(loader, &SceneLoader::streamedElementsReady, this, &ElementRegistry::onStreamedElementsReady); } -std::optional ElementRegistry::find(uint32_t object_id) const { +std::optional ElementRegistry::findBasicElementInfo(uint32_t object_id) const { auto it = elements_.find(object_id); if (it == elements_.end()) return std::nullopt; return it->second; } +std::optional ElementRegistry::findEntity(uint32_t object_id) const { + if (!loader_) return std::nullopt; + + auto info = findBasicElementInfo(object_id); + if (!info) return std::nullopt; + + auto* file = loader_->ifcFile(info->model_id); + if (!file) return std::nullopt; + + try { + auto instance = file->instance_by_id(info->ifc_id); + if (!instance) return std::nullopt; + return instance; + } catch (...) { + return std::nullopt; + } +} + void ElementRegistry::onSidecarElementsReady(uint32_t /*mid*/, std::vector elements, std::string string_table) { diff --git a/src/interface/ElementRegistry.h b/src/interface/ElementRegistry.h index 471e53f31f..b059c10c5d 100644 --- a/src/interface/ElementRegistry.h +++ b/src/interface/ElementRegistry.h @@ -23,10 +23,11 @@ #include #include +#include "../ifcparse/express.h" #include +#include #include #include -#include class SceneLoader; struct PackedElementInfo; @@ -50,7 +51,8 @@ public: explicit ElementRegistry(QObject* parent = nullptr); void bindLoader(SceneLoader* loader); - std::optional find(uint32_t object_id) const; + std::optional findBasicElementInfo(uint32_t object_id) const; + std::optional findEntity(uint32_t object_id) const; private: void onSidecarElementsReady(uint32_t mid, @@ -58,6 +60,7 @@ private: std::string string_table); void onStreamedElementsReady(uint32_t mid, std::vector elements); + SceneLoader* loader_ = nullptr; std::unordered_map elements_; }; diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 28907cc495..5119a1fb6c 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -388,7 +388,7 @@ void MainWindow::setupPanels() { models_panel_ = new components::Panel("Models", models_widget, this, true); spatial_panel_ = new components::Panel("Spatial Hierarchy", spatial_widget, this); - properties_panel_ = new components::Panel("Properties", properties_widget, this); + properties_panel_ = new components::Panel("Properties", properties_widget, this, false, true); layers_panel_ = new components::Panel("Layers", new panels::todo::TodoPanelWidget("Layers", this), this); stored_views_panel_ = new components::Panel( "Stored Views", new panels::todo::TodoPanelWidget("Stored Views", this), this); diff --git a/src/interface/components/KeyValueTable.cpp b/src/interface/components/KeyValueTable.cpp index 0fbc436b3d..61937f7df4 100644 --- a/src/interface/components/KeyValueTable.cpp +++ b/src/interface/components/KeyValueTable.cpp @@ -31,7 +31,7 @@ namespace ifcinterface::components { KeyValueTable::KeyValueTable(const QList& rows, QWidget* parent) : QWidget(parent) { - setObjectName("attributeList"); + setObjectName("keyValueTable"); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -57,7 +57,7 @@ KeyValueTable::KeyValueTable(const QList& rows, QWidget* paren auto* value = new QLabel(row_data.value, row); value->setObjectName(row_data.value_object_name.isEmpty() - ? "propertyValueLabel" + ? "keyValueValueLabel" : row_data.value_object_name); value->setWordWrap(true); value->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); diff --git a/src/interface/components/KeyValueTable.h b/src/interface/components/KeyValueTable.h index 3e1ba10f73..807376ce94 100644 --- a/src/interface/components/KeyValueTable.h +++ b/src/interface/components/KeyValueTable.h @@ -30,7 +30,7 @@ namespace ifcinterface::components { struct KeyValueTableRow { QString key; QString value; - QString value_object_name = "propertyValueLabel"; + QString value_object_name = "keyValueValueLabel"; QString trailing_icon_path; QString trailing_icon_object_name; int key_minimum_width = 0; diff --git a/src/interface/components/Panel.cpp b/src/interface/components/Panel.cpp index 3d73c98f6c..8356a4e739 100644 --- a/src/interface/components/Panel.cpp +++ b/src/interface/components/Panel.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -70,7 +71,7 @@ public: } // namespace -Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_settings) +Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_settings, bool scrollable) : QDockWidget(title, parent) { auto* outer = new QFrame(); @@ -86,7 +87,24 @@ Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_s auto* frame_layout = new QVBoxLayout(frame); frame_layout->setContentsMargins(0, style::metrics::padding, 0, style::metrics::padding); frame_layout->setSpacing(0); - frame_layout->addWidget(content); + + if (scrollable) { + auto* scroll = new QScrollArea(frame); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + auto* scroll_body = new QWidget(scroll); + scroll_body->setObjectName("panelScrollBody"); + auto* scroll_body_layout = new QVBoxLayout(scroll_body); + scroll_body_layout->setContentsMargins(0, 0, 0, 0); + scroll_body_layout->setSpacing(0); + scroll_body_layout->addWidget(content); + + scroll->setWidget(scroll_body); + frame_layout->addWidget(scroll); + } else { + frame_layout->addWidget(content); + } outer_layout->addWidget(frame); setObjectName(title); diff --git a/src/interface/components/Panel.h b/src/interface/components/Panel.h index 9739d53552..b786f636e9 100644 --- a/src/interface/components/Panel.h +++ b/src/interface/components/Panel.h @@ -31,7 +31,11 @@ class Panel : public QDockWidget { Q_OBJECT public: - explicit Panel(const QString& title, QWidget* content, QWidget* parent = nullptr, bool has_settings = false); + explicit Panel(const QString& title, + QWidget* content, + QWidget* parent = nullptr, + bool has_settings = false, + bool scrollable = false); }; } // namespace ifcinterface::components diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp index 9c0b14a924..25f4cc226c 100644 --- a/src/interface/components/Style.cpp +++ b/src/interface/components/Style.cpp @@ -164,7 +164,7 @@ QString buildAppStyleSheet() { color: #9aa4b3; background: transparent; } - QWidget#attributeList { + QWidget#keyValueTable { background: transparent; } QTreeView::item, QListView::item, QTableView::item { @@ -237,7 +237,7 @@ QString buildAppStyleSheet() { QLabel#relationshipIconLabel { background: transparent; } - QWidget#inspectorPanel { + QWidget#panelScrollBody { background: #2b2f36; } QFrame#panelSectionHeader { @@ -274,24 +274,10 @@ QString buildAppStyleSheet() { color: #9aa4b3; background: transparent; } - QLabel#propertyValueLabel { + QLabel#keyValueValueLabel { color: #dce2eb; background: transparent; } - QLabel#relationshipValueLabel { - color: #dce2eb; - background: transparent; - } - QLabel#todoPanelTitle { - font-size: 14px; - font-weight: 600; - color: #e1e6ee; - background: transparent; - } - QLabel#todoPanelBody { - color: #8f98a6; - background: transparent; - } )") .arg(metrics::panel_radius) .arg(metrics::control_padding_y) diff --git a/src/interface/panels/properties/PropertiesPanelView.cpp b/src/interface/panels/properties/PropertiesPanelView.cpp index 291589762a..82fcee6f57 100644 --- a/src/interface/panels/properties/PropertiesPanelView.cpp +++ b/src/interface/panels/properties/PropertiesPanelView.cpp @@ -23,6 +23,7 @@ #include "PropertiesPanelWidget.h" #include "../../ElementRegistry.h" +#include "../../../ifcviewer/AppSettings.h" #include "../../../ifcviewer/ViewportWindow.h" namespace ifcinterface::panels::properties { @@ -79,8 +80,13 @@ void PropertiesPanelView::refresh(uint32_t object_id) { {"Paint Coverage", "42.78 m2"}}}, }; - if (registry_) { - auto info = registry_->find(object_id); + if (!registry_) { + widget_->render(state); + return; + } + + if (!AppSettings::instance().loadDataSource()) { + auto info = registry_->findBasicElementInfo(object_id); if (info && !info->type.isEmpty()) { state.entity.entity_class = info->type; if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { @@ -96,6 +102,17 @@ void PropertiesPanelView::refresh(uint32_t object_id) { if (info && !info->guid.isEmpty()) { state.attributes[0].value = info->guid; } + + widget_->render(state); + return; + } + + auto entity = registry_->findEntity(object_id); + if (entity) { + state.entity.entity_class = QString::fromStdString(entity->declaration().name()); + if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { + state.property_sets[1].rows[0].value = state.entity.entity_class; + } } widget_->render(state); } diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp index 33db92fbb4..fce0d5fec5 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.cpp +++ b/src/interface/panels/properties/PropertiesPanelWidget.cpp @@ -28,7 +28,6 @@ #include #include #include -#include #include namespace { @@ -42,7 +41,7 @@ QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySe QList rows; for (const auto& row : property_set.rows) { - rows.append({row.key, row.value, "propertyValueLabel", "", "", 0}); + rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0}); } layout->addWidget(new ifcinterface::components::KeyValueTable(rows, group)); return group; @@ -51,7 +50,7 @@ QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySe QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { QList table_rows; for (const auto& row : rows) { - table_rows.append({row.key, row.value, "propertyValueLabel", "", "", 0}); + table_rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0}); } return new ifcinterface::components::KeyValueTable(table_rows, parent); } @@ -61,7 +60,7 @@ QWidget* makeRelationshipList(const QListsetContentsMargins(0, 0, 0, 0); - root->setSpacing(0); - - scroll_ = new QScrollArea(this); - scroll_->setWidgetResizable(true); - scroll_->setFrameShape(QFrame::NoFrame); - - content_ = new QWidget(scroll_); - content_->setObjectName("inspectorPanel"); - content_layout_ = new QVBoxLayout(content_); + content_layout_ = new QVBoxLayout(this); content_layout_->setContentsMargins(0, 0, 0, 0); content_layout_->setSpacing(12); - - scroll_->setWidget(content_); - root->addWidget(scroll_); } void PropertiesPanelWidget::render(const PropertiesPanelState& state) { @@ -100,7 +86,7 @@ void PropertiesPanelWidget::render(const PropertiesPanelState& state) { delete item; } - auto* entity_card = new QFrame(content_); + auto* entity_card = new QFrame(this); entity_card->setObjectName("entityClassCard"); auto* entity_layout = new QHBoxLayout(entity_card); entity_layout->setContentsMargins(10, 8, 10, 8); @@ -123,23 +109,23 @@ void PropertiesPanelWidget::render(const PropertiesPanelState& state) { QList property_set_widgets; for (const auto& property_set : state.property_sets) { - property_set_widgets.append(makePropertySetPanel(property_set, content_)); + property_set_widgets.append(makePropertySetPanel(property_set, this)); } QList quantity_set_widgets; for (const auto& property_set : state.quantity_sets) { - quantity_set_widgets.append(makePropertySetPanel(property_set, content_)); + quantity_set_widgets.append(makePropertySetPanel(property_set, this)); } - auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, "", content_); + auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, "", this); entity_section->addBodyWidget(entity_card); - auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, "", content_); - attributes_section->addBodyWidget(makeAttributeList(state.attributes, content_)); - auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, "", content_); - relationships_section->addBodyWidget(makeRelationshipList(state.relationships, content_)); - auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, "Filter properties or sets", content_); + auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, "", this); + attributes_section->addBodyWidget(makeAttributeList(state.attributes, this)); + auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, "", this); + relationships_section->addBodyWidget(makeRelationshipList(state.relationships, this)); + auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, "Filter properties or sets", this); for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); - auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, "Filter quantities or sets", content_); + auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, "Filter quantities or sets", this); for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); content_layout_->addWidget(entity_section); diff --git a/src/interface/panels/properties/PropertiesPanelWidget.h b/src/interface/panels/properties/PropertiesPanelWidget.h index ea929c22de..95b584bd50 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.h +++ b/src/interface/panels/properties/PropertiesPanelWidget.h @@ -25,7 +25,6 @@ #include -class QScrollArea; class QVBoxLayout; namespace ifcinterface::panels::properties { @@ -38,8 +37,6 @@ public: void render(const PropertiesPanelState& state); private: - QScrollArea* scroll_ = nullptr; - QWidget* content_ = nullptr; QVBoxLayout* content_layout_ = nullptr; }; diff --git a/src/interface/panels/todo/TodoPanelWidget.cpp b/src/interface/panels/todo/TodoPanelWidget.cpp index 36104e7b6d..32befba591 100644 --- a/src/interface/panels/todo/TodoPanelWidget.cpp +++ b/src/interface/panels/todo/TodoPanelWidget.cpp @@ -42,10 +42,8 @@ TodoPanelWidget::TodoPanelWidget(const QString& title, QWidget* parent) body_layout->setSpacing(12); auto* heading = new QLabel(title, body); - heading->setObjectName("todoPanelTitle"); auto* content = new QLabel("Coming soon", body); - content->setObjectName("todoPanelBody"); content->setAlignment(Qt::AlignCenter); body_layout->addWidget(heading); From 5b2721c1c4da01b8a2b2c3fb610df2be420a1868 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 6 May 2026 13:13:37 +1000 Subject: [PATCH 111/120] Interface mockup 7 --- src/interface/MainWindow.cpp | 2 + src/interface/components/KeyValueTable.cpp | 40 ++++------- src/interface/components/Panel.cpp | 6 +- src/interface/components/Section.cpp | 30 ++++---- src/interface/components/Style.cpp | 68 +++++++++++-------- src/interface/components/Style.h | 19 +++--- .../properties/PropertiesPanelWidget.cpp | 4 +- src/interface/panels/todo/TodoPanelWidget.cpp | 1 + 8 files changed, 86 insertions(+), 84 deletions(-) diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 5119a1fb6c..7f141bd9b5 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -65,6 +65,7 @@ MainWindow::MainWindow(QWidget* parent) } void MainWindow::setupChrome() { + setObjectName("appWindow"); setWindowTitle("IfcOpenShell Interface"); setDockOptions(QMainWindow::AllowNestedDocks | QMainWindow::AllowTabbedDocks | @@ -100,6 +101,7 @@ QWidget* MainWindow::makeRibbonGroup(const QString& title, const QListsetObjectName("ribbonGroupLabel"); + label->setProperty("textRole", "secondary"); label->setAlignment(Qt::AlignCenter); group_layout->addLayout(button_row); group_layout->addWidget(label); diff --git a/src/interface/components/KeyValueTable.cpp b/src/interface/components/KeyValueTable.cpp index 61937f7df4..068e66ae58 100644 --- a/src/interface/components/KeyValueTable.cpp +++ b/src/interface/components/KeyValueTable.cpp @@ -22,9 +22,8 @@ #include "SvgIcon.h" -#include +#include #include -#include namespace ifcinterface::components { @@ -33,49 +32,40 @@ KeyValueTable::KeyValueTable(const QList& rows, QWidget* paren { setObjectName("keyValueTable"); - auto* layout = new QVBoxLayout(this); + auto* layout = new QGridLayout(this); layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); + layout->setHorizontalSpacing(12); + layout->setVerticalSpacing(6); + layout->setColumnStretch(1, 1); + int row_index = 0; for (const auto& row_data : rows) { - auto* row = new QWidget(this); - if (!row_data.trailing_icon_path.isEmpty()) { - row->setObjectName("relationshipRow"); - } else { - row->setObjectName("keyValueRow"); - } - - auto* row_layout = new QHBoxLayout(row); - row_layout->setContentsMargins(0, 0, 0, 0); - row_layout->setSpacing(12); - - auto* key = new QLabel(row_data.key, row); - key->setObjectName("propertyKeyLabel"); + auto* key = new QLabel(row_data.key, this); + key->setProperty("textRole", "secondary"); if (row_data.key_minimum_width > 0) { key->setMinimumWidth(row_data.key_minimum_width); } - auto* value = new QLabel(row_data.value, row); + auto* value = new QLabel(row_data.value, this); value->setObjectName(row_data.value_object_name.isEmpty() ? "keyValueValueLabel" : row_data.value_object_name); value->setWordWrap(true); value->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - row_layout->addWidget(key); - row_layout->addWidget(value, 1); + layout->addWidget(key, row_index, 0, Qt::AlignLeft | Qt::AlignTop); + layout->addWidget(value, row_index, 1); if (!row_data.trailing_icon_path.isEmpty()) { - auto* icon = new QLabel(row); + auto* icon = new QLabel(this); icon->setObjectName(row_data.trailing_icon_object_name.isEmpty() - ? "relationshipIconLabel" + ? "keyValueTrailingIconLabel" : row_data.trailing_icon_object_name); icon->setPixmap(icons::makePanelSvgPixmap(row_data.trailing_icon_path, QSize(14, 14))); icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - row_layout->addWidget(icon, 0, Qt::AlignRight | Qt::AlignVCenter); + layout->addWidget(icon, row_index, 2, Qt::AlignRight | Qt::AlignTop); } - - layout->addWidget(row); + ++row_index; } } diff --git a/src/interface/components/Panel.cpp b/src/interface/components/Panel.cpp index 8356a4e739..b080f1867b 100644 --- a/src/interface/components/Panel.cpp +++ b/src/interface/components/Panel.cpp @@ -46,7 +46,7 @@ public: layout->setSpacing(6); auto* text = new QLabel(title.toUpper(), this); - text->setObjectName("dockTitleText"); + text->setObjectName("panelTitleText"); layout->addWidget(text); layout->addStretch(1); @@ -56,7 +56,7 @@ public: settings->setAutoRaise(true); settings->setCursor(Qt::ArrowCursor); settings->setFixedSize(18, 18); - settings->setObjectName("dockTitleButton"); + settings->setObjectName("panelTitleButton"); settings->setToolTip(QString("%1 settings").arg(title)); connect(settings, &QToolButton::clicked, this, [this, title]() { auto* anchor = parentWidget(); @@ -85,7 +85,7 @@ Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_s auto* frame = new QFrame(outer); frame->setObjectName("panel"); auto* frame_layout = new QVBoxLayout(frame); - frame_layout->setContentsMargins(0, style::metrics::padding, 0, style::metrics::padding); + frame_layout->setContentsMargins(0, style::metrics::section_body_padding, 0, style::metrics::section_body_padding); frame_layout->setSpacing(0); if (scrollable) { diff --git a/src/interface/components/Section.cpp b/src/interface/components/Section.cpp index a7899b9ce6..ab4c5bed79 100644 --- a/src/interface/components/Section.cpp +++ b/src/interface/components/Section.cpp @@ -50,14 +50,14 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, setObjectName("panelSection"); auto* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(style::metrics::section_spacing); + layout->setSpacing(style::metrics::padding); if (header_mode == SectionHeaderMode::Visible) { auto* header = new QFrame(this); header->setObjectName("panelSectionHeader"); auto* header_layout = new QHBoxLayout(header); header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(style::metrics::section_spacing); + header_layout->setSpacing(style::metrics::padding); auto* toggle = new QToolButton(header); toggle->setObjectName("panelSectionHeaderButton"); @@ -90,8 +90,10 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, auto* filter_wrapper = new QWidget(this); filter_wrapper->setObjectName("panelSectionFilterWrapper"); auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(style::metrics::filter_body_padding_x, 0, - style::metrics::filter_body_padding_x, 0); + filter_wrapper_layout->setContentsMargins(style::metrics::section_body_padding, + 0, + style::metrics::section_body_padding, + 0); filter_wrapper_layout->setSpacing(0); filter_wrapper_layout->addWidget(filter_field_); layout->addWidget(filter_wrapper); @@ -110,8 +112,10 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, auto* filter_wrapper = new QWidget(this); filter_wrapper->setObjectName("panelSectionFilterWrapper"); auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(style::metrics::hidden_filter_body_padding_x, 0, - style::metrics::hidden_filter_body_padding_x, 0); + filter_wrapper_layout->setContentsMargins(style::metrics::section_body_padding, + 0, + style::metrics::section_body_padding, + 0); filter_wrapper_layout->setSpacing(0); filter_wrapper_layout->addWidget(filter_field_); layout->addWidget(filter_wrapper); @@ -120,15 +124,11 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, body_ = new QWidget(this); body_->setObjectName("panelSectionBody"); body_layout_ = new QVBoxLayout(body_); - if (header_mode == SectionHeaderMode::Visible) { - body_layout_->setContentsMargins(style::metrics::section_body_padding_x, - style::metrics::section_body_padding_top, - style::metrics::section_body_padding_x, 0); - } else { - body_layout_->setContentsMargins(style::metrics::hidden_section_body_padding_x, 0, - style::metrics::hidden_section_body_padding_x, 0); - } - body_layout_->setSpacing(style::metrics::section_spacing); + body_layout_->setContentsMargins(style::metrics::section_body_padding, + 0, + style::metrics::section_body_padding, + 0); + body_layout_->setSpacing(style::metrics::padding); layout->addWidget(body_); } diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp index 25f4cc226c..bb77d62865 100644 --- a/src/interface/components/Style.cpp +++ b/src/interface/components/Style.cpp @@ -24,12 +24,9 @@ namespace ifcinterface::components::style { QString buildAppStyleSheet() { return QString(R"( - QMainWindow { - background: #26292f; - } - QWidget { - color: #d0d5dd; + QMainWindow#appWindow { background: #26292f; + color: %6; selection-background-color: #39b54a; selection-color: #14161a; } @@ -63,7 +60,6 @@ QString buildAppStyleSheet() { border-right: 1px solid #434852; } QLabel#ribbonGroupLabel { - color: #7f8796; font-size: 9px; font-weight: 600; letter-spacing: 0.08em; @@ -92,18 +88,39 @@ QString buildAppStyleSheet() { QDockWidget { color: #d0d5dd; } - QLabel#dockTitleText { + QLabel { + color: %6; + background: transparent; + } + QAbstractItemView, + QTreeWidget, + QListWidget, + QTableWidget, + QLineEdit, + QToolButton { + color: %6; + } + QLabel[textRole="secondary"] { + color: %7; + } + QLabel[textRole="disabled"] { + color: %8; + } + QLabel[textRole="warning"] { + color: %9; + } + QLabel#panelTitleText { color: #dfe4ec; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; } - QToolButton#dockTitleButton { + QToolButton#panelTitleButton { color: #8e97a5; border: none; background: transparent; } - QToolButton#dockTitleButton:hover { + QToolButton#panelTitleButton:hover { color: #ffffff; background: #353a42; } @@ -144,7 +161,7 @@ QString buildAppStyleSheet() { background: #31353d; border: 1px solid #434a55; border-radius: %1px; - padding: %2px %3px; + padding: %2px %2px; color: #d9dfeb; } QLineEdit:focus { @@ -160,10 +177,6 @@ QString buildAppStyleSheet() { font-weight: 700; background: transparent; } - QLabel#entityTypeLabel { - color: #9aa4b3; - background: transparent; - } QWidget#keyValueTable { background: transparent; } @@ -199,7 +212,7 @@ QString buildAppStyleSheet() { border-top: 1px solid #1a1c20; } QStatusBar QLabel { - color: #97a1af; + color: %7; background: transparent; border: none; padding: 2px 8px; @@ -218,7 +231,7 @@ QString buildAppStyleSheet() { } QGroupBox#propertySetCard::title { subcontrol-origin: margin; - left: %4px; + left: %2px; padding: 0 4px; color: #d5dbe5; } @@ -231,10 +244,7 @@ QString buildAppStyleSheet() { QWidget#panelSectionFilterWrapper { background: transparent; } - QWidget#relationshipRow { - background: transparent; - } - QLabel#relationshipIconLabel { + QLabel#keyValueTrailingIconLabel { background: transparent; } QWidget#panelScrollBody { @@ -249,7 +259,7 @@ QString buildAppStyleSheet() { color: #e1e7f0; font-weight: 700; text-align: left; - padding: %5px; + padding: %3px; margin: 0; } QToolButton#panelSectionHeaderButton:hover { @@ -262,7 +272,7 @@ QString buildAppStyleSheet() { QToolButton#panelSectionFilterToggle { background: transparent; border: none; - padding: %5px; + padding: %3px; } QToolButton#panelSectionFilterToggle:hover { background: #353a42; @@ -270,20 +280,18 @@ QString buildAppStyleSheet() { QWidget#panelSectionBody { background: transparent; } - QLabel#propertyKeyLabel { - color: #9aa4b3; - background: transparent; - } QLabel#keyValueValueLabel { color: #dce2eb; background: transparent; } )") .arg(metrics::panel_radius) - .arg(metrics::control_padding_y) - .arg(metrics::control_padding_x) - .arg(metrics::card_padding) - .arg(metrics::section_header_padding); + .arg(metrics::padding) + .arg(metrics::section_header_padding) + .arg(palette::primary_text) + .arg(palette::secondary_text) + .arg(palette::disabled_text) + .arg(palette::warning_text); } } // namespace ifcinterface::components::style diff --git a/src/interface/components/Style.h b/src/interface/components/Style.h index 8c382fa804..a521dfced3 100644 --- a/src/interface/components/Style.h +++ b/src/interface/components/Style.h @@ -26,20 +26,21 @@ namespace ifcinterface::components::style::metrics { inline constexpr int padding = 6; -inline constexpr int section_spacing = 6; -inline constexpr int section_body_padding_x = 10; -inline constexpr int section_body_padding_top = 6; -inline constexpr int hidden_section_body_padding_x = 8; +inline constexpr int section_body_padding = 10; inline constexpr int section_header_padding = 2; -inline constexpr int filter_body_padding_x = 10; -inline constexpr int hidden_filter_body_padding_x = 8; -inline constexpr int card_padding = 10; -inline constexpr int control_padding_y = 6; -inline constexpr int control_padding_x = 8; inline constexpr int panel_radius = 3; } // namespace ifcinterface::components::style::metrics +namespace ifcinterface::components::style::palette { + +inline constexpr auto primary_text = "#d0d5dd"; +inline constexpr auto secondary_text = "#9aa4b3"; +inline constexpr auto disabled_text = "#8f98a6"; +inline constexpr auto warning_text = "#e4b35a"; + +} // namespace ifcinterface::components::style::palette + namespace ifcinterface::components::style { QString buildAppStyleSheet(); diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp index fce0d5fec5..406884c7e7 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.cpp +++ b/src/interface/panels/properties/PropertiesPanelWidget.cpp @@ -62,7 +62,7 @@ QWidget* makeRelationshipList(const QListsetObjectName("entityClassLabel"); auto* entity_type = new QLabel(state.entity.predefined_type, entity_text); - entity_type->setObjectName("entityTypeLabel"); + entity_type->setProperty("textRole", "secondary"); entity_text_layout->addWidget(entity_class); entity_text_layout->addWidget(entity_type); entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); diff --git a/src/interface/panels/todo/TodoPanelWidget.cpp b/src/interface/panels/todo/TodoPanelWidget.cpp index 32befba591..45ff4c043c 100644 --- a/src/interface/panels/todo/TodoPanelWidget.cpp +++ b/src/interface/panels/todo/TodoPanelWidget.cpp @@ -44,6 +44,7 @@ TodoPanelWidget::TodoPanelWidget(const QString& title, QWidget* parent) auto* heading = new QLabel(title, body); auto* content = new QLabel("Coming soon", body); + content->setProperty("textRole", "disabled"); content->setAlignment(Qt::AlignCenter); body_layout->addWidget(heading); From f07afda09c752fbba2bb225b160bfb26164e9abb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 6 May 2026 21:35:12 +1000 Subject: [PATCH 112/120] Interface mockup 8 --- src/interface/CMakeLists.txt | 36 +-- src/interface/MainWindow.cpp | 19 +- src/interface/components/KeyValueTable.cpp | 2 +- src/interface/components/Panel.cpp | 2 +- src/interface/components/Section.cpp | 110 +++----- src/interface/components/Section.h | 11 +- src/interface/components/Style.cpp | 249 +++++++++++------- src/interface/components/Style.h | 23 ++ src/interface/components/SvgIcon.cpp | 4 +- src/interface/components/SvgIcon.h | 4 +- src/interface/icons/check.svg | 3 + src/interface/icons/xmark-circle.svg | 5 + src/interface/interface_resources.qrc | 2 + .../properties/PropertiesPanelWidget.cpp | 139 ---------- .../{PropertiesPanelTypes.h => Types.h} | 0 .../{PropertiesPanelView.cpp => View.cpp} | 4 +- .../{PropertiesPanelView.h => View.h} | 2 +- src/interface/panels/properties/Widget.cpp | 219 +++++++++++++++ .../{PropertiesPanelWidget.h => Widget.h} | 19 +- src/interface/panels/settings/Dialog.cpp | 222 ++++++++++++++++ src/interface/panels/settings/Dialog.h | 59 +++++ .../{SpatialHierarchyPanelTypes.h => Types.h} | 0 ...SpatialHierarchyPanelView.cpp => View.cpp} | 4 +- .../{SpatialHierarchyPanelView.h => View.h} | 2 +- ...ialHierarchyPanelWidget.cpp => Widget.cpp} | 8 +- ...SpatialHierarchyPanelWidget.h => Widget.h} | 2 +- .../todo/{TodoPanelWidget.cpp => Widget.cpp} | 4 +- .../todo/{TodoPanelWidget.h => Widget.h} | 0 28 files changed, 804 insertions(+), 350 deletions(-) create mode 100644 src/interface/icons/check.svg create mode 100644 src/interface/icons/xmark-circle.svg delete mode 100644 src/interface/panels/properties/PropertiesPanelWidget.cpp rename src/interface/panels/properties/{PropertiesPanelTypes.h => Types.h} (100%) rename src/interface/panels/properties/{PropertiesPanelView.cpp => View.cpp} (98%) rename src/interface/panels/properties/{PropertiesPanelView.h => View.h} (98%) create mode 100644 src/interface/panels/properties/Widget.cpp rename src/interface/panels/properties/{PropertiesPanelWidget.h => Widget.h} (71%) create mode 100644 src/interface/panels/settings/Dialog.cpp create mode 100644 src/interface/panels/settings/Dialog.h rename src/interface/panels/spatial_hierarchy/{SpatialHierarchyPanelTypes.h => Types.h} (100%) rename src/interface/panels/spatial_hierarchy/{SpatialHierarchyPanelView.cpp => View.cpp} (97%) rename src/interface/panels/spatial_hierarchy/{SpatialHierarchyPanelView.h => View.h} (98%) rename src/interface/panels/spatial_hierarchy/{SpatialHierarchyPanelWidget.cpp => Widget.cpp} (93%) rename src/interface/panels/spatial_hierarchy/{SpatialHierarchyPanelWidget.h => Widget.h} (98%) rename src/interface/panels/todo/{TodoPanelWidget.cpp => Widget.cpp} (97%) rename src/interface/panels/todo/{TodoPanelWidget.h => Widget.h} (100%) diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index 370dabb600..71460abcf4 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -39,23 +39,25 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h ${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelTypes.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelWidget.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelView.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/ModelsPanelView.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/todo/TodoPanelWidget.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/todo/TodoPanelWidget.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelTypes.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelWidget.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelWidget.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelView.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/PropertiesPanelView.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h - ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/SpatialHierarchyPanelView.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Types.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Widget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Widget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/View.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/View.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/todo/Widget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/todo/Widget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/Types.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/Widget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/Widget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/View.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/properties/View.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/settings/Dialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/settings/Dialog.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/Types.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/Widget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/Widget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/View.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/View.h ${CMAKE_CURRENT_SOURCE_DIR}/interface_resources.qrc ) diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 7f141bd9b5..45e55b1574 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -27,13 +27,14 @@ #include "components/Panel.h" #include "components/Style.h" #include "components/SvgIcon.h" -#include "panels/todo/TodoPanelWidget.h" -#include "panels/models/ModelsPanelView.h" -#include "panels/models/ModelsPanelWidget.h" -#include "panels/properties/PropertiesPanelView.h" -#include "panels/properties/PropertiesPanelWidget.h" -#include "panels/spatial_hierarchy/SpatialHierarchyPanelView.h" -#include "panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h" +#include "panels/todo/Widget.h" +#include "panels/models/View.h" +#include "panels/models/Widget.h" +#include "panels/properties/View.h" +#include "panels/properties/Widget.h" +#include "panels/settings/Dialog.h" +#include "panels/spatial_hierarchy/View.h" +#include "panels/spatial_hierarchy/Widget.h" #include #include @@ -169,7 +170,8 @@ QWidget* MainWindow::buildHomeRibbonPage() { auto* settings_button = makeRibbonAction("Settings", ":/icons/settings.svg"); connect(settings_button, &QToolButton::clicked, this, [this]() { - setStatusMessage("Settings", "Settings coming soon"); + panels::settings::SettingsDialog dialog(this); + dialog.exec(); }); row->addWidget(makeRibbonGroup("PROJECT", {new_project, open_project, open_cloud, open_recent, save_project, save_project_as})); @@ -454,7 +456,6 @@ void MainWindow::setupStatus() { } void MainWindow::setupLoader() { - AppSettings::instance().setLoadDataSource(false); loader_ = new SceneLoader(viewport_, this); element_registry_->bindLoader(loader_); connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); diff --git a/src/interface/components/KeyValueTable.cpp b/src/interface/components/KeyValueTable.cpp index 068e66ae58..48c84acc36 100644 --- a/src/interface/components/KeyValueTable.cpp +++ b/src/interface/components/KeyValueTable.cpp @@ -61,7 +61,7 @@ KeyValueTable::KeyValueTable(const QList& rows, QWidget* paren icon->setObjectName(row_data.trailing_icon_object_name.isEmpty() ? "keyValueTrailingIconLabel" : row_data.trailing_icon_object_name); - icon->setPixmap(icons::makePanelSvgPixmap(row_data.trailing_icon_path, QSize(14, 14))); + icon->setPixmap(icons::makeSvgPixmap(row_data.trailing_icon_path, QSize(14, 14))); icon->setAlignment(Qt::AlignRight | Qt::AlignVCenter); layout->addWidget(icon, row_index, 2, Qt::AlignRight | Qt::AlignTop); } diff --git a/src/interface/components/Panel.cpp b/src/interface/components/Panel.cpp index b080f1867b..30ccebfa14 100644 --- a/src/interface/components/Panel.cpp +++ b/src/interface/components/Panel.cpp @@ -52,7 +52,7 @@ public: layout->addStretch(1); if (has_settings) { auto* settings = new QToolButton(this); - settings->setIcon(icons::makePanelSvgIcon(":/icons/settings.svg")); + settings->setIcon(icons::makeSvgIcon(":/icons/settings.svg")); settings->setAutoRaise(true); settings->setCursor(Qt::ArrowCursor); settings->setFixedSize(18, 18); diff --git a/src/interface/components/Section.cpp b/src/interface/components/Section.cpp index ab4c5bed79..2837dee1dc 100644 --- a/src/interface/components/Section.cpp +++ b/src/interface/components/Section.cpp @@ -21,30 +21,15 @@ #include "Section.h" #include "Style.h" -#include "SvgIcon.h" #include #include -#include #include #include namespace ifcinterface::components { -namespace { - -QWidget* makeSectionFilterField(const QString& placeholder, QWidget* parent = nullptr) { - auto* field = new QLineEdit(parent); - field->setPlaceholderText(placeholder); - field->setClearButtonEnabled(true); - field->addAction(icons::makePanelSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); - return field; -} - -} // namespace - -Section::Section(const QString& title, SectionHeaderMode header_mode, - const QString& filter_placeholder, QWidget* parent) +Section::Section(const QString& title, SectionHeaderMode header_mode, QWidget* parent) : QWidget(parent) { setObjectName("panelSection"); @@ -55,70 +40,26 @@ Section::Section(const QString& title, SectionHeaderMode header_mode, if (header_mode == SectionHeaderMode::Visible) { auto* header = new QFrame(this); header->setObjectName("panelSectionHeader"); - auto* header_layout = new QHBoxLayout(header); - header_layout->setContentsMargins(0, 0, 0, 0); - header_layout->setSpacing(style::metrics::padding); + header_layout_ = new QHBoxLayout(header); + header_layout_->setContentsMargins(0, 0, 0, 0); + header_layout_->setSpacing(style::metrics::padding); - auto* toggle = new QToolButton(header); - toggle->setObjectName("panelSectionHeaderButton"); - toggle->setText(title); - toggle->setCheckable(true); - toggle->setChecked(true); - toggle->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - toggle->setArrowType(Qt::DownArrow); - header_layout->addWidget(toggle); - header_layout->addStretch(1); - - if (!filter_placeholder.isEmpty()) { - auto* filter_toggle = new QToolButton(header); - filter_toggle->setObjectName("panelSectionFilterToggle"); - filter_toggle->setCheckable(true); - filter_toggle->setIcon(icons::makePanelSvgIcon(":/icons/filter.svg")); - filter_toggle->setAutoRaise(true); - header_layout->addWidget(filter_toggle); - - filter_field_ = qobject_cast(makeSectionFilterField(filter_placeholder, this)); - filter_field_->setVisible(false); - connect(filter_toggle, &QToolButton::toggled, filter_field_, [this](bool visible) { - filter_field_->setVisible(visible); - if (visible) filter_field_->setFocus(); - }); - } + toggle_button_ = new QToolButton(header); + toggle_button_->setObjectName("panelSectionHeaderButton"); + toggle_button_->setText(title); + toggle_button_->setCheckable(true); + toggle_button_->setChecked(true); + toggle_button_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + toggle_button_->setArrowType(Qt::DownArrow); + header_layout_->addWidget(toggle_button_); + header_layout_->addStretch(1); layout->addWidget(header); - if (filter_field_) { - auto* filter_wrapper = new QWidget(this); - filter_wrapper->setObjectName("panelSectionFilterWrapper"); - auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(style::metrics::section_body_padding, - 0, - style::metrics::section_body_padding, - 0); - filter_wrapper_layout->setSpacing(0); - filter_wrapper_layout->addWidget(filter_field_); - layout->addWidget(filter_wrapper); - } - connect(toggle, &QToolButton::toggled, this, [this, toggle](bool expanded) { - toggle->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + connect(toggle_button_, &QToolButton::toggled, this, [this](bool expanded) { + toggle_button_->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); body_->setVisible(expanded); - if (filter_field_) { - auto* wrapper = filter_field_->parentWidget(); - if (wrapper) wrapper->setVisible(expanded && filter_field_->isVisible()); - } }); - } else if (!filter_placeholder.isEmpty()) { - filter_field_ = qobject_cast(makeSectionFilterField(filter_placeholder, this)); - auto* filter_wrapper = new QWidget(this); - filter_wrapper->setObjectName("panelSectionFilterWrapper"); - auto* filter_wrapper_layout = new QVBoxLayout(filter_wrapper); - filter_wrapper_layout->setContentsMargins(style::metrics::section_body_padding, - 0, - style::metrics::section_body_padding, - 0); - filter_wrapper_layout->setSpacing(0); - filter_wrapper_layout->addWidget(filter_field_); - layout->addWidget(filter_wrapper); } body_ = new QWidget(this); @@ -136,4 +77,25 @@ void Section::addBodyWidget(QWidget* widget) { body_layout_->addWidget(widget); } +void Section::clearBody() { + while (auto* item = body_layout_->takeAt(0)) { + if (auto* widget = item->widget()) widget->deleteLater(); + delete item; + } +} + +void Section::addHeaderWidget(QWidget* widget) { + if (!header_layout_) return; + header_layout_->addWidget(widget); +} + +bool Section::isExpanded() const { + return !toggle_button_ || toggle_button_->isChecked(); +} + +void Section::setExpanded(bool expanded) { + if (!toggle_button_) return; + toggle_button_->setChecked(expanded); +} + } // namespace ifcinterface::components diff --git a/src/interface/components/Section.h b/src/interface/components/Section.h index 4dacae514d..bf206b500a 100644 --- a/src/interface/components/Section.h +++ b/src/interface/components/Section.h @@ -23,7 +23,8 @@ #include -class QLineEdit; +class QHBoxLayout; +class QToolButton; class QVBoxLayout; namespace ifcinterface::components { @@ -38,15 +39,19 @@ class Section : public QWidget { public: explicit Section(const QString& title, SectionHeaderMode header_mode = SectionHeaderMode::Visible, - const QString& filter_placeholder = {}, QWidget* parent = nullptr); void addBodyWidget(QWidget* widget); + void clearBody(); + void addHeaderWidget(QWidget* widget); + bool isExpanded() const; + void setExpanded(bool expanded); private: QWidget* body_ = nullptr; QVBoxLayout* body_layout_ = nullptr; - QLineEdit* filter_field_ = nullptr; + QHBoxLayout* header_layout_ = nullptr; + QToolButton* toggle_button_ = nullptr; }; } // namespace ifcinterface::components diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp index bb77d62865..eb00179f96 100644 --- a/src/interface/components/Style.cpp +++ b/src/interface/components/Style.cpp @@ -23,41 +23,49 @@ namespace ifcinterface::components::style { QString buildAppStyleSheet() { - return QString(R"( + QString stylesheet = QStringLiteral(R"( QMainWindow#appWindow { - background: #26292f; - color: %6; - selection-background-color: #39b54a; - selection-color: #14161a; + background: ${app_background}; + color: ${primary_text}; + selection-background-color: ${selection_background}; + selection-color: ${selection_text}; + } + QDialog#appDialog { + background: ${app_background}; + color: ${primary_text}; } QFrame#ribbonShell { - background: #2d3138; - border-bottom: 1px solid #1b1d22; + background: ${ribbon_shell_background}; + border-bottom: 1px solid ${border}; } QTabBar::tab { background: transparent; - color: #8d97a7; + color: ${secondary_text}; padding: 8px 14px; margin-right: 2px; border-bottom: 2px solid transparent; } QTabBar::tab:selected { - color: #f2f5fa; - border-bottom: 2px solid #39b54a; + color: ${primary_text}; + border-bottom: 2px solid ${selection_background}; } QTabBar::tab:hover { - color: #ffffff; + color: ${ribbon_tab_hover_text}; } QFrame#ribbonBand { - background: #31353d; - border-top: 1px solid #3b4048; + background: ${ribbon_band_background}; + border-top: 1px solid ${border}; + } + QTabWidget::pane { + border: none; + background: transparent; } QFrame#ribbonPage { background: transparent; } QFrame#ribbonGroup { background: transparent; - border-right: 1px solid #434852; + border-right: 1px solid ${border}; } QLabel#ribbonGroupLabel { font-size: 9px; @@ -69,27 +77,27 @@ QString buildAppStyleSheet() { border: none; padding: 6px 4px 4px 4px; font-size: 11px; - color: #d6dce6; + color: ${primary_text}; } QToolButton#ribbonButton:hover { - background: #3a3f48; + background: ${ribbon_button_hover}; } QToolButton#ribbonButton:pressed { - background: #24282f; + background: ${ribbon_button_pressed}; } QFrame#viewportShell { - background: #202329; - border-top: 1px solid #1d2025; + background: ${viewport_shell_background}; + border-top: none; } QFrame#viewportFrame { - background: #1a1d22; - border: 1px solid #333942; + background: ${viewport_background}; + border: 1px solid ${border}; } QDockWidget { - color: #d0d5dd; + color: ${primary_text}; } QLabel { - color: %6; + color: ${primary_text}; background: transparent; } QAbstractItemView, @@ -97,83 +105,127 @@ QString buildAppStyleSheet() { QListWidget, QTableWidget, QLineEdit, + QSpinBox, + QDoubleSpinBox, + QCheckBox, + QPushButton, QToolButton { - color: %6; + color: ${primary_text}; } QLabel[textRole="secondary"] { - color: %7; + color: ${secondary_text}; } QLabel[textRole="disabled"] { - color: %8; + color: ${disabled_text}; } QLabel[textRole="warning"] { - color: %9; + color: ${warning_text}; } QLabel#panelTitleText { - color: #dfe4ec; + color: ${primary_text}; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; } QToolButton#panelTitleButton { - color: #8e97a5; + color: ${panel_title_button}; border: none; background: transparent; } QToolButton#panelTitleButton:hover { - color: #ffffff; - background: #353a42; + color: ${ribbon_tab_hover_text}; + background: ${panel_title_button_hover}; } QFrame#panel { - background: #2b2f36; - border: 1px solid #3e444e; - border-radius: %1px; + background: ${panel_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; } QTreeWidget, QListWidget, QTableWidget, QAbstractScrollArea { - background: #2b2f36; + background: ${panel_background}; border: none; outline: none; - gridline-color: #333842; + gridline-color: ${border}; } QTreeWidget::viewport, QListWidget::viewport, QTableWidget::viewport { - background: #2b2f36; + background: ${panel_background}; } QHeaderView::section { - background: #31353d; - color: #b5becc; + background: ${control_background}; + color: ${primary_text}; border: none; - border-bottom: 1px solid #434a55; + border-bottom: 1px solid ${border}; padding: 7px 8px; font-weight: 600; } QTableCornerButton::section { - background: #31353d; + background: ${control_background}; border: none; } QScrollArea { - background: #2b2f36; + background: ${panel_background}; border: none; } QScrollArea > QWidget > QWidget { - background: #2b2f36; + background: ${panel_background}; } QLineEdit { - background: #31353d; - border: 1px solid #434a55; - border-radius: %1px; - padding: %2px %2px; - color: #d9dfeb; + background: ${control_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + padding: ${padding}px ${padding}px; + color: ${primary_text}; } QLineEdit:focus { - border: 1px solid #5b6472; + border: 1px solid ${control_border_focus}; } - QFrame#entityClassCard { - background: #26292f; - border: 1px solid #404650; - border-radius: %1px; + QSpinBox, QDoubleSpinBox { + background: ${control_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + padding: ${padding}px ${padding}px; + } + QSpinBox:focus, QDoubleSpinBox:focus { + border: 1px solid ${control_border_focus}; + } + QCheckBox { + background: transparent; + spacing: 8px; + } + QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + background: ${control_background}; + } + QCheckBox::indicator:hover { + background: ${ribbon_button_hover}; + } + QCheckBox::indicator:checked { + border: 1px solid ${selection_background}; + background: ${selection_background}; + } + QPushButton { + background: ${control_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + padding: ${padding}px ${padding}px; + } + QPushButton:hover { + background: ${ribbon_button_hover}; + } + QPushButton:pressed { + background: ${ribbon_button_pressed}; + } + QFrame#entityClassBox, + QGroupBox#propertySetBox { + background: ${box_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; } QLabel#entityClassLabel { - color: #eef2f8; + color: ${primary_text}; font-weight: 700; background: transparent; } @@ -194,13 +246,13 @@ QString buildAppStyleSheet() { margin: 0 2px 2px 2px; } QScrollBar::handle:vertical, QScrollBar::handle:horizontal { - background: #525a67; - border-radius: %1px; + background: ${scroll_handle}; + border-radius: ${panel_radius}px; min-height: 24px; min-width: 24px; } QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover { - background: #697385; + background: ${scroll_handle_hover}; } QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page { @@ -208,35 +260,29 @@ QString buildAppStyleSheet() { border: none; } QStatusBar { - background: #24272c; - border-top: 1px solid #1a1c20; + background: ${status_background}; } QStatusBar QLabel { - color: %7; + color: ${secondary_text}; background: transparent; border: none; padding: 2px 8px; } QGroupBox { background: transparent; - border: 1px solid #404650; - border-radius: %1px; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; margin-top: 10px; padding-top: 10px; } - QGroupBox#propertySetCard { - background: #26292f; - border: 1px solid #404650; - border-radius: %1px; - } - QGroupBox#propertySetCard::title { + QGroupBox#propertySetBox::title { subcontrol-origin: margin; - left: %2px; + left: ${padding}px; padding: 0 4px; - color: #d5dbe5; + color: ${primary_text}; } - QGroupBox#propertySetCard > QWidget { - background: #26292f; + QGroupBox#propertySetBox > QWidget { + background: ${box_background}; } QWidget#panelSection { background: transparent; @@ -248,22 +294,22 @@ QString buildAppStyleSheet() { background: transparent; } QWidget#panelScrollBody { - background: #2b2f36; + background: ${panel_background}; } QFrame#panelSectionHeader { - background: #26292f; + background: ${section_header_background}; } QToolButton#panelSectionHeaderButton { background: transparent; border: none; - color: #e1e7f0; + color: ${primary_text}; font-weight: 700; text-align: left; - padding: %3px; + padding: ${section_header_padding}px; margin: 0; } QToolButton#panelSectionHeaderButton:hover { - color: #ffffff; + color: ${ribbon_tab_hover_text}; } QToolButton#panelSectionHeaderButton::menu-indicator { image: none; @@ -272,26 +318,53 @@ QString buildAppStyleSheet() { QToolButton#panelSectionFilterToggle { background: transparent; border: none; - padding: %3px; + padding: ${section_header_padding}px; } QToolButton#panelSectionFilterToggle:hover { - background: #353a42; + background: ${panel_title_button_hover}; } QWidget#panelSectionBody { background: transparent; } QLabel#keyValueValueLabel { - color: #dce2eb; + color: ${key_value_value_text}; background: transparent; } - )") - .arg(metrics::panel_radius) - .arg(metrics::padding) - .arg(metrics::section_header_padding) - .arg(palette::primary_text) - .arg(palette::secondary_text) - .arg(palette::disabled_text) - .arg(palette::warning_text); + )"); + + stylesheet.replace("${panel_radius}", QString::number(metrics::panel_radius)); + stylesheet.replace("${padding}", QString::number(metrics::padding)); + stylesheet.replace("${section_header_padding}", QString::number(metrics::section_header_padding)); + + stylesheet.replace("${app_background}", palette::app_background); + stylesheet.replace("${border}", palette::border); + stylesheet.replace("${selection_background}", palette::selection_background); + stylesheet.replace("${selection_text}", palette::selection_text); + stylesheet.replace("${ribbon_shell_background}", palette::ribbon_shell_background); + stylesheet.replace("${ribbon_tab_hover_text}", palette::ribbon_tab_hover_text); + stylesheet.replace("${ribbon_band_background}", palette::ribbon_band_background); + stylesheet.replace("${ribbon_button_hover}", palette::ribbon_button_hover); + stylesheet.replace("${ribbon_button_pressed}", palette::ribbon_button_pressed); + stylesheet.replace("${viewport_shell_background}", palette::viewport_shell_background); + stylesheet.replace("${viewport_background}", palette::viewport_background); + stylesheet.replace("${panel_title_button}", palette::panel_title_button); + stylesheet.replace("${panel_title_button_hover}", palette::panel_title_button_hover); + stylesheet.replace("${panel_background}", palette::panel_background); + stylesheet.replace("${control_background}", palette::control_background); + stylesheet.replace("${control_border_focus}", palette::control_border_focus); + stylesheet.replace("${box_background}", palette::box_background); + stylesheet.replace("${scroll_handle}", palette::scroll_handle); + stylesheet.replace("${scroll_handle_hover}", palette::scroll_handle_hover); + stylesheet.replace("${status_background}", palette::status_background); + stylesheet.replace("${section_header_background}", palette::section_header_background); + stylesheet.replace("${key_value_value_text}", palette::key_value_value_text); + + stylesheet.replace("${primary_text}", palette::primary_text); + stylesheet.replace("${secondary_text}", palette::secondary_text); + stylesheet.replace("${disabled_text}", palette::disabled_text); + stylesheet.replace("${warning_text}", palette::warning_text); + + return stylesheet; } } // namespace ifcinterface::components::style diff --git a/src/interface/components/Style.h b/src/interface/components/Style.h index a521dfced3..84f6426d3e 100644 --- a/src/interface/components/Style.h +++ b/src/interface/components/Style.h @@ -34,10 +34,33 @@ inline constexpr int panel_radius = 3; namespace ifcinterface::components::style::palette { +inline constexpr auto app_background = "#26292f"; +inline constexpr auto border = "#3e444e"; +inline constexpr auto selection_background = "#39b54a"; +inline constexpr auto ribbon_shell_background = "#2d3138"; +inline constexpr auto ribbon_tab_hover_text = "#ffffff"; +inline constexpr auto ribbon_band_background = "#31353d"; +inline constexpr auto ribbon_button_hover = "#3a3f48"; +inline constexpr auto ribbon_button_pressed = "#24282f"; +inline constexpr auto viewport_shell_background = "#202329"; +inline constexpr auto viewport_background = "#1a1d22"; +inline constexpr auto panel_title_button = "#8e97a5"; +inline constexpr auto panel_title_button_hover = "#353a42"; +inline constexpr auto panel_background = "#2b2f36"; +inline constexpr auto control_background = "#31353d"; +inline constexpr auto control_border_focus = "#5b6472"; +inline constexpr auto box_background = "#26292f"; +inline constexpr auto scroll_handle = "#525a67"; +inline constexpr auto scroll_handle_hover = "#697385"; +inline constexpr auto status_background = "#26292f"; +inline constexpr auto section_header_background = "#26292f"; +inline constexpr auto key_value_value_text = "#dce2eb"; + inline constexpr auto primary_text = "#d0d5dd"; inline constexpr auto secondary_text = "#9aa4b3"; inline constexpr auto disabled_text = "#8f98a6"; inline constexpr auto warning_text = "#e4b35a"; +inline constexpr auto selection_text = "#14161a"; } // namespace ifcinterface::components::style::palette diff --git a/src/interface/components/SvgIcon.cpp b/src/interface/components/SvgIcon.cpp index 8c6dfd8765..2c6470f240 100644 --- a/src/interface/components/SvgIcon.cpp +++ b/src/interface/components/SvgIcon.cpp @@ -61,11 +61,11 @@ QIcon makeTintedSvgIcon(const QString& icon_path, const QString& normal, return icon; } -QIcon makePanelSvgIcon(const QString& icon_path) { +QIcon makeSvgIcon(const QString& icon_path) { return makeTintedSvgIcon(icon_path, "#e7ebf2", "#ffffff", "#6f7988"); } -QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size) { +QPixmap makeSvgPixmap(const QString& icon_path, const QSize& size) { return renderTintedSvgPixmap(icon_path, "#e7ebf2", size); } diff --git a/src/interface/components/SvgIcon.h b/src/interface/components/SvgIcon.h index d1c7e7417d..c8d3bb949a 100644 --- a/src/interface/components/SvgIcon.h +++ b/src/interface/components/SvgIcon.h @@ -33,8 +33,8 @@ QIcon makeTintedSvgIcon(const QString& icon_path, const QString& normal = "#39b54a", const QString& active = "#53c763", const QString& disabled = "#6f7988"); -QIcon makePanelSvgIcon(const QString& icon_path); -QPixmap makePanelSvgPixmap(const QString& icon_path, const QSize& size); +QIcon makeSvgIcon(const QString& icon_path); +QPixmap makeSvgPixmap(const QString& icon_path, const QSize& size); } // namespace ifcinterface::components::icons diff --git a/src/interface/icons/check.svg b/src/interface/icons/check.svg new file mode 100644 index 0000000000..50579a0557 --- /dev/null +++ b/src/interface/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/interface/icons/xmark-circle.svg b/src/interface/icons/xmark-circle.svg new file mode 100644 index 0000000000..371b4878ea --- /dev/null +++ b/src/interface/icons/xmark-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/interface/interface_resources.qrc b/src/interface/interface_resources.qrc index 453e64d9e7..fcb8a40df5 100644 --- a/src/interface/interface_resources.qrc +++ b/src/interface/interface_resources.qrc @@ -41,5 +41,7 @@ icons/cube-dots.svg icons/cursor-pointer.svg icons/sidebar-expand.svg + icons/check.svg + icons/xmark-circle.svg diff --git a/src/interface/panels/properties/PropertiesPanelWidget.cpp b/src/interface/panels/properties/PropertiesPanelWidget.cpp deleted file mode 100644 index 406884c7e7..0000000000 --- a/src/interface/panels/properties/PropertiesPanelWidget.cpp +++ /dev/null @@ -1,139 +0,0 @@ -// This file was generated with the assistance of an AI coding tool. -/******************************************************************************** - * * - * 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 "PropertiesPanelWidget.h" - -#include "../../components/KeyValueTable.h" -#include "../../components/Section.h" -#include "../../components/SvgIcon.h" - -#include -#include -#include -#include -#include - -namespace { - -QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { - auto* group = new QGroupBox(property_set.title, parent); - group->setObjectName("propertySetCard"); - auto* layout = new QVBoxLayout(group); - layout->setContentsMargins(10, 10, 10, 10); - layout->setSpacing(0); - - QList rows; - for (const auto& row : property_set.rows) { - rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0}); - } - layout->addWidget(new ifcinterface::components::KeyValueTable(rows, group)); - return group; -} - -QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { - QList table_rows; - for (const auto& row : rows) { - table_rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0}); - } - return new ifcinterface::components::KeyValueTable(table_rows, parent); -} - -QWidget* makeRelationshipList(const QList& rows, QWidget* parent = nullptr) { - QList table_rows; - for (const auto& row_data : rows) { - table_rows.append({row_data.key, - row_data.value, - "keyValueValueLabel", - ":/icons/cursor-pointer.svg", - "keyValueTrailingIconLabel", - 72}); - } - return new ifcinterface::components::KeyValueTable(table_rows, parent); -} - -} // namespace - -namespace ifcinterface::panels::properties { - -PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) - : QWidget(parent) -{ - content_layout_ = new QVBoxLayout(this); - content_layout_->setContentsMargins(0, 0, 0, 0); - content_layout_->setSpacing(12); -} - -void PropertiesPanelWidget::render(const PropertiesPanelState& state) { - while (auto* item = content_layout_->takeAt(0)) { - if (auto* widget = item->widget()) widget->deleteLater(); - delete item; - } - - auto* entity_card = new QFrame(this); - entity_card->setObjectName("entityClassCard"); - auto* entity_layout = new QHBoxLayout(entity_card); - entity_layout->setContentsMargins(10, 8, 10, 8); - entity_layout->setSpacing(10); - auto* entity_icon = new QLabel(entity_card); - entity_icon->setPixmap(components::icons::makePanelSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); - entity_icon->setAlignment(Qt::AlignCenter); - auto* entity_text = new QWidget(entity_card); - auto* entity_text_layout = new QVBoxLayout(entity_text); - entity_text_layout->setContentsMargins(0, 0, 0, 0); - entity_text_layout->setSpacing(2); - auto* entity_class = new QLabel(state.entity.entity_class, entity_text); - entity_class->setObjectName("entityClassLabel"); - auto* entity_type = new QLabel(state.entity.predefined_type, entity_text); - entity_type->setProperty("textRole", "secondary"); - entity_text_layout->addWidget(entity_class); - entity_text_layout->addWidget(entity_type); - entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); - entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); - - QList property_set_widgets; - for (const auto& property_set : state.property_sets) { - property_set_widgets.append(makePropertySetPanel(property_set, this)); - } - - QList quantity_set_widgets; - for (const auto& property_set : state.quantity_sets) { - quantity_set_widgets.append(makePropertySetPanel(property_set, this)); - } - - auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, "", this); - entity_section->addBodyWidget(entity_card); - auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, "", this); - attributes_section->addBodyWidget(makeAttributeList(state.attributes, this)); - auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, "", this); - relationships_section->addBodyWidget(makeRelationshipList(state.relationships, this)); - auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, "Filter properties or sets", this); - for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); - auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, "Filter quantities or sets", this); - for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); - - content_layout_->addWidget(entity_section); - content_layout_->addWidget(attributes_section); - content_layout_->addWidget(relationships_section); - content_layout_->addWidget(properties_section); - content_layout_->addWidget(quantities_section); - content_layout_->addStretch(1); -} - -} // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelTypes.h b/src/interface/panels/properties/Types.h similarity index 100% rename from src/interface/panels/properties/PropertiesPanelTypes.h rename to src/interface/panels/properties/Types.h diff --git a/src/interface/panels/properties/PropertiesPanelView.cpp b/src/interface/panels/properties/View.cpp similarity index 98% rename from src/interface/panels/properties/PropertiesPanelView.cpp rename to src/interface/panels/properties/View.cpp index 82fcee6f57..3b58dc4ca9 100644 --- a/src/interface/panels/properties/PropertiesPanelView.cpp +++ b/src/interface/panels/properties/View.cpp @@ -18,9 +18,9 @@ * * ********************************************************************************/ -#include "PropertiesPanelView.h" +#include "View.h" -#include "PropertiesPanelWidget.h" +#include "Widget.h" #include "../../ElementRegistry.h" #include "../../../ifcviewer/AppSettings.h" diff --git a/src/interface/panels/properties/PropertiesPanelView.h b/src/interface/panels/properties/View.h similarity index 98% rename from src/interface/panels/properties/PropertiesPanelView.h rename to src/interface/panels/properties/View.h index 65abe82f4f..c38120fc5a 100644 --- a/src/interface/panels/properties/PropertiesPanelView.h +++ b/src/interface/panels/properties/View.h @@ -21,7 +21,7 @@ #ifndef IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H #define IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H -#include "PropertiesPanelTypes.h" +#include "Types.h" #include diff --git a/src/interface/panels/properties/Widget.cpp b/src/interface/panels/properties/Widget.cpp new file mode 100644 index 0000000000..0afb0bc89f --- /dev/null +++ b/src/interface/panels/properties/Widget.cpp @@ -0,0 +1,219 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Widget.h" + +#include "../../components/KeyValueTable.h" +#include "../../components/Section.h" +#include "../../components/Style.h" +#include "../../components/SvgIcon.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { + auto* group = new QGroupBox(property_set.title, parent); + group->setObjectName("propertySetBox"); + auto* layout = new QVBoxLayout(group); + layout->setContentsMargins(10, 10, 10, 10); + layout->setSpacing(0); + + QList rows; + for (const auto& row : property_set.rows) { + rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0}); + } + layout->addWidget(new ifcinterface::components::KeyValueTable(rows, group)); + return group; +} + +QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { + QList table_rows; + for (const auto& row : rows) { + table_rows.append({row.key, row.value, "keyValueValueLabel", "", "", 0}); + } + return new ifcinterface::components::KeyValueTable(table_rows, parent); +} + +QWidget* makeRelationshipList(const QList& rows, QWidget* parent = nullptr) { + QList table_rows; + for (const auto& row_data : rows) { + table_rows.append({row_data.key, + row_data.value, + "keyValueValueLabel", + ":/icons/cursor-pointer.svg", + "keyValueTrailingIconLabel", + 72}); + } + return new ifcinterface::components::KeyValueTable(table_rows, parent); +} + +QWidget* makeFilterWrapper(QLineEdit** field_out, QWidget* parent = nullptr) { + auto* wrapper = new QWidget(parent); + wrapper->setObjectName("panelSectionFilterWrapper"); + auto* layout = new QVBoxLayout(wrapper); + layout->setContentsMargins(ifcinterface::components::style::metrics::section_body_padding, + 0, + ifcinterface::components::style::metrics::section_body_padding, + 0); + layout->setSpacing(0); + + auto* field = new QLineEdit(wrapper); + field->setClearButtonEnabled(true); + field->addAction(ifcinterface::components::icons::makeSvgIcon(":/icons/filter.svg"), QLineEdit::LeadingPosition); + field->setVisible(false); + layout->addWidget(field); + + if (field_out) *field_out = field; + return wrapper; +} + +} // namespace + +namespace ifcinterface::panels::properties { + +PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) + : QWidget(parent) +{ + content_layout_ = new QVBoxLayout(this); + content_layout_->setContentsMargins(0, 0, 0, 0); + content_layout_->setSpacing(12); + + auto* entity_box = new QFrame(this); + entity_box->setObjectName("entityClassBox"); + auto* entity_layout = new QHBoxLayout(entity_box); + entity_layout->setContentsMargins(10, 8, 10, 8); + entity_layout->setSpacing(10); + auto* entity_icon = new QLabel(entity_box); + entity_icon->setPixmap(components::icons::makeSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); + entity_icon->setAlignment(Qt::AlignCenter); + auto* entity_text = new QWidget(entity_box); + auto* entity_text_layout = new QVBoxLayout(entity_text); + entity_text_layout->setContentsMargins(0, 0, 0, 0); + entity_text_layout->setSpacing(2); + entity_class_label_ = new QLabel(entity_text); + entity_class_label_->setObjectName("entityClassLabel"); + entity_type_label_ = new QLabel(entity_text); + entity_type_label_->setProperty("textRole", "secondary"); + entity_text_layout->addWidget(entity_class_label_); + entity_text_layout->addWidget(entity_type_label_); + entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); + entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); + + entity_section_ = new components::Section("", components::SectionHeaderMode::Hidden, this); + entity_section_->addBodyWidget(entity_box); + attributes_section_ = new components::Section("Attributes", components::SectionHeaderMode::Visible, this); + relationships_section_ = new components::Section("Relationships", components::SectionHeaderMode::Visible, this); + properties_section_ = new components::Section("Properties", components::SectionHeaderMode::Visible, this); + quantities_section_ = new components::Section("Quantities", components::SectionHeaderMode::Visible, this); + + properties_filter_toggle_ = new QToolButton(properties_section_); + properties_filter_toggle_->setObjectName("panelSectionFilterToggle"); + properties_filter_toggle_->setCheckable(true); + properties_filter_toggle_->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg")); + properties_filter_toggle_->setAutoRaise(true); + properties_section_->addHeaderWidget(properties_filter_toggle_); + + quantities_filter_toggle_ = new QToolButton(quantities_section_); + quantities_filter_toggle_->setObjectName("panelSectionFilterToggle"); + quantities_filter_toggle_->setCheckable(true); + quantities_filter_toggle_->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg")); + quantities_filter_toggle_->setAutoRaise(true); + quantities_section_->addHeaderWidget(quantities_filter_toggle_); + + properties_filter_wrapper_ = makeFilterWrapper(&properties_filter_field_, properties_section_); + properties_filter_field_->setPlaceholderText("Filter properties or sets"); + quantities_filter_wrapper_ = makeFilterWrapper(&quantities_filter_field_, quantities_section_); + quantities_filter_field_->setPlaceholderText("Filter quantities or sets"); + + connect(properties_filter_toggle_, &QToolButton::toggled, properties_filter_field_, [this](bool visible) { + properties_filter_field_->setVisible(visible); + properties_filter_wrapper_->setVisible(visible); + if (visible) properties_filter_field_->setFocus(); + }); + connect(quantities_filter_toggle_, &QToolButton::toggled, quantities_filter_field_, [this](bool visible) { + quantities_filter_field_->setVisible(visible); + quantities_filter_wrapper_->setVisible(visible); + if (visible) quantities_filter_field_->setFocus(); + }); + + properties_filter_wrapper_->setVisible(false); + quantities_filter_wrapper_->setVisible(false); + + content_layout_->addWidget(entity_section_); + content_layout_->addWidget(attributes_section_); + content_layout_->addWidget(relationships_section_); + content_layout_->addWidget(properties_section_); + content_layout_->addWidget(quantities_section_); + content_layout_->addStretch(1); +} + +void PropertiesPanelWidget::render(const PropertiesPanelState& state) { + const bool attributes_expanded = attributes_section_->isExpanded(); + const bool relationships_expanded = relationships_section_->isExpanded(); + const bool properties_expanded = properties_section_->isExpanded(); + const bool quantities_expanded = quantities_section_->isExpanded(); + const bool properties_filter_visible = properties_filter_toggle_->isChecked(); + const bool quantities_filter_visible = quantities_filter_toggle_->isChecked(); + const QString properties_filter_text = properties_filter_field_->text(); + const QString quantities_filter_text = quantities_filter_field_->text(); + + entity_class_label_->setText(state.entity.entity_class); + entity_type_label_->setText(state.entity.predefined_type); + + attributes_section_->clearBody(); + relationships_section_->clearBody(); + properties_section_->clearBody(); + quantities_section_->clearBody(); + + QList property_set_widgets; + for (const auto& property_set : state.property_sets) { + property_set_widgets.append(makePropertySetPanel(property_set, this)); + } + + QList quantity_set_widgets; + for (const auto& property_set : state.quantity_sets) { + quantity_set_widgets.append(makePropertySetPanel(property_set, this)); + } + + attributes_section_->addBodyWidget(makeAttributeList(state.attributes, this)); + relationships_section_->addBodyWidget(makeRelationshipList(state.relationships, this)); + properties_section_->addBodyWidget(properties_filter_wrapper_); + for (auto* widget : property_set_widgets) properties_section_->addBodyWidget(widget); + quantities_section_->addBodyWidget(quantities_filter_wrapper_); + for (auto* widget : quantity_set_widgets) quantities_section_->addBodyWidget(widget); + + attributes_section_->setExpanded(attributes_expanded); + relationships_section_->setExpanded(relationships_expanded); + properties_section_->setExpanded(properties_expanded); + quantities_section_->setExpanded(quantities_expanded); + properties_filter_field_->setText(properties_filter_text); + quantities_filter_field_->setText(quantities_filter_text); + properties_filter_toggle_->setChecked(properties_filter_visible); + quantities_filter_toggle_->setChecked(quantities_filter_visible); +} + +} // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/PropertiesPanelWidget.h b/src/interface/panels/properties/Widget.h similarity index 71% rename from src/interface/panels/properties/PropertiesPanelWidget.h rename to src/interface/panels/properties/Widget.h index 95b584bd50..f5dc78b19a 100644 --- a/src/interface/panels/properties/PropertiesPanelWidget.h +++ b/src/interface/panels/properties/Widget.h @@ -21,11 +21,15 @@ #ifndef IFCINTERFACE_PANELS_PROPERTIESPANELWIDGET_H #define IFCINTERFACE_PANELS_PROPERTIESPANELWIDGET_H -#include "PropertiesPanelTypes.h" +#include "Types.h" #include class QVBoxLayout; +class QLabel; +class QLineEdit; +class QToolButton; +namespace ifcinterface::components { class Section; } namespace ifcinterface::panels::properties { @@ -38,6 +42,19 @@ public: private: QVBoxLayout* content_layout_ = nullptr; + QLabel* entity_class_label_ = nullptr; + QLabel* entity_type_label_ = nullptr; + components::Section* entity_section_ = nullptr; + components::Section* attributes_section_ = nullptr; + components::Section* relationships_section_ = nullptr; + components::Section* properties_section_ = nullptr; + components::Section* quantities_section_ = nullptr; + QWidget* properties_filter_wrapper_ = nullptr; + QWidget* quantities_filter_wrapper_ = nullptr; + QLineEdit* properties_filter_field_ = nullptr; + QLineEdit* quantities_filter_field_ = nullptr; + QToolButton* properties_filter_toggle_ = nullptr; + QToolButton* quantities_filter_toggle_ = nullptr; }; } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/settings/Dialog.cpp b/src/interface/panels/settings/Dialog.cpp new file mode 100644 index 0000000000..e181d25db1 --- /dev/null +++ b/src/interface/panels/settings/Dialog.cpp @@ -0,0 +1,222 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Dialog.h" + +#include "../../../ifcviewer/AppSettings.h" +#include "../../components/Section.h" +#include "../../components/Style.h" +#include "../../components/SvgIcon.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ifcinterface::panels::settings { + +SettingsDialog::SettingsDialog(QWidget* parent) + : QDialog(parent) +{ + setObjectName("appDialog"); + setWindowTitle("Settings"); + setModal(true); + resize(520, 420); + setStyleSheet(components::style::buildAppStyleSheet()); + setupUi(); +} + +void SettingsDialog::showEvent(QShowEvent* event) { + syncFromSettings(); + QDialog::showEvent(event); +} + +void SettingsDialog::setupUi() { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(12, 12, 12, 12); + root->setSpacing(0); + + auto* panel = new QFrame(this); + panel->setObjectName("panel"); + auto* panel_layout = new QVBoxLayout(panel); + panel_layout->setContentsMargins(0, components::style::metrics::section_body_padding, 0, + components::style::metrics::section_body_padding); + panel_layout->setSpacing(12); + + auto* tabs = new QTabWidget(panel); + + auto* graphics_tab = new QWidget(tabs); + auto* graphics_layout = new QVBoxLayout(graphics_tab); + graphics_layout->setContentsMargins(0, 0, 0, 0); + graphics_layout->setSpacing(12); + + auto* general_section = new components::Section("General", components::SectionHeaderMode::Visible, graphics_tab); + auto* general_body = new QWidget(general_section); + auto* general_form = new QFormLayout(general_body); + general_form->setContentsMargins(0, 0, 0, 0); + general_form->setHorizontalSpacing(16); + general_form->setVerticalSpacing(10); + + geometry_library_edit_ = new QLineEdit(general_body); + geometry_library_edit_->setMinimumWidth(300); + general_form->addRow("Geometry Library", geometry_library_edit_); + + show_stats_check_ = new QCheckBox(general_body); + general_form->addRow("Show Performance Stats", show_stats_check_); + + backface_culling_check_ = new QCheckBox(general_body); + backface_culling_check_->setToolTip( + "Skip triangles facing away from the camera. Big FPS win on closed solids; " + "disable if you see holes in open geometry."); + general_form->addRow("Backface Culling", backface_culling_check_); + + general_section->addBodyWidget(general_body); + + auto* loading_section = new components::Section("Loading", components::SectionHeaderMode::Visible, graphics_tab); + auto* loading_body = new QWidget(loading_section); + auto* loading_form = new QFormLayout(loading_body); + loading_form->setContentsMargins(0, 0, 0, 0); + loading_form->setHorizontalSpacing(16); + loading_form->setVerticalSpacing(10); + + load_data_source_checkbox_ = new QCheckBox(loading_body); + load_data_source_checkbox_->setToolTip( + "Keep the .ifc/.rdb open after loading so element properties can be queried. " + "Disable for geometry-only viewing."); + loading_form->addRow("Load Property Data Source", load_data_source_checkbox_); + + apply_coordinate_operation_check_ = new QCheckBox(loading_body); + apply_coordinate_operation_check_->setToolTip( + "Apply each model's IfcCoordinateOperation after load so it lands in " + "georeferenced map coordinates."); + loading_form->addRow("Apply Coordinate Operation", apply_coordinate_operation_check_); + + void_limit_spin_ = new QSpinBox(loading_body); + void_limit_spin_->setRange(0, 100000); + loading_form->addRow("Void Limit", void_limit_spin_); + + deflection_tolerance_spin_ = new QDoubleSpinBox(loading_body); + deflection_tolerance_spin_->setRange(0.000001, 1000.0); + deflection_tolerance_spin_->setDecimals(6); + deflection_tolerance_spin_->setSingleStep(0.001); + loading_form->addRow("Deflection Tolerance", deflection_tolerance_spin_); + + angular_tolerance_spin_ = new QDoubleSpinBox(loading_body); + angular_tolerance_spin_->setRange(0.000001, 3.141592); + angular_tolerance_spin_->setDecimals(6); + angular_tolerance_spin_->setSingleStep(0.05); + loading_form->addRow("Angular Tolerance", angular_tolerance_spin_); + + auto* description = new QLabel( + "These settings are shared with the viewer backend and persist via QSettings.", + loading_body); + description->setProperty("textRole", "secondary"); + description->setWordWrap(true); + loading_form->addRow(QString(), description); + + loading_section->addBodyWidget(loading_body); + graphics_layout->addWidget(general_section); + graphics_layout->addWidget(loading_section); + graphics_layout->addStretch(1); + + auto make_placeholder_tab = [tabs](const QString& title, const QString& detail) { + auto* tab = new QWidget(tabs); + auto* layout = new QVBoxLayout(tab); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(12); + + auto* section = new components::Section(title, components::SectionHeaderMode::Visible, tab); + auto* body = new QWidget(section); + auto* body_layout = new QVBoxLayout(body); + body_layout->setContentsMargins(0, 0, 0, 0); + body_layout->setSpacing(8); + + auto* heading = new QLabel(title, body); + auto* content = new QLabel(detail, body); + content->setProperty("textRole", "secondary"); + content->setWordWrap(true); + + body_layout->addWidget(heading); + body_layout->addWidget(content); + section->addBodyWidget(body); + layout->addWidget(section); + layout->addStretch(1); + return tab; + }; + + tabs->addTab(make_placeholder_tab("Navigation", "Navigation preferences and interaction modes will live here."), + "Navigation"); + tabs->addTab(make_placeholder_tab("Keybindings", "Shortcut presets and command bindings will live here."), + "Keybindings"); + tabs->addTab(graphics_tab, "Graphics"); + tabs->addTab(make_placeholder_tab("Theme", "Theme, density, and UI appearance settings will live here."), + "Theme"); + tabs->addTab(make_placeholder_tab("About", "Version, credits, and environment information will live here."), + "About"); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, panel); + if (auto* ok = buttons->button(QDialogButtonBox::Ok)) { + ok->setText("OK"); + ok->setIcon(components::icons::makeSvgIcon(":/icons/check.svg")); + } + if (auto* cancel = buttons->button(QDialogButtonBox::Cancel)) { + cancel->setText("Cancel"); + cancel->setIcon(components::icons::makeSvgIcon(":/icons/xmark-circle.svg")); + } + connect(buttons, &QDialogButtonBox::accepted, this, &SettingsDialog::onAccepted); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + panel_layout->addWidget(tabs); + panel_layout->addWidget(buttons); + root->addWidget(panel); +} + +void SettingsDialog::syncFromSettings() { + geometry_library_edit_->setText(AppSettings::instance().geometryLibrary()); + show_stats_check_->setChecked(AppSettings::instance().showStats()); + backface_culling_check_->setChecked(AppSettings::instance().backfaceCulling()); + load_data_source_checkbox_->setChecked(AppSettings::instance().loadDataSource()); + apply_coordinate_operation_check_->setChecked(AppSettings::instance().applyCoordinateOperation()); + void_limit_spin_->setValue(AppSettings::instance().voidLimit()); + deflection_tolerance_spin_->setValue(AppSettings::instance().deflectionTolerance()); + angular_tolerance_spin_->setValue(AppSettings::instance().angularTolerance()); +} + +void SettingsDialog::onAccepted() { + AppSettings::instance().setGeometryLibrary(geometry_library_edit_->text()); + AppSettings::instance().setShowStats(show_stats_check_->isChecked()); + AppSettings::instance().setBackfaceCulling(backface_culling_check_->isChecked()); + AppSettings::instance().setLoadDataSource(load_data_source_checkbox_->isChecked()); + AppSettings::instance().setApplyCoordinateOperation(apply_coordinate_operation_check_->isChecked()); + AppSettings::instance().setVoidLimit(void_limit_spin_->value()); + AppSettings::instance().setDeflectionTolerance(deflection_tolerance_spin_->value()); + AppSettings::instance().setAngularTolerance(angular_tolerance_spin_->value()); + accept(); +} + +} // namespace ifcinterface::panels::settings diff --git a/src/interface/panels/settings/Dialog.h b/src/interface/panels/settings/Dialog.h new file mode 100644 index 0000000000..3b34ad8687 --- /dev/null +++ b/src/interface/panels/settings/Dialog.h @@ -0,0 +1,59 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_SETTINGSDIALOG_H +#define IFCINTERFACE_PANELS_SETTINGSDIALOG_H + +#include + +class QCheckBox; +class QDoubleSpinBox; +class QLineEdit; +class QShowEvent; +class QSpinBox; + +namespace ifcinterface::panels::settings { + +class SettingsDialog : public QDialog { + Q_OBJECT +public: + explicit SettingsDialog(QWidget* parent = nullptr); + +protected: + void showEvent(QShowEvent* event) override; + +private: + void setupUi(); + void syncFromSettings(); + void onAccepted(); + + QLineEdit* geometry_library_edit_ = nullptr; + QCheckBox* show_stats_check_ = nullptr; + QCheckBox* backface_culling_check_ = nullptr; + QCheckBox* load_data_source_checkbox_ = nullptr; + QCheckBox* apply_coordinate_operation_check_ = nullptr; + QSpinBox* void_limit_spin_ = nullptr; + QDoubleSpinBox* deflection_tolerance_spin_ = nullptr; + QDoubleSpinBox* angular_tolerance_spin_ = nullptr; +}; + +} // namespace ifcinterface::panels::settings + +#endif diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h b/src/interface/panels/spatial_hierarchy/Types.h similarity index 100% rename from src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelTypes.h rename to src/interface/panels/spatial_hierarchy/Types.h diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp b/src/interface/panels/spatial_hierarchy/View.cpp similarity index 97% rename from src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp rename to src/interface/panels/spatial_hierarchy/View.cpp index a3948558b8..65077906e8 100644 --- a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.cpp +++ b/src/interface/panels/spatial_hierarchy/View.cpp @@ -18,9 +18,9 @@ * * ********************************************************************************/ -#include "SpatialHierarchyPanelView.h" +#include "View.h" -#include "SpatialHierarchyPanelWidget.h" +#include "Widget.h" namespace ifcinterface::panels::spatial_hierarchy { diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h b/src/interface/panels/spatial_hierarchy/View.h similarity index 98% rename from src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h rename to src/interface/panels/spatial_hierarchy/View.h index 1924b7f9f3..4df2ddc124 100644 --- a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelView.h +++ b/src/interface/panels/spatial_hierarchy/View.h @@ -21,7 +21,7 @@ #ifndef IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELVIEW_H #define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELVIEW_H -#include "SpatialHierarchyPanelTypes.h" +#include "Types.h" #include diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp b/src/interface/panels/spatial_hierarchy/Widget.cpp similarity index 93% rename from src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp rename to src/interface/panels/spatial_hierarchy/Widget.cpp index 5c48a1b24a..72230dd247 100644 --- a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.cpp +++ b/src/interface/panels/spatial_hierarchy/Widget.cpp @@ -18,7 +18,7 @@ * * ********************************************************************************/ -#include "SpatialHierarchyPanelWidget.h" +#include "Widget.h" #include "../../components/Section.h" #include "../../components/SvgIcon.h" @@ -37,7 +37,7 @@ SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - auto* section = new components::Section("", components::SectionHeaderMode::Hidden, "", this); + auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this); tree_ = new QTreeWidget(section); tree_->setColumnCount(2); @@ -71,8 +71,8 @@ void SpatialHierarchyPanelWidget::addNode(QTreeWidgetItem* parent, const TreeNod auto* item = new QTreeWidgetItem(parent, {node.name, ""}); item->setData(1, Qt::UserRole, node.visible); item->setSizeHint(0, QSize(0, 24)); - item->setIcon(0, components::icons::makePanelSvgIcon(iconPath(node.kind))); - item->setIcon(1, components::icons::makePanelSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); + item->setIcon(0, components::icons::makeSvgIcon(iconPath(node.kind))); + item->setIcon(1, components::icons::makeSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); for (const auto& child : node.children) { addNode(item, child); } diff --git a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h b/src/interface/panels/spatial_hierarchy/Widget.h similarity index 98% rename from src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h rename to src/interface/panels/spatial_hierarchy/Widget.h index b5e15720ce..134b78c5ce 100644 --- a/src/interface/panels/spatial_hierarchy/SpatialHierarchyPanelWidget.h +++ b/src/interface/panels/spatial_hierarchy/Widget.h @@ -21,7 +21,7 @@ #ifndef IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELWIDGET_H #define IFCINTERFACE_PANELS_SPATIALHIERARCHYPANELWIDGET_H -#include "SpatialHierarchyPanelTypes.h" +#include "Types.h" #include diff --git a/src/interface/panels/todo/TodoPanelWidget.cpp b/src/interface/panels/todo/Widget.cpp similarity index 97% rename from src/interface/panels/todo/TodoPanelWidget.cpp rename to src/interface/panels/todo/Widget.cpp index 45ff4c043c..1f2e58d3dc 100644 --- a/src/interface/panels/todo/TodoPanelWidget.cpp +++ b/src/interface/panels/todo/Widget.cpp @@ -18,7 +18,7 @@ * * ********************************************************************************/ -#include "TodoPanelWidget.h" +#include "Widget.h" #include "../../components/Section.h" @@ -34,7 +34,7 @@ TodoPanelWidget::TodoPanelWidget(const QString& title, QWidget* parent) layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - auto* section = new components::Section("", components::SectionHeaderMode::Hidden, "", this); + auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this); auto* body = new QWidget(section); auto* body_layout = new QVBoxLayout(body); diff --git a/src/interface/panels/todo/TodoPanelWidget.h b/src/interface/panels/todo/Widget.h similarity index 100% rename from src/interface/panels/todo/TodoPanelWidget.h rename to src/interface/panels/todo/Widget.h From 0bb6df6a6bf84d7ba1fa339e14466cd9a754b967 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 10:16:15 +1000 Subject: [PATCH 113/120] ifcparse: skip flush+compact for read-only RocksDB on destruction Read-only handles reject Flush/CompactRange, so the destructor's status assertion always fired on shutdown when the streamer's sidecar was opened with read_only=true. Track the flag and skip the write path; also guard against a null db when the initial open failed. Co-Authored-By: Claude Opus 4.7 --- src/ifcparse/file.cpp | 23 ++++++++++++++--------- src/ifcparse/storage.h | 2 ++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/ifcparse/file.cpp b/src/ifcparse/file.cpp index 5f9b4dba5a..b4a277d04a 100644 --- a/src/ifcparse/file.cpp +++ b/src/ifcparse/file.cpp @@ -467,6 +467,7 @@ ifcopenshell::impl::rocks_db_file_storage::rocks_db_file_storage(const std::stri // @todo by_identity is probably not correct here, this mapping is Name -> Identity, so Fn should have access to full pair? // , byidentity_(&byid_, [this](size_t v) { return assert_existance(v, by_identity); }, [](ifcopenshell::IfcBaseClass* v) { return v->identity(); }) { + read_only_ = readonly; #ifdef IFOPSH_WITH_ROCKSDB wopts.disableWAL = true; #endif @@ -475,18 +476,22 @@ ifcopenshell::impl::rocks_db_file_storage::rocks_db_file_storage(const std::stri ifcopenshell::impl::rocks_db_file_storage::~rocks_db_file_storage() { #ifdef IFOPSH_WITH_ROCKSDB - rocksdb::FlushOptions flush_options; - flush_options.allow_write_stall = true; - flush_options.wait = true; // Wait until flush completes. - rocksdb::Status s = db->Flush(flush_options); + if (db != nullptr) { + if (!read_only_) { + rocksdb::FlushOptions flush_options; + flush_options.allow_write_stall = true; + flush_options.wait = true; // Wait until flush completes. + rocksdb::Status s = db->Flush(flush_options); - // compact entire db - db->CompactRange(rocksdb::CompactRangeOptions{}, nullptr, nullptr); + // compact entire db + db->CompactRange(rocksdb::CompactRangeOptions{}, nullptr, nullptr); - assert(s.ok()); + assert(s.ok()); + } - db->Close(); - delete db; + db->Close(); + delete db; + } #endif } diff --git a/src/ifcparse/storage.h b/src/ifcparse/storage.h index 9944da494e..c9f09638b4 100644 --- a/src/ifcparse/storage.h +++ b/src/ifcparse/storage.h @@ -390,6 +390,8 @@ namespace ifcopenshell { typedef rocksdb_map_adapter> entities_by_ref_t; entities_by_ref_t byref_excl_; + bool read_only_ = false; + // @todo naming rocks_db_file_storage(const std::string& path, ifcopenshell::file* owner_file, bool read_only = false); ~rocks_db_file_storage(); From f2b655fcf61d7e68733f47a4bff3caf66154d27f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 11:25:30 +1000 Subject: [PATCH 114/120] ifcviewer-full: print object volume on click via lazy GPU readback Adds neutral primitives on ViewportWindow (readbackMeshTriangles, findInstance) so consumers can compute per-object geometry queries without the library retaining a CPU triangle copy. Measurement.cpp in ifcviewer-full uses them to sum signed-tetrahedra in mesh-local space, weighted by |det(placement_3x3)| per instance for mapped-item scaling. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 6 ++ src/ifcviewer-full/Measurement.cpp | 89 ++++++++++++++++++++++++++++++ src/ifcviewer-full/Measurement.h | 38 +++++++++++++ src/ifcviewer/ViewportWindow.cpp | 62 +++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 25 +++++++++ 5 files changed, 220 insertions(+) create mode 100644 src/ifcviewer-full/Measurement.cpp create mode 100644 src/ifcviewer-full/Measurement.h diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index c1d1220b15..57e60282cc 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 "Measurement.h" #include "ModelTransformationDialog.h" #include "SettingsWindow.h" #include "LodBuilder.h" @@ -871,6 +872,11 @@ void MainWindow::onObjectPicked(uint32_t object_id) { } populateProperties(object_id); + + if (object_id != 0) { + const double v = volumeOfObjects(*viewport_, {object_id}); + qInfo("Volume of object %u: %.6f m^3", object_id, v); + } } void MainWindow::onTreeSelectionChanged() { diff --git a/src/ifcviewer-full/Measurement.cpp b/src/ifcviewer-full/Measurement.cpp new file mode 100644 index 0000000000..1283fcd9d4 --- /dev/null +++ b/src/ifcviewer-full/Measurement.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "Measurement.h" + +#include "ViewportWindow.h" + +#include +#include +#include +#include + +namespace { + +double meshLocalVolume(const ViewportWindow::MeshTriangles& tris) { + // Signed tetrahedra from the origin: V = sum( a · (b × c) ) / 6. + // Absolute value at the end so winding convention doesn't matter. + double sum = 0.0; + const size_t n = tris.indices.size(); + for (size_t i = 0; i + 2 < n; i += 3) { + const uint32_t ia = tris.indices[i + 0]; + const uint32_t ib = tris.indices[i + 1]; + const uint32_t ic = tris.indices[i + 2]; + const float* a = &tris.positions[3 * ia]; + const float* b = &tris.positions[3 * ib]; + const float* c = &tris.positions[3 * ic]; + const double cx = double(b[1]) * c[2] - double(b[2]) * c[1]; + const double cy = double(b[2]) * c[0] - double(b[0]) * c[2]; + const double cz = double(b[0]) * c[1] - double(b[1]) * c[0]; + sum += double(a[0]) * cx + double(a[1]) * cy + double(a[2]) * cz; + } + return std::abs(sum) / 6.0; +} + +double det3(const float M[16]) { + // Upper-left 3x3 of a column-major 4x4: M[col * 4 + row]. + const double m00 = M[0], m10 = M[1], m20 = M[2]; + const double m01 = M[4], m11 = M[5], m21 = M[6]; + const double m02 = M[8], m12 = M[9], m22 = M[10]; + return m00 * (m11 * m22 - m12 * m21) + - m01 * (m10 * m22 - m12 * m20) + + m02 * (m10 * m21 - m11 * m20); +} + +} // namespace + +double volumeOfObjects(ViewportWindow& vp, + const std::vector& object_ids) { + if (object_ids.empty()) return 0.0; + + // Group selected instances by (model_id, mesh_id) so each unique mesh + // is read back at most once per call. Each entry stores the |det| of + // every instance of that mesh in the request. + std::unordered_map> by_mesh; + by_mesh.reserve(object_ids.size()); + for (uint32_t oid : object_ids) { + ViewportWindow::InstanceLookup lk; + if (!vp.findInstance(oid, lk)) continue; + const uint64_t key = (uint64_t(lk.model_id) << 32) | lk.mesh_id; + by_mesh[key].push_back(std::abs(det3(lk.placement_transformation))); + } + + double total = 0.0; + ViewportWindow::MeshTriangles tris; + for (const auto& [key, dets] : by_mesh) { + const uint32_t model_id = uint32_t(key >> 32); + const uint32_t mesh_id = uint32_t(key & 0xffffffffu); + if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) continue; + const double v = meshLocalVolume(tris); + for (double d : dets) total += v * d; + } + return total; +} diff --git a/src/ifcviewer-full/Measurement.h b/src/ifcviewer-full/Measurement.h new file mode 100644 index 0000000000..847fcd7d69 --- /dev/null +++ b/src/ifcviewer-full/Measurement.h @@ -0,0 +1,38 @@ +/******************************************************************************** + * * + * 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 IFCVIEWER_FULL_MEASUREMENT_H +#define IFCVIEWER_FULL_MEASUREMENT_H + +#include +#include + +class ViewportWindow; + +// Sum of mesh-local volumes (m³) of every instance whose object_id is in +// `object_ids`. Groups by (model, mesh) so each unique mesh is read back +// from the GPU at most once per call; instances of the same mesh are scaled +// by |det(placement_3x3)| to pick up mapped-item scale/mirror. Volume is +// taken as the absolute value of the signed-tetrahedra sum, so winding +// convention does not matter. Returns 0.0 for empty input or when nothing +// resolves. Recomputes from scratch on every call — no cache. +double volumeOfObjects(ViewportWindow& vp, + const std::vector& object_ids); + +#endif // IFCVIEWER_FULL_MEASUREMENT_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 8330ce16a7..cd64ad2510 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -3747,3 +3747,65 @@ void ViewportWindow::printSelectedObjectCoords() { qInfo("printSelectedObjectCoords: object_id %u not found in any model", selected_object_id_); } + +bool ViewportWindow::readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id, + MeshTriangles& out) { + out.positions.clear(); + out.indices.clear(); + if (!gl_initialized_) return false; + auto it = models_gpu_.find(model_id); + if (it == models_gpu_.end()) return false; + const ModelGpuData& m = it->second; + if (!m.finalized) return false; + if (mesh_id >= m.meshes.size()) return false; + const MeshInfo& mi = m.meshes[mesh_id]; + if (mi.vertex_count == 0 || mi.index_count == 0) return false; + + context_->makeCurrent(this); + + // Vertices: stride is INSTANCED_VERTEX_STRIDE_BYTES (12), positions are + // the first 6 bytes (3 x uint16) of each vertex. Read the full range + // and stride through it — simpler than chasing a position-only buffer. + std::vector raw(size_t(mi.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES); + gl_->glGetNamedBufferSubData(m.vbo, mi.vbo_byte_offset, + GLsizeiptr(raw.size()), raw.data()); + + out.positions.resize(size_t(mi.vertex_count) * 3); + const float ox = mi.local_aabb_min[0]; + const float oy = mi.local_aabb_min[1]; + const float oz = mi.local_aabb_min[2]; + const float sx = (mi.local_aabb_max[0] - mi.local_aabb_min[0]) / 65535.0f; + const float sy = (mi.local_aabb_max[1] - mi.local_aabb_min[1]) / 65535.0f; + const float sz = (mi.local_aabb_max[2] - mi.local_aabb_min[2]) / 65535.0f; + for (uint32_t i = 0; i < mi.vertex_count; ++i) { + const uint16_t* p = reinterpret_cast( + raw.data() + size_t(i) * INSTANCED_VERTEX_STRIDE_BYTES); + out.positions[3 * i + 0] = ox + sx * float(p[0]); + out.positions[3 * i + 1] = oy + sy * float(p[1]); + out.positions[3 * i + 2] = oz + sz * float(p[2]); + } + + // Indices: LOD0 only — measurements should use the full-resolution mesh. + out.indices.resize(mi.index_count); + gl_->glGetNamedBufferSubData(m.ebo, mi.ebo_byte_offset, + GLsizeiptr(mi.index_count * sizeof(uint32_t)), + out.indices.data()); + return true; +} + +bool ViewportWindow::findInstance(uint32_t object_id, InstanceLookup& out) const { + if (object_id == 0) return false; + for (const auto& kv : models_gpu_) { + const ModelGpuData& m = kv.second; + for (const InstanceCpu& inst : m.instances) { + if (inst.object_id != object_id) continue; + out.model_id = inst.model_id; + out.mesh_id = inst.mesh_id; + std::memcpy(out.placement_transformation, + inst.placement_transformation, + sizeof(out.placement_transformation)); + return true; + } + } + return false; +} diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 74cfdb36ad..09f89a03b8 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -183,6 +183,31 @@ public: // (CoordinateOperation · placement_transformation), in metres. void printSelectedObjectCoords(); + // Mesh-local CPU triangles read back from the VBO/EBO. Positions are + // dequantised against the mesh's local AABB; units match the streamer + // (metres). Indices are mesh-local (0..vertex_count-1). + struct MeshTriangles { + std::vector positions; // 3 * vertex_count + std::vector indices; // 3 * triangle_count + }; + + // Lazy GPU readback of one mesh's triangles. Fails if model_id / + // mesh_id aren't live, the model isn't finalised, or GL isn't ready. + // Stalls the GL pipeline for the readback — call from the main thread + // and not from inside render(). + bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id, + MeshTriangles& out); + + // Pure CPU lookup: object_id → owning model + mesh + raw streamer + // placement matrix (column-major, pre-CoordinateOperation / + // FederatedFalseOrigin / ModelTransformation). + struct InstanceLookup { + uint32_t model_id = 0; + uint32_t mesh_id = 0; + float placement_transformation[16]{}; + }; + bool findInstance(uint32_t object_id, InstanceLookup& out) const; + // Federation pipeline: composed instance transform = // FederatedFalseOrigin · ModelTransformation · CoordinateOperation // · placement_transformation From 016c27874803d0e865f15659a4e1092eee03ee46 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 12:03:22 +1000 Subject: [PATCH 115/120] ifcviewer-full: console-print accumulating coplanar-patch area tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a click-to-measure area mode triggered by Ctrl+Shift+A. Each LMB click expands the picked triangle into its connected coplanar patch (BFS over shared edges, dot(normal, seed) > 0.9999); re-clicking removes that patch; Alt+LMB skips expansion for a single triangle. Picks across different meshes accumulate as separate patches. ViewportWindow gains pickMeshLocalAt (screen pick → mesh-local hit via inverse composed transform) and a tool-mode pattern mirroring the section tool (toggleAreaTool, surfacePickedInTool signal, areaToolToggled signal, Esc to exit). Per-mesh adjacency is built lazily on first pick of each mesh and dropped on tool toggle. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 13 ++ src/ifcviewer-full/MainWindow.h | 3 + src/ifcviewer-full/Measurement.cpp | 234 +++++++++++++++++++++++++++++ src/ifcviewer-full/Measurement.h | 54 +++++++ src/ifcviewer/ViewportWindow.cpp | 57 ++++++- src/ifcviewer/ViewportWindow.h | 34 +++++ 6 files changed, 391 insertions(+), 4 deletions(-) diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 57e60282cc..2510ad799c 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -206,6 +206,16 @@ void MainWindow::setupUi() { setCentralWidget(viewport_container_); connect(viewport_, &ViewportWindow::objectPicked, this, &MainWindow::onObjectPicked); + connect(viewport_, &ViewportWindow::surfacePickedInTool, this, + [this](int x, int y, int modifiers) { + const bool alt = (modifiers & Qt::AltModifier) != 0; + area_measurement_.onPick(*viewport_, x, y, alt); + }); + connect(viewport_, &ViewportWindow::areaToolToggled, this, + [this](bool active) { + area_measurement_.clear(); + qInfo("Area tool %s", active ? "on (LMB to add patch, Alt+LMB single tri, click again to remove, Esc exits)" : "off"); + }); auto* tree_dock = new QDockWidget("Elements", this); tree_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); @@ -283,6 +293,9 @@ void MainWindow::setupMenus() { view_menu->addAction("Print Selected &Coords", this, [this]() { viewport_->printSelectedObjectCoords(); }, QKeySequence("Ctrl+Shift+P")); + view_menu->addAction("&Measure Area", this, [this]() { + viewport_->toggleAreaTool(); + }, QKeySequence("Ctrl+Shift+A")); view_menu->addSeparator(); view_menu->addAction("Set &Home View", this, &MainWindow::onSetHomeView); view_menu->addAction("&Go to Home View", this, &MainWindow::onGoHomeView); diff --git a/src/ifcviewer-full/MainWindow.h b/src/ifcviewer-full/MainWindow.h index ff395fe51f..fa1bc35f91 100644 --- a/src/ifcviewer-full/MainWindow.h +++ b/src/ifcviewer-full/MainWindow.h @@ -32,6 +32,7 @@ #include #include +#include "Measurement.h" #include "ViewportWindow.h" #include "SceneLoader.h" @@ -198,6 +199,8 @@ private: QString pending_camera_; int pending_benchmark_ = 0; + + AreaMeasurement area_measurement_; }; #endif // MAINWINDOW_H diff --git a/src/ifcviewer-full/Measurement.cpp b/src/ifcviewer-full/Measurement.cpp index 1283fcd9d4..0fff48cda1 100644 --- a/src/ifcviewer-full/Measurement.cpp +++ b/src/ifcviewer-full/Measurement.cpp @@ -21,9 +21,14 @@ #include "ViewportWindow.h" +#include + +#include #include #include +#include #include +#include #include namespace { @@ -87,3 +92,232 @@ double volumeOfObjects(ViewportWindow& vp, } return total; } + +namespace { + +// edge_key: undirected edge between two mesh-local vertex indices. +uint64_t edgeKey(uint32_t a, uint32_t b) { + if (a > b) std::swap(a, b); + return (uint64_t(a) << 32) | uint64_t(b); +} + +// Triangle area = 0.5 * |(b - a) × (c - a)|. Also returns the unit normal +// (zeroed for degenerate tris). +double triAreaAndNormal(const float* a, const float* b, const float* c, + float n_out[3]) { + const double bax = double(b[0]) - a[0]; + const double bay = double(b[1]) - a[1]; + const double baz = double(b[2]) - a[2]; + const double cax = double(c[0]) - a[0]; + const double cay = double(c[1]) - a[1]; + const double caz = double(c[2]) - a[2]; + const double nx = bay * caz - baz * cay; + const double ny = baz * cax - bax * caz; + const double nz = bax * cay - bay * cax; + const double len = std::sqrt(nx * nx + ny * ny + nz * nz); + if (len > 0.0) { + n_out[0] = float(nx / len); + n_out[1] = float(ny / len); + n_out[2] = float(nz / len); + } else { + n_out[0] = n_out[1] = n_out[2] = 0.0f; + } + return 0.5 * len; +} + +// Squared distance from `p` to triangle (a, b, c) — clipped to the +// triangle's interior or boundary, whichever is closest. Standard +// implementation (Ericson, "Real-Time Collision Detection"). +double pointTriangleDistSq(const float p[3], + const float a[3], const float b[3], const float c[3]) { + auto sub = [](const float u[3], const float v[3], double r[3]) { + r[0] = double(u[0]) - v[0]; + r[1] = double(u[1]) - v[1]; + r[2] = double(u[2]) - v[2]; + }; + auto dot = [](const double u[3], const double v[3]) { + return u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; + }; + double ab[3], ac[3], ap[3]; + sub(b, a, ab); + sub(c, a, ac); + sub(p, a, ap); + const double d1 = dot(ab, ap); + const double d2 = dot(ac, ap); + if (d1 <= 0.0 && d2 <= 0.0) { + return ap[0]*ap[0] + ap[1]*ap[1] + ap[2]*ap[2]; + } + double bp[3]; + sub(p, b, bp); + const double d3 = dot(ab, bp); + const double d4 = dot(ac, bp); + if (d3 >= 0.0 && d4 <= d3) { + return bp[0]*bp[0] + bp[1]*bp[1] + bp[2]*bp[2]; + } + const double vc = d1 * d4 - d3 * d2; + if (vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0) { + const double v = d1 / (d1 - d3); + const double qx = ap[0] - v * ab[0]; + const double qy = ap[1] - v * ab[1]; + const double qz = ap[2] - v * ab[2]; + return qx*qx + qy*qy + qz*qz; + } + double cp[3]; + sub(p, c, cp); + const double d5 = dot(ab, cp); + const double d6 = dot(ac, cp); + if (d6 >= 0.0 && d5 <= d6) { + return cp[0]*cp[0] + cp[1]*cp[1] + cp[2]*cp[2]; + } + const double vb = d5 * d2 - d1 * d6; + if (vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0) { + const double w = d2 / (d2 - d6); + const double qx = ap[0] - w * ac[0]; + const double qy = ap[1] - w * ac[1]; + const double qz = ap[2] - w * ac[2]; + return qx*qx + qy*qy + qz*qz; + } + const double va = d3 * d6 - d5 * d4; + if (va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0) { + const double w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + const double qx = double(b[0]) + w * (double(c[0]) - b[0]) - p[0]; + const double qy = double(b[1]) + w * (double(c[1]) - b[1]) - p[1]; + const double qz = double(b[2]) + w * (double(c[2]) - b[2]) - p[2]; + return qx*qx + qy*qy + qz*qz; + } + // Inside the triangle — return perpendicular distance to its plane. + const double denom = 1.0 / (va + vb + vc); + const double v = vb * denom; + const double w = vc * denom; + const double qx = double(a[0]) + v * ab[0] + w * ac[0] - p[0]; + const double qy = double(a[1]) + v * ab[1] + w * ac[1] - p[1]; + const double qz = double(a[2]) + v * ab[2] + w * ac[2] - p[2]; + return qx*qx + qy*qy + qz*qz; +} + +constexpr double kCoplanarDot = 0.9999; // ~0.81° tolerance + +} // namespace + +AreaMeasurement::AreaMeasurement() = default; + +void AreaMeasurement::clear() { + mesh_cache_.clear(); + selected_.clear(); + total_area_m2_ = 0.0; +} + +AreaMeasurement::MeshCache* AreaMeasurement::meshCache(ViewportWindow& vp, + uint32_t model_id, + uint32_t mesh_id) { + const uint64_t key = (uint64_t(model_id) << 32) | uint64_t(mesh_id); + auto it = mesh_cache_.find(key); + if (it != mesh_cache_.end()) return &it->second; + + ViewportWindow::MeshTriangles tris; + if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) return nullptr; + + MeshCache c; + c.positions = std::move(tris.positions); + c.indices = std::move(tris.indices); + const size_t n_tris = c.indices.size() / 3; + c.tri_normals.resize(n_tris * 3); + c.tri_areas.resize(n_tris); + c.edges.reserve(n_tris * 3); + for (size_t t = 0; t < n_tris; ++t) { + const uint32_t ia = c.indices[3 * t + 0]; + const uint32_t ib = c.indices[3 * t + 1]; + const uint32_t ic = c.indices[3 * t + 2]; + const float* a = &c.positions[3 * ia]; + const float* b = &c.positions[3 * ib]; + const float* cc = &c.positions[3 * ic]; + float n[3]; + c.tri_areas[t] = triAreaAndNormal(a, b, cc, n); + c.tri_normals[3 * t + 0] = n[0]; + c.tri_normals[3 * t + 1] = n[1]; + c.tri_normals[3 * t + 2] = n[2]; + c.edges[edgeKey(ia, ib)].push_back(uint32_t(t)); + c.edges[edgeKey(ib, ic)].push_back(uint32_t(t)); + c.edges[edgeKey(ic, ia)].push_back(uint32_t(t)); + } + return &mesh_cache_.emplace(key, std::move(c)).first->second; +} + +void AreaMeasurement::onPick(ViewportWindow& vp, int x, int y, bool alt) { + ViewportWindow::MeshLocalPick pick; + if (!vp.pickMeshLocalAt(x, y, pick)) return; + + MeshCache* cache = meshCache(vp, pick.model_id, pick.mesh_id); + if (!cache) return; + const size_t n_tris = cache->indices.size() / 3; + if (n_tris == 0) return; + + // Find the seed triangle: the one whose interior (or boundary) is + // closest to the pick's mesh-local point. + uint32_t seed = 0; + double best = std::numeric_limits::infinity(); + for (size_t t = 0; t < n_tris; ++t) { + const uint32_t ia = cache->indices[3 * t + 0]; + const uint32_t ib = cache->indices[3 * t + 1]; + const uint32_t ic = cache->indices[3 * t + 2]; + const double d = pointTriangleDistSq(pick.mesh_local, + &cache->positions[3 * ia], + &cache->positions[3 * ib], + &cache->positions[3 * ic]); + if (d < best) { + best = d; + seed = uint32_t(t); + } + } + + // Expand to coplanar patch (BFS over shared edges). Alt skips it. + std::vector patch; + if (alt) { + patch.push_back(seed); + } else { + const float* sn = &cache->tri_normals[3 * seed]; + std::unordered_set visited; + visited.insert(seed); + std::queue frontier; + frontier.push(seed); + while (!frontier.empty()) { + const uint32_t t = frontier.front(); frontier.pop(); + patch.push_back(t); + for (int e = 0; e < 3; ++e) { + const uint32_t ia = cache->indices[3 * t + e]; + const uint32_t ib = cache->indices[3 * t + (e + 1) % 3]; + auto it = cache->edges.find(edgeKey(ia, ib)); + if (it == cache->edges.end()) continue; + for (uint32_t nt : it->second) { + if (nt == t || visited.count(nt)) continue; + const float* nn = &cache->tri_normals[3 * nt]; + const double dot = double(sn[0]) * nn[0] + + double(sn[1]) * nn[1] + + double(sn[2]) * nn[2]; + if (dot < kCoplanarDot) continue; + visited.insert(nt); + frontier.push(nt); + } + } + } + } + + // Toggle: if the seed was already in the set, remove the patch; + // otherwise add it. + const uint64_t seed_key = triKey(pick.model_id, pick.mesh_id, seed); + const bool removing = selected_.count(seed_key) > 0; + double delta = 0.0; + for (uint32_t t : patch) { + const uint64_t k = triKey(pick.model_id, pick.mesh_id, t); + if (removing) { + if (selected_.erase(k) > 0) delta -= cache->tri_areas[t]; + } else { + if (selected_.insert(k).second) delta += cache->tri_areas[t]; + } + } + total_area_m2_ += delta; + + qInfo("Area %s%.6f m^2 (total: %.6f m^2, %zu tris)", + delta >= 0.0 ? "+" : "", delta, + total_area_m2_, selected_.size()); +} diff --git a/src/ifcviewer-full/Measurement.h b/src/ifcviewer-full/Measurement.h index 847fcd7d69..e88f650bd6 100644 --- a/src/ifcviewer-full/Measurement.h +++ b/src/ifcviewer-full/Measurement.h @@ -21,6 +21,8 @@ #define IFCVIEWER_FULL_MEASUREMENT_H #include +#include +#include #include class ViewportWindow; @@ -35,4 +37,56 @@ class ViewportWindow; double volumeOfObjects(ViewportWindow& vp, const std::vector& object_ids); +// Click-to-accumulate area measurement. Each pick resolves the screen +// click to a (model, mesh, triangle) using ViewportWindow's primitives, +// expands it into the connected coplanar patch (BFS over shared edges, +// dot(normal, seed_normal) > 0.9999), then either adds or removes that +// patch from the running set depending on whether the seed triangle was +// already in. Alt-click skips the BFS expansion (single-triangle). +// Picks across different meshes are kept as separate patches and their +// areas are summed. +// +// State is cleared on construction, on clear(), and is expected to be +// reset by the host (e.g. when the viewport's area tool toggles off). +class AreaMeasurement { +public: + AreaMeasurement(); + + // Main entry point: handle one click in area-tool mode. alt = true + // suppresses BFS expansion. Logs the per-click delta and running total + // via qInfo. Misses are silent. + void onPick(ViewportWindow& vp, int x, int y, bool alt); + + // Wipe all accumulated triangles and per-mesh adjacency caches. + void clear(); + + double totalArea() const { return total_area_m2_; } + size_t triangleCount() const { return selected_.size(); } + +private: + // Cached per-mesh data: triangles + edge→triangles adjacency. Keyed + // by (model_id << 32) | mesh_id. Filled lazily on first pick of that + // mesh, dropped on clear(). + struct MeshCache { + std::vector positions; // 3 * N_verts + std::vector indices; // 3 * N_tris + std::vector tri_normals; // 3 * N_tris (unit, mesh-local) + std::vector tri_areas; // N_tris + // edge_key (min<<32 | max) → list of triangle indices touching it. + std::unordered_map> edges; + }; + MeshCache* meshCache(ViewportWindow& vp, uint32_t model_id, uint32_t mesh_id); + + // Selection key: (uint64) packing model_id (high 24), mesh_id (mid 24), + // triangle index (low 16). 16 bits is enough — meshes with > 65k tris + // are rare and the streamer chunks them anyway. + static uint64_t triKey(uint32_t model_id, uint32_t mesh_id, uint32_t tri) { + return (uint64_t(model_id) << 40) | (uint64_t(mesh_id) << 16) | uint64_t(tri); + } + + std::unordered_map mesh_cache_; + std::unordered_set selected_; + double total_area_m2_ = 0.0; +}; + #endif // IFCVIEWER_FULL_MEASUREMENT_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index cd64ad2510..907f4c6a02 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1688,6 +1688,13 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { return; } } + // Esc also exits the area tool. + if (area_tool_active_ + && key == Qt::Key_Escape + && !event->isAutoRepeat()) { + toggleAreaTool(); + return; + } QWindow::keyPressEvent(event); } @@ -3473,10 +3480,15 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { if (active_button_ == Qt::LeftButton && !section_tool_active_ && (e->pos() - last_mouse_pos_).manhattanLength() < 5) { - uint32_t id = pickObjectAt(e->pos().x(), e->pos().y()); - selected_object_id_ = id; - emit objectPicked(id); - requestUpdate(); // selection highlight changed + if (area_tool_active_) { + emit surfacePickedInTool(e->pos().x(), e->pos().y(), + int(e->modifiers())); + } else { + uint32_t id = pickObjectAt(e->pos().x(), e->pos().y()); + selected_object_id_ = id; + emit objectPicked(id); + requestUpdate(); // selection highlight changed + } } const bool was_navigating = (active_button_ == Qt::MiddleButton); active_button_ = Qt::NoButton; @@ -3809,3 +3821,40 @@ bool ViewportWindow::findInstance(uint32_t object_id, InstanceLookup& out) const } return false; } + +bool ViewportWindow::pickMeshLocalAt(int x, int y, MeshLocalPick& out) { + uint32_t obj_id = 0; + QVector3D world_pos, world_normal; + if (!pickSurfaceAt(x, y, obj_id, world_pos, world_normal)) return false; + + for (const auto& kv : models_gpu_) { + const ModelGpuData& m = kv.second; + for (const InstanceCpu& inst : m.instances) { + if (inst.object_id != obj_id) continue; + using Mat4f = Eigen::Matrix; + const Eigen::Matrix4f T = Eigen::Map(inst.transform); + const Eigen::Matrix4f Ti = T.inverse(); + const Eigen::Vector4f wp(world_pos.x(), world_pos.y(), world_pos.z(), 1.0f); + const Eigen::Vector4f mp = Ti * wp; + out.object_id = obj_id; + out.model_id = inst.model_id; + out.mesh_id = inst.mesh_id; + out.mesh_local[0] = mp.x(); + out.mesh_local[1] = mp.y(); + out.mesh_local[2] = mp.z(); + out.world_pos[0] = world_pos.x(); + out.world_pos[1] = world_pos.y(); + out.world_pos[2] = world_pos.z(); + out.world_normal[0] = world_normal.x(); + out.world_normal[1] = world_normal.y(); + out.world_normal[2] = world_normal.z(); + return true; + } + } + return false; +} + +void ViewportWindow::toggleAreaTool() { + area_tool_active_ = !area_tool_active_; + emit areaToolToggled(area_tool_active_); +} diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 09f89a03b8..c436dda57c 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -208,6 +208,29 @@ public: }; bool findInstance(uint32_t object_id, InstanceLookup& out) const; + // Pick + resolve to mesh-local space. Runs pickSurfaceAt to get the + // world-space hit, then inverts the instance's composed transform + // (FederatedFalseOrigin · ModelTransformation · CoordinateOperation + // · placement_transformation) to express the hit in the mesh's own + // coordinates — what readbackMeshTriangles returns. Returns false if + // the click missed geometry or its object_id has no live instance. + struct MeshLocalPick { + uint32_t object_id = 0; + uint32_t model_id = 0; + uint32_t mesh_id = 0; + float mesh_local[3] = {0, 0, 0}; + float world_pos[3] = {0, 0, 0}; + float world_normal[3]= {0, 0, 0}; + }; + bool pickMeshLocalAt(int x, int y, MeshLocalPick& out); + + // Area tool: while active, LMB clicks emit surfacePickedInTool with + // the click coordinates instead of swapping object selection — the + // app interprets them (typically by calling pickMeshLocalAt and + // accumulating triangle area). Esc exits. + void toggleAreaTool(); + bool areaToolActive() const { return area_tool_active_; } + // Federation pipeline: composed instance transform = // FederatedFalseOrigin · ModelTransformation · CoordinateOperation // · placement_transformation @@ -302,6 +325,14 @@ signals: void objectPicked(uint32_t object_id); void initialized(); void frameStatsUpdated(const ViewportWindow::FrameStats& stats); + // Emitted instead of objectPicked when the area tool is active. The + // app is expected to call pickMeshLocalAt(x, y, ...) and accumulate. + // modifiers carries the Qt::KeyboardModifiers held at click time so + // the app can branch on Alt etc. + void surfacePickedInTool(int x, int y, int modifiers); + // Emitted whenever toggleAreaTool flips the mode. The app uses this + // to reset accumulator state on entry/exit. + void areaToolToggled(bool active); protected: void exposeEvent(QExposeEvent* event) override; @@ -601,6 +632,9 @@ private: // Selection uint32_t selected_object_id_ = 0; + // Area-measurement tool: see toggleAreaTool / surfacePickedInTool. + bool area_tool_active_ = false; + // Active section planes. Uploaded as uniform array each frame to the // main + pick programs; capped at MaxSectionPlanes. std::vector section_planes_; From 359693c562456ddde642f344cddfd29306b29baa Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 12:21:55 +1000 Subject: [PATCH 116/120] Interface mockup 9 --- src/interface/ElementRegistry.cpp | 4 + src/interface/ElementRegistry.h | 1 + src/interface/MainWindow.cpp | 301 +++++++++++++++++---- src/interface/MainWindow.h | 20 +- src/interface/components/Buttons.cpp | 78 ++++++ src/interface/components/Buttons.h | 45 +++ src/interface/components/Dialog.cpp | 79 ++++++ src/interface/components/Dialog.h | 46 ++++ src/interface/components/Panel.cpp | 21 +- src/interface/components/Panel.h | 8 +- src/interface/components/Style.cpp | 37 ++- src/interface/components/Tabs.cpp | 40 +++ src/interface/components/Tabs.h | 45 +++ src/interface/icons/cube-bandage.svg | 10 + src/interface/icons/database-restore.svg | 6 + src/interface/icons/database.svg | 6 + src/interface/interface_resources.qrc | 3 + src/interface/main.cpp | 2 + src/interface/panels/add_model/Dialog.cpp | 140 ++++++++++ src/interface/panels/add_model/Dialog.h | 50 ++++ src/interface/panels/properties/View.cpp | 4 + src/interface/panels/properties/View.h | 1 + src/interface/panels/properties/Widget.cpp | 225 ++++++++------- src/interface/panels/properties/Widget.h | 22 +- src/interface/panels/settings/Dialog.cpp | 32 +-- src/interface/panels/settings/Dialog.h | 4 +- 26 files changed, 1031 insertions(+), 199 deletions(-) create mode 100644 src/interface/components/Buttons.cpp create mode 100644 src/interface/components/Buttons.h create mode 100644 src/interface/components/Dialog.cpp create mode 100644 src/interface/components/Dialog.h create mode 100644 src/interface/components/Tabs.cpp create mode 100644 src/interface/components/Tabs.h create mode 100644 src/interface/icons/cube-bandage.svg create mode 100644 src/interface/icons/database-restore.svg create mode 100644 src/interface/icons/database.svg create mode 100644 src/interface/panels/add_model/Dialog.cpp create mode 100644 src/interface/panels/add_model/Dialog.h diff --git a/src/interface/ElementRegistry.cpp b/src/interface/ElementRegistry.cpp index 7583ff3dd6..12f84e5d23 100644 --- a/src/interface/ElementRegistry.cpp +++ b/src/interface/ElementRegistry.cpp @@ -39,6 +39,10 @@ void ElementRegistry::bindLoader(SceneLoader* loader) { this, &ElementRegistry::onStreamedElementsReady); } +void ElementRegistry::clear() { + elements_.clear(); +} + std::optional ElementRegistry::findBasicElementInfo(uint32_t object_id) const { auto it = elements_.find(object_id); if (it == elements_.end()) return std::nullopt; diff --git a/src/interface/ElementRegistry.h b/src/interface/ElementRegistry.h index b059c10c5d..e6ee785184 100644 --- a/src/interface/ElementRegistry.h +++ b/src/interface/ElementRegistry.h @@ -51,6 +51,7 @@ public: explicit ElementRegistry(QObject* parent = nullptr); void bindLoader(SceneLoader* loader); + void clear(); std::optional findBasicElementInfo(uint32_t object_id) const; std::optional findEntity(uint32_t object_id) const; diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 45e55b1574..4185c2b016 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -21,12 +21,15 @@ #include "MainWindow.h" #include "../ifcviewer/AppSettings.h" +#include "../ifcviewer/Federation.h" #include "../ifcviewer/SceneLoader.h" #include "../ifcviewer/ViewportWindow.h" #include "ElementRegistry.h" +#include "components/Buttons.h" #include "components/Panel.h" #include "components/Style.h" -#include "components/SvgIcon.h" +#include "components/Tabs.h" +#include "panels/add_model/Dialog.h" #include "panels/todo/Widget.h" #include "panels/models/View.h" #include "panels/models/Widget.h" @@ -38,15 +41,17 @@ #include #include +#include #include #include #include #include +#include #include #include #include #include -#include +#include #include #include @@ -55,7 +60,12 @@ namespace ifcinterface::shell { MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { + federation_ = new Federation(this); element_registry_ = new ifcinterface::ElementRegistry(this); + connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { + setWindowModified(dirty); + updateWindowTitle(); + }); setupChrome(); setupViewport(); setupPanels(); @@ -67,46 +77,18 @@ MainWindow::MainWindow(QWidget* parent) void MainWindow::setupChrome() { setObjectName("appWindow"); - setWindowTitle("IfcOpenShell Interface"); setDockOptions(QMainWindow::AllowNestedDocks | QMainWindow::AllowTabbedDocks | QMainWindow::GroupedDragging); - setStyleSheet(components::style::buildAppStyleSheet()); + updateWindowTitle(); } QToolButton* MainWindow::makeRibbonAction(const QString& text, const QString& icon_path) { - auto* button = new QToolButton(this); - button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); - button->setIcon(icon_path.endsWith(".svg") - ? components::icons::makeTintedSvgIcon(icon_path) - : QIcon(icon_path)); - button->setIconSize(QSize(20, 20)); - button->setText(text); - button->setMinimumSize(QSize(68, 54)); - button->setObjectName("ribbonButton"); - button->setAutoRaise(false); - return button; + return components::buttons::makeButton(text, icon_path, this, QSize(90, 54)); } QWidget* MainWindow::makeRibbonGroup(const QString& title, const QList& buttons) { - auto* group = new QFrame(this); - group->setObjectName("ribbonGroup"); - auto* group_layout = new QVBoxLayout(group); - group_layout->setContentsMargins(8, 6, 8, 4); - group_layout->setSpacing(4); - auto* button_row = new QHBoxLayout(); - button_row->setContentsMargins(0, 0, 0, 0); - button_row->setSpacing(4); - for (auto* button : buttons) { - button_row->addWidget(button); - } - auto* label = new QLabel(title, group); - label->setObjectName("ribbonGroupLabel"); - label->setProperty("textRole", "secondary"); - label->setAlignment(Qt::AlignCenter); - group_layout->addLayout(button_row); - group_layout->addWidget(label); - return group; + return components::buttons::makeButtonGroup(title, buttons, this); } void MainWindow::setStatusMessage(const QString& mode, const QString& detail) { @@ -137,13 +119,9 @@ QWidget* MainWindow::buildHomeRibbonPage() { row->setSpacing(0); auto* new_project = makeRibbonAction("New Project", ":/icons/plus-square.svg"); - connect(new_project, &QToolButton::clicked, this, [this]() { - setStatusMessage("Project", "New Project coming soon"); - }); + connect(new_project, &QToolButton::clicked, this, &MainWindow::onNewProject); auto* open_project = makeRibbonAction("Open Project", ":/icons/download-square.svg"); - connect(open_project, &QToolButton::clicked, this, [this]() { - setStatusMessage("Project", "Open Project coming soon"); - }); + connect(open_project, &QToolButton::clicked, this, &MainWindow::onOpenProject); auto* open_cloud = makeRibbonAction("Open Cloud", ":/icons/cloud-square.svg"); connect(open_cloud, &QToolButton::clicked, this, [this]() { setStatusMessage("Project", "Open Cloud Project coming soon"); @@ -153,13 +131,9 @@ QWidget* MainWindow::buildHomeRibbonPage() { setStatusMessage("Project", "Open Recent coming soon"); }); auto* save_project = makeRibbonAction("Save Project", ":/icons/floppy-disk.svg"); - connect(save_project, &QToolButton::clicked, this, [this]() { - setStatusMessage("Project", "Save Project coming soon"); - }); + connect(save_project, &QToolButton::clicked, this, &MainWindow::onSaveProject); auto* save_project_as = makeRibbonAction("Save As", ":/icons/floppy-disk-arrow-in.svg"); - connect(save_project_as, &QToolButton::clicked, this, [this]() { - setStatusMessage("Project", "Save Project As coming soon"); - }); + connect(save_project_as, &QToolButton::clicked, this, &MainWindow::onSaveProjectAs); auto* add_model = makeRibbonAction("Add Model", ":/icons/cube.svg"); connect(add_model, &QToolButton::clicked, this, &MainWindow::onAddFiles); @@ -322,14 +296,12 @@ void MainWindow::setupRibbon() { shell_layout->setContentsMargins(0, 0, 0, 0); shell_layout->setSpacing(0); - ribbon_tabs_ = new QTabBar(shell); + ribbon_tabs_ = new components::TabBar(shell); ribbon_tabs_->addTab("Home"); ribbon_tabs_->addTab("Navigate"); ribbon_tabs_->addTab("Inspect"); ribbon_tabs_->addTab("Panels"); ribbon_tabs_->setCurrentIndex(0); - ribbon_tabs_->setExpanding(false); - ribbon_tabs_->setDrawBase(false); auto* ribbon_band = new QFrame(shell); ribbon_band->setObjectName("ribbonBand"); @@ -347,7 +319,7 @@ void MainWindow::setupRibbon() { shell_layout->addWidget(ribbon_tabs_); shell_layout->addWidget(ribbon_band); - connect(ribbon_tabs_, &QTabBar::currentChanged, + connect(ribbon_tabs_, &components::TabBar::currentChanged, ribbon_pages_, &QStackedWidget::setCurrentIndex); setMenuWidget(shell); @@ -481,8 +453,16 @@ void MainWindow::setupLoader() { } void MainWindow::addFiles(const QStringList& paths) { - if (paths.isEmpty()) return; - loader_->addFiles(paths); + QStringList accepted_paths; + QStringList accepted_fed_ids; + for (const auto& path : paths) { + const QString fed_id = federation_->addModel(path); + if (fed_id.isEmpty()) continue; + accepted_paths << path; + accepted_fed_ids << fed_id; + } + loadModelsFromPaths(accepted_paths, accepted_fed_ids); + updateWindowTitle(); } QString MainWindow::formatElapsed(qint64 ms) const { @@ -492,12 +472,225 @@ QString MainWindow::formatElapsed(qint64 ms) const { } void MainWindow::onAddFiles() { - const QStringList paths = QFileDialog::getOpenFileNames( - this, "Add IFC Files", QString(), - "IFC Viewer Cache (*.ifcview)"); + panels::add_model::AddModelDialog dialog(this); + if (dialog.exec() != QDialog::Accepted) return; + + QStringList paths; + switch (dialog.selectedMode()) { + case panels::add_model::SourceMode::IfcFile: { + QFileDialog file_dialog(this, "Add IFC Files"); + file_dialog.setFileMode(QFileDialog::ExistingFiles); + file_dialog.setNameFilter("IFC Files (*.ifc);;All Files (*)"); + file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (file_dialog.exec() == QDialog::Accepted) { + paths = file_dialog.selectedFiles(); + } + break; + } + case panels::add_model::SourceMode::IfcDatabase: { + QFileDialog database_dialog(this, "Add IFC Databases"); + database_dialog.setFileMode(QFileDialog::Directory); + database_dialog.setOption(QFileDialog::ShowDirsOnly, true); + database_dialog.setOption(QFileDialog::DontResolveSymlinks, true); + database_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (auto* list = database_dialog.findChild("listView")) { + list->setSelectionMode(QAbstractItemView::ExtendedSelection); + } + if (auto* tree = database_dialog.findChild()) { + tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + } + if (database_dialog.exec() == QDialog::Accepted) { + paths = database_dialog.selectedFiles(); + } + break; + } + case panels::add_model::SourceMode::GeometryOnly: { + QFileDialog file_dialog(this, "Add Geometry Only"); + file_dialog.setFileMode(QFileDialog::ExistingFiles); + file_dialog.setNameFilter("IFC Viewer Cache (*.ifcview);;All Files (*)"); + file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (file_dialog.exec() == QDialog::Accepted) { + paths = file_dialog.selectedFiles(); + } + break; + } + case panels::add_model::SourceMode::None: + return; + } addFiles(paths); } +void MainWindow::clearScene() { + if (viewport_) viewport_->setSelectedObjectId(0); + if (properties_view_) properties_view_->clearSelection(); + + const auto model_ids = model_id_to_fed_id_.keys(); + for (uint32_t mid : model_ids) { + viewport_->removeModel(mid); + loader_->removeModel(mid); + } + + fed_id_to_model_id_.clear(); + model_id_to_fed_id_.clear(); + element_registry_->clear(); +} + +bool MainWindow::confirmDiscardIfDirty() { + if (!federation_->isDirty()) return true; + const auto result = QMessageBox::question( + this, "Unsaved Project", + "The current project has unsaved changes. Save before continuing?", + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, + QMessageBox::Save); + if (result == QMessageBox::Cancel) return false; + if (result == QMessageBox::Save) return saveProject(); + return true; +} + +void MainWindow::loadModelsFromPaths(const QStringList& paths, const QStringList& fed_ids) { + if (paths.isEmpty()) return; + const auto ids = loader_->addFiles(paths); + for (int i = 0; i < paths.size() && i < static_cast(ids.size()) && i < fed_ids.size(); ++i) { + fed_id_to_model_id_[fed_ids[i]] = ids[i]; + model_id_to_fed_id_[ids[i]] = fed_ids[i]; + } +} + +bool MainWindow::openProject(const QString& path) { + if (loader_ && loader_->isLoading()) { + QMessageBox::information( + this, "Open Project", + "Wait until the current model load finishes before opening another project."); + return false; + } + if (!confirmDiscardIfDirty()) return false; + + QStringList warnings; + QString err; + if (!federation_->load(path, &warnings, &err)) { + QMessageBox::warning(this, "Open Project", + QString("Could not open project:\n%1").arg(err)); + return false; + } + + clearScene(); + + QStringList paths; + QStringList fed_ids; + QStringList missing; + for (const auto& model : federation_->models()) { + if (model.source_kind != "local") continue; + if (!QFileInfo::exists(model.source_path)) { + missing << model.source_path; + continue; + } + paths << model.source_path; + fed_ids << model.id; + } + loadModelsFromPaths(paths, fed_ids); + + for (const auto& msg : missing) { + warnings << QString("Source not found, kept in project: %1").arg(msg); + } + if (!warnings.isEmpty()) { + QMessageBox::warning(this, "Open Project", + "Project opened with warnings:\n\n" + warnings.join("\n")); + } + + federation_->markClean(); + if (federation_->hasHomeView()) { + const auto& hv = federation_->homeView(); + viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), + hv.distance, hv.yaw, hv.pitch); + } + updateWindowTitle(); + setStatusMessage("Project", QFileInfo(path).fileName()); + return true; +} + +bool MainWindow::saveProject() { + if (federation_->filePath().isEmpty()) return saveProjectAs(); + + QString err; + if (!federation_->save(federation_->filePath(), &err)) { + QMessageBox::warning(this, "Save Project", + QString("Could not save project:\n%1").arg(err)); + return false; + } + updateWindowTitle(); + setStatusMessage("Project", QFileInfo(federation_->filePath()).fileName()); + return true; +} + +bool MainWindow::saveProjectAs() { + QString suggested = federation_->filePath(); + if (suggested.isEmpty()) suggested = "project.ifcfed"; + + QFileDialog file_dialog(this, "Save Project As", suggested); + file_dialog.setAcceptMode(QFileDialog::AcceptSave); + file_dialog.setFileMode(QFileDialog::AnyFile); + file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)"); + file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (file_dialog.exec() != QDialog::Accepted) return false; + QString path = file_dialog.selectedFiles().value(0); + if (path.isEmpty()) return false; + if (!path.endsWith(".ifcfed", Qt::CaseInsensitive)) path += ".ifcfed"; + + QString err; + if (!federation_->save(path, &err)) { + QMessageBox::warning(this, "Save Project", + QString("Could not save project:\n%1").arg(err)); + return false; + } + updateWindowTitle(); + setStatusMessage("Project", QFileInfo(path).fileName()); + return true; +} + +void MainWindow::updateWindowTitle() { + const QString project_path = federation_ ? federation_->filePath() : QString(); + if (project_path.isEmpty() && (!federation_ || federation_->models().empty())) { + setWindowTitle("IfcOpenShell Interface"); + } else if (project_path.isEmpty()) { + setWindowTitle("untitled[*] - IfcOpenShell Interface"); + } else { + setWindowTitle(QFileInfo(project_path).fileName() + "[*] - IfcOpenShell Interface"); + } +} + +void MainWindow::onNewProject() { + if (loader_ && loader_->isLoading()) { + QMessageBox::information( + this, "New Project", + "Wait until the current model load finishes before creating a new project."); + return; + } + if (!confirmDiscardIfDirty()) return; + clearScene(); + federation_->clear(); + updateWindowTitle(); + setStatusMessage("Project", "Untitled"); +} + +void MainWindow::onOpenProject() { + QFileDialog file_dialog(this, "Open Project"); + file_dialog.setFileMode(QFileDialog::ExistingFile); + file_dialog.setNameFilter("IFC Federation (*.ifcfed);;All Files (*)"); + file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (file_dialog.exec() != QDialog::Accepted) return; + const QString path = file_dialog.selectedFiles().value(0); + if (path.isEmpty()) return; + openProject(path); +} + +void MainWindow::onSaveProject() { + saveProject(); +} + +void MainWindow::onSaveProjectAs() { + saveProjectAs(); +} + void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { status_mode_label_->setText("Loading"); status_selection_label_->setText(display_name); diff --git a/src/interface/MainWindow.h b/src/interface/MainWindow.h index 0ca4fc317a..a50f3da91c 100644 --- a/src/interface/MainWindow.h +++ b/src/interface/MainWindow.h @@ -21,17 +21,19 @@ #ifndef IFCINTERFACE_SHELL_MAINWINDOW_H #define IFCINTERFACE_SHELL_MAINWINDOW_H +#include #include #include class QLabel; class QDockWidget; class QStackedWidget; -class QTabBar; class QToolButton; +class Federation; class ViewportWindow; class SceneLoader; namespace ifcinterface { class ElementRegistry; } +namespace ifcinterface::components { class TabBar; } namespace ifcinterface::panels::models { class ModelsPanelView; } namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelView; } namespace ifcinterface::panels::properties { class PropertiesPanelView; } @@ -54,6 +56,13 @@ private: QWidget* buildNavigateRibbonPage(); QWidget* buildInspectRibbonPage(); QWidget* buildPanelsRibbonPage(); + void clearScene(); + bool confirmDiscardIfDirty(); + void loadModelsFromPaths(const QStringList& paths, const QStringList& fed_ids); + bool openProject(const QString& path); + bool saveProject(); + bool saveProjectAs(); + void updateWindowTitle(); QToolButton* makeRibbonAction(const QString& text, const QString& icon_path); QWidget* makeRibbonGroup(const QString& title, const QList& buttons); QToolButton* makePanelToggle(const QString& text, QDockWidget* dock); @@ -69,12 +78,17 @@ private slots: void onLoadCancelled(uint32_t mid); void onLoadError(uint32_t mid, QString message); void onAllLoadsFinished(); + void onNewProject(); + void onOpenProject(); + void onSaveProject(); + void onSaveProjectAs(); private: + Federation* federation_ = nullptr; QLabel* status_mode_label_ = nullptr; QLabel* status_selection_label_ = nullptr; QLabel* status_perf_label_ = nullptr; - QTabBar* ribbon_tabs_ = nullptr; + ifcinterface::components::TabBar* ribbon_tabs_ = nullptr; QStackedWidget* ribbon_pages_ = nullptr; ViewportWindow* viewport_ = nullptr; SceneLoader* loader_ = nullptr; @@ -93,6 +107,8 @@ private: ifcinterface::panels::models::ModelsPanelView* models_view_ = nullptr; ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView* spatial_view_ = nullptr; ifcinterface::panels::properties::PropertiesPanelView* properties_view_ = nullptr; + QHash fed_id_to_model_id_; + QHash model_id_to_fed_id_; }; } // namespace ifcinterface::shell diff --git a/src/interface/components/Buttons.cpp b/src/interface/components/Buttons.cpp new file mode 100644 index 0000000000..219717bb16 --- /dev/null +++ b/src/interface/components/Buttons.cpp @@ -0,0 +1,78 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Buttons.h" + +#include "SvgIcon.h" + +#include +#include +#include +#include +#include + +namespace ifcinterface::components::buttons { + +QToolButton* makeButton(const QString& text, + const QString& icon_path, + QWidget* parent, + const QSize& minimum_size) { + auto* button = new QToolButton(parent); + button->setObjectName("ribbonButton"); + button->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + button->setIcon(components::icons::makeTintedSvgIcon(icon_path)); + button->setIconSize(QSize(20, 20)); + button->setText(text); + button->setMinimumSize(minimum_size); + button->setAutoRaise(false); + return button; +} + +QWidget* makeButtonGroup(const QString& title, + const QList& buttons, + QWidget* parent, + bool trailing_separator, + int vertical_spacing) { + auto* group = new QFrame(parent); + group->setObjectName("ribbonGroup"); + group->setProperty("separator", trailing_separator); + + auto* group_layout = new QVBoxLayout(group); + group_layout->setContentsMargins(8, 6, 8, 4); + group_layout->setSpacing(vertical_spacing); + + auto* button_row = new QHBoxLayout(); + button_row->setContentsMargins(0, 0, 0, 0); + button_row->setSpacing(4); + for (auto* button : buttons) { + button_row->addWidget(button); + } + + auto* label = new QLabel(title, group); + label->setObjectName("ribbonGroupLabel"); + label->setProperty("textRole", "secondary"); + label->setAlignment(Qt::AlignCenter); + + group_layout->addLayout(button_row); + group_layout->addWidget(label); + return group; +} + +} // namespace ifcinterface::components::buttons diff --git a/src/interface/components/Buttons.h b/src/interface/components/Buttons.h new file mode 100644 index 0000000000..9dbc0eb724 --- /dev/null +++ b/src/interface/components/Buttons.h @@ -0,0 +1,45 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_BUTTONS_H +#define IFCINTERFACE_COMPONENTS_BUTTONS_H + +#include +#include + +class QToolButton; +class QWidget; + +namespace ifcinterface::components::buttons { + +QToolButton* makeButton(const QString& text, + const QString& icon_path, + QWidget* parent, + const QSize& minimum_size = QSize(90, 54)); + +QWidget* makeButtonGroup(const QString& title, + const QList& buttons, + QWidget* parent, + bool trailing_separator = true, + int vertical_spacing = 4); + +} // namespace ifcinterface::components::buttons + +#endif diff --git a/src/interface/components/Dialog.cpp b/src/interface/components/Dialog.cpp new file mode 100644 index 0000000000..6b111fcc88 --- /dev/null +++ b/src/interface/components/Dialog.cpp @@ -0,0 +1,79 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Dialog.h" + +#include "Style.h" + +#include +#include +#include +#include + +namespace ifcinterface::components { + +Dialog::Dialog(QWidget* parent, bool scrollable) + : QDialog(parent) +{ + auto* outer_layout = new QVBoxLayout(this); + outer_layout->setContentsMargins(style::metrics::padding, + style::metrics::padding, + style::metrics::padding, + style::metrics::padding); + outer_layout->setSpacing(0); + + auto* frame = new QFrame(this); + frame->setObjectName("panel"); + auto* frame_layout = new QVBoxLayout(frame); + frame_layout->setContentsMargins(0, + style::metrics::section_body_padding, + 0, + style::metrics::section_body_padding); + frame_layout->setSpacing(0); + + if (scrollable) { + auto* scroll = new QScrollArea(frame); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + + auto* scroll_body = new QWidget(scroll); + scroll_body->setObjectName("panelScrollBody"); + body_layout_ = new QVBoxLayout(scroll_body); + body_layout_->setContentsMargins(0, 0, 0, 0); + body_layout_->setSpacing(0); + + scroll->setWidget(scroll_body); + frame_layout->addWidget(scroll); + } else { + auto* body = new QWidget(frame); + body_layout_ = new QVBoxLayout(body); + body_layout_->setContentsMargins(0, 0, 0, 0); + body_layout_->setSpacing(0); + frame_layout->addWidget(body); + } + + outer_layout->addWidget(frame); +} + +void Dialog::addBodyWidget(QWidget* widget) { + body_layout_->addWidget(widget); +} + +} // namespace ifcinterface::components diff --git a/src/interface/components/Dialog.h b/src/interface/components/Dialog.h new file mode 100644 index 0000000000..79a119adda --- /dev/null +++ b/src/interface/components/Dialog.h @@ -0,0 +1,46 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_DIALOG_H +#define IFCINTERFACE_COMPONENTS_DIALOG_H + +#include + +class QVBoxLayout; +class QWidget; + +namespace ifcinterface::components { + +class Dialog : public QDialog { + Q_OBJECT + +public: + explicit Dialog(QWidget* parent = nullptr, + bool scrollable = false); + + void addBodyWidget(QWidget* widget); + +private: + QVBoxLayout* body_layout_ = nullptr; +}; + +} // namespace ifcinterface::components + +#endif diff --git a/src/interface/components/Panel.cpp b/src/interface/components/Panel.cpp index 30ccebfa14..96bb2df3d9 100644 --- a/src/interface/components/Panel.cpp +++ b/src/interface/components/Panel.cpp @@ -95,15 +95,22 @@ Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_s auto* scroll_body = new QWidget(scroll); scroll_body->setObjectName("panelScrollBody"); - auto* scroll_body_layout = new QVBoxLayout(scroll_body); - scroll_body_layout->setContentsMargins(0, 0, 0, 0); - scroll_body_layout->setSpacing(0); - scroll_body_layout->addWidget(content); + body_layout_ = new QVBoxLayout(scroll_body); + body_layout_->setContentsMargins(0, 0, 0, 0); + body_layout_->setSpacing(0); scroll->setWidget(scroll_body); frame_layout->addWidget(scroll); } else { - frame_layout->addWidget(content); + auto* body = new QWidget(frame); + body_layout_ = new QVBoxLayout(body); + body_layout_->setContentsMargins(0, 0, 0, 0); + body_layout_->setSpacing(0); + frame_layout->addWidget(body); + } + + if (content) { + body_layout_->addWidget(content); } outer_layout->addWidget(frame); @@ -115,4 +122,8 @@ Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_s setWidget(outer); } +void Panel::addBodyWidget(QWidget* widget) { + body_layout_->addWidget(widget); +} + } // namespace ifcinterface::components diff --git a/src/interface/components/Panel.h b/src/interface/components/Panel.h index b786f636e9..70767314f8 100644 --- a/src/interface/components/Panel.h +++ b/src/interface/components/Panel.h @@ -24,6 +24,7 @@ #include class QWidget; +class QVBoxLayout; namespace ifcinterface::components { @@ -32,10 +33,15 @@ class Panel : public QDockWidget { public: explicit Panel(const QString& title, - QWidget* content, + QWidget* content = nullptr, QWidget* parent = nullptr, bool has_settings = false, bool scrollable = false); + + void addBodyWidget(QWidget* widget); + +private: + QVBoxLayout* body_layout_ = nullptr; }; } // namespace ifcinterface::components diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp index eb00179f96..e993871c0e 100644 --- a/src/interface/components/Style.cpp +++ b/src/interface/components/Style.cpp @@ -34,29 +34,39 @@ QString buildAppStyleSheet() { background: ${app_background}; color: ${primary_text}; } + QFileDialog, + QFileDialog QWidget, + QFileDialog QStackedWidget, + QFileDialog QSplitter { + background: ${app_background}; + color: ${primary_text}; + } QFrame#ribbonShell { background: ${ribbon_shell_background}; border-bottom: 1px solid ${border}; } - QTabBar::tab { + QTabBar#appTabBar { + background: ${ribbon_shell_background}; + } + QTabBar#appTabBar::tab { background: transparent; color: ${secondary_text}; padding: 8px 14px; margin-right: 2px; border-bottom: 2px solid transparent; } - QTabBar::tab:selected { + QTabBar#appTabBar::tab:selected { color: ${primary_text}; border-bottom: 2px solid ${selection_background}; } - QTabBar::tab:hover { + QTabBar#appTabBar::tab:hover { color: ${ribbon_tab_hover_text}; } QFrame#ribbonBand { background: ${ribbon_band_background}; border-top: 1px solid ${border}; } - QTabWidget::pane { + QTabWidget#appTabWidget::pane { border: none; background: transparent; } @@ -67,6 +77,9 @@ QString buildAppStyleSheet() { background: transparent; border-right: 1px solid ${border}; } + QFrame#ribbonGroup[separator="false"] { + border-right: none; + } QLabel#ribbonGroupLabel { font-size: 9px; font-weight: 600; @@ -105,6 +118,7 @@ QString buildAppStyleSheet() { QListWidget, QTableWidget, QLineEdit, + QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox, @@ -179,6 +193,21 @@ QString buildAppStyleSheet() { QLineEdit:focus { border: 1px solid ${control_border_focus}; } + QComboBox { + background: ${control_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + padding: ${padding}px ${padding}px; + } + QComboBox:focus { + border: 1px solid ${control_border_focus}; + } + QComboBox QAbstractItemView { + background: ${panel_background}; + border: 1px solid ${border}; + selection-background-color: ${selection_background}; + selection-color: ${selection_text}; + } QSpinBox, QDoubleSpinBox { background: ${control_background}; border: 1px solid ${border}; diff --git a/src/interface/components/Tabs.cpp b/src/interface/components/Tabs.cpp new file mode 100644 index 0000000000..86c4466a29 --- /dev/null +++ b/src/interface/components/Tabs.cpp @@ -0,0 +1,40 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Tabs.h" + +namespace ifcinterface::components { + +TabBar::TabBar(QWidget* parent) + : QTabBar(parent) +{ + setObjectName("appTabBar"); + setExpanding(false); + setDrawBase(false); +} + +TabWidget::TabWidget(QWidget* parent) + : QTabWidget(parent) +{ + setObjectName("appTabWidget"); + setTabBar(new TabBar(this)); +} + +} // namespace ifcinterface::components diff --git a/src/interface/components/Tabs.h b/src/interface/components/Tabs.h new file mode 100644 index 0000000000..ea70ce5f1c --- /dev/null +++ b/src/interface/components/Tabs.h @@ -0,0 +1,45 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_COMPONENTS_TABS_H +#define IFCINTERFACE_COMPONENTS_TABS_H + +#include +#include + +namespace ifcinterface::components { + +class TabBar : public QTabBar { + Q_OBJECT + +public: + explicit TabBar(QWidget* parent = nullptr); +}; + +class TabWidget : public QTabWidget { + Q_OBJECT + +public: + explicit TabWidget(QWidget* parent = nullptr); +}; + +} // namespace ifcinterface::components + +#endif diff --git a/src/interface/icons/cube-bandage.svg b/src/interface/icons/cube-bandage.svg new file mode 100644 index 0000000000..424f251a12 --- /dev/null +++ b/src/interface/icons/cube-bandage.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/interface/icons/database-restore.svg b/src/interface/icons/database-restore.svg new file mode 100644 index 0000000000..c3f1692043 --- /dev/null +++ b/src/interface/icons/database-restore.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/icons/database.svg b/src/interface/icons/database.svg new file mode 100644 index 0000000000..686e9bdd6e --- /dev/null +++ b/src/interface/icons/database.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/interface/interface_resources.qrc b/src/interface/interface_resources.qrc index fcb8a40df5..feb89ef781 100644 --- a/src/interface/interface_resources.qrc +++ b/src/interface/interface_resources.qrc @@ -43,5 +43,8 @@ icons/sidebar-expand.svg icons/check.svg icons/xmark-circle.svg + icons/database.svg + icons/database-restore.svg + icons/cube-bandage.svg diff --git a/src/interface/main.cpp b/src/interface/main.cpp index 57e40f679c..353d587f01 100644 --- a/src/interface/main.cpp +++ b/src/interface/main.cpp @@ -19,6 +19,7 @@ ********************************************************************************/ #include "MainWindow.h" +#include "components/Style.h" #include #include @@ -70,6 +71,7 @@ int main(int argc, char* argv[]) { parser.process(app); installUiFont(); + app.setStyleSheet(ifcinterface::components::style::buildAppStyleSheet()); ifcinterface::shell::MainWindow window; window.show(); diff --git a/src/interface/panels/add_model/Dialog.cpp b/src/interface/panels/add_model/Dialog.cpp new file mode 100644 index 0000000000..1987cf123f --- /dev/null +++ b/src/interface/panels/add_model/Dialog.cpp @@ -0,0 +1,140 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Dialog.h" + +#include "../../components/Dialog.h" +#include "../../components/Buttons.h" +#include "../../components/Section.h" +#include "../../components/Style.h" + +#include +#include +#include +#include +#include + +namespace ifcinterface::panels::add_model { + +namespace { + +class HoverDescriptionFilter : public QObject { +public: + HoverDescriptionFilter(QLabel* label, QString hover_text, QString default_text) + : label_(label), hover_text_(std::move(hover_text)), default_text_(std::move(default_text)) {} + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + Q_UNUSED(watched); + if (event->type() == QEvent::Enter) { + label_->setText(hover_text_); + } else if (event->type() == QEvent::Leave) { + label_->setText(default_text_); + } + return false; + } + +private: + QLabel* label_ = nullptr; + QString hover_text_; + QString default_text_; +}; + +} // namespace + +AddModelDialog::AddModelDialog(QWidget* parent) + : components::Dialog(parent) +{ + setObjectName("appDialog"); + setWindowTitle("Add Model"); + setModal(true); + setStyleSheet(components::style::buildAppStyleSheet()); + setupUi(); +} + +void AddModelDialog::setupUi() { + if (auto* root = qobject_cast(layout())) { + root->setSizeConstraint(QLayout::SetFixedSize); + } + + const QString default_description = "Choose what to add to the project"; + auto* description_section = new components::Section("", components::SectionHeaderMode::Hidden, this); + auto* description = new QLabel(default_description, description_section); + description->setProperty("textRole", "secondary"); + description->setWordWrap(true); + description->setAlignment(Qt::AlignCenter); + description->setMinimumWidth((90 * 4) + (components::style::metrics::padding * 3)); + description->setMinimumHeight(description->fontMetrics().lineSpacing() * 2 + 4); + description_section->addBodyWidget(description); + + auto* choices_section = new components::Section("", components::SectionHeaderMode::Hidden, this); + auto* choices = new QWidget(choices_section); + auto* row = new QHBoxLayout(choices); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(components::style::metrics::padding); + + auto* add_ifc = components::buttons::makeButton("Add IFC File", ":/icons/cube.svg", choices, QSize(90, 68)); + connect(add_ifc, &QToolButton::clicked, this, [this]() { + selected_mode_ = SourceMode::IfcFile; + accept(); + }); + add_ifc->installEventFilter(new HoverDescriptionFilter( + description, + "Add IFC files and load both geometry and data.", + default_description)); + + auto* add_database = components::buttons::makeButton("Add IFC\nDatabase", ":/icons/database.svg", choices, QSize(90, 68)); + connect(add_database, &QToolButton::clicked, this, [this]() { + selected_mode_ = SourceMode::IfcDatabase; + accept(); + }); + add_database->installEventFilter(new HoverDescriptionFilter( + description, + "Add IFC RDB databases for optimised performance", + default_description)); + + auto* add_geometry = components::buttons::makeButton("Add Geometry", ":/icons/cube-bandage.svg", choices, QSize(90, 68)); + connect(add_geometry, &QToolButton::clicked, this, [this]() { + selected_mode_ = SourceMode::GeometryOnly; + accept(); + }); + add_geometry->installEventFilter(new HoverDescriptionFilter( + description, + "Add pure geometry for fast visualisation", + default_description)); + + auto* convert_database = components::buttons::makeButton("Convert IFC File\nto Database", ":/icons/database-restore.svg", choices, QSize(90, 68)); + connect(convert_database, &QToolButton::clicked, this, [description]() { + description->setText("IFC-to-database conversion is coming soon."); + }); + convert_database->installEventFilter(new HoverDescriptionFilter( + description, + "Convert IFC files to databases for smaller filesizes, reduced memory, and faster access. No data is lost.", + default_description)); + + row->addWidget(components::buttons::makeButtonGroup("ADD", {add_ifc, add_database, add_geometry}, choices, true, 8)); + row->addWidget(components::buttons::makeButtonGroup("TOOLS", {convert_database}, choices, false, 8)); + choices_section->addBodyWidget(choices); + + addBodyWidget(description_section); + addBodyWidget(choices_section); +} + +} // namespace ifcinterface::panels::add_model diff --git a/src/interface/panels/add_model/Dialog.h b/src/interface/panels/add_model/Dialog.h new file mode 100644 index 0000000000..5e39e855de --- /dev/null +++ b/src/interface/panels/add_model/Dialog.h @@ -0,0 +1,50 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_ADDMODELDIALOG_H +#define IFCINTERFACE_PANELS_ADDMODELDIALOG_H + +#include "../../components/Dialog.h" + +namespace ifcinterface::panels::add_model { + +enum class SourceMode { + None, + IfcFile, + IfcDatabase, + GeometryOnly, +}; + +class AddModelDialog : public components::Dialog { + Q_OBJECT +public: + explicit AddModelDialog(QWidget* parent = nullptr); + + SourceMode selectedMode() const { return selected_mode_; } + +private: + void setupUi(); + + SourceMode selected_mode_ = SourceMode::None; +}; + +} // namespace ifcinterface::panels::add_model + +#endif diff --git a/src/interface/panels/properties/View.cpp b/src/interface/panels/properties/View.cpp index 3b58dc4ca9..c6535e3d80 100644 --- a/src/interface/panels/properties/View.cpp +++ b/src/interface/panels/properties/View.cpp @@ -40,6 +40,10 @@ PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, refresh(0); } +void PropertiesPanelView::clearSelection() { + refresh(0); +} + void PropertiesPanelView::refresh(uint32_t object_id) { PropertiesPanelState state; state.entity = {"IfcWall", "SOLIDWALL"}; diff --git a/src/interface/panels/properties/View.h b/src/interface/panels/properties/View.h index c38120fc5a..f68b1dfc45 100644 --- a/src/interface/panels/properties/View.h +++ b/src/interface/panels/properties/View.h @@ -38,6 +38,7 @@ public: ViewportWindow* viewport, ifcinterface::ElementRegistry* registry, QObject* parent = nullptr); + void clearSelection(); private: void refresh(uint32_t object_id); diff --git a/src/interface/panels/properties/Widget.cpp b/src/interface/panels/properties/Widget.cpp index 0afb0bc89f..316488c760 100644 --- a/src/interface/panels/properties/Widget.cpp +++ b/src/interface/panels/properties/Widget.cpp @@ -35,6 +35,13 @@ namespace { +void clearLayout(QVBoxLayout* layout) { + while (auto* item = layout->takeAt(0)) { + if (auto* widget = item->widget()) widget->deleteLater(); + delete item; + } +} + QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { auto* group = new QGroupBox(property_set.title, parent); group->setObjectName("propertySetBox"); @@ -91,6 +98,34 @@ QWidget* makeFilterWrapper(QLineEdit** field_out, QWidget* parent = nullptr) { return wrapper; } +QFrame* makeEntityBox(const ifcinterface::panels::properties::EntitySummary& entity, QWidget* parent = nullptr) { + auto* entity_box = new QFrame(parent); + entity_box->setObjectName("entityClassBox"); + auto* entity_layout = new QHBoxLayout(entity_box); + entity_layout->setContentsMargins(10, 8, 10, 8); + entity_layout->setSpacing(10); + + auto* entity_icon = new QLabel(entity_box); + entity_icon->setPixmap(ifcinterface::components::icons::makeSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); + entity_icon->setAlignment(Qt::AlignCenter); + + auto* entity_text = new QWidget(entity_box); + auto* entity_text_layout = new QVBoxLayout(entity_text); + entity_text_layout->setContentsMargins(0, 0, 0, 0); + entity_text_layout->setSpacing(2); + + auto* entity_class_label = new QLabel(entity.entity_class, entity_text); + entity_class_label->setObjectName("entityClassLabel"); + auto* entity_type_label = new QLabel(entity.predefined_type, entity_text); + entity_type_label->setProperty("textRole", "secondary"); + + entity_text_layout->addWidget(entity_class_label); + entity_text_layout->addWidget(entity_type_label); + entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); + entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); + return entity_box; +} + } // namespace namespace ifcinterface::panels::properties { @@ -101,93 +136,10 @@ PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) content_layout_ = new QVBoxLayout(this); content_layout_->setContentsMargins(0, 0, 0, 0); content_layout_->setSpacing(12); - - auto* entity_box = new QFrame(this); - entity_box->setObjectName("entityClassBox"); - auto* entity_layout = new QHBoxLayout(entity_box); - entity_layout->setContentsMargins(10, 8, 10, 8); - entity_layout->setSpacing(10); - auto* entity_icon = new QLabel(entity_box); - entity_icon->setPixmap(components::icons::makeSvgPixmap(":/icons/cube-dots.svg", QSize(28, 28))); - entity_icon->setAlignment(Qt::AlignCenter); - auto* entity_text = new QWidget(entity_box); - auto* entity_text_layout = new QVBoxLayout(entity_text); - entity_text_layout->setContentsMargins(0, 0, 0, 0); - entity_text_layout->setSpacing(2); - entity_class_label_ = new QLabel(entity_text); - entity_class_label_->setObjectName("entityClassLabel"); - entity_type_label_ = new QLabel(entity_text); - entity_type_label_->setProperty("textRole", "secondary"); - entity_text_layout->addWidget(entity_class_label_); - entity_text_layout->addWidget(entity_type_label_); - entity_layout->addWidget(entity_icon, 0, Qt::AlignVCenter); - entity_layout->addWidget(entity_text, 1, Qt::AlignVCenter); - - entity_section_ = new components::Section("", components::SectionHeaderMode::Hidden, this); - entity_section_->addBodyWidget(entity_box); - attributes_section_ = new components::Section("Attributes", components::SectionHeaderMode::Visible, this); - relationships_section_ = new components::Section("Relationships", components::SectionHeaderMode::Visible, this); - properties_section_ = new components::Section("Properties", components::SectionHeaderMode::Visible, this); - quantities_section_ = new components::Section("Quantities", components::SectionHeaderMode::Visible, this); - - properties_filter_toggle_ = new QToolButton(properties_section_); - properties_filter_toggle_->setObjectName("panelSectionFilterToggle"); - properties_filter_toggle_->setCheckable(true); - properties_filter_toggle_->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg")); - properties_filter_toggle_->setAutoRaise(true); - properties_section_->addHeaderWidget(properties_filter_toggle_); - - quantities_filter_toggle_ = new QToolButton(quantities_section_); - quantities_filter_toggle_->setObjectName("panelSectionFilterToggle"); - quantities_filter_toggle_->setCheckable(true); - quantities_filter_toggle_->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg")); - quantities_filter_toggle_->setAutoRaise(true); - quantities_section_->addHeaderWidget(quantities_filter_toggle_); - - properties_filter_wrapper_ = makeFilterWrapper(&properties_filter_field_, properties_section_); - properties_filter_field_->setPlaceholderText("Filter properties or sets"); - quantities_filter_wrapper_ = makeFilterWrapper(&quantities_filter_field_, quantities_section_); - quantities_filter_field_->setPlaceholderText("Filter quantities or sets"); - - connect(properties_filter_toggle_, &QToolButton::toggled, properties_filter_field_, [this](bool visible) { - properties_filter_field_->setVisible(visible); - properties_filter_wrapper_->setVisible(visible); - if (visible) properties_filter_field_->setFocus(); - }); - connect(quantities_filter_toggle_, &QToolButton::toggled, quantities_filter_field_, [this](bool visible) { - quantities_filter_field_->setVisible(visible); - quantities_filter_wrapper_->setVisible(visible); - if (visible) quantities_filter_field_->setFocus(); - }); - - properties_filter_wrapper_->setVisible(false); - quantities_filter_wrapper_->setVisible(false); - - content_layout_->addWidget(entity_section_); - content_layout_->addWidget(attributes_section_); - content_layout_->addWidget(relationships_section_); - content_layout_->addWidget(properties_section_); - content_layout_->addWidget(quantities_section_); - content_layout_->addStretch(1); } void PropertiesPanelWidget::render(const PropertiesPanelState& state) { - const bool attributes_expanded = attributes_section_->isExpanded(); - const bool relationships_expanded = relationships_section_->isExpanded(); - const bool properties_expanded = properties_section_->isExpanded(); - const bool quantities_expanded = quantities_section_->isExpanded(); - const bool properties_filter_visible = properties_filter_toggle_->isChecked(); - const bool quantities_filter_visible = quantities_filter_toggle_->isChecked(); - const QString properties_filter_text = properties_filter_field_->text(); - const QString quantities_filter_text = quantities_filter_field_->text(); - - entity_class_label_->setText(state.entity.entity_class); - entity_type_label_->setText(state.entity.predefined_type); - - attributes_section_->clearBody(); - relationships_section_->clearBody(); - properties_section_->clearBody(); - quantities_section_->clearBody(); + clearLayout(content_layout_); QList property_set_widgets; for (const auto& property_set : state.property_sets) { @@ -199,21 +151,98 @@ void PropertiesPanelWidget::render(const PropertiesPanelState& state) { quantity_set_widgets.append(makePropertySetPanel(property_set, this)); } - attributes_section_->addBodyWidget(makeAttributeList(state.attributes, this)); - relationships_section_->addBodyWidget(makeRelationshipList(state.relationships, this)); - properties_section_->addBodyWidget(properties_filter_wrapper_); - for (auto* widget : property_set_widgets) properties_section_->addBodyWidget(widget); - quantities_section_->addBodyWidget(quantities_filter_wrapper_); - for (auto* widget : quantity_set_widgets) quantities_section_->addBodyWidget(widget); + auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, this); + entity_section->addBodyWidget(makeEntityBox(state.entity, this)); - attributes_section_->setExpanded(attributes_expanded); - relationships_section_->setExpanded(relationships_expanded); - properties_section_->setExpanded(properties_expanded); - quantities_section_->setExpanded(quantities_expanded); - properties_filter_field_->setText(properties_filter_text); - quantities_filter_field_->setText(quantities_filter_text); - properties_filter_toggle_->setChecked(properties_filter_visible); - quantities_filter_toggle_->setChecked(quantities_filter_visible); + auto* attributes_section = new components::Section("Attributes", components::SectionHeaderMode::Visible, this); + attributes_section->addBodyWidget(makeAttributeList(state.attributes, this)); + attributes_section->setExpanded(attributes_expanded_); + + auto* relationships_section = new components::Section("Relationships", components::SectionHeaderMode::Visible, this); + relationships_section->addBodyWidget(makeRelationshipList(state.relationships, this)); + relationships_section->setExpanded(relationships_expanded_); + + auto* properties_section = new components::Section("Properties", components::SectionHeaderMode::Visible, this); + auto* properties_filter_toggle = new QToolButton(properties_section); + properties_filter_toggle->setObjectName("panelSectionFilterToggle"); + properties_filter_toggle->setCheckable(true); + properties_filter_toggle->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg")); + properties_filter_toggle->setAutoRaise(true); + properties_section->addHeaderWidget(properties_filter_toggle); + QLineEdit* properties_filter_field = nullptr; + auto* properties_filter_wrapper = makeFilterWrapper(&properties_filter_field, properties_section); + properties_filter_field->setPlaceholderText("Filter properties or sets"); + properties_filter_field->setText(properties_filter_text_); + properties_filter_wrapper->setVisible(properties_filter_visible_); + properties_filter_field->setVisible(properties_filter_visible_); + connect(properties_filter_toggle, &QToolButton::toggled, properties_filter_field, [this, properties_filter_field, properties_filter_wrapper](bool visible) { + properties_filter_visible_ = visible; + properties_filter_field->setVisible(visible); + properties_filter_wrapper->setVisible(visible); + if (visible) properties_filter_field->setFocus(); + }); + connect(properties_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) { + properties_filter_text_ = text; + }); + properties_section->addBodyWidget(properties_filter_wrapper); + for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); + properties_section->setExpanded(properties_expanded_); + properties_filter_toggle->setChecked(properties_filter_visible_); + + auto* quantities_section = new components::Section("Quantities", components::SectionHeaderMode::Visible, this); + auto* quantities_filter_toggle = new QToolButton(quantities_section); + quantities_filter_toggle->setObjectName("panelSectionFilterToggle"); + quantities_filter_toggle->setCheckable(true); + quantities_filter_toggle->setIcon(components::icons::makeSvgIcon(":/icons/filter.svg")); + quantities_filter_toggle->setAutoRaise(true); + quantities_section->addHeaderWidget(quantities_filter_toggle); + QLineEdit* quantities_filter_field = nullptr; + auto* quantities_filter_wrapper = makeFilterWrapper(&quantities_filter_field, quantities_section); + quantities_filter_field->setPlaceholderText("Filter quantities or sets"); + quantities_filter_field->setText(quantities_filter_text_); + quantities_filter_wrapper->setVisible(quantities_filter_visible_); + quantities_filter_field->setVisible(quantities_filter_visible_); + connect(quantities_filter_toggle, &QToolButton::toggled, quantities_filter_field, [this, quantities_filter_field, quantities_filter_wrapper](bool visible) { + quantities_filter_visible_ = visible; + quantities_filter_field->setVisible(visible); + quantities_filter_wrapper->setVisible(visible); + if (visible) quantities_filter_field->setFocus(); + }); + connect(quantities_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) { + quantities_filter_text_ = text; + }); + quantities_section->addBodyWidget(quantities_filter_wrapper); + for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); + quantities_section->setExpanded(quantities_expanded_); + quantities_filter_toggle->setChecked(quantities_filter_visible_); + + if (auto* button = attributes_section->findChild("panelSectionHeaderButton")) { + connect(button, &QToolButton::toggled, this, [this](bool expanded) { + attributes_expanded_ = expanded; + }); + } + if (auto* button = relationships_section->findChild("panelSectionHeaderButton")) { + connect(button, &QToolButton::toggled, this, [this](bool expanded) { + relationships_expanded_ = expanded; + }); + } + if (auto* button = properties_section->findChild("panelSectionHeaderButton")) { + connect(button, &QToolButton::toggled, this, [this](bool expanded) { + properties_expanded_ = expanded; + }); + } + if (auto* button = quantities_section->findChild("panelSectionHeaderButton")) { + connect(button, &QToolButton::toggled, this, [this](bool expanded) { + quantities_expanded_ = expanded; + }); + } + + content_layout_->addWidget(entity_section); + content_layout_->addWidget(attributes_section); + content_layout_->addWidget(relationships_section); + content_layout_->addWidget(properties_section); + content_layout_->addWidget(quantities_section); + content_layout_->addStretch(1); } } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/Widget.h b/src/interface/panels/properties/Widget.h index f5dc78b19a..bc15e447ca 100644 --- a/src/interface/panels/properties/Widget.h +++ b/src/interface/panels/properties/Widget.h @@ -23,6 +23,7 @@ #include "Types.h" +#include #include class QVBoxLayout; @@ -42,19 +43,14 @@ public: private: QVBoxLayout* content_layout_ = nullptr; - QLabel* entity_class_label_ = nullptr; - QLabel* entity_type_label_ = nullptr; - components::Section* entity_section_ = nullptr; - components::Section* attributes_section_ = nullptr; - components::Section* relationships_section_ = nullptr; - components::Section* properties_section_ = nullptr; - components::Section* quantities_section_ = nullptr; - QWidget* properties_filter_wrapper_ = nullptr; - QWidget* quantities_filter_wrapper_ = nullptr; - QLineEdit* properties_filter_field_ = nullptr; - QLineEdit* quantities_filter_field_ = nullptr; - QToolButton* properties_filter_toggle_ = nullptr; - QToolButton* quantities_filter_toggle_ = nullptr; + bool attributes_expanded_ = true; + bool relationships_expanded_ = true; + bool properties_expanded_ = true; + bool quantities_expanded_ = true; + bool properties_filter_visible_ = false; + bool quantities_filter_visible_ = false; + QString properties_filter_text_; + QString quantities_filter_text_; }; } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/settings/Dialog.cpp b/src/interface/panels/settings/Dialog.cpp index e181d25db1..33c6ee75cb 100644 --- a/src/interface/panels/settings/Dialog.cpp +++ b/src/interface/panels/settings/Dialog.cpp @@ -21,9 +21,11 @@ #include "Dialog.h" #include "../../../ifcviewer/AppSettings.h" +#include "../../components/Dialog.h" #include "../../components/Section.h" #include "../../components/Style.h" #include "../../components/SvgIcon.h" +#include "../../components/Tabs.h" #include #include @@ -35,13 +37,12 @@ #include #include #include -#include #include namespace ifcinterface::panels::settings { SettingsDialog::SettingsDialog(QWidget* parent) - : QDialog(parent) + : components::Dialog(parent) { setObjectName("appDialog"); setWindowTitle("Settings"); @@ -57,23 +58,12 @@ void SettingsDialog::showEvent(QShowEvent* event) { } void SettingsDialog::setupUi() { - auto* root = new QVBoxLayout(this); - root->setContentsMargins(12, 12, 12, 12); - root->setSpacing(0); - - auto* panel = new QFrame(this); - panel->setObjectName("panel"); - auto* panel_layout = new QVBoxLayout(panel); - panel_layout->setContentsMargins(0, components::style::metrics::section_body_padding, 0, - components::style::metrics::section_body_padding); - panel_layout->setSpacing(12); - - auto* tabs = new QTabWidget(panel); + auto* tabs = new components::TabWidget(this); auto* graphics_tab = new QWidget(tabs); auto* graphics_layout = new QVBoxLayout(graphics_tab); graphics_layout->setContentsMargins(0, 0, 0, 0); - graphics_layout->setSpacing(12); + graphics_layout->setSpacing(components::style::metrics::padding); auto* general_section = new components::Section("General", components::SectionHeaderMode::Visible, graphics_tab); auto* general_body = new QWidget(general_section); @@ -148,7 +138,7 @@ void SettingsDialog::setupUi() { auto* tab = new QWidget(tabs); auto* layout = new QVBoxLayout(tab); layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(12); + layout->setSpacing(components::style::metrics::padding); auto* section = new components::Section(title, components::SectionHeaderMode::Visible, tab); auto* body = new QWidget(section); @@ -179,7 +169,7 @@ void SettingsDialog::setupUi() { tabs->addTab(make_placeholder_tab("About", "Version, credits, and environment information will live here."), "About"); - auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, panel); + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); if (auto* ok = buttons->button(QDialogButtonBox::Ok)) { ok->setText("OK"); ok->setIcon(components::icons::makeSvgIcon(":/icons/check.svg")); @@ -191,9 +181,11 @@ void SettingsDialog::setupUi() { connect(buttons, &QDialogButtonBox::accepted, this, &SettingsDialog::onAccepted); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); - panel_layout->addWidget(tabs); - panel_layout->addWidget(buttons); - root->addWidget(panel); + auto* actions_section = new components::Section("", components::SectionHeaderMode::Hidden, this); + actions_section->addBodyWidget(buttons); + + addBodyWidget(tabs); + addBodyWidget(actions_section); } void SettingsDialog::syncFromSettings() { diff --git a/src/interface/panels/settings/Dialog.h b/src/interface/panels/settings/Dialog.h index 3b34ad8687..d97fab3db6 100644 --- a/src/interface/panels/settings/Dialog.h +++ b/src/interface/panels/settings/Dialog.h @@ -21,7 +21,7 @@ #ifndef IFCINTERFACE_PANELS_SETTINGSDIALOG_H #define IFCINTERFACE_PANELS_SETTINGSDIALOG_H -#include +#include "../../components/Dialog.h" class QCheckBox; class QDoubleSpinBox; @@ -31,7 +31,7 @@ class QSpinBox; namespace ifcinterface::panels::settings { -class SettingsDialog : public QDialog { +class SettingsDialog : public components::Dialog { Q_OBJECT public: explicit SettingsDialog(QWidget* parent = nullptr); From a340e6cf9a62ec35c8369a84ad8c747f325a44ba Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 14:12:27 +1000 Subject: [PATCH 117/120] Interface mockup 10 --- src/interface/CMakeLists.txt | 8 ++++++ src/interface/MainWindow.cpp | 15 +++++------ src/interface/MainWindow.h | 9 ++++--- src/interface/components/Dialog.cpp | 4 +-- src/interface/components/Panel.cpp | 11 ++++++-- src/interface/components/Panel.h | 1 + src/interface/components/Style.cpp | 6 +++++ src/interface/panels/properties/Widget.cpp | 25 ++++++------------- src/interface/panels/properties/Widget.h | 10 +++----- .../panels/spatial_hierarchy/Widget.cpp | 9 ++----- .../panels/spatial_hierarchy/Widget.h | 4 +-- 11 files changed, 53 insertions(+), 49 deletions(-) diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index 71460abcf4..d18066b5b6 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -33,12 +33,20 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.h ${CMAKE_CURRENT_SOURCE_DIR}/components/Style.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/Style.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/Dialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/Dialog.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/Tabs.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/Tabs.h ${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/KeyValueTable.h + ${CMAKE_CURRENT_SOURCE_DIR}/components/Buttons.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/components/Buttons.h ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/Section.h ${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/components/Panel.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/add_model/Dialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/add_model/Dialog.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Types.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Widget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Widget.h diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 4185c2b016..901fcbf806 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -348,23 +348,20 @@ void MainWindow::setupViewport() { } void MainWindow::setupPanels() { - auto* models_widget = new panels::models::ModelsPanelWidget(this); - auto* spatial_widget = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); - auto* properties_widget = new panels::properties::PropertiesPanelWidget(this); + models_panel_ = new panels::models::ModelsPanelWidget(this); + spatial_panel_ = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); + properties_panel_ = new panels::properties::PropertiesPanelWidget(this); - models_view_ = new panels::models::ModelsPanelView(models_widget, this); - spatial_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_widget, this); + models_view_ = new panels::models::ModelsPanelView(models_panel_, this); + spatial_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, this); properties_view_ = new panels::properties::PropertiesPanelView( - properties_widget, viewport_, element_registry_, this); + properties_panel_, viewport_, element_registry_, this); connect(models_view_, &panels::models::ModelsPanelView::statusMessageRequested, this, &MainWindow::setStatusMessage); connect(spatial_view_, &panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, this, &MainWindow::setStatusMessage); - models_panel_ = new components::Panel("Models", models_widget, this, true); - spatial_panel_ = new components::Panel("Spatial Hierarchy", spatial_widget, this); - properties_panel_ = new components::Panel("Properties", properties_widget, this, false, true); layers_panel_ = new components::Panel("Layers", new panels::todo::TodoPanelWidget("Layers", this), this); stored_views_panel_ = new components::Panel( "Stored Views", new panels::todo::TodoPanelWidget("Stored Views", this), this); diff --git a/src/interface/MainWindow.h b/src/interface/MainWindow.h index a50f3da91c..f42f0bb854 100644 --- a/src/interface/MainWindow.h +++ b/src/interface/MainWindow.h @@ -34,8 +34,11 @@ class ViewportWindow; class SceneLoader; namespace ifcinterface { class ElementRegistry; } namespace ifcinterface::components { class TabBar; } +namespace ifcinterface::panels::models { class ModelsPanelWidget; } namespace ifcinterface::panels::models { class ModelsPanelView; } +namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelWidget; } namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelView; } +namespace ifcinterface::panels::properties { class PropertiesPanelWidget; } namespace ifcinterface::panels::properties { class PropertiesPanelView; } namespace ifcinterface::shell { @@ -94,10 +97,10 @@ private: SceneLoader* loader_ = nullptr; ifcinterface::ElementRegistry* element_registry_ = nullptr; QWidget* viewport_container_ = nullptr; - QDockWidget* models_panel_ = nullptr; - QDockWidget* spatial_panel_ = nullptr; + ifcinterface::panels::models::ModelsPanelWidget* models_panel_ = nullptr; + ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelWidget* spatial_panel_ = nullptr; QDockWidget* layers_panel_ = nullptr; - QDockWidget* properties_panel_ = nullptr; + ifcinterface::panels::properties::PropertiesPanelWidget* properties_panel_ = nullptr; QDockWidget* stored_views_panel_ = nullptr; QDockWidget* search_panel_ = nullptr; QDockWidget* spreadsheet_panel_ = nullptr; diff --git a/src/interface/components/Dialog.cpp b/src/interface/components/Dialog.cpp index 6b111fcc88..410e3a581b 100644 --- a/src/interface/components/Dialog.cpp +++ b/src/interface/components/Dialog.cpp @@ -57,7 +57,7 @@ Dialog::Dialog(QWidget* parent, bool scrollable) scroll_body->setObjectName("panelScrollBody"); body_layout_ = new QVBoxLayout(scroll_body); body_layout_->setContentsMargins(0, 0, 0, 0); - body_layout_->setSpacing(0); + body_layout_->setSpacing(style::metrics::section_body_padding); scroll->setWidget(scroll_body); frame_layout->addWidget(scroll); @@ -65,7 +65,7 @@ Dialog::Dialog(QWidget* parent, bool scrollable) auto* body = new QWidget(frame); body_layout_ = new QVBoxLayout(body); body_layout_->setContentsMargins(0, 0, 0, 0); - body_layout_->setSpacing(0); + body_layout_->setSpacing(style::metrics::section_body_padding); frame_layout->addWidget(body); } diff --git a/src/interface/components/Panel.cpp b/src/interface/components/Panel.cpp index 96bb2df3d9..7ba96631d6 100644 --- a/src/interface/components/Panel.cpp +++ b/src/interface/components/Panel.cpp @@ -97,7 +97,7 @@ Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_s scroll_body->setObjectName("panelScrollBody"); body_layout_ = new QVBoxLayout(scroll_body); body_layout_->setContentsMargins(0, 0, 0, 0); - body_layout_->setSpacing(0); + body_layout_->setSpacing(style::metrics::section_body_padding); scroll->setWidget(scroll_body); frame_layout->addWidget(scroll); @@ -105,7 +105,7 @@ Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_s auto* body = new QWidget(frame); body_layout_ = new QVBoxLayout(body); body_layout_->setContentsMargins(0, 0, 0, 0); - body_layout_->setSpacing(0); + body_layout_->setSpacing(style::metrics::section_body_padding); frame_layout->addWidget(body); } @@ -126,4 +126,11 @@ void Panel::addBodyWidget(QWidget* widget) { body_layout_->addWidget(widget); } +void Panel::clearBodyWidgets() { + while (auto* item = body_layout_->takeAt(0)) { + if (auto* widget = item->widget()) widget->deleteLater(); + delete item; + } +} + } // namespace ifcinterface::components diff --git a/src/interface/components/Panel.h b/src/interface/components/Panel.h index 70767314f8..fe48df51a5 100644 --- a/src/interface/components/Panel.h +++ b/src/interface/components/Panel.h @@ -39,6 +39,7 @@ public: bool scrollable = false); void addBodyWidget(QWidget* widget); + void clearBodyWidgets(); private: QVBoxLayout* body_layout_ = nullptr; diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp index e993871c0e..f7b7c5f14b 100644 --- a/src/interface/components/Style.cpp +++ b/src/interface/components/Style.cpp @@ -34,6 +34,12 @@ QString buildAppStyleSheet() { background: ${app_background}; color: ${primary_text}; } + QMessageBox, + QMessageBox QWidget, + QMessageBox QLabel { + background: ${app_background}; + color: ${primary_text}; + } QFileDialog, QFileDialog QWidget, QFileDialog QStackedWidget, diff --git a/src/interface/panels/properties/Widget.cpp b/src/interface/panels/properties/Widget.cpp index 316488c760..515da7554a 100644 --- a/src/interface/panels/properties/Widget.cpp +++ b/src/interface/panels/properties/Widget.cpp @@ -35,13 +35,6 @@ namespace { -void clearLayout(QVBoxLayout* layout) { - while (auto* item = layout->takeAt(0)) { - if (auto* widget = item->widget()) widget->deleteLater(); - delete item; - } -} - QWidget* makePropertySetPanel(const ifcinterface::panels::properties::PropertySet& property_set, QWidget* parent = nullptr) { auto* group = new QGroupBox(property_set.title, parent); group->setObjectName("propertySetBox"); @@ -131,15 +124,12 @@ QFrame* makeEntityBox(const ifcinterface::panels::properties::EntitySummary& ent namespace ifcinterface::panels::properties { PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) - : QWidget(parent) + : components::Panel("Properties", nullptr, parent, false, true) { - content_layout_ = new QVBoxLayout(this); - content_layout_->setContentsMargins(0, 0, 0, 0); - content_layout_->setSpacing(12); } void PropertiesPanelWidget::render(const PropertiesPanelState& state) { - clearLayout(content_layout_); + clearBodyWidgets(); QList property_set_widgets; for (const auto& property_set : state.property_sets) { @@ -237,12 +227,11 @@ void PropertiesPanelWidget::render(const PropertiesPanelState& state) { }); } - content_layout_->addWidget(entity_section); - content_layout_->addWidget(attributes_section); - content_layout_->addWidget(relationships_section); - content_layout_->addWidget(properties_section); - content_layout_->addWidget(quantities_section); - content_layout_->addStretch(1); + addBodyWidget(entity_section); + addBodyWidget(attributes_section); + addBodyWidget(relationships_section); + addBodyWidget(properties_section); + addBodyWidget(quantities_section); } } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/Widget.h b/src/interface/panels/properties/Widget.h index bc15e447ca..752b339a19 100644 --- a/src/interface/panels/properties/Widget.h +++ b/src/interface/panels/properties/Widget.h @@ -23,18 +23,17 @@ #include "Types.h" -#include -#include +#include "../../components/Panel.h" + +#include -class QVBoxLayout; class QLabel; class QLineEdit; class QToolButton; -namespace ifcinterface::components { class Section; } namespace ifcinterface::panels::properties { -class PropertiesPanelWidget : public QWidget { +class PropertiesPanelWidget : public components::Panel { Q_OBJECT public: explicit PropertiesPanelWidget(QWidget* parent = nullptr); @@ -42,7 +41,6 @@ public: void render(const PropertiesPanelState& state); private: - QVBoxLayout* content_layout_ = nullptr; bool attributes_expanded_ = true; bool relationships_expanded_ = true; bool properties_expanded_ = true; diff --git a/src/interface/panels/spatial_hierarchy/Widget.cpp b/src/interface/panels/spatial_hierarchy/Widget.cpp index 72230dd247..39f1efe4d9 100644 --- a/src/interface/panels/spatial_hierarchy/Widget.cpp +++ b/src/interface/panels/spatial_hierarchy/Widget.cpp @@ -26,17 +26,12 @@ #include #include #include -#include namespace ifcinterface::panels::spatial_hierarchy { SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) - : QWidget(parent) + : components::Panel("Spatial Hierarchy", nullptr, parent) { - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); - auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this); tree_ = new QTreeWidget(section); @@ -51,7 +46,7 @@ SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) tree_->header()->resizeSection(1, 28); tree_->header()->hide(); section->addBodyWidget(tree_); - layout->addWidget(section); + addBodyWidget(section); connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { if (!item || column != 1) return; diff --git a/src/interface/panels/spatial_hierarchy/Widget.h b/src/interface/panels/spatial_hierarchy/Widget.h index 134b78c5ce..5cb9c9610c 100644 --- a/src/interface/panels/spatial_hierarchy/Widget.h +++ b/src/interface/panels/spatial_hierarchy/Widget.h @@ -23,14 +23,14 @@ #include "Types.h" -#include +#include "../../components/Panel.h" class QTreeWidget; class QTreeWidgetItem; namespace ifcinterface::panels::spatial_hierarchy { -class SpatialHierarchyPanelWidget : public QWidget { +class SpatialHierarchyPanelWidget : public components::Panel { Q_OBJECT public: explicit SpatialHierarchyPanelWidget(QWidget* parent = nullptr); From d565dc3ff3b4f04c6a54ee4881999c4a4014b0e3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 14:45:23 +1000 Subject: [PATCH 118/120] Interface mockup 11 --- src/interface/CMakeLists.txt | 4 + src/interface/MainWindow.cpp | 94 +++++++------ src/interface/MainWindow.h | 9 +- src/interface/panels/viewport/Controller.cpp | 138 +++++++++++++++++++ src/interface/panels/viewport/Controller.h | 60 ++++++++ src/interface/panels/viewport/Widget.cpp | 60 ++++++++ src/interface/panels/viewport/Widget.h | 45 ++++++ 7 files changed, 366 insertions(+), 44 deletions(-) create mode 100644 src/interface/panels/viewport/Controller.cpp create mode 100644 src/interface/panels/viewport/Controller.h create mode 100644 src/interface/panels/viewport/Widget.cpp create mode 100644 src/interface/panels/viewport/Widget.h diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index d18066b5b6..5eee8a0824 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -66,6 +66,10 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/Widget.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/View.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/spatial_hierarchy/View.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/viewport/Widget.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/viewport/Widget.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/viewport/Controller.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/viewport/Controller.h ${CMAKE_CURRENT_SOURCE_DIR}/interface_resources.qrc ) diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 901fcbf806..96f311401e 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -38,11 +38,12 @@ #include "panels/settings/Dialog.h" #include "panels/spatial_hierarchy/View.h" #include "panels/spatial_hierarchy/Widget.h" +#include "panels/viewport/Controller.h" +#include "panels/viewport/Widget.h" #include #include #include -#include #include #include #include @@ -163,33 +164,29 @@ QWidget* MainWindow::buildNavigateRibbonPage() { row->setSpacing(0); auto* set_home = makeRibbonAction("Set Home", ":/icons/home.svg"); - connect(set_home, &QToolButton::clicked, this, [this]() { - setStatusMessage("Camera", "Set home view coming soon"); - }); + connect(set_home, &QToolButton::clicked, this, &MainWindow::onSetHomeView); auto* go_home = makeRibbonAction("Go Home", ":/icons/home-alt.svg"); - connect(go_home, &QToolButton::clicked, this, [this]() { - setStatusMessage("Camera", "Go to home view coming soon"); - }); + connect(go_home, &QToolButton::clicked, this, &MainWindow::onGoHomeView); auto* view_all = makeRibbonAction("View All", ":/icons/cube-scan.svg"); connect(view_all, &QToolButton::clicked, this, [this]() { - if (viewport_) viewport_->viewAll(); + if (viewport_widget_) viewport_widget_->viewport()->viewAll(); }); auto* view_selected = makeRibbonAction("View Selected", ":/icons/cube-scan-solid.svg"); connect(view_selected, &QToolButton::clicked, this, [this]() { - if (viewport_) viewport_->focusOnSelectedObject(); + if (viewport_widget_) viewport_widget_->viewport()->focusOnSelectedObject(); }); auto* plan_view = makeRibbonAction("Plan", ":/icons/planimetry.svg"); connect(plan_view, &QToolButton::clicked, this, [this]() { - if (viewport_) viewport_->setStandardView(90.0f, 90.0f); + if (viewport_widget_) viewport_widget_->viewport()->setStandardView(90.0f, 90.0f); }); auto* front_view = makeRibbonAction("Front", ":/icons/city.svg"); connect(front_view, &QToolButton::clicked, this, [this]() { - if (viewport_) viewport_->setStandardView(0.0f, 0.0f); + if (viewport_widget_) viewport_widget_->viewport()->setStandardView(0.0f, 0.0f); }); auto* side_view = makeRibbonAction("Side", ":/icons/building.svg"); connect(side_view, &QToolButton::clicked, this, [this]() { - if (viewport_) viewport_->setStandardView(90.0f, 0.0f); + if (viewport_widget_) viewport_widget_->viewport()->setStandardView(90.0f, 0.0f); }); auto* align_object = makeRibbonAction("Align Object", ":/icons/cellar.svg"); connect(align_object, &QToolButton::clicked, this, [this]() { @@ -197,9 +194,10 @@ QWidget* MainWindow::buildNavigateRibbonPage() { }); auto* projection_button = makeRibbonAction("Perspective", ":/icons/perspective-view.svg"); connect(projection_button, &QToolButton::clicked, this, [this, projection_button]() { - if (!viewport_) return; - viewport_->toggleProjection(); - projection_button->setText(viewport_->projectionOrtho() ? "Ortho" : "Perspective"); + if (!viewport_widget_) return; + viewport_widget_->viewport()->toggleProjection(); + projection_button->setText( + viewport_widget_->viewport()->projectionOrtho() ? "Ortho" : "Perspective"); }); auto* orbit_mode = makeRibbonAction("Orbit", ":/icons/rotate-camera-right.svg"); @@ -326,25 +324,8 @@ void MainWindow::setupRibbon() { } void MainWindow::setupViewport() { - viewport_ = new ViewportWindow(); - viewport_container_ = QWidget::createWindowContainer(viewport_, this); - viewport_container_->setMinimumSize(400, 300); - viewport_container_->setFocusPolicy(Qt::StrongFocus); - - auto* shell = new QFrame(this); - shell->setObjectName("viewportShell"); - auto* root = new QVBoxLayout(shell); - root->setContentsMargins(10, 10, 10, 10); - root->setSpacing(0); - - auto* frame = new QFrame(shell); - frame->setObjectName("viewportFrame"); - auto* frame_layout = new QVBoxLayout(frame); - frame_layout->setContentsMargins(0, 0, 0, 0); - frame_layout->addWidget(viewport_container_); - - root->addWidget(frame); - setCentralWidget(shell); + viewport_widget_ = new panels::viewport::ViewportWidget(this); + setCentralWidget(viewport_widget_); } void MainWindow::setupPanels() { @@ -355,7 +336,7 @@ void MainWindow::setupPanels() { models_view_ = new panels::models::ModelsPanelView(models_panel_, this); spatial_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, this); properties_view_ = new panels::properties::PropertiesPanelView( - properties_panel_, viewport_, element_registry_, this); + properties_panel_, viewport_widget_->viewport(), element_registry_, this); connect(models_view_, &panels::models::ModelsPanelView::statusMessageRequested, this, &MainWindow::setStatusMessage); @@ -425,8 +406,11 @@ void MainWindow::setupStatus() { } void MainWindow::setupLoader() { - loader_ = new SceneLoader(viewport_, this); + loader_ = new SceneLoader(viewport_widget_->viewport(), this); element_registry_->bindLoader(loader_); + viewport_controller_ = new panels::viewport::ViewportController( + federation_, loader_, viewport_widget_->viewport(), + &fed_id_to_model_id_, &model_id_to_fed_id_, this); connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); connect(loader_, &SceneLoader::loadedFromSidecar, this, &MainWindow::onLoadedFromSidecar); connect(loader_, &SceneLoader::loadedFromStream, this, &MainWindow::onLoadedFromStream); @@ -434,7 +418,7 @@ void MainWindow::setupLoader() { connect(loader_, &SceneLoader::loadError, this, &MainWindow::onLoadError); connect(loader_, &SceneLoader::allLoadsFinished, this, &MainWindow::onAllLoadsFinished); - connect(viewport_, &ViewportWindow::frameStatsUpdated, this, + connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this, [this](const ViewportWindow::FrameStats& s) { if (!status_perf_label_->isVisible()) return; status_perf_label_->setText( @@ -447,6 +431,7 @@ void MainWindow::setupLoader() { .arg(s.total_triangles) .arg(s.gl_draw_calls)); }); + } void MainWindow::addFiles(const QStringList& paths) { @@ -518,12 +503,12 @@ void MainWindow::onAddFiles() { } void MainWindow::clearScene() { - if (viewport_) viewport_->setSelectedObjectId(0); + if (viewport_widget_) viewport_widget_->viewport()->setSelectedObjectId(0); if (properties_view_) properties_view_->clearSelection(); const auto model_ids = model_id_to_fed_id_.keys(); for (uint32_t mid : model_ids) { - viewport_->removeModel(mid); + viewport_widget_->viewport()->removeModel(mid); loader_->removeModel(mid); } @@ -595,10 +580,11 @@ bool MainWindow::openProject(const QString& path) { } federation_->markClean(); + viewport_controller_->applyFederatedFalseOrigin(); if (federation_->hasHomeView()) { const auto& hv = federation_->homeView(); - viewport_->setCamera(hv.target.x(), hv.target.y(), hv.target.z(), - hv.distance, hv.yaw, hv.pitch); + viewport_widget_->viewport()->setCamera( + hv.target.x(), hv.target.y(), hv.target.z(), hv.distance, hv.yaw, hv.pitch); } updateWindowTitle(); setStatusMessage("Project", QFileInfo(path).fileName()); @@ -665,6 +651,7 @@ void MainWindow::onNewProject() { if (!confirmDiscardIfDirty()) return; clearScene(); federation_->clear(); + viewport_controller_->applyFederatedFalseOrigin(); updateWindowTitle(); setStatusMessage("Project", "Untitled"); } @@ -688,6 +675,31 @@ void MainWindow::onSaveProjectAs() { saveProjectAs(); } +void MainWindow::onSetHomeView() { + auto camera = viewport_widget_->viewport()->cameraState(); + Federation::HomeView home_view; + home_view.target = camera.target; + home_view.distance = camera.distance; + home_view.yaw = camera.yaw; + home_view.pitch = camera.pitch; + federation_->setHomeView(home_view); + updateWindowTitle(); + setStatusMessage("Camera", "Home view updated"); +} + +void MainWindow::onGoHomeView() { + if (!federation_->hasHomeView()) { + setStatusMessage("Camera", "No home view set for this project"); + return; + } + + const auto& home_view = federation_->homeView(); + viewport_widget_->viewport()->setCamera( + home_view.target.x(), home_view.target.y(), home_view.target.z(), + home_view.distance, home_view.yaw, home_view.pitch); + setStatusMessage("Camera", "Home view restored"); +} + void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { status_mode_label_->setText("Loading"); status_selection_label_->setText(display_name); diff --git a/src/interface/MainWindow.h b/src/interface/MainWindow.h index f42f0bb854..18a7b93a25 100644 --- a/src/interface/MainWindow.h +++ b/src/interface/MainWindow.h @@ -30,7 +30,6 @@ class QDockWidget; class QStackedWidget; class QToolButton; class Federation; -class ViewportWindow; class SceneLoader; namespace ifcinterface { class ElementRegistry; } namespace ifcinterface::components { class TabBar; } @@ -40,6 +39,8 @@ namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelW namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelView; } namespace ifcinterface::panels::properties { class PropertiesPanelWidget; } namespace ifcinterface::panels::properties { class PropertiesPanelView; } +namespace ifcinterface::panels::viewport { class ViewportController; } +namespace ifcinterface::panels::viewport { class ViewportWidget; } namespace ifcinterface::shell { @@ -81,6 +82,8 @@ private slots: void onLoadCancelled(uint32_t mid); void onLoadError(uint32_t mid, QString message); void onAllLoadsFinished(); + void onSetHomeView(); + void onGoHomeView(); void onNewProject(); void onOpenProject(); void onSaveProject(); @@ -93,10 +96,10 @@ private: QLabel* status_perf_label_ = nullptr; ifcinterface::components::TabBar* ribbon_tabs_ = nullptr; QStackedWidget* ribbon_pages_ = nullptr; - ViewportWindow* viewport_ = nullptr; + ifcinterface::panels::viewport::ViewportWidget* viewport_widget_ = nullptr; + ifcinterface::panels::viewport::ViewportController* viewport_controller_ = nullptr; SceneLoader* loader_ = nullptr; ifcinterface::ElementRegistry* element_registry_ = nullptr; - QWidget* viewport_container_ = nullptr; ifcinterface::panels::models::ModelsPanelWidget* models_panel_ = nullptr; ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelWidget* spatial_panel_ = nullptr; QDockWidget* layers_panel_ = nullptr; diff --git a/src/interface/panels/viewport/Controller.cpp b/src/interface/panels/viewport/Controller.cpp new file mode 100644 index 0000000000..14fc3dfea7 --- /dev/null +++ b/src/interface/panels/viewport/Controller.cpp @@ -0,0 +1,138 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Controller.h" + +#include "../../../ifcviewer/AppSettings.h" +#include "../../../ifcviewer/Federation.h" +#include "../../../ifcviewer/SceneLoader.h" +#include "../../../ifcviewer/ViewportWindow.h" + +#include + +namespace ifcinterface::panels::viewport { + +ViewportController::ViewportController(Federation* federation, + SceneLoader* loader, + ViewportWindow* viewport, + const QHash* fed_id_to_model_id, + const QHash* model_id_to_fed_id, + QObject* parent) + : QObject(parent) + , federation_(federation) + , loader_(loader) + , viewport_(viewport) + , fed_id_to_model_id_(fed_id_to_model_id) + , model_id_to_fed_id_(model_id_to_fed_id) +{ + connect(federation_, &Federation::federatedFalseOriginChanged, + this, &ViewportController::applyFederatedFalseOrigin); + connect(federation_, &Federation::configChanged, this, [this]() { + applyFederatedFalseOrigin(); + for (auto it = model_id_to_fed_id_->cbegin(); it != model_id_to_fed_id_->cend(); ++it) { + applyModelTransformation(it.key()); + } + }); + connect(federation_, &Federation::modelTransformationChanged, + this, [this](const QString& fed_id) { + auto it = fed_id_to_model_id_->find(fed_id); + if (it != fed_id_to_model_id_->end()) { + applyModelTransformation(it.value()); + } + }); + connect(loader_, &SceneLoader::loadedFromSidecar, this, + [this](uint32_t mid, qint64 /*elapsed_ms*/) { + applyCoordinateOperation(mid); + maybeGuessFederatedFalseOrigin(mid); + }); + connect(loader_, &SceneLoader::dataSourceReady, this, + [this](uint32_t mid) { + applyCoordinateOperation(mid); + }); + connect(loader_, &SceneLoader::loadedFromStream, this, + [this](uint32_t mid, qint64 /*elapsed_ms*/) { + applyCoordinateOperation(mid); + maybeGuessFederatedFalseOrigin(mid); + }); + connect(&AppSettings::instance(), + &AppSettings::applyCoordinateOperationChanged, + this, [this](bool /*enabled*/) { + for (auto it = model_id_to_fed_id_->cbegin(); it != model_id_to_fed_id_->cend(); ++it) { + applyCoordinateOperation(it.key()); + } + }); +} + +void ViewportController::applyCoordinateOperation(uint32_t mid) { + Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); + if (AppSettings::instance().applyCoordinateOperation()) { + if (const ModelGeoref* georef = loader_->modelGeoref(mid)) { + if (georef->has_coordinate_operation) { + matrix = georef->coordinate_operation_meters; + } + } + } + viewport_->setModelCoordinateOperation(mid, matrix); + applyModelTransformation(mid); +} + +void ViewportController::applyModelTransformation(uint32_t mid) { + Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); + auto fed_it = model_id_to_fed_id_->find(mid); + if (fed_it != model_id_to_fed_id_->end()) { + if (const Federation::Model* model = federation_->findById(fed_it.value())) { + ModelUnits units; + Eigen::Matrix4d coordinate_operation = Eigen::Matrix4d::Identity(); + if (const ModelGeoref* georef = loader_->modelGeoref(mid)) { + units = georef->units; + if (AppSettings::instance().applyCoordinateOperation() && + georef->has_coordinate_operation) { + coordinate_operation = georef->coordinate_operation_meters; + } + } + matrix = composeModelTransformation( + model->model_transformation, federation_->config(), units, coordinate_operation); + } + } + viewport_->setModelTransformation(mid, matrix); +} + +void ViewportController::applyFederatedFalseOrigin() { + viewport_->setFederatedFalseOrigin( + composeFederatedFalseOrigin(federation_->federatedFalseOrigin(), federation_->config())); +} + +void ViewportController::maybeGuessFederatedFalseOrigin(uint32_t mid) { + if (!federation_->filePath().isEmpty()) return; + + const FederatedFalseOrigin& current = federation_->federatedFalseOrigin(); + const FederatedFalseOrigin defaults; + if (current.xyz != defaults.xyz || current.rz_deg != defaults.rz_deg) return; + + const Eigen::Matrix4d* placement = loader_->firstPlacement(mid); + const ModelGeoref* georef = loader_->modelGeoref(mid); + if (placement == nullptr || georef == nullptr) return; + + federation_->setFederatedFalseOrigin(guessFederatedFalseOrigin( + *placement, *georef, federation_->config(), + AppSettings::instance().applyCoordinateOperation())); +} + +} // namespace ifcinterface::panels::viewport diff --git a/src/interface/panels/viewport/Controller.h b/src/interface/panels/viewport/Controller.h new file mode 100644 index 0000000000..25506cc9dd --- /dev/null +++ b/src/interface/panels/viewport/Controller.h @@ -0,0 +1,60 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_VIEWPORT_CONTROLLER_H +#define IFCINTERFACE_PANELS_VIEWPORT_CONTROLLER_H + +#include +#include + +class Federation; +class SceneLoader; +class ViewportWindow; + +namespace ifcinterface::panels::viewport { + +class ViewportController : public QObject { + Q_OBJECT + +public: + explicit ViewportController(Federation* federation, + SceneLoader* loader, + ViewportWindow* viewport, + const QHash* fed_id_to_model_id, + const QHash* model_id_to_fed_id, + QObject* parent = nullptr); + + void applyFederatedFalseOrigin(); + +private: + void applyCoordinateOperation(uint32_t mid); + void applyModelTransformation(uint32_t mid); + void maybeGuessFederatedFalseOrigin(uint32_t mid); + + Federation* federation_ = nullptr; + SceneLoader* loader_ = nullptr; + ViewportWindow* viewport_ = nullptr; + const QHash* fed_id_to_model_id_ = nullptr; + const QHash* model_id_to_fed_id_ = nullptr; +}; + +} // namespace ifcinterface::panels::viewport + +#endif diff --git a/src/interface/panels/viewport/Widget.cpp b/src/interface/panels/viewport/Widget.cpp new file mode 100644 index 0000000000..9b6faacd15 --- /dev/null +++ b/src/interface/panels/viewport/Widget.cpp @@ -0,0 +1,60 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "Widget.h" + +#include "../../../ifcviewer/ViewportWindow.h" + +#include +#include +#include + +namespace ifcinterface::panels::viewport { + +ViewportWidget::ViewportWidget(QWidget* parent) + : QWidget(parent) +{ + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + auto* shell = new QFrame(this); + shell->setObjectName("viewportShell"); + auto* shell_layout = new QVBoxLayout(shell); + shell_layout->setContentsMargins(10, 10, 10, 10); + shell_layout->setSpacing(0); + + auto* frame = new QFrame(shell); + frame->setObjectName("viewportFrame"); + auto* frame_layout = new QVBoxLayout(frame); + frame_layout->setContentsMargins(0, 0, 0, 0); + frame_layout->setSpacing(0); + + viewport_ = new ViewportWindow(); + viewport_container_ = QWidget::createWindowContainer(viewport_, frame); + viewport_container_->setMinimumSize(400, 300); + viewport_container_->setFocusPolicy(Qt::StrongFocus); + + frame_layout->addWidget(viewport_container_); + shell_layout->addWidget(frame); + root->addWidget(shell); +} + +} // namespace ifcinterface::panels::viewport diff --git a/src/interface/panels/viewport/Widget.h b/src/interface/panels/viewport/Widget.h new file mode 100644 index 0000000000..08f9676759 --- /dev/null +++ b/src/interface/panels/viewport/Widget.h @@ -0,0 +1,45 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_PANELS_VIEWPORT_WIDGET_H +#define IFCINTERFACE_PANELS_VIEWPORT_WIDGET_H + +#include + +class ViewportWindow; + +namespace ifcinterface::panels::viewport { + +class ViewportWidget : public QWidget { + Q_OBJECT + +public: + explicit ViewportWidget(QWidget* parent = nullptr); + + ViewportWindow* viewport() const { return viewport_; } + +private: + ViewportWindow* viewport_ = nullptr; + QWidget* viewport_container_ = nullptr; +}; + +} // namespace ifcinterface::panels::viewport + +#endif From ad62dd6d91aaeb0ce638fe4ae43841604d67c4bb Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 16:43:38 +1000 Subject: [PATCH 119/120] ifcviewer: viewport overlay subsystem (highlight tris + HUD text) New OverlayRenderer module owns every client-supplied overlay primitive drawn after the main pass: tinted, depth-aware highlight triangles via its own GL shader, and top-left HUD text via QPainter on a QOpenGLPaintDevice. Public surface on ViewportWindow is just two forwarders (setHighlightTriangles, setHudText). ViewportWindow's MeshLocalPick now exposes the instance's composed transform so consumers can map mesh-local geometry back to world space without re-querying. AreaMeasurement uses both: its selection key is now (object_id, tri) so per-instance highlighting works for two distinct walls sharing a mesh, and on every pick it rebuilds the world-space tri list and the HUD readout. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/MainWindow.cpp | 13 +- src/ifcviewer-full/Measurement.cpp | 54 +++++++- src/ifcviewer-full/Measurement.h | 43 +++++-- src/ifcviewer/OverlayRenderer.cpp | 199 +++++++++++++++++++++++++++++ src/ifcviewer/OverlayRenderer.h | 75 +++++++++++ src/ifcviewer/ViewportWindow.cpp | 27 ++++ src/ifcviewer/ViewportWindow.h | 24 ++++ 7 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 src/ifcviewer/OverlayRenderer.cpp create mode 100644 src/ifcviewer/OverlayRenderer.h diff --git a/src/ifcviewer-full/MainWindow.cpp b/src/ifcviewer-full/MainWindow.cpp index 2510ad799c..d7b7886b8c 100644 --- a/src/ifcviewer-full/MainWindow.cpp +++ b/src/ifcviewer-full/MainWindow.cpp @@ -210,11 +210,20 @@ void MainWindow::setupUi() { [this](int x, int y, int modifiers) { const bool alt = (modifiers & Qt::AltModifier) != 0; area_measurement_.onPick(*viewport_, x, y, alt); + viewport_->setHudText(QString("Area: %1 m² (%2 tris)") + .arg(area_measurement_.totalArea(), 0, 'f', 4) + .arg(area_measurement_.triangleCount())); }); connect(viewport_, &ViewportWindow::areaToolToggled, this, [this](bool active) { - area_measurement_.clear(); - qInfo("Area tool %s", active ? "on (LMB to add patch, Alt+LMB single tri, click again to remove, Esc exits)" : "off"); + area_measurement_.clear(*viewport_); + if (active) { + viewport_->setHudText("Area: 0.0000 m² (0 tris)"); + status_label_->setText("Area tool: LMB add, Alt+LMB single tri, click again to remove, Esc exits"); + } else { + viewport_->setHudText(QString()); + status_label_->setText("Ready"); + } }); auto* tree_dock = new QDockWidget("Elements", this); diff --git a/src/ifcviewer-full/Measurement.cpp b/src/ifcviewer-full/Measurement.cpp index 0fff48cda1..d487d7bd83 100644 --- a/src/ifcviewer-full/Measurement.cpp +++ b/src/ifcviewer-full/Measurement.cpp @@ -201,10 +201,40 @@ constexpr double kCoplanarDot = 0.9999; // ~0.81° tolerance AreaMeasurement::AreaMeasurement() = default; -void AreaMeasurement::clear() { +void AreaMeasurement::clear(ViewportWindow& vp) { mesh_cache_.clear(); selected_.clear(); total_area_m2_ = 0.0; + vp.setHighlightTriangles({}, 0, 0, 0, 0); +} + +void AreaMeasurement::rebuildHighlight(ViewportWindow& vp) { + // Push every selected triangle's three world-space vertices to the + // overlay. Mesh-local positions × per-instance composed transform. + std::vector world_xyz; + world_xyz.reserve(selected_.size() * 9); + for (const auto& [key, sel] : selected_) { + const uint64_t cache_key = (uint64_t(sel.model_id) << 32) + | uint64_t(sel.mesh_id); + auto cit = mesh_cache_.find(cache_key); + if (cit == mesh_cache_.end()) continue; + const MeshCache& c = cit->second; + if (size_t(sel.tri) * 3 + 2 >= c.indices.size()) continue; + const float* M = sel.composed_transform; // column-major + for (int e = 0; e < 3; ++e) { + const uint32_t vi = c.indices[3 * sel.tri + e]; + const float* p = &c.positions[3 * vi]; + // World = M * (p, 1). Column-major: M[col*4 + row]. + const float wx = M[0]*p[0] + M[4]*p[1] + M[8]*p[2] + M[12]; + const float wy = M[1]*p[0] + M[5]*p[1] + M[9]*p[2] + M[13]; + const float wz = M[2]*p[0] + M[6]*p[1] + M[10]*p[2] + M[14]; + world_xyz.push_back(wx); + world_xyz.push_back(wy); + world_xyz.push_back(wz); + } + } + // Translucent cyan-ish tint — readable on both light and dark surfaces. + vp.setHighlightTriangles(world_xyz, 0.20f, 0.85f, 1.00f, 0.45f); } AreaMeasurement::MeshCache* AreaMeasurement::meshCache(ViewportWindow& vp, @@ -304,19 +334,33 @@ void AreaMeasurement::onPick(ViewportWindow& vp, int x, int y, bool alt) { // Toggle: if the seed was already in the set, remove the patch; // otherwise add it. - const uint64_t seed_key = triKey(pick.model_id, pick.mesh_id, seed); + const uint64_t seed_key = triKey(pick.object_id, seed); const bool removing = selected_.count(seed_key) > 0; double delta = 0.0; for (uint32_t t : patch) { - const uint64_t k = triKey(pick.model_id, pick.mesh_id, t); + const uint64_t k = triKey(pick.object_id, t); if (removing) { - if (selected_.erase(k) > 0) delta -= cache->tri_areas[t]; + auto it = selected_.find(k); + if (it != selected_.end()) { + delta -= cache->tri_areas[t]; + selected_.erase(it); + } } else { - if (selected_.insert(k).second) delta += cache->tri_areas[t]; + SelectedTri sel; + sel.model_id = pick.model_id; + sel.mesh_id = pick.mesh_id; + sel.tri = t; + std::memcpy(sel.composed_transform, pick.composed_transform, + sizeof(sel.composed_transform)); + if (selected_.emplace(k, sel).second) { + delta += cache->tri_areas[t]; + } } } total_area_m2_ += delta; + rebuildHighlight(vp); + qInfo("Area %s%.6f m^2 (total: %.6f m^2, %zu tris)", delta >= 0.0 ? "+" : "", delta, total_area_m2_, selected_.size()); diff --git a/src/ifcviewer-full/Measurement.h b/src/ifcviewer-full/Measurement.h index e88f650bd6..4b0c3d4bd0 100644 --- a/src/ifcviewer-full/Measurement.h +++ b/src/ifcviewer-full/Measurement.h @@ -22,7 +22,6 @@ #include #include -#include #include class ViewportWindow; @@ -38,14 +37,16 @@ double volumeOfObjects(ViewportWindow& vp, const std::vector& object_ids); // Click-to-accumulate area measurement. Each pick resolves the screen -// click to a (model, mesh, triangle) using ViewportWindow's primitives, +// click to a (instance, triangle) using ViewportWindow's primitives, // expands it into the connected coplanar patch (BFS over shared edges, // dot(normal, seed_normal) > 0.9999), then either adds or removes that // patch from the running set depending on whether the seed triangle was // already in. Alt-click skips the BFS expansion (single-triangle). -// Picks across different meshes are kept as separate patches and their -// areas are summed. +// Picks on different instances (even of the same mesh) are kept as +// separate patches and their areas are summed. // +// On every pick the world-space triangles of the running set are pushed +// to ViewportWindow::setHighlightTriangles for in-viewport shading. // State is cleared on construction, on clear(), and is expected to be // reset by the host (e.g. when the viewport's area tool toggles off). class AreaMeasurement { @@ -57,8 +58,9 @@ public: // via qInfo. Misses are silent. void onPick(ViewportWindow& vp, int x, int y, bool alt); - // Wipe all accumulated triangles and per-mesh adjacency caches. - void clear(); + // Wipe all accumulated triangles, per-mesh adjacency caches, and the + // viewport overlay. + void clear(ViewportWindow& vp); double totalArea() const { return total_area_m2_; } size_t triangleCount() const { return selected_.size(); } @@ -77,16 +79,29 @@ private: }; MeshCache* meshCache(ViewportWindow& vp, uint32_t model_id, uint32_t mesh_id); - // Selection key: (uint64) packing model_id (high 24), mesh_id (mid 24), - // triangle index (low 16). 16 bits is enough — meshes with > 65k tris - // are rare and the streamer chunks them anyway. - static uint64_t triKey(uint32_t model_id, uint32_t mesh_id, uint32_t tri) { - return (uint64_t(model_id) << 40) | (uint64_t(mesh_id) << 16) | uint64_t(tri); + // Per-selected-triangle record. The composed transform is captured at + // pick time so the overlay rebuild doesn't have to re-query the + // viewport for it (and so the overlay keeps working if the picked + // instance later goes hidden). + struct SelectedTri { + uint32_t model_id; + uint32_t mesh_id; + uint32_t tri; + float composed_transform[16]; + }; + + // Selection key: object_id (high 32) | tri index (low 32). Packing + // by object_id rather than mesh_id means two distinct instances of + // the same mesh contribute independently, as the user spec'd. + static uint64_t triKey(uint32_t object_id, uint32_t tri) { + return (uint64_t(object_id) << 32) | uint64_t(tri); } - std::unordered_map mesh_cache_; - std::unordered_set selected_; - double total_area_m2_ = 0.0; + void rebuildHighlight(ViewportWindow& vp); + + std::unordered_map mesh_cache_; + std::unordered_map selected_; + double total_area_m2_ = 0.0; }; #endif // IFCVIEWER_FULL_MEASUREMENT_H diff --git a/src/ifcviewer/OverlayRenderer.cpp b/src/ifcviewer/OverlayRenderer.cpp new file mode 100644 index 0000000000..60384061ac --- /dev/null +++ b/src/ifcviewer/OverlayRenderer.cpp @@ -0,0 +1,199 @@ +/******************************************************************************** + * * + * 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 "OverlayRenderer.h" + +#include +#include +#include +#include +#include + +namespace { + +GLuint compile(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* src) { + GLuint s = gl->glCreateShader(type); + gl->glShaderSource(s, 1, &src, nullptr); + gl->glCompileShader(s); + GLint ok = 0; + gl->glGetShaderiv(s, GL_COMPILE_STATUS, &ok); + if (!ok) { + char log[2048]; + gl->glGetShaderInfoLog(s, sizeof(log), nullptr, log); + qWarning("OverlayRenderer shader compile error: %s", log); + } + return s; +} + +GLuint link(QOpenGLFunctions_4_5_Core* gl, GLuint vs, GLuint fs) { + GLuint p = gl->glCreateProgram(); + gl->glAttachShader(p, vs); + gl->glAttachShader(p, fs); + gl->glLinkProgram(p); + GLint ok = 0; + gl->glGetProgramiv(p, GL_LINK_STATUS, &ok); + if (!ok) { + char log[2048]; + gl->glGetProgramInfoLog(p, sizeof(log), nullptr, log); + qWarning("OverlayRenderer program link error: %s", log); + } + gl->glDeleteShader(vs); + gl->glDeleteShader(fs); + return p; +} + +const char* VERT_SRC = R"( +#version 450 core +layout(location = 0) in vec3 in_pos; +uniform mat4 u_view_proj; +void main() { + gl_Position = u_view_proj * vec4(in_pos, 1.0); +} +)"; + +const char* FRAG_SRC = R"( +#version 450 core +uniform vec4 u_color; +out vec4 frag_color; +void main() { + frag_color = u_color; +} +)"; + +} // namespace + +void OverlayRenderer::initialize(QOpenGLFunctions_4_5_Core* gl) { + if (gl_) return; + gl_ = gl; + + GLuint vs = compile(gl_, GL_VERTEX_SHADER, VERT_SRC); + GLuint fs = compile(gl_, GL_FRAGMENT_SHADER, FRAG_SRC); + program_ = link(gl_, vs, fs); + u_view_proj_ = gl_->glGetUniformLocation(program_, "u_view_proj"); + u_color_ = gl_->glGetUniformLocation(program_, "u_color"); + + gl_->glCreateVertexArrays(1, &vao_); + gl_->glCreateBuffers(1, &vbo_); + gl_->glEnableVertexArrayAttrib(vao_, 0); + gl_->glVertexArrayAttribFormat(vao_, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribBinding(vao_, 0, 0); + gl_->glVertexArrayVertexBuffer(vao_, 0, vbo_, 0, 3 * sizeof(float)); +} + +void OverlayRenderer::release() { + if (!gl_) return; + if (vbo_) gl_->glDeleteBuffers(1, &vbo_); + if (vao_) gl_->glDeleteVertexArrays(1, &vao_); + if (program_) gl_->glDeleteProgram(program_); + program_ = vao_ = vbo_ = 0; + vbo_capacity_ = 0; + vertex_count_ = 0; + gl_ = nullptr; +} + +void OverlayRenderer::setHudText(const QString& text) { + hud_text_ = text; +} + +void OverlayRenderer::setHighlightTriangles(const std::vector& world_xyz, + float r, float g, float b, float a) { + if (!gl_) return; + color_[0] = r; color_[1] = g; color_[2] = b; color_[3] = a; + vertex_count_ = GLsizei(world_xyz.size() / 3); + if (vertex_count_ == 0) return; + + const size_t bytes = world_xyz.size() * sizeof(float); + if (bytes > vbo_capacity_) { + // Grow with a little headroom so frequent appends don't realloc. + const size_t new_cap = bytes + bytes / 2; + gl_->glNamedBufferData(vbo_, GLsizeiptr(new_cap), + nullptr, GL_DYNAMIC_DRAW); + vbo_capacity_ = new_cap; + } + gl_->glNamedBufferSubData(vbo_, 0, GLsizeiptr(bytes), world_xyz.data()); +} + +void OverlayRenderer::render(const float view_proj[16], + int pixel_w, int pixel_h, qreal dpr) { + if (!gl_) return; + + // GL pass: tinted highlight triangles. + if (program_ && vertex_count_ > 0 && color_[3] > 0.0f) { + gl_->glUseProgram(program_); + gl_->glUniformMatrix4fv(u_view_proj_, 1, GL_FALSE, view_proj); + gl_->glUniform4fv(u_color_, 1, color_); + + // Save the GL state we touch and restore at the end so the rest of + // the render pass keeps seeing what it expects. + GLboolean prev_blend = gl_->glIsEnabled(GL_BLEND); + GLboolean prev_cull = gl_->glIsEnabled(GL_CULL_FACE); + GLboolean prev_depth_msk = GL_TRUE; + gl_->glGetBooleanv(GL_DEPTH_WRITEMASK, &prev_depth_msk); + GLint prev_depth_func = GL_LESS; + gl_->glGetIntegerv(GL_DEPTH_FUNC, &prev_depth_func); + GLint prev_blend_src = GL_ONE, prev_blend_dst = GL_ZERO; + gl_->glGetIntegerv(GL_BLEND_SRC_ALPHA, &prev_blend_src); + gl_->glGetIntegerv(GL_BLEND_DST_ALPHA, &prev_blend_dst); + + gl_->glEnable(GL_BLEND); + gl_->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl_->glDisable(GL_CULL_FACE); // both sides tinted + gl_->glDepthMask(GL_FALSE); // tint, don't occlude + gl_->glDepthFunc(GL_LEQUAL); // win the coplanar fight + + gl_->glBindVertexArray(vao_); + gl_->glDrawArrays(GL_TRIANGLES, 0, vertex_count_); + gl_->glBindVertexArray(0); + + if (!prev_blend) gl_->glDisable(GL_BLEND); + gl_->glBlendFunc(prev_blend_src, prev_blend_dst); + if (prev_cull) gl_->glEnable(GL_CULL_FACE); + gl_->glDepthMask(prev_depth_msk); + gl_->glDepthFunc(prev_depth_func); + } + + // QPainter pass: HUD text. This rebinds programs/VAOs internally, so + // it has to come after every other GL primitive in the overlay. + if (!hud_text_.isEmpty() && pixel_w > 0 && pixel_h > 0) { + QOpenGLPaintDevice device(QSize(pixel_w, pixel_h)); + device.setDevicePixelRatio(dpr); + QPainter painter(&device); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + + QFont font("monospace", 11); + font.setStyleHint(QFont::TypeWriter); + painter.setFont(font); + const QFontMetrics fm(font); + const int pad_x = 10, pad_y = 6, margin = 12; + const int text_w = fm.horizontalAdvance(hud_text_); + const int text_h = fm.height(); + const QRect bg(margin, margin, + text_w + 2 * pad_x, + text_h + 2 * pad_y); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 160)); + painter.drawRoundedRect(bg, 4, 4); + painter.setPen(Qt::white); + painter.drawText(bg.adjusted(pad_x, pad_y, -pad_x, -pad_y), + Qt::AlignLeft | Qt::AlignVCenter, + hud_text_); + } +} diff --git a/src/ifcviewer/OverlayRenderer.h b/src/ifcviewer/OverlayRenderer.h new file mode 100644 index 0000000000..78f343c9b2 --- /dev/null +++ b/src/ifcviewer/OverlayRenderer.h @@ -0,0 +1,75 @@ +/******************************************************************************** + * * + * 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 IFCVIEWER_OVERLAYRENDERER_H +#define IFCVIEWER_OVERLAYRENDERER_H + +#include +#include + +#include + +// Neutral overlay-primitive renderer attached to ViewportWindow. Today it +// draws a single tinted, translucent triangle list in world space — used by +// the area-measurement tool to shade selected coplanar patches. More +// primitives (lines, points, world-anchored labels) will land here as +// future tools require them. +// +// Lifetime: owned by ViewportWindow, initialised in the same context. All +// public methods assume the caller has already made the GL context current. +class OverlayRenderer { +public: + void initialize(QOpenGLFunctions_4_5_Core* gl); + void release(); + + // Replace the highlight-triangle list. `world_xyz` is 3 floats per + // vertex, 3 verts per triangle, in world space (post-composed-transform). + // Empty disables the overlay. Color is RGBA in [0, 1]. + void setHighlightTriangles(const std::vector& world_xyz, + float r, float g, float b, float a); + + // Top-left HUD text drawn via QPainter on the GL surface as part of + // render(). Empty hides the HUD. + void setHudText(const QString& text); + + // Render every overlay primitive in order: GL highlight triangles + // (using `view_proj`, column-major float[16]), then HUD text via + // QPainter on a QOpenGLPaintDevice sized to (pixel_w × pixel_h) + // with the supplied device pixel ratio. Caller is responsible for + // ensuring glViewport covers the full surface — the QPainter pass + // after this call leaves GL state in an undefined shape, so treat + // this as the last GL operation per frame before swapBuffers (or + // sandwich it before any pass that re-binds its own programs). + void render(const float view_proj[16], + int pixel_w, int pixel_h, qreal device_pixel_ratio); + +private: + QOpenGLFunctions_4_5_Core* gl_ = nullptr; + GLuint program_ = 0; + GLuint vao_ = 0; + GLuint vbo_ = 0; + size_t vbo_capacity_ = 0; // bytes + GLsizei vertex_count_ = 0; + float color_[4] = {0, 0, 0, 0}; + GLint u_view_proj_ = -1; + GLint u_color_ = -1; + QString hud_text_; +}; + +#endif // IFCVIEWER_OVERLAYRENDERER_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 907f4c6a02..081f5e5585 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -670,6 +670,7 @@ ViewportWindow::~ViewportWindow() { if (hiz_resolve_depth_tex_) gl_->glDeleteTextures(1, &hiz_resolve_depth_tex_); if (hiz_downsample_program_) gl_->glDeleteProgram(hiz_downsample_program_); if (hiz_downsample_vao_) gl_->glDeleteVertexArrays(1, &hiz_downsample_vao_); + overlay_renderer_.release(); } context_->doneCurrent(); } @@ -690,6 +691,7 @@ void ViewportWindow::initGL() { buildAxisGizmo(); buildPivotIndicator(); buildSectionPlaneGizmo(); + overlay_renderer_.initialize(gl_); gl_->glEnable(GL_DEPTH_TEST); gl_->glEnable(GL_MULTISAMPLE); @@ -2726,6 +2728,16 @@ void ViewportWindow::render() { renderEdgePass(); renderPivotIndicator(); renderSectionPlanes(); + // Overlay handles highlight tris + HUD text; renderAxisGizmo() must + // come after because it shrinks glViewport to the corner badge and + // does not restore. + { + const qreal dpr = devicePixelRatio(); + overlay_renderer_.render(vp.constData(), + int(width() * dpr), + int(height() * dpr), + dpr); + } renderAxisGizmo(); // Build HiZ from this frame's resolved depth for next frame's cull. @@ -3848,6 +3860,8 @@ bool ViewportWindow::pickMeshLocalAt(int x, int y, MeshLocalPick& out) { out.world_normal[0] = world_normal.x(); out.world_normal[1] = world_normal.y(); out.world_normal[2] = world_normal.z(); + std::memcpy(out.composed_transform, inst.transform, + sizeof(out.composed_transform)); return true; } } @@ -3858,3 +3872,16 @@ void ViewportWindow::toggleAreaTool() { area_tool_active_ = !area_tool_active_; emit areaToolToggled(area_tool_active_); } + +void ViewportWindow::setHighlightTriangles(const std::vector& world_xyz, + float r, float g, float b, float a) { + if (!gl_initialized_) return; + context_->makeCurrent(this); + overlay_renderer_.setHighlightTriangles(world_xyz, r, g, b, a); + requestUpdate(); +} + +void ViewportWindow::setHudText(const QString& text) { + overlay_renderer_.setHudText(text); + requestUpdate(); +} diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index c436dda57c..132c6acb65 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -45,6 +45,7 @@ QT_END_NAMESPACE #include "BvhAccel.h" #include "InstancedGeometry.h" +#include "OverlayRenderer.h" #include "SidecarCache.h" // Matches GL_DRAW_INDIRECT_BUFFER layout for glMultiDrawElementsIndirect. @@ -221,6 +222,11 @@ public: float mesh_local[3] = {0, 0, 0}; float world_pos[3] = {0, 0, 0}; float world_normal[3]= {0, 0, 0}; + // The instance's composed (FederatedFalseOrigin · ModelTransformation + // · CoordinateOperation · placement_transformation) matrix in + // column-major form. Mesh-local positions × this = world. Caching + // by callers becomes stale if any federation matrix is later edited. + float composed_transform[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; }; bool pickMeshLocalAt(int x, int y, MeshLocalPick& out); @@ -231,6 +237,19 @@ public: void toggleAreaTool(); bool areaToolActive() const { return area_tool_active_; } + // Replace the overlay highlight-triangle list rendered after the main + // pass. `world_xyz` is 3 floats per vertex, 3 verts per triangle, in + // world space. Empty disables the overlay. Triggers a viewport + // update so the change becomes visible immediately. + void setHighlightTriangles(const std::vector& world_xyz, + float r, float g, float b, float a); + + // Top-left HUD text drawn via QPainter on top of the GL surface at + // the end of each frame. Empty hides the HUD. Used today by the + // area-measurement tool for the running total; later tools can pile + // additional readouts in by extending this with a multi-line API. + void setHudText(const QString& text); + // Federation pipeline: composed instance transform = // FederatedFalseOrigin · ModelTransformation · CoordinateOperation // · placement_transformation @@ -635,6 +654,11 @@ private: // Area-measurement tool: see toggleAreaTool / surfacePickedInTool. bool area_tool_active_ = false; + // Renders any client-supplied overlay primitives (highlight triangles + // today; lines/points/labels later) in their own pass after the main + // geometry, with depth-test on / depth-write off. + OverlayRenderer overlay_renderer_; + // Active section planes. Uploaded as uniform array each frame to the // main + pick programs; capped at MaxSectionPlanes. std::vector section_planes_; From bae1eddda9ced86a3bb0aeb5c9955818c252fc02 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 May 2026 17:10:04 +1000 Subject: [PATCH 120/120] Interface mockup 12 --- src/interface/CMakeLists.txt | 4 + src/interface/ElementRegistry.cpp | 10 ++ src/interface/ElementRegistry.h | 1 + src/interface/MainWindow.cpp | 115 +++++++++--------- src/interface/MainWindow.h | 7 +- src/interface/SessionState.cpp | 107 ++++++++++++++++ src/interface/SessionState.h | 91 ++++++++++++++ src/interface/components/Style.cpp | 27 ++++ src/interface/panels/properties/View.cpp | 23 ++-- src/interface/panels/properties/View.h | 9 +- .../panels/spatial_hierarchy/View.cpp | 10 +- src/interface/panels/spatial_hierarchy/View.h | 9 +- src/interface/panels/viewport/Controller.cpp | 99 +++++++++------ src/interface/panels/viewport/Controller.h | 15 +-- 14 files changed, 399 insertions(+), 128 deletions(-) create mode 100644 src/interface/SessionState.cpp create mode 100644 src/interface/SessionState.h diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt index 5eee8a0824..4ccf79cdb6 100644 --- a/src/interface/CMakeLists.txt +++ b/src/interface/CMakeLists.txt @@ -27,6 +27,8 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ElementRegistry.h + ${CMAKE_CURRENT_SOURCE_DIR}/SessionState.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SessionState.h ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/components/SvgIcon.cpp @@ -48,6 +50,8 @@ set(INTERFACE_FILES ${CMAKE_CURRENT_SOURCE_DIR}/panels/add_model/Dialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/add_model/Dialog.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Types.h + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Controller.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Controller.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Widget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/Widget.h ${CMAKE_CURRENT_SOURCE_DIR}/panels/models/View.cpp diff --git a/src/interface/ElementRegistry.cpp b/src/interface/ElementRegistry.cpp index 12f84e5d23..724c5ad987 100644 --- a/src/interface/ElementRegistry.cpp +++ b/src/interface/ElementRegistry.cpp @@ -43,6 +43,16 @@ void ElementRegistry::clear() { elements_.clear(); } +void ElementRegistry::removeModel(uint32_t model_id) { + for (auto it = elements_.begin(); it != elements_.end();) { + if (it->second.model_id == model_id) { + it = elements_.erase(it); + } else { + ++it; + } + } +} + std::optional ElementRegistry::findBasicElementInfo(uint32_t object_id) const { auto it = elements_.find(object_id); if (it == elements_.end()) return std::nullopt; diff --git a/src/interface/ElementRegistry.h b/src/interface/ElementRegistry.h index e6ee785184..abad8451da 100644 --- a/src/interface/ElementRegistry.h +++ b/src/interface/ElementRegistry.h @@ -52,6 +52,7 @@ public: void bindLoader(SceneLoader* loader); void clear(); + void removeModel(uint32_t model_id); std::optional findBasicElementInfo(uint32_t object_id) const; std::optional findEntity(uint32_t object_id) const; diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp index 96f311401e..78fac76f51 100644 --- a/src/interface/MainWindow.cpp +++ b/src/interface/MainWindow.cpp @@ -25,11 +25,13 @@ #include "../ifcviewer/SceneLoader.h" #include "../ifcviewer/ViewportWindow.h" #include "ElementRegistry.h" +#include "SessionState.h" #include "components/Buttons.h" #include "components/Panel.h" #include "components/Style.h" #include "components/Tabs.h" #include "panels/add_model/Dialog.h" +#include "panels/models/Controller.h" #include "panels/todo/Widget.h" #include "panels/models/View.h" #include "panels/models/Widget.h" @@ -63,6 +65,9 @@ MainWindow::MainWindow(QWidget* parent) { federation_ = new Federation(this); element_registry_ = new ifcinterface::ElementRegistry(this); + session_state_ = new ifcinterface::SessionState(this); + session_state_->bindFederation(federation_); + session_state_->bindElementRegistry(element_registry_); connect(federation_, &Federation::dirtyChanged, this, [this](bool dirty) { setWindowModified(dirty); updateWindowTitle(); @@ -92,11 +97,6 @@ QWidget* MainWindow::makeRibbonGroup(const QString& title, const QListsetText(mode); - status_selection_label_->setText(detail); -} - QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) { auto* button = makeRibbonAction(text, ":/icons/sidebar-expand.svg"); button->setCheckable(true); @@ -125,11 +125,11 @@ QWidget* MainWindow::buildHomeRibbonPage() { connect(open_project, &QToolButton::clicked, this, &MainWindow::onOpenProject); auto* open_cloud = makeRibbonAction("Open Cloud", ":/icons/cloud-square.svg"); connect(open_cloud, &QToolButton::clicked, this, [this]() { - setStatusMessage("Project", "Open Cloud Project coming soon"); + session_state_->setStatusMessage("Project", "Open Cloud Project coming soon"); }); auto* open_recent = makeRibbonAction("Open Recent", ":/icons/clock-rotate-right.svg"); connect(open_recent, &QToolButton::clicked, this, [this]() { - setStatusMessage("Project", "Open Recent coming soon"); + session_state_->setStatusMessage("Project", "Open Recent coming soon"); }); auto* save_project = makeRibbonAction("Save Project", ":/icons/floppy-disk.svg"); connect(save_project, &QToolButton::clicked, this, &MainWindow::onSaveProject); @@ -140,7 +140,7 @@ QWidget* MainWindow::buildHomeRibbonPage() { connect(add_model, &QToolButton::clicked, this, &MainWindow::onAddFiles); auto* sync_models = makeRibbonAction("Sync Models", ":/icons/refresh-double.svg"); connect(sync_models, &QToolButton::clicked, this, [this]() { - setStatusMessage("Models", "Sync models coming soon"); + session_state_->setStatusMessage("Models", "Sync models coming soon"); }); auto* settings_button = makeRibbonAction("Settings", ":/icons/settings.svg"); @@ -190,7 +190,7 @@ QWidget* MainWindow::buildNavigateRibbonPage() { }); auto* align_object = makeRibbonAction("Align Object", ":/icons/cellar.svg"); connect(align_object, &QToolButton::clicked, this, [this]() { - setStatusMessage("Orientation", "Align to object coming soon"); + session_state_->setStatusMessage("Orientation", "Align to object coming soon"); }); auto* projection_button = makeRibbonAction("Perspective", ":/icons/perspective-view.svg"); connect(projection_button, &QToolButton::clicked, this, [this, projection_button]() { @@ -202,11 +202,11 @@ QWidget* MainWindow::buildNavigateRibbonPage() { auto* orbit_mode = makeRibbonAction("Orbit", ":/icons/rotate-camera-right.svg"); connect(orbit_mode, &QToolButton::clicked, this, [this]() { - setStatusMessage("Mode", "Orbit mode active"); + session_state_->setStatusMessage("Mode", "Orbit mode active"); }); auto* fly_mode = makeRibbonAction("Fly", ":/icons/drone.svg"); connect(fly_mode, &QToolButton::clicked, this, [this]() { - setStatusMessage("Mode", "Fly mode coming soon"); + session_state_->setStatusMessage("Mode", "Fly mode coming soon"); }); row->addWidget(makeRibbonGroup("CAMERA", {set_home, go_home, view_all, view_selected})); @@ -225,32 +225,32 @@ QWidget* MainWindow::buildInspectRibbonPage() { auto* hide_selected = makeRibbonAction("Hide", ":/icons/eye-closed.svg"); connect(hide_selected, &QToolButton::clicked, this, [this]() { - setStatusMessage("Selection", "Hide selected coming soon"); + session_state_->setStatusMessage("Selection", "Hide selected coming soon"); }); auto* isolate_selected = makeRibbonAction("Isolate", ":/icons/eye-solid.svg"); connect(isolate_selected, &QToolButton::clicked, this, [this]() { - setStatusMessage("Selection", "Isolate selected coming soon"); + session_state_->setStatusMessage("Selection", "Isolate selected coming soon"); }); auto* show_all = makeRibbonAction("Show All", ":/icons/eye.svg"); connect(show_all, &QToolButton::clicked, this, [this]() { - setStatusMessage("Selection", "Show all coming soon"); + session_state_->setStatusMessage("Selection", "Show all coming soon"); }); auto* invert_selection = makeRibbonAction("Invert", ":/icons/intersect.svg"); connect(invert_selection, &QToolButton::clicked, this, [this]() { - setStatusMessage("Selection", "Invert selection coming soon"); + session_state_->setStatusMessage("Selection", "Invert selection coming soon"); }); auto* distance = makeRibbonAction("Distance", ":/icons/select-edge3d.svg"); connect(distance, &QToolButton::clicked, this, [this]() { - setStatusMessage("Measure", "Distance coming soon"); + session_state_->setStatusMessage("Measure", "Distance coming soon"); }); auto* area = makeRibbonAction("Area", ":/icons/select-face3d.svg"); connect(area, &QToolButton::clicked, this, [this]() { - setStatusMessage("Measure", "Area coming soon"); + session_state_->setStatusMessage("Measure", "Area coming soon"); }); auto* volume = makeRibbonAction("Volume", ":/icons/select-point3d.svg"); connect(volume, &QToolButton::clicked, this, [this]() { - setStatusMessage("Measure", "Volume coming soon"); + session_state_->setStatusMessage("Measure", "Volume coming soon"); }); row->addWidget(makeRibbonGroup("SELECTION", {hide_selected, isolate_selected, show_all, invert_selection})); @@ -333,15 +333,11 @@ void MainWindow::setupPanels() { spatial_panel_ = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); properties_panel_ = new panels::properties::PropertiesPanelWidget(this); - models_view_ = new panels::models::ModelsPanelView(models_panel_, this); - spatial_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, this); - properties_view_ = new panels::properties::PropertiesPanelView( - properties_panel_, viewport_widget_->viewport(), element_registry_, this); - - connect(models_view_, &panels::models::ModelsPanelView::statusMessageRequested, - this, &MainWindow::setStatusMessage); - connect(spatial_view_, &panels::spatial_hierarchy::SpatialHierarchyPanelView::statusMessageRequested, - this, &MainWindow::setStatusMessage); + models_controller_ = new panels::models::ModelsPanelController( + models_panel_, session_state_, viewport_widget_->viewport(), element_registry_, this); + models_view_ = new panels::models::ModelsPanelView(models_panel_, session_state_, this); + spatial_view_ = new panels::spatial_hierarchy::SpatialHierarchyPanelView(spatial_panel_, session_state_, this); + properties_view_ = new panels::properties::PropertiesPanelView(properties_panel_, session_state_, this); layers_panel_ = new components::Panel("Layers", new panels::todo::TodoPanelWidget("Layers", this), this); stored_views_panel_ = new components::Panel( @@ -403,14 +399,20 @@ void MainWindow::setupStatus() { status_perf_label_->setVisible(show); if (!show) status_perf_label_->clear(); }); + connect(session_state_, &ifcinterface::SessionState::statusMessageChanged, + this, [this](const QString& mode, const QString& detail) { + status_mode_label_->setText(mode); + status_selection_label_->setText(detail); + }); + session_state_->setStatusMessage("Ready", "No selection"); } void MainWindow::setupLoader() { loader_ = new SceneLoader(viewport_widget_->viewport(), this); element_registry_->bindLoader(loader_); + session_state_->bindLoader(loader_); viewport_controller_ = new panels::viewport::ViewportController( - federation_, loader_, viewport_widget_->viewport(), - &fed_id_to_model_id_, &model_id_to_fed_id_, this); + session_state_, viewport_widget_->viewport(), this); connect(loader_, &SceneLoader::loadStarted, this, &MainWindow::onLoadStarted); connect(loader_, &SceneLoader::loadedFromSidecar, this, &MainWindow::onLoadedFromSidecar); connect(loader_, &SceneLoader::loadedFromStream, this, &MainWindow::onLoadedFromStream); @@ -431,7 +433,11 @@ void MainWindow::setupLoader() { .arg(s.total_triangles) .arg(s.gl_draw_calls)); }); - + connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked, + this, [this](uint32_t object_id) { + session_state_->setSelectedObjectId(object_id); + session_state_->notifySelectionChanged(); + }); } void MainWindow::addFiles(const QStringList& paths) { @@ -504,17 +510,18 @@ void MainWindow::onAddFiles() { void MainWindow::clearScene() { if (viewport_widget_) viewport_widget_->viewport()->setSelectedObjectId(0); - if (properties_view_) properties_view_->clearSelection(); + session_state_->setSelectedObjectId(0); + session_state_->notifySelectionChanged(); - const auto model_ids = model_id_to_fed_id_.keys(); + const auto model_ids = session_state_->modelIds(); for (uint32_t mid : model_ids) { viewport_widget_->viewport()->removeModel(mid); loader_->removeModel(mid); } - fed_id_to_model_id_.clear(); - model_id_to_fed_id_.clear(); + session_state_->clearModelMappings(); element_registry_->clear(); + session_state_->notifyModelsChanged(); } bool MainWindow::confirmDiscardIfDirty() { @@ -533,9 +540,9 @@ void MainWindow::loadModelsFromPaths(const QStringList& paths, const QStringList if (paths.isEmpty()) return; const auto ids = loader_->addFiles(paths); for (int i = 0; i < paths.size() && i < static_cast(ids.size()) && i < fed_ids.size(); ++i) { - fed_id_to_model_id_[fed_ids[i]] = ids[i]; - model_id_to_fed_id_[ids[i]] = fed_ids[i]; + session_state_->setModelMapping(fed_ids[i], ids[i]); } + session_state_->notifyModelsChanged(); } bool MainWindow::openProject(const QString& path) { @@ -587,7 +594,8 @@ bool MainWindow::openProject(const QString& path) { hv.target.x(), hv.target.y(), hv.target.z(), hv.distance, hv.yaw, hv.pitch); } updateWindowTitle(); - setStatusMessage("Project", QFileInfo(path).fileName()); + session_state_->setStatusMessage("Project", QFileInfo(path).fileName()); + session_state_->notifyProjectOpened(path); return true; } @@ -601,7 +609,7 @@ bool MainWindow::saveProject() { return false; } updateWindowTitle(); - setStatusMessage("Project", QFileInfo(federation_->filePath()).fileName()); + session_state_->setStatusMessage("Project", QFileInfo(federation_->filePath()).fileName()); return true; } @@ -626,7 +634,7 @@ bool MainWindow::saveProjectAs() { return false; } updateWindowTitle(); - setStatusMessage("Project", QFileInfo(path).fileName()); + session_state_->setStatusMessage("Project", QFileInfo(path).fileName()); return true; } @@ -653,7 +661,8 @@ void MainWindow::onNewProject() { federation_->clear(); viewport_controller_->applyFederatedFalseOrigin(); updateWindowTitle(); - setStatusMessage("Project", "Untitled"); + session_state_->setStatusMessage("Project", "Untitled"); + session_state_->notifyProjectReset(); } void MainWindow::onOpenProject() { @@ -684,12 +693,12 @@ void MainWindow::onSetHomeView() { home_view.pitch = camera.pitch; federation_->setHomeView(home_view); updateWindowTitle(); - setStatusMessage("Camera", "Home view updated"); + session_state_->setStatusMessage("Camera", "Home view updated"); } void MainWindow::onGoHomeView() { if (!federation_->hasHomeView()) { - setStatusMessage("Camera", "No home view set for this project"); + session_state_->setStatusMessage("Camera", "No home view set for this project"); return; } @@ -697,44 +706,40 @@ void MainWindow::onGoHomeView() { viewport_widget_->viewport()->setCamera( home_view.target.x(), home_view.target.y(), home_view.target.z(), home_view.distance, home_view.yaw, home_view.pitch); - setStatusMessage("Camera", "Home view restored"); + session_state_->setStatusMessage("Camera", "Home view restored"); } void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { - status_mode_label_->setText("Loading"); - status_selection_label_->setText(display_name); + session_state_->setStatusMessage("Loading", display_name); } void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { - status_mode_label_->setText("Loaded"); - status_selection_label_->setText( + session_state_->setStatusMessage( + "Loaded", QString("%1 from cache in %2") .arg(loader_->displayName(mid)) .arg(formatElapsed(elapsed_ms))); } void MainWindow::onLoadedFromStream(uint32_t mid, qint64 elapsed_ms) { - status_mode_label_->setText("Loaded"); - status_selection_label_->setText( + session_state_->setStatusMessage( + "Loaded", QString("%1 streamed in %2") .arg(loader_->displayName(mid)) .arg(formatElapsed(elapsed_ms))); } void MainWindow::onLoadCancelled(uint32_t mid) { - status_mode_label_->setText("Cancelled"); - status_selection_label_->setText(loader_->displayName(mid)); + session_state_->setStatusMessage("Cancelled", loader_->displayName(mid)); } void MainWindow::onLoadError(uint32_t /*mid*/, QString message) { - status_mode_label_->setText("Error"); - status_selection_label_->setText(message); + session_state_->setStatusMessage("Error", message); QMessageBox::warning(this, "IfcInterfaceMockup", message); } void MainWindow::onAllLoadsFinished() { - status_mode_label_->setText("Loaded"); - status_selection_label_->setText(QString("%1 model(s)").arg(loader_->modelCount())); + session_state_->setStatusMessage("Loaded", QString("%1 model(s)").arg(loader_->modelCount())); } } // namespace ifcinterface::shell diff --git a/src/interface/MainWindow.h b/src/interface/MainWindow.h index 18a7b93a25..c42612576d 100644 --- a/src/interface/MainWindow.h +++ b/src/interface/MainWindow.h @@ -32,7 +32,9 @@ class QToolButton; class Federation; class SceneLoader; namespace ifcinterface { class ElementRegistry; } +namespace ifcinterface { class SessionState; } namespace ifcinterface::components { class TabBar; } +namespace ifcinterface::panels::models { class ModelsPanelController; } namespace ifcinterface::panels::models { class ModelsPanelWidget; } namespace ifcinterface::panels::models { class ModelsPanelView; } namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelWidget; } @@ -70,7 +72,6 @@ private: QToolButton* makeRibbonAction(const QString& text, const QString& icon_path); QWidget* makeRibbonGroup(const QString& title, const QList& buttons); QToolButton* makePanelToggle(const QString& text, QDockWidget* dock); - void setStatusMessage(const QString& mode, const QString& detail); void addFiles(const QStringList& paths); QString formatElapsed(qint64 ms) const; @@ -100,6 +101,7 @@ private: ifcinterface::panels::viewport::ViewportController* viewport_controller_ = nullptr; SceneLoader* loader_ = nullptr; ifcinterface::ElementRegistry* element_registry_ = nullptr; + ifcinterface::SessionState* session_state_ = nullptr; ifcinterface::panels::models::ModelsPanelWidget* models_panel_ = nullptr; ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelWidget* spatial_panel_ = nullptr; QDockWidget* layers_panel_ = nullptr; @@ -110,11 +112,10 @@ private: QDockWidget* audit_panel_ = nullptr; QDockWidget* clash_panel_ = nullptr; QDockWidget* issues_panel_ = nullptr; + ifcinterface::panels::models::ModelsPanelController* models_controller_ = nullptr; ifcinterface::panels::models::ModelsPanelView* models_view_ = nullptr; ifcinterface::panels::spatial_hierarchy::SpatialHierarchyPanelView* spatial_view_ = nullptr; ifcinterface::panels::properties::PropertiesPanelView* properties_view_ = nullptr; - QHash fed_id_to_model_id_; - QHash model_id_to_fed_id_; }; } // namespace ifcinterface::shell diff --git a/src/interface/SessionState.cpp b/src/interface/SessionState.cpp new file mode 100644 index 0000000000..b8488e65dc --- /dev/null +++ b/src/interface/SessionState.cpp @@ -0,0 +1,107 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 "SessionState.h" + +#include "../ifcviewer/Federation.h" + +namespace ifcinterface { + +SessionState::SessionState(QObject* parent) + : QObject(parent) +{ +} + +void SessionState::bindFederation(Federation* federation) { + federation_ = federation; +} + +void SessionState::bindLoader(SceneLoader* loader) { + loader_ = loader; +} + +void SessionState::bindElementRegistry(ElementRegistry* element_registry) { + element_registry_ = element_registry; +} + +void SessionState::setSelectedObjectId(uint32_t object_id) { + selected_object_id_ = object_id; +} + +void SessionState::setStatusMessage(const QString& mode, const QString& detail) { + status_mode_ = mode; + status_detail_ = detail; + emit statusMessageChanged(status_mode_, status_detail_); +} + +void SessionState::setModelMapping(const QString& fed_id, uint32_t model_id) { + fed_id_to_model_id_[fed_id] = model_id; + model_id_to_fed_id_[model_id] = fed_id; +} + +void SessionState::removeModelMappingByFedId(const QString& fed_id) { + auto it = fed_id_to_model_id_.find(fed_id); + if (it == fed_id_to_model_id_.end()) return; + model_id_to_fed_id_.remove(it.value()); + fed_id_to_model_id_.erase(it); +} + +void SessionState::clearModelMappings() { + fed_id_to_model_id_.clear(); + model_id_to_fed_id_.clear(); +} + +uint32_t SessionState::modelIdForFedId(const QString& fed_id) const { + return fed_id_to_model_id_.value(fed_id, 0); +} + +QString SessionState::fedIdForModelId(uint32_t model_id) const { + return model_id_to_fed_id_.value(model_id); +} + +QList SessionState::modelIds() const { + return model_id_to_fed_id_.keys(); +} + +void SessionState::notifySelectionChanged() { + emit selectionChanged(selected_object_id_); +} + +void SessionState::notifyModelsChanged() { + emit modelsChanged(); +} + +void SessionState::notifyFederationStructureChanged() { + emit federationStructureChanged(); +} + +void SessionState::notifyVisibilityChanged() { + emit visibilityChanged(); +} + +void SessionState::notifyProjectOpened(const QString& path) { + emit projectOpened(path); +} + +void SessionState::notifyProjectReset() { + emit projectReset(); +} + +} // namespace ifcinterface diff --git a/src/interface/SessionState.h b/src/interface/SessionState.h new file mode 100644 index 0000000000..4b543fcb43 --- /dev/null +++ b/src/interface/SessionState.h @@ -0,0 +1,91 @@ +// This file was generated with the assistance of an AI coding tool. +/******************************************************************************** + * * + * 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 IFCINTERFACE_SESSIONSTATE_H +#define IFCINTERFACE_SESSIONSTATE_H + +#include +#include +#include + +class Federation; +class SceneLoader; + +namespace ifcinterface { + +class ElementRegistry; + +class SessionState : public QObject { + Q_OBJECT + +public: + explicit SessionState(QObject* parent = nullptr); + + void bindFederation(Federation* federation); + void bindLoader(SceneLoader* loader); + void bindElementRegistry(ElementRegistry* element_registry); + + Federation* federation() const { return federation_; } + SceneLoader* loader() const { return loader_; } + ElementRegistry* elementRegistry() const { return element_registry_; } + QString statusMode() const { return status_mode_; } + QString statusDetail() const { return status_detail_; } + + void setSelectedObjectId(uint32_t object_id); + uint32_t selectedObjectId() const { return selected_object_id_; } + void setStatusMessage(const QString& mode, const QString& detail); + + void setModelMapping(const QString& fed_id, uint32_t model_id); + void removeModelMappingByFedId(const QString& fed_id); + void clearModelMappings(); + uint32_t modelIdForFedId(const QString& fed_id) const; + QString fedIdForModelId(uint32_t model_id) const; + QList modelIds() const; + + void notifySelectionChanged(); + void notifyModelsChanged(); + void notifyFederationStructureChanged(); + void notifyVisibilityChanged(); + void notifyProjectOpened(const QString& path); + void notifyProjectReset(); + +signals: + void projectOpened(const QString& path); + void projectReset(); + void modelsChanged(); + void federationStructureChanged(); + void visibilityChanged(); + void selectionChanged(uint32_t object_id); + void statusMessageChanged(const QString& mode, const QString& detail); + +private: + Federation* federation_ = nullptr; + SceneLoader* loader_ = nullptr; + ElementRegistry* element_registry_ = nullptr; + uint32_t selected_object_id_ = 0; + QString status_mode_; + QString status_detail_; + QHash fed_id_to_model_id_; + QHash model_id_to_fed_id_; +}; + +} // namespace ifcinterface + +#endif diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp index f7b7c5f14b..a115247716 100644 --- a/src/interface/components/Style.cpp +++ b/src/interface/components/Style.cpp @@ -253,6 +253,33 @@ QString buildAppStyleSheet() { QPushButton:pressed { background: ${ribbon_button_pressed}; } + QMenu { + background: ${panel_background}; + color: ${primary_text}; + border: 1px solid ${border}; + padding: 4px; + } + QMenu::item { + background: transparent; + color: ${primary_text}; + padding: 6px 24px 6px 10px; + border-radius: ${panel_radius}px; + } + QMenu::item:selected { + background: ${selection_background}; + color: ${selection_text}; + } + QMenu::item:disabled { + color: ${disabled_text}; + } + QMenu::separator { + height: 1px; + background: ${border}; + margin: 4px 8px; + } + QMenu::icon { + padding-left: 2px; + } QFrame#entityClassBox, QGroupBox#propertySetBox { background: ${box_background}; diff --git a/src/interface/panels/properties/View.cpp b/src/interface/panels/properties/View.cpp index c6535e3d80..aa93b85422 100644 --- a/src/interface/panels/properties/View.cpp +++ b/src/interface/panels/properties/View.cpp @@ -23,28 +23,27 @@ #include "Widget.h" #include "../../ElementRegistry.h" +#include "../../SessionState.h" #include "../../../ifcviewer/AppSettings.h" -#include "../../../ifcviewer/ViewportWindow.h" namespace ifcinterface::panels::properties { PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, - ViewportWindow* viewport, - ifcinterface::ElementRegistry* registry, + ifcinterface::SessionState* session_state, QObject* parent) - : QObject(parent), widget_(widget), registry_(registry) + : QObject(parent), widget_(widget), session_state_(session_state) { - connect(viewport, &ViewportWindow::objectPicked, this, [this](uint32_t object_id) { + connect(session_state_, &ifcinterface::SessionState::selectionChanged, this, [this](uint32_t object_id) { refresh(object_id); }); + connect(session_state_, &ifcinterface::SessionState::projectReset, this, [this]() { + refresh(0); + }); refresh(0); } -void PropertiesPanelView::clearSelection() { - refresh(0); -} - void PropertiesPanelView::refresh(uint32_t object_id) { + auto* registry = session_state_->elementRegistry(); PropertiesPanelState state; state.entity = {"IfcWall", "SOLIDWALL"}; state.attributes = { @@ -84,13 +83,13 @@ void PropertiesPanelView::refresh(uint32_t object_id) { {"Paint Coverage", "42.78 m2"}}}, }; - if (!registry_) { + if (!registry) { widget_->render(state); return; } if (!AppSettings::instance().loadDataSource()) { - auto info = registry_->findBasicElementInfo(object_id); + auto info = registry->findBasicElementInfo(object_id); if (info && !info->type.isEmpty()) { state.entity.entity_class = info->type; if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { @@ -111,7 +110,7 @@ void PropertiesPanelView::refresh(uint32_t object_id) { return; } - auto entity = registry_->findEntity(object_id); + auto entity = registry->findEntity(object_id); if (entity) { state.entity.entity_class = QString::fromStdString(entity->declaration().name()); if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { diff --git a/src/interface/panels/properties/View.h b/src/interface/panels/properties/View.h index f68b1dfc45..8ed49e1bfd 100644 --- a/src/interface/panels/properties/View.h +++ b/src/interface/panels/properties/View.h @@ -25,8 +25,7 @@ #include -namespace ifcinterface { class ElementRegistry; } -class ViewportWindow; +namespace ifcinterface { class SessionState; } namespace ifcinterface::panels::properties { class PropertiesPanelWidget; @@ -35,16 +34,14 @@ class PropertiesPanelView : public QObject { Q_OBJECT public: explicit PropertiesPanelView(PropertiesPanelWidget* widget, - ViewportWindow* viewport, - ifcinterface::ElementRegistry* registry, + ifcinterface::SessionState* session_state, QObject* parent = nullptr); - void clearSelection(); private: void refresh(uint32_t object_id); PropertiesPanelWidget* widget_ = nullptr; - ifcinterface::ElementRegistry* registry_ = nullptr; + ifcinterface::SessionState* session_state_ = nullptr; }; } // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/spatial_hierarchy/View.cpp b/src/interface/panels/spatial_hierarchy/View.cpp index 65077906e8..de78b3db31 100644 --- a/src/interface/panels/spatial_hierarchy/View.cpp +++ b/src/interface/panels/spatial_hierarchy/View.cpp @@ -22,6 +22,8 @@ #include "Widget.h" +#include "../../SessionState.h" + namespace ifcinterface::panels::spatial_hierarchy { namespace { @@ -37,8 +39,10 @@ TreeNode* findNodeRecursive(QList& nodes, const NodePath& path, int de } // namespace -SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanelWidget* widget, QObject* parent) - : QObject(parent), widget_(widget) +SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanelWidget* widget, + ifcinterface::SessionState* session_state, + QObject* parent) + : QObject(parent), widget_(widget), session_state_(session_state) { nodes_ = { {"Site A", ItemKind::Site, true, @@ -52,7 +56,7 @@ SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanelWidget if (auto* node = findNode(path)) { node->visible = !node->visible; reload(); - emit statusMessageRequested("Spatial", node->visible ? "Item shown" : "Item hidden"); + session_state_->setStatusMessage("Spatial", node->visible ? "Item shown" : "Item hidden"); } }); diff --git a/src/interface/panels/spatial_hierarchy/View.h b/src/interface/panels/spatial_hierarchy/View.h index 4df2ddc124..123db712ef 100644 --- a/src/interface/panels/spatial_hierarchy/View.h +++ b/src/interface/panels/spatial_hierarchy/View.h @@ -25,6 +25,7 @@ #include +namespace ifcinterface { class SessionState; } namespace ifcinterface::panels::spatial_hierarchy { class SpatialHierarchyPanelWidget; @@ -32,16 +33,16 @@ class SpatialHierarchyPanelWidget; class SpatialHierarchyPanelView : public QObject { Q_OBJECT public: - explicit SpatialHierarchyPanelView(SpatialHierarchyPanelWidget* widget, QObject* parent = nullptr); - -signals: - void statusMessageRequested(const QString& mode, const QString& detail); + explicit SpatialHierarchyPanelView(SpatialHierarchyPanelWidget* widget, + ifcinterface::SessionState* session_state, + QObject* parent = nullptr); private: void reload(); TreeNode* findNode(const NodePath& path); SpatialHierarchyPanelWidget* widget_ = nullptr; + ifcinterface::SessionState* session_state_ = nullptr; QList nodes_; }; diff --git a/src/interface/panels/viewport/Controller.cpp b/src/interface/panels/viewport/Controller.cpp index 14fc3dfea7..acbe20e896 100644 --- a/src/interface/panels/viewport/Controller.cpp +++ b/src/interface/panels/viewport/Controller.cpp @@ -20,6 +20,7 @@ #include "Controller.h" +#include "../../SessionState.h" #include "../../../ifcviewer/AppSettings.h" #include "../../../ifcviewer/Federation.h" #include "../../../ifcviewer/SceneLoader.h" @@ -29,61 +30,74 @@ namespace ifcinterface::panels::viewport { -ViewportController::ViewportController(Federation* federation, - SceneLoader* loader, +ViewportController::ViewportController(ifcinterface::SessionState* session_state, ViewportWindow* viewport, - const QHash* fed_id_to_model_id, - const QHash* model_id_to_fed_id, QObject* parent) : QObject(parent) - , federation_(federation) - , loader_(loader) + , session_state_(session_state) , viewport_(viewport) - , fed_id_to_model_id_(fed_id_to_model_id) - , model_id_to_fed_id_(model_id_to_fed_id) { - connect(federation_, &Federation::federatedFalseOriginChanged, + Federation* federation = session_state_->federation(); + SceneLoader* loader = session_state_->loader(); + connect(federation, &Federation::federatedFalseOriginChanged, this, &ViewportController::applyFederatedFalseOrigin); - connect(federation_, &Federation::configChanged, this, [this]() { + connect(federation, &Federation::configChanged, this, [this]() { applyFederatedFalseOrigin(); - for (auto it = model_id_to_fed_id_->cbegin(); it != model_id_to_fed_id_->cend(); ++it) { - applyModelTransformation(it.key()); + for (uint32_t mid : session_state_->modelIds()) { + applyModelTransformation(mid); } }); - connect(federation_, &Federation::modelTransformationChanged, + connect(federation, &Federation::modelTransformationChanged, this, [this](const QString& fed_id) { - auto it = fed_id_to_model_id_->find(fed_id); - if (it != fed_id_to_model_id_->end()) { - applyModelTransformation(it.value()); + const uint32_t mid = session_state_->modelIdForFedId(fed_id); + if (mid != 0) applyModelTransformation(mid); + }); + connect(federation, &Federation::modelVisibilityChanged, + this, [this](const QString& fed_id, bool /*visible*/) { + const uint32_t mid = session_state_->modelIdForFedId(fed_id); + if (mid != 0) applyModelVisibility(mid); + }); + connect(federation, &Federation::modelGroupChanged, + this, [this](const QString& fed_id, const QString& /*group_id*/) { + const uint32_t mid = session_state_->modelIdForFedId(fed_id); + if (mid != 0) applyModelVisibility(mid); + }); + connect(federation, &Federation::groupVisibilityChanged, + this, [this](const QString&, bool /*visible*/) { + for (uint32_t mid : session_state_->modelIds()) { + applyModelVisibility(mid); } }); - connect(loader_, &SceneLoader::loadedFromSidecar, this, + connect(loader, &SceneLoader::loadedFromSidecar, this, [this](uint32_t mid, qint64 /*elapsed_ms*/) { applyCoordinateOperation(mid); + applyModelVisibility(mid); maybeGuessFederatedFalseOrigin(mid); }); - connect(loader_, &SceneLoader::dataSourceReady, this, + connect(loader, &SceneLoader::dataSourceReady, this, [this](uint32_t mid) { applyCoordinateOperation(mid); }); - connect(loader_, &SceneLoader::loadedFromStream, this, + connect(loader, &SceneLoader::loadedFromStream, this, [this](uint32_t mid, qint64 /*elapsed_ms*/) { applyCoordinateOperation(mid); + applyModelVisibility(mid); maybeGuessFederatedFalseOrigin(mid); }); connect(&AppSettings::instance(), &AppSettings::applyCoordinateOperationChanged, this, [this](bool /*enabled*/) { - for (auto it = model_id_to_fed_id_->cbegin(); it != model_id_to_fed_id_->cend(); ++it) { - applyCoordinateOperation(it.key()); + for (uint32_t mid : session_state_->modelIds()) { + applyCoordinateOperation(mid); } }); } void ViewportController::applyCoordinateOperation(uint32_t mid) { + SceneLoader* loader = session_state_->loader(); Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); if (AppSettings::instance().applyCoordinateOperation()) { - if (const ModelGeoref* georef = loader_->modelGeoref(mid)) { + if (const ModelGeoref* georef = loader->modelGeoref(mid)) { if (georef->has_coordinate_operation) { matrix = georef->coordinate_operation_meters; } @@ -94,13 +108,15 @@ void ViewportController::applyCoordinateOperation(uint32_t mid) { } void ViewportController::applyModelTransformation(uint32_t mid) { + Federation* federation = session_state_->federation(); + SceneLoader* loader = session_state_->loader(); Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); - auto fed_it = model_id_to_fed_id_->find(mid); - if (fed_it != model_id_to_fed_id_->end()) { - if (const Federation::Model* model = federation_->findById(fed_it.value())) { + const QString fed_id = session_state_->fedIdForModelId(mid); + if (!fed_id.isEmpty()) { + if (const Federation::Model* model = federation->findById(fed_id)) { ModelUnits units; Eigen::Matrix4d coordinate_operation = Eigen::Matrix4d::Identity(); - if (const ModelGeoref* georef = loader_->modelGeoref(mid)) { + if (const ModelGeoref* georef = loader->modelGeoref(mid)) { units = georef->units; if (AppSettings::instance().applyCoordinateOperation() && georef->has_coordinate_operation) { @@ -108,30 +124,45 @@ void ViewportController::applyModelTransformation(uint32_t mid) { } } matrix = composeModelTransformation( - model->model_transformation, federation_->config(), units, coordinate_operation); + model->model_transformation, federation->config(), units, coordinate_operation); } } viewport_->setModelTransformation(mid, matrix); } +void ViewportController::applyModelVisibility(uint32_t mid) { + Federation* federation = session_state_->federation(); + const QString fed_id = session_state_->fedIdForModelId(mid); + if (fed_id.isEmpty()) return; + + if (federation->isModelEffectivelyVisible(fed_id)) { + viewport_->showModel(mid); + } else { + viewport_->hideModel(mid); + } +} + void ViewportController::applyFederatedFalseOrigin() { + Federation* federation = session_state_->federation(); viewport_->setFederatedFalseOrigin( - composeFederatedFalseOrigin(federation_->federatedFalseOrigin(), federation_->config())); + composeFederatedFalseOrigin(federation->federatedFalseOrigin(), federation->config())); } void ViewportController::maybeGuessFederatedFalseOrigin(uint32_t mid) { - if (!federation_->filePath().isEmpty()) return; + Federation* federation = session_state_->federation(); + SceneLoader* loader = session_state_->loader(); + if (!federation->filePath().isEmpty()) return; - const FederatedFalseOrigin& current = federation_->federatedFalseOrigin(); + const FederatedFalseOrigin& current = federation->federatedFalseOrigin(); const FederatedFalseOrigin defaults; if (current.xyz != defaults.xyz || current.rz_deg != defaults.rz_deg) return; - const Eigen::Matrix4d* placement = loader_->firstPlacement(mid); - const ModelGeoref* georef = loader_->modelGeoref(mid); + const Eigen::Matrix4d* placement = loader->firstPlacement(mid); + const ModelGeoref* georef = loader->modelGeoref(mid); if (placement == nullptr || georef == nullptr) return; - federation_->setFederatedFalseOrigin(guessFederatedFalseOrigin( - *placement, *georef, federation_->config(), + federation->setFederatedFalseOrigin(guessFederatedFalseOrigin( + *placement, *georef, federation->config(), AppSettings::instance().applyCoordinateOperation())); } diff --git a/src/interface/panels/viewport/Controller.h b/src/interface/panels/viewport/Controller.h index 25506cc9dd..69aff80b4d 100644 --- a/src/interface/panels/viewport/Controller.h +++ b/src/interface/panels/viewport/Controller.h @@ -21,11 +21,9 @@ #ifndef IFCINTERFACE_PANELS_VIEWPORT_CONTROLLER_H #define IFCINTERFACE_PANELS_VIEWPORT_CONTROLLER_H -#include #include -class Federation; -class SceneLoader; +namespace ifcinterface { class SessionState; } class ViewportWindow; namespace ifcinterface::panels::viewport { @@ -34,11 +32,8 @@ class ViewportController : public QObject { Q_OBJECT public: - explicit ViewportController(Federation* federation, - SceneLoader* loader, + explicit ViewportController(ifcinterface::SessionState* session_state, ViewportWindow* viewport, - const QHash* fed_id_to_model_id, - const QHash* model_id_to_fed_id, QObject* parent = nullptr); void applyFederatedFalseOrigin(); @@ -46,13 +41,11 @@ public: private: void applyCoordinateOperation(uint32_t mid); void applyModelTransformation(uint32_t mid); + void applyModelVisibility(uint32_t mid); void maybeGuessFederatedFalseOrigin(uint32_t mid); - Federation* federation_ = nullptr; - SceneLoader* loader_ = nullptr; + ifcinterface::SessionState* session_state_ = nullptr; ViewportWindow* viewport_ = nullptr; - const QHash* fed_id_to_model_id_ = nullptr; - const QHash* model_id_to_fed_id_ = nullptr; }; } // namespace ifcinterface::panels::viewport