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 f82c03ccb1..332fdd27aa 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -71,6 +71,8 @@ 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_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) @@ -291,7 +293,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) @@ -659,6 +662,28 @@ 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) +endif() # Cmake uninstall target if(NOT TARGET uninstall) 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/ifcgeom/kernels/CMakeLists.txt b/src/ifcgeom/kernels/CMakeLists.txt index 5b00bbc7f8..9a8fa79f34 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 "$") if (NOT WASM_BUILD) # wasm-ld (?) trips up on multiple defined symbols. IfcParse already brings diff --git a/src/ifcgeom/mapping/CMakeLists.txt b/src/ifcgeom/mapping/CMakeLists.txt index 57b9aaefd7..aec66fd5d7 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 "$") if (NOT WASM_BUILD) # wasm-ld (?) trips up on multiple defined symbols. IfcParse already brings diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 5c46e2d0d0..68083e9a48 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 @@ -388,15 +393,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/ifcopenshell-python/ifcopenshell/entity_instance.py b/src/ifcopenshell-python/ifcopenshell/entity_instance.py index 026352dd03..6d8cfb2146 100644 --- a/src/ifcopenshell-python/ifcopenshell/entity_instance.py +++ b/src/ifcopenshell-python/ifcopenshell/entity_instance.py @@ -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/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/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..c9f09638b4 100644 --- a/src/ifcparse/storage.h +++ b/src/ifcparse/storage.h @@ -31,6 +31,7 @@ namespace rocksdb { #include #include #include +#include #include #include #include @@ -389,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(); 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-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 new file mode 100644 index 0000000000..d7b7886b8c --- /dev/null +++ b/src/ifcviewer-full/MainWindow.cpp @@ -0,0 +1,1322 @@ +/******************************************************************************** + * * + * 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 "AppSettings.h" +#include "Federation.h" +#include "FederationSettingsDialog.h" +#include "Measurement.h" +#include "ModelTransformationDialog.h" +#include "SettingsWindow.h" +#include "LodBuilder.h" +#include "SidecarCache.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +MainWindow::MainWindow(QWidget* parent) + : QMainWindow(parent) +{ + setupUi(); + 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::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::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); + }); + + 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::dataSourceReady, + this, &MainWindow::onDataSourceReady); + connect(loader_, &SceneLoader::streamedElementsReady, + 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, + 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)") + .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(); + }); + + // 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); +} + +MainWindow::~MainWindow() = default; + +void MainWindow::setupUi() { + 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); + 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); + 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(*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); + 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); + 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); + + 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); + + progress_bar_ = new QProgressBar(); + 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_); +} + +void MainWindow::setupMenus() { + auto* file_menu = menuBar()->addMenu("&File"); + 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->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(); + 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->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); +} + +void MainWindow::onFileOpen() { + QStringList paths = QFileDialog::getOpenFileNames( + this, "Add IFC Files", QString(), + "IFC Files (*.ifc *.ifcxml *.ifczip);;" + "IFC Viewer Cache (*.ifcview);;" + "All Files (*)"); + if (!paths.isEmpty()) { + addFiles(paths); + } +} + +void MainWindow::onDatabaseOpen() { + 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); + } +} + +void MainWindow::onFileSettings() { + if (settings_ == nullptr) { + settings_ = new SettingsWindow(this); + } + settings_->open(); + settings_->activateWindow(); + 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::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; + 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 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(); + root->setText(0, display); + root->setText(1, "IFC Model"); + root->setData(0, Qt::UserRole, static_cast(0)); + tree_roots_[mid] = root; + reparentModelTreeRoot(mid); + } +} + +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(); + + // 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; + 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(); + + // 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(), + 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); + loader_->removeModel(mid); + removeModelUi(mid); + } + 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() { + 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); + status_label_->setText("Loading: " + display_name); +} + +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; + } + } + + QString display_name = QString::fromStdString(name); + if (display_name.isEmpty()) { + display_name = QString::fromStdString(type) + " #" + QString::number(ifc_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); + + tree_items_[object_id] = item; +} + +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(); + element_tree_->setUpdatesEnabled(false); + + 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; + + 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::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(); + 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::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); + // 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::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") + .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); + applyModelVisibilityToViewport(mid); + maybeGuessFederatedFalseOrigin(mid); +} + +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); + } +} + +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; + 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); + } + + 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); + qDebug(" Sidecar write: %lld ms (%s)", t.elapsed(), ok ? "ok" : "FAILED"); +} + +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; + 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") + .arg(element_map_.size()) + .arg(loader_->modelCount()) + .arg(formatElapsed(elapsed_ms))); + + applyCoordinateOperationToViewport(mid); + applyModelVisibilityToViewport(mid); + maybeGuessFederatedFalseOrigin(mid); + + writeSidecarForModel(mid); +} + +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); +} + +void MainWindow::onAllLoadsFinished() { + applyPendingBenchmark(); +} + +void MainWindow::onObjectPicked(uint32_t object_id) { + viewport_->setSelectedObjectId(object_id); + + 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); + + 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() { + 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::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)); + + auto* file = loader_->ifcFile(info.model_id); + if (!file) return; + + auto product = file->instance_by_id(info.ifc_id); + if (!product) return; + + 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 (...) { + str_val = "<" + std::string(ifcopenshell::argument_type_to_string(val.type())) + ">"; + } + addRow(QString::fromStdString(attr->name()), QString::fromStdString(str_val)); + } + } catch (...) {} + } + } +} + +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; + } +} + +QString MainWindow::formatElapsed(qint64 ms) const { + return (ms >= 1000) + ? 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 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 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(!effective); + for (int col = 0; col < element_tree_->columnCount(); ++col) { + root_it->second->setFont(col, f); + root_it->second->setForeground( + col, + 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); + 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); + + 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 == 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()); + } +} + +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(); +} + +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 new file mode 100644 index 0000000000..fa1bc35f91 --- /dev/null +++ b/src/ifcviewer-full/MainWindow.h @@ -0,0 +1,206 @@ +/******************************************************************************** + * * + * 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 + +#include "Measurement.h" +#include "ViewportWindow.h" +#include "SceneLoader.h" + +class Federation; +class SettingsWindow; +class FederationSettingsDialog; +class ModelTransformationDialog; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget* parent = nullptr); + ~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 onFederationSettings(); + 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); + void onSidecarElementsReady(uint32_t mid, + 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); + void onLoadError(uint32_t mid, QString message); + void onAllLoadsFinished(); + +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, + 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 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, 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 + // 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 + // 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); + + // 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(); + + // 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; + SceneLoader* loader_ = nullptr; + 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; + QProgressBar* progress_bar_ = nullptr; + QLabel* status_label_ = nullptr; + QLabel* stats_label_ = nullptr; + + // 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. + 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_; + // 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); + } + + 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 new file mode 100644 index 0000000000..d487d7bd83 --- /dev/null +++ b/src/ifcviewer-full/Measurement.cpp @@ -0,0 +1,367 @@ +/******************************************************************************** + * * + * 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 +#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; +} + +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(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, + 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.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.object_id, t); + if (removing) { + auto it = selected_.find(k); + if (it != selected_.end()) { + delta -= cache->tri_areas[t]; + selected_.erase(it); + } + } else { + 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 new file mode 100644 index 0000000000..4b0c3d4bd0 --- /dev/null +++ b/src/ifcviewer-full/Measurement.h @@ -0,0 +1,107 @@ +/******************************************************************************** + * * + * 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 +#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); + +// Click-to-accumulate area measurement. Each pick resolves the screen +// 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 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 { +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, 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(); } + +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); + + // 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); + } + + 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-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 diff --git a/src/ifcviewer-full/SettingsWindow.cpp b/src/ifcviewer-full/SettingsWindow.cpp new file mode 100644 index 0000000000..9a7032fd86 --- /dev/null +++ b/src/ifcviewer-full/SettingsWindow.cpp @@ -0,0 +1,138 @@ +/******************************************************************************** + * * + * 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 +#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_); + + 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_); + + 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_); + + 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( + "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_); + + 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); + + 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()); + 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()); +} + +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()); + 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(); +} diff --git a/src/ifcviewer-full/SettingsWindow.h b/src/ifcviewer-full/SettingsWindow.h new file mode 100644 index 0000000000..070e90867e --- /dev/null +++ b/src/ifcviewer-full/SettingsWindow.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 SETTINGSWINDOW_H +#define SETTINGSWINDOW_H + +#include + +class QCheckBox; +class QDoubleSpinBox; +class QLineEdit; +class QShowEvent; +class QSpinBox; + +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; + 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; +}; + +#endif diff --git a/src/ifcviewer-full/main.cpp b/src/ifcviewer-full/main.cpp new file mode 100644 index 0000000000..8f861e3f31 --- /dev/null +++ b/src/ifcviewer-full/main.cpp @@ -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 . * + * * + ********************************************************************************/ + +#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("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"}, + "Run N frames then print stats and exit", "frames"}); + parser.process(app); + + MainWindow window; + window.show(); + + auto args = parser.positionalArguments(); + 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")); + } + if (parser.isSet("benchmark")) { + window.setPendingBenchmark(parser.value("benchmark").toInt()); + } + + return app.exec(); +} 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..ebca8f303e --- /dev/null +++ b/src/ifcviewer-minimal/MinimalWindow.cpp @@ -0,0 +1,149 @@ +/******************************************************************************** + * * + * 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 +#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_); + + 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::loadCancelled, + this, &MinimalWindow::onLoadCancelled); + 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; + 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(); + }); + + setWindowTitle("IfcViewerMinimal"); + resize(1200, 800); +} + +void MinimalWindow::addFiles(const QStringList& paths) { + loader_->addFiles(paths); +} + +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(loader_->displayName(mid)) + .arg(formatElapsed(elapsed_ms))); +} + +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::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); +} + +void MinimalWindow::onAllLoadsFinished() { + status_label_->setText(QString("Loaded %1 model(s)").arg(loader_->modelCount())); + applyPendingBenchmark(); +} + +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..a70ed04144 --- /dev/null +++ b/src/ifcviewer-minimal/MinimalWindow.h @@ -0,0 +1,60 @@ +/******************************************************************************** + * * + * 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 "ViewportWindow.h" +#include "SceneLoader.h" + +class MinimalWindow : public QMainWindow { + Q_OBJECT +public: + explicit MinimalWindow(QWidget* parent = nullptr); + ~MinimalWindow() = default; + + void addFiles(const QStringList& paths); + void setPendingCamera(const QString& params); + void setPendingBenchmark(int frames); + +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(); + +private: + void applyPendingBenchmark(); + + ViewportWindow* viewport_ = nullptr; + SceneLoader* loader_ = nullptr; + QWidget* viewport_container_ = nullptr; + QLabel* status_label_ = nullptr; + QLabel* stats_label_ = nullptr; + + 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/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp new file mode 100644 index 0000000000..d6383ee4c5 --- /dev/null +++ b/src/ifcviewer/AppSettings.cpp @@ -0,0 +1,165 @@ +/******************************************************************************** + * * + * 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"; +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"; +constexpr double kDeflectionToleranceDefault = 0.001; +constexpr const char* kAngularToleranceKey = "loading/angular_tolerance"; +constexpr double kAngularToleranceDefault = 0.5; +} + +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); +} + +bool AppSettings::showStats() const { + return show_stats_; +} + +void AppSettings::setShowStats(bool value) { + if (show_stats_ == value) return; + show_stats_ = value; + persist(); + 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); +} + +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); +} + +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_; +} + +void AppSettings::setVoidLimit(int value) { + if (value < 0) value = 0; + if (void_limit_ == value) return; + void_limit_ = value; + persist(); + 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(); + 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(); + 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() { + QSettings settings; + settings.setValue(kGeometryLibraryKey, geometry_library_); + 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 new file mode 100644 index 0000000000..ac96ab3392 --- /dev/null +++ b/src/ifcviewer/AppSettings.h @@ -0,0 +1,100 @@ +/******************************************************************************** + * * + * 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); + + bool showStats() const; + void setShowStats(bool value); + + 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); + + // 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. + 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 applyCoordinateOperationChanged(bool value); + void voidLimitChanged(int value); + void deflectionToleranceChanged(double value); + void angularToleranceChanged(double value); + +private: + AppSettings(); + void load(); + void persist(); + + QString geometry_library_; + 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; +}; + +#endif // APPSETTINGS_H diff --git a/src/ifcviewer/BvhAccel.cpp b/src/ifcviewer/BvhAccel.cpp new file mode 100644 index 0000000000..4b115bfa4c --- /dev/null +++ b/src/ifcviewer/BvhAccel.cpp @@ -0,0 +1,145 @@ +/******************************************************************************** + * * + * 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 + +namespace { + +struct Centroid { + float x, y, z; +}; + +Centroid computeCentroid(const BvhItem& it) { + return { + (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& 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& it = items[indices[i]]; + for (int a = 0; a < 3; ++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]; + } + } +} + +void buildRecursive(ModelBvh& mbvh, + 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(items, &mbvh.item_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; + } + + 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; + + uint32_t mid = count / 2; + std::nth_element( + 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(items[a]); + Centroid cb = computeCentroid(items[b]); + return (&ca.x)[axis] < (&cb.x)[axis]; + }); + + node.count = 0; + node.axis = static_cast(axis); + + buildRecursive(mbvh, items, start, mid); + + uint32_t right_child_idx = static_cast(mbvh.nodes.size()); + buildRecursive(mbvh, items, start + mid, count - mid); + + 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 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(); + + 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); + } + + for (auto& [model_id, idxs] : model_items) { + if (idxs.size() < BVH_MIN_OBJECTS) continue; + + 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; +} diff --git a/src/ifcviewer/BvhAccel.h b/src/ifcviewer/BvhAccel.h new file mode 100644 index 0000000000..7281dff511 --- /dev/null +++ b/src/ifcviewer/BvhAccel.h @@ -0,0 +1,70 @@ +/******************************************************************************** + * * + * 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 + +// 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; + +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 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"); + +struct ModelBvh { + uint32_t model_id = 0; + std::vector nodes; + std::vector item_indices; // indices into the model's InstanceCpu array +}; + +struct BvhSet { + std::unordered_map models; + std::unordered_set bvh_model_ids; +}; + +// 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); + +// 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/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt new file mode 100644 index 0000000000..9e8385a2d1 --- /dev/null +++ b/src/ifcviewer/CMakeLists.txt @@ -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 . # +# # +################################################################################ + +message("Running CMakeLists.txt in /src/ifcviewer") + +set(QT_VERSION 6 CACHE STRING "Qt version") +# 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) +find_package(meshoptimizer 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_library(IfcViewer ${IFCVIEWER_FILES}) + +set_target_properties(IfcViewer PROPERTIES + AUTOMOC 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 PUBLIC + IfcGeom + IfcParse + ${OpenCASCADE_LIBRARIES} + ${Boost_LIBRARIES} + ${CGAL_LIBRARIES} + Qt${QT_VERSION}::Core + Qt${QT_VERSION}::Gui + Qt${QT_VERSION}::Widgets + Qt${QT_VERSION}::OpenGL + OpenGL::GL + meshoptimizer::meshoptimizer +) + +if(UNIX AND NOT APPLE) + find_package(Threads REQUIRED) + target_link_libraries(IfcViewer PUBLIC Threads::Threads) +endif() + +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/Federation.cpp b/src/ifcviewer/Federation.cpp new file mode 100644 index 0000000000..c6183adcf3 --- /dev/null +++ b/src/ifcviewer/Federation.cpp @@ -0,0 +1,808 @@ +/******************************************************************************** + * * + * 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 "Geolocation.h" +#include "Unit.h" + +#include +#include +#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; +} + +// 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); + 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 + +// === Stage-3/4 compose helpers === + +double federationUnitToMeters(const FederationConfig& cfg) { + return convert(1.0, cfg.unit_prefix, cfg.unit_name, "", "METRE"); +} + +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; + 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); +} + +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.coordinate_operation_meters = helmert * wcs_m.inverse(); + } else { + out.coordinate_operation_meters = helmert; + } + out.has_coordinate_operation = true; + 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, + 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-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 = (coordinate_operation_meters * a_h).head<3>(); + } else { + // a is in the model's map unit, expressed in the + // post-CoordinateOperation 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() { + 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(); + root_groups_.clear(); + config_ = FederationConfig{}; + federated_false_origin_ = FederatedFalseOrigin{}; + 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); + emit configChanged(); +} + +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); + emit federatedFalseOriginChanged(); +} + +void Federation::setModelTransformation(const QString& fed_id, + const ModelTransformation& xf) { + for (auto& m : models_) { + if (m.id != fed_id) continue; + m.model_transformation = xf; + setDirty(true); + emit modelTransformationChanged(fed_id); + return; + } +} + +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::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); +} + +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); + + 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("federated_false_origin"); ov.isObject()) { + QJsonObject oo = ov.toObject(); + QJsonArray xyz = oo.value("xyz").toArray(); + if (xyz.size() == 3) { + federated_false_origin_.xyz = Eigen::Vector3d( + xyz[0].toDouble(), xyz[1].toDouble(), xyz[2].toDouble()); + } + 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()) { + 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(); + + if (QJsonValue tv = mo.value("model_transformation"); tv.isObject()) { + QJsonObject to = tv.toObject(); + const QString af = to.value("a_frame").toString("ModelGlobal"); + 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.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"); + 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)); + } + + 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); + + { + 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(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"] = federated_false_origin_.rz_deg; + 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; + 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; + + // 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; + 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["model_transformation"] = to; + } + + if (!m.visible) mo["visible"] = false; + if (!m.group_id.isEmpty()) mo["group_id"] = m.group_id; + + 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 new file mode 100644 index 0000000000..2377e337bf --- /dev/null +++ b/src/ifcviewer/Federation.h @@ -0,0 +1,331 @@ +/******************************************************************************** + * * + * 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 +#include +#include +#include + +namespace ifcopenshell { class file; } + +// === Federation transformation pipeline === +// +// A federation places one or more IFC models in a shared scene. Each model's +// final per-instance transform is the composition of four named stages: +// +// FederatedFalseOrigin · ModelTransformation · CoordinateOperation +// · PlacementTransformation +// +// where: +// - 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 FederatedFalseOrigin.xyz and +// ModelTransformation::{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 = ""; +}; + +// 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 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 }; + +// 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) +// 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 + 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; +}; + +// 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 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 +// CoordinateOperation matrix. Pure compute; safe to call repeatedly if the +// 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&); + +// Compose FederatedFalseOrigin into a 4x4 matrix in metres. +Eigen::Matrix4d composeFederatedFalseOrigin(const FederatedFalseOrigin&, + const FederationConfig&); + +// 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) === +// +// 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, 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: + 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" + 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); + + // 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(); + + void setConfig(const FederationConfig&); + 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_; } + 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 FederatedFalseOrigin& federatedFalseOrigin() const { return federated_false_origin_; } + +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); + 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; + HomeView home_view_; + bool dirty_ = false; +}; + +#endif // FEDERATION_H diff --git a/src/ifcviewer/Geolocation.cpp b/src/ifcviewer/Geolocation.cpp new file mode 100644 index 0000000000..e9b6889003 --- /dev/null +++ b/src/ifcviewer/Geolocation.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 "Geolocation.h" +#include "Placement.h" + +#include "../ifcparse/express.h" +#include "../ifcparse/file.h" +#include "../ifcparse/instance_data.h" +#include "../ifcparse/schema.h" + +#include +#include +#include + +namespace { + +// 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; + const auto& decl = wcs.declaration(); + if (!(decl.is("IfcAxis2Placement3D") || decl.is("IfcAxis2PlacementLinear"))) { + 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; +} + +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; +} + +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 new file mode 100644 index 0000000000..13eaecd8b1 --- /dev/null +++ b/src/ifcviewer/Geolocation.h @@ -0,0 +1,108 @@ +/******************************************************************************** + * * + * 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 "../ifcparse/express.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); + +// 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); + +// "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/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp new file mode 100644 index 0000000000..da7f6fc624 --- /dev/null +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -0,0 +1,715 @@ +/******************************************************************************** + * * + * 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 "../ifcgeom/taxonomy.h" +#include "../ifcgeom/IfcGeomFilter.h" +#include "../ifcparse/express.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; +}; + +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) +{ +} + +GeometryStreamer::~GeometryStreamer() { + cancel(); + if (worker_thread_ && worker_thread_->isRunning()) { + worker_thread_->quit(); + worker_thread_->wait(); + } +} + +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(); + if (worker_thread_ && worker_thread_->isRunning()) { + worker_thread_->quit(); + worker_thread_->wait(); + } + } + + cancel_requested_ = false; + succeeded_ = false; + running_ = true; + progress_ = 0; + next_object_id_ = start_object_id; + model_id_ = model_id; + + { + 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; + if (succeeded_.load()) { + emit finished(); + } else if (cancel_requested_.load()) { + emit cancelled(); + } + }); + + 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; +} + +// 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. +// 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, + const Eigen::Vector3d& offset) { + 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); + + // 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); + 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; +} + +// 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], + 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 { + // 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; + } + + ifcopenshell::geometry::Settings settings; + // 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", 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 + // 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; + + // Mirror bonsai's IfcImporter.process_element_filter: walk IfcElement + // (plus IfcProxy on IFC2X3/IFC4), drop IfcFeatureElement except + // 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; + { + 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 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) { + 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 (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()); + } + + // 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; + // 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; + uint32_t total_meshes = 0; + QElapsedTimer stream_timer; + stream_timer.start(); + + // 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); + + // 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 base_settings = settings; + if (is_gross) { + base_settings.set("disable-opening-subtractions", true); + } + + // Elements that haven't yet produced geometry from any context. + std::set remaining = include_ids; + + auto run_iterator = [&](ifcopenshell::geometry::Settings& iter_settings, + int sub_lo, int sub_hi) -> bool { + if (remaining.empty()) return true; + + std::vector filters; + IfcGeom::instance_id_filter idf{ + /*include=*/true, /*traverse=*/false, remaining}; + 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, 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; + } + + 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++; + 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) { + // 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(); + 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()) { + emit meshReady(std::move(mesh_chunk)); + } + } + + // 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) { + 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; + 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); + + 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()); + + 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; + }; + + 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); + + 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); + succeeded_ = !cancel_requested_.load(); +} diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h new file mode 100644 index 0000000000..ac31035c43 --- /dev/null +++ b/src/ifcviewer/GeometryStreamer.h @@ -0,0 +1,96 @@ +/******************************************************************************** + * * + * 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 "../ifcparse/file.h" +#include "../ifcgeom/Iterator.h" + +#include "InstancedGeometry.h" + +struct ElementInfo { + uint32_t object_id; + uint32_t model_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, 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_; } + uint32_t modelId() const { return model_id_; } + + ifcopenshell::file* ifcFile() const { return ifc_file_.get(); } + + // Thread-safe access to discovered elements + std::vector drainElements(); + +signals: + void progressChanged(int percent); + void meshReady(MeshChunk chunk); + void instanceReady(InstanceChunk chunk); + void finished(); + void cancelled(); + void errorOccurred(const QString& message); + +private: + void run(const std::string& path, int num_threads); + + std::unique_ptr ifc_file_; + 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_; + std::vector pending_elements_; + + uint32_t next_object_id_ = 1; + uint32_t model_id_ = 0; +}; + +#endif // GEOMETRYSTREAMER_H diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h new file mode 100644 index 0000000000..9b2eb02bba --- /dev/null +++ b/src/ifcviewer/InstancedGeometry.h @@ -0,0 +1,147 @@ +/******************************************************************************** + * * + * 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, +// 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 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 = 12; + +// 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 = 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. +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. +// +// 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; // 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) == 56, "MeshInfo must be 56 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 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 mesh_id = 0; // index into per-model MeshGpu[] + 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. +// +// `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; + float placement_transformation[16]{}; + 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/LodBuilder.cpp b/src/ifcviewer/LodBuilder.cpp new file mode 100644 index 0000000000..dbda389971 --- /dev/null +++ b/src/ifcviewer/LodBuilder.cpp @@ -0,0 +1,182 @@ +/******************************************************************************** + * * + * 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 total_vertex_count = sd.vertices.size() / vtx_stride_bytes; + + // Env var knobs so we can tune without rebuilding. + // 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. + 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"); + + 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 dequant_pos; // 3 floats/vertex, dequantized + simplified.reserve(1024); + dequant_pos.reserve(1024 * 3); + + 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; + + // 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( + 3, static_cast(mesh.index_count * target_ratio) / 3 * 3); + + // 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 = 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, + " [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 " + "(target_error=%.3f target_ratio=%.3f min_savings=%.3f)\n", + dbg_accepted, dbg_rejected_noreduce, dbg_rejected_savings, + 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..df1638b58f --- /dev/null +++ b/src/ifcviewer/LodBuilder.h @@ -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 . * + * * + ********************************************************************************/ + +#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 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 +// 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 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, + 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/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/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/README.md b/src/ifcviewer/README.md new file mode 100644 index 0000000000..4d8290d736 --- /dev/null +++ b/src/ifcviewer/README.md @@ -0,0 +1,993 @@ +# 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 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()`. 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. +- **Quantized local-coordinate vertex format (12 B):** position as + `u16x3` normalised against each mesh's local AABB, octahedral-encoded + 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 + 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). +- **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 + `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. +- **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. + 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, 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 | +| `LodBuilder.h/cpp` | Post-stream decimation of unique meshes via meshoptimizer (`simplifySloppy`) | +| `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 | + +## Dependencies + +- **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). +- **[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 + +IfcViewer is part of the IfcOpenShell CMake project. From the repo 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 +``` + +If Qt6 is not in a standard location, pass `-DQT_DIR=/path/to/qt6`. + +## Usage + +```sh +./IfcViewer arch.ifc struct.ifc mep.ifc +./IfcViewer # then File -> Add Files +``` + +### Controls + +| Input | Action | +|-------|--------| +| Middle mouse drag | Orbit camera | +| Shift + middle mouse drag | Pan camera | +| Scroll wheel | Zoom | +| Left click | Select object | + +### 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 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). + +Rendering performance has evolved in phases. Each builds on the previous, +and smaller models never pay for optimisations they don't need. + +### Phase 1 — Per-object Frustum Culling + +**Status:** implemented (and still the fallback for small models / during +streaming). + +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). + +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. + +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. + +### Phase 2 — BVH Acceleration + Sidecar Cache + +**Status:** implemented. + +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). + +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. + +#### Activation + +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. + +It activates in one of two ways: + +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. + +Models under 32 instances skip the BVH. + +#### BVH node layout (32 B, two per cache line) + +```cpp +struct BvhNode { + 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 +}; +``` + +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. + +#### Build: 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. + +O(n log n) total. No SAH — for frustum culling (6-plane tests, early +subtree reject) the quality difference vs median is negligible. + +#### Traversal: stack-based, no recursion + +``` +stack[64] = { 0 } // root +while stack not empty: + node = nodes[stack.pop()] + if node.aabb outside frustum: continue + if leaf: + 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) +``` + +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`, 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) +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) +uint32_t + PackedElementInfo[] element tree records +uint32_t + char[] string table +``` + +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 +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 +own because it's what makes the whole thing fast. + +Per-model state on the GPU: + +| Buffer | Contents | Lifetime | +|--------|----------|----------| +| `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. | +| `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 / 12 + uint32_t baseInstance; // offset into the flat visible-index array +}; +``` + +The vertex shader reads `visible[gl_BaseInstanceARB + gl_InstanceID]` to +get the real instance id, then indexes into the instance SSBO: + +```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); +``` + +`gl_BaseInstanceARB` requires `GL_ARB_shader_draw_parameters`, which is +available on all GL-4.6-capable drivers. + +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. + +### 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 ceiling lies elsewhere, and it took a couple of +bad hypotheses to pin down. + +#### Profiled scene + +10 models / 379 k instances / 128 M triangles, everything in view, no +camera motion, GTX 1650 (PCIe dGPU, 4 GB VRAM): + +| Metric | Value | +|--------|-------| +| FPS | 6.7 | +| Frame time | 149 ms | +| gl_draws | 10 | +| Sub-draws packed in indirect buffers | 67 037 | + +`nvidia-smi` reports 95 % GPU utilisation during render — the GPU is +the thing that's pinned. + +#### False lead: "the per-frame uploads are the bottleneck" + +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): + +| 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 | + +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. + +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.** + +#### What actually isolates the draw cost + +Two diagnostic env vars now live in `render()`: + +- `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. + +Results on the profiled scene: + +| 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 — ✅ done + +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. + +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 +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 — ✅ done + +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` + +`meshopt_simplifySloppy` is a **voxel-clustering decimator** — it +quantises positions into cells and merges everything in a cell to a +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_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_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 — ✅ done (v1, CPU-side) + +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 + +- **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. +- **Transparent geometry would need special handling**, but the + current renderer doesn't have any, so no-op for now. + +#### 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 compute culling — experiments and removal + +##### What we tried + +Five iterations of GPU compute culling were explored 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. + +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. + +3. **Hybrid with synchronous readback** — added contribution culling, + read survivors back with `glGetNamedBufferSubData`. The 4–7 ms + pipeline stall negated all GPU savings. + +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. + +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. + +##### Why it was removed + +Benchmark with motion-adaptive culling + HiZ active (Phase 3G): + +| Path | FPS | +|------|-----| +| CPU BVH (parallel) | 51.0 | +| GPU cull + async readback | 52.0 | + +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. + +**Removed** in favour of keeping the codebase simple. The CPU BVH path +is now the only cull path. + +##### Lessons learned + +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 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` +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. + +#### 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 | + +##### 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. **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 + 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 +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 (done, CPU readback) +many models, serial cull single-thread BVH trv Phase 3D parallel cull (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) +``` + +## Roadmap + +- [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 +- [x] Non-blocking sidecar loading (background thread I/O) +- [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 +- [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 (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 (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) +- [ ] Vulkan/MoltenVK backend for macOS +- [ ] Embedded Python scripting console diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp new file mode 100644 index 0000000000..62ae291513 --- /dev/null +++ b/src/ifcviewer/SceneLoader.cpp @@ -0,0 +1,412 @@ +/******************************************************************************** + * * + * 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 "AppSettings.h" + +#include +#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(); + joinDataSourceThreads(); +} + +void SceneLoader::joinSidecarThread() { + if (sidecar_read_thread_.joinable()) + 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; +} + +QString SceneLoader::displayName(uint32_t mid) const { + auto it = models_.find(mid); + return it == models_.end() ? QString() : it->second.display_name; +} + +ifcopenshell::file* SceneLoader::ifcFile(uint32_t mid) const { + auto it = models_.find(mid); + 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; +} + +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()); + for (const auto& path : paths) { + uint32_t id = next_model_id_++; + 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); + } + + 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::cancelled, + this, &SceneLoader::onStreamerCancelled, Qt::QueuedConnection); + connect(streamer, &GeometryStreamer::errorOccurred, + 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_); + if (it == models_.end() || it->second.streamer == nullptr) return; + it->second.streamer->cancel(); +} + +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(); + 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, mid]() { + QElapsedTimer rt; rt.start(); + 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]() { + 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; + } + + // 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; + } + + 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); + + 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); + + 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); +} + +void SceneLoader::onStreamerMeshReady(MeshChunk chunk) { + viewport_->uploadMeshChunk(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); +} + +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); + + // 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); + } + } + } + + loading_model_id_ = 0; + startNextLoad(); +} + +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 new file mode 100644 index 0000000000..992b7def3f --- /dev/null +++ b/src/ifcviewer/SceneLoader.h @@ -0,0 +1,172 @@ +/******************************************************************************** + * * + * 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 "Federation.h" +#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); + 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; + + // 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); + + // 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); + + // 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 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. + 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 loadCancelled(uint32_t mid); + + 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 onStreamerCancelled(); + void onStreamerError(const QString& msg); + void onElementPollTick(); + +private: + struct Model { + uint32_t id = 0; + QString file_path; + 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; + + // 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(); + 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_; + 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_; + // 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_; +}; + +#endif // SCENELOADER_H diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp new file mode 100644 index 0000000000..86951f7395 --- /dev/null +++ b/src/ifcviewer/SidecarCache.cpp @@ -0,0 +1,161 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +// v11 layout (all multi-byte fields native-endian; endianness marker in header). +// +// SidecarHeader (12 bytes) +// +// uint32_t num_vertex_bytes +// 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) +// +// uint32_t num_meshes +// MeshInfo[num_meshes] +// +// uint32_t num_instances +// 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] +// uint32_t string_table_bytes +// char[string_table_bytes] + +#include "SidecarCache.h" + +#include +#include + +struct SidecarHeader { + uint32_t magic; + uint32_t version; + uint32_t endian; +}; + +// 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) { + 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 +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) { + 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 }; + if (fwrite(&hdr, sizeof(hdr), 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; } + + // 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()); + 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) { + 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(); + + 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(); + + // 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; + 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/SidecarCache.h b/src/ifcviewer/SidecarCache.h new file mode 100644 index 0000000000..ac33e200de --- /dev/null +++ b/src/ifcviewer/SidecarCache.h @@ -0,0 +1,117 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +// 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 "InstancedGeometry.h" + +#include +#include +#include +#include +#include + +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. +// 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. +// v7 = VBO vertices shrunk to 12 B/vertex (normal oct i8x2 replaces i16x2, +// eliminating 2-byte pad + saving 2 bytes on normal). +// 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). +// v9 = unused `reserved` field dropped from header (16 B -> 12 B). +// 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. +// 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 +// 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 needed to display an already-tessellated model without +// 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 (12 B/vertex as of v7). + std::vector vertices; + std::vector indices; + + // Mesh dictionary and per-instance data. + 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; +}; + +// 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); + +#endif // SIDECARCACHE_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 diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp new file mode 100644 index 0000000000..081f5e5585 --- /dev/null +++ b/src/ifcviewer/ViewportWindow.cpp @@ -0,0 +1,3887 @@ +/******************************************************************************** + * * + * 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 "AppSettings.h" + +#include +#include +#include +#include +#include +#include +#include +#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 +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 +// ----------------------------------------------------------------------------- +// +// 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 (i8x2 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 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]`. + +static const char* MAIN_VERTEX_SHADER = R"( +#version 450 core +#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; // i8x2 normalized -> [-1,1] +layout(location = 2) in vec4 a_color; + +struct InstanceRecord { + mat4 transform; + uint object_id; + uint color_override; + uint mesh_id; + uint _pad1; +}; +layout(std430, binding = 0) readonly buffer Instances { + InstanceRecord 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; + +out vec3 v_normal; +out vec4 v_color; +out vec3 v_world_pos; +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]; + 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); + v_world_pos = world.xyz; + gl_Position = u_view_projection * world; + + // 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. + 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_normal = normalize(n); + + 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; +} +)"; + +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; // 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; +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 + // 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; + + // 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); +} +)"; + +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; + uint object_id; + uint color_override; + uint mesh_id; + uint _pad1; +}; +layout(std430, binding = 0) readonly buffer Instances { + InstanceRecord 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; + +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); + uint iid = visible[slot]; + 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); + 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; +} +)"; + +static const char* PICK_FRAGMENT_SHADER = R"( +#version 450 core +flat in uint 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"( +#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); } +)"; + +// 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); } +)"; + +// 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; } +)"; + +// 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); + gl->glCompileShader(shader); + GLint ok = 0; + gl->glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); + if (!ok) { + char log[2048]; + gl->glGetShaderInfoLog(shader, sizeof(log), nullptr, log); + qWarning("Shader compile error: %s", log); + } + return shader; +} + +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 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[2048]; + gl->glGetProgramInfoLog(prog, sizeof(log), nullptr, log); + qWarning("Program link error: %s", log); + } + gl->glDeleteShader(vert); + gl->glDeleteShader(frag); + return prog; +} + +// ----------------------------------------------------------------------------- + +// 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 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], + 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 i8x2. int8 gives ~1.4° worst-case error — fine for BIM. + float oct[2]; + octEncode(src + 3, oct); + 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 * 127.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. +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) { + 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; + } + } +} + +// 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. +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) +{ + 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); + + // 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() { + 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.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); + } + 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 (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_); + 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_); + 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_); + overlay_renderer_.release(); + } + 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"); return; } + + buildShaders(); + buildAxisGizmo(); + buildPivotIndicator(); + buildSectionPlaneGizmo(); + overlay_renderer_.initialize(gl_); + + 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); + requestUpdate(); + }); + + 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); + + // position (3 x u16 normalized @ 0) + gl_->glEnableVertexArrayAttrib(vao, 0); + gl_->glVertexArrayAttribFormat(vao, 0, 3, GL_UNSIGNED_SHORT, GL_TRUE, + INSTANCED_VERTEX_POS_OFFSET); + gl_->glVertexArrayAttribBinding(vao, 0, 0); + + // normal oct-encoded (2 x i8 normalized @ 6) + gl_->glEnableVertexArrayAttrib(vao, 1); + gl_->glVertexArrayAttribFormat(vao, 1, 2, GL_BYTE, GL_TRUE, + INSTANCED_VERTEX_NORMAL_OFFSET); + gl_->glVertexArrayAttribBinding(vao, 1, 0); + + // color (4 x u8 normalized @ 8) + gl_->glEnableVertexArrayAttrib(vao, 2); + gl_->glVertexArrayAttribFormat(vao, 2, 4, GL_UNSIGNED_BYTE, GL_TRUE, + INSTANCED_VERTEX_COLOR_OFFSET); + gl_->glVertexArrayAttribBinding(vao, 2, 0); +} + +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); + } + { + 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, 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, 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); + hiz_downsample_program_ = linkProgram(gl_, vs, fs); + gl_->glCreateVertexArrays(1, &hiz_downsample_vao_); + } +} + +void ViewportWindow::buildAxisGizmo() { + static const float axis_data[] = { + 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); +} + +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; + if (new_capacity > MAX_BUFFER_SIZE) { + 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, INSTANCED_VERTEX_STRIDE_BYTES); + qInfo("Model VBO grew to %zu MB", m.vbo_capacity / (1024*1024)); + 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; + if (new_capacity > MAX_BUFFER_SIZE) { + 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)); + return true; +} + +ModelGpuData& ViewportWindow::getOrCreateModel(uint32_t model_id) { + auto it = models_gpu_.find(model_id); + if (it != models_gpu_.end()) return it->second; + + 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); + + // 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; +} + +void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { + 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); + + ModelGpuData& m = getOrCreateModel(chunk.model_id); + + // 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; + } + if (m.ebo_used + ib_size > m.ebo_capacity) { + if (!growModelEbo(m, m.ebo_used + ib_size)) return; + } + + MeshInfo info; + info.vbo_byte_offset = static_cast(m.vbo_used); + 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] = 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, quant.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; + + // 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) { + 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); + + 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; + // 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); + + // 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. + 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.mesh_id = inst.mesh_id; + 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; + } + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::finalizeModel(uint32_t model_id) { + 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); + if (it == models_gpu_.end()) return; + ModelGpuData& m = it->second; + + // 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; } + for (const auto& inst : m.instances) { + if (inst.mesh_id < m.meshes.size()) ++m.meshes[inst.mesh_id].instance_count; + } + + buildBvhForModel(m, 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 " + "(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 + 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), + m.ssbo_capacity / (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. VBO is + // raw bytes at the quantized layout. + if (m.vbo_used > 0) { + out.vertices.resize(m.vbo_used); + 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_) { + 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. + 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); + 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); + } + + 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(); + 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(vb_bytes / INSTANCED_VERTEX_STRIDE_BYTES); + 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; + + // 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) { + 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.mesh_id = src.mesh_id; + 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()); + + // 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()); + 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; + 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)", + 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), + vb_bytes / (1024.0*1024.0), + ib_bytes / (1024.0*1024.0), + ssbo_bytes / (1024.0*1024.0)); +} + +void ViewportWindow::applyLodExtension(uint32_t model_id, const SidecarData& sd) { + 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; + + 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; + have_cached_cull_ = false; + requestUpdate(); + 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; + have_cached_cull_ = false; + requestUpdate(); +} + +void ViewportWindow::resetScene() { + 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); + 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); + } + models_gpu_.clear(); + selected_object_id_ = 0; + have_cached_cull_ = false; + requestUpdate(); +} + +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; + have_cached_cull_ = false; + requestUpdate(); + } +} + +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; + have_cached_cull_ = false; + requestUpdate(); + } +} + +void ViewportWindow::removeModel(uint32_t model_id) { + 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()) { + 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); + 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); + have_cached_cull_ = false; + requestUpdate(); + } +} + +void ViewportWindow::setSelectedObjectId(uint32_t id) { + selected_object_id_ = 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(); +} + +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; + 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); +} + +ViewportWindow::CameraState ViewportWindow::cameraState() const { + return { camera_target_, camera_distance_, camera_yaw_, camera_pitch_ }; +} + +void ViewportWindow::keyPressEvent(QKeyEvent* event) { + 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; + } + + // 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; + } + // 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 + && !event->isAutoRepeat()) { + 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_) { + 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; + } + } + // Esc also exits the area tool. + if (area_tool_active_ + && key == Qt::Key_Escape + && !event->isAutoRepeat()) { + toggleAreaTool(); + 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 +// (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); + + // 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_DEPTH24_STENCIL8, win_w, win_h); + gl_->glCreateFramebuffers(1, &hiz_resolve_fbo_); + gl_->glNamedFramebufferTexture(hiz_resolve_fbo_, GL_DEPTH_STENCIL_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); + 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; + 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); + } + + // 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, + 0, 0, win_w, win_h, + GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT, GL_NEAREST); + + // 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 + // 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()); + + { + 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(), + 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); + + // 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)); + 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; + + 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]; + for (int y = y0; y < y1; ++y) { + const float* row = level + static_cast(y) * mw; + for (int x = x0; x < x1; ++x) { + if (aabb_near_depth <= row[x]) return false; + } + } + return true; +} + +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_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; + } + + 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; + gl_->glGetTextureSubImage(pick_color_tex_, 0, px, py, 0, 1, 1, 1, + GL_RED_INTEGER, GL_UNSIGNED_INT, sizeof(pixel), &pixel); + 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.origin = point; + p.d = -QVector3D::dotProduct(n, point); + section_planes_.push_back(p); + have_cached_cull_ = false; + requestUpdate(); + 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::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); + 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); + 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 + // 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()); + }; + 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(); + } + 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 + // 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 + // 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(); + // 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 — 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]) { + 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 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; + }; + + // 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 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(); + }; + + // 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. + // + // 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_; + 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) + // 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 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_.fetch_add(1, std::memory_order_relaxed); + 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(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 (!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; + // 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]; + 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); + } + 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), + // 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. + 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()); + + // 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 : 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; + + cull_emit_ns_ += phase_timer.nsecsElapsed(); +} + +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(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); + size_t new_cap = m.visible_ssbo_capacity ? m.visible_ssbo_capacity : 4096; + 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; + } + if (!m.visible_flat.empty()) { + gl_->glNamedBufferSubData(m.visible_ssbo, 0, + m.visible_flat.size() * sizeof(uint32_t), m.visible_flat.data()); + } + + // Upload indirect command buffer. + size_t ind_bytes = m.indirect_scratch.size() * sizeof(DrawElementsIndirectCommand); + 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; + 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, m.indirect_scratch.data()); + cull_upload_ns_ += phase_timer.nsecsElapsed(); +} + +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)); + camera_eye_ = eye; + view_matrix_.setToIdentity(); + // 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; + 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() { + if (!gl_initialized_ || !isExposed()) return; + + 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(); + + 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_; + 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_)); + 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"); + 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()); + // 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_); + + visible_triangles_ = 0; + visible_objects_ = 0; + gl_draw_calls_ = 0; + indirect_sub_draws_ = 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 camera_moving = !camera_unchanged; + 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; + // 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; + } 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. + gl_->glFrontFace(GL_CCW); + + 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) { + uploadCullResults(m); + } + 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_->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; + 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 && !skip_mdi) { + 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 && !skip_mdi) { + gl_->glFrontFace(GL_CW); + gl_->glMultiDrawElementsIndirect( + GL_TRIANGLES, GL_UNSIGNED_INT, + reinterpret_cast(m.indirect_forward_count * sizeof(DrawElementsIndirectCommand)), + static_cast(rev), 0); + ++gl_draw_calls_; + gl_->glFrontFace(GL_CCW); + } + + 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; + } + 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); + + 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. + // Synchronous glReadPixels inside — cost ~0.5 ms at 256x128 on a + // 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(); + } + + 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. + // 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; + + // 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; + + 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) { + 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; + + 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; + for (const auto& [mid, mm] : models_gpu_) { + num_models++; + 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()); + total_vbo += mm.vbo_capacity; + total_ebo += mm.ebo_capacity; + total_ssbo += mm.ssbo_instance_count * sizeof(InstanceGpu); + } + + FrameStats stats; + stats.fps = last_fps_; + stats.frame_time_ms = 1000.0f / last_fps_; + stats.total_objects = total_obj; + stats.visible_objects = visible_objects_; + stats.total_triangles = total_tri; + stats.visible_triangles = visible_triangles_; + stats.unique_meshes = total_meshes; + stats.gl_draw_calls = gl_draw_calls_; + 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_.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[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_.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), + 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"); + } + } +} + +void ViewportWindow::renderPickPass() { + gl_->glBindFramebuffer(GL_FRAMEBUFFER, pick_fbo_); + gl_->glViewport(0, 0, pick_width_, pick_height_); + 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_; + float planes[6][4]; + extractFrustumPlanes(vp, planes); + + 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); + + for (auto& [model_id, m] : models_gpu_) { + if (m.hidden || !m.ssbo || m.ssbo_instance_count == 0) continue; + + // 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); + 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; + 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); +} + +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::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; + + const int h = height() * devicePixelRatio(); + if (h <= 0) return; + + // 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()); + 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(); + 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()) { + if (!gl_initialized_) initGL(); + else requestUpdate(); + } +} +void ViewportWindow::resizeEvent(QResizeEvent*) { + 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; + case QEvent::Wheel: handleWheel(static_cast(e)); return true; + default: return QWindow::event(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(); + if (e->button() == Qt::MiddleButton) { + 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 (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) { + 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; + if (was_navigating && pivot_indicator_visible_) { + setPivotIndicatorVisible(false); + requestUpdate(); + } +} +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; + } + + 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) { + if (e->modifiers() & Qt::ShiftModifier) { + 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; + } else { + camera_yaw_ -= delta.x() * 0.3f; + camera_pitch_ += delta.y() * 0.3f; + camera_pitch_ = qBound(-89.0f, camera_pitch_, 89.0f); + } + requestUpdate(); + } +} +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_); + 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); +} + +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_); +} + +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; +} + +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(); + std::memcpy(out.composed_transform, inst.transform, + sizeof(out.composed_transform)); + return true; + } + } + return false; +} + +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 new file mode 100644 index 0000000000..132c6acb65 --- /dev/null +++ b/src/ifcviewer/ViewportWindow.h @@ -0,0 +1,700 @@ +/******************************************************************************** + * * + * 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 + +QT_BEGIN_NAMESPACE +class QTimer; +QT_END_NAMESPACE + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "BvhAccel.h" +#include "InstancedGeometry.h" +#include "OverlayRenderer.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. +// 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; + 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; + 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) + uint32_t total_triangles = 0; + + std::vector meshes; + 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; + + // 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; + ModelBvh bvh; + + // Dynamic visible-instance index buffer (std430, binding = 1). + // Re-uploaded each frame from visible_flat_. + GLuint visible_ssbo = 0; + size_t visible_ssbo_capacity = 0; // bytes + + // 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; // 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; + std::vector dirty_meshes; + + 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 +// 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: + explicit ViewportWindow(QWindow* parent = nullptr); + ~ViewportWindow(); + + // Streaming ingress. + void uploadMeshChunk(const MeshChunk& chunk); + void uploadInstanceChunk(const InstanceChunk& chunk); + + // 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(); + + // 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); + + // 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); + + // 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(); + + // 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; + + // 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}; + // 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); + + // 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_; } + + // 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 + // 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); + + // 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 + 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_; } + + // 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; + + // 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; + float yaw; // degrees + float pitch; // degrees + }; + CameraState cameraState() const; + + struct FrameStats { + float fps; + float frame_time_ms; + uint32_t total_objects; + uint32_t visible_objects; + uint32_t total_triangles; + uint32_t visible_triangles; + uint32_t unique_meshes; + uint32_t gl_draw_calls; // actual glMultiDrawElementsIndirect issues per frame + uint32_t indirect_sub_draws; // total commands packed into those indirect buffers + }; + +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; + void resizeEvent(QResizeEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; + void keyReleaseEvent(QKeyEvent* event) override; + 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(); + 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; + // 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 + // 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(); + // 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 + // 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); + ModelGpuData& getOrCreateModel(uint32_t model_id); + + // 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. + // + // `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); + + // 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); + + // 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); + 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; + std::deque pending_ops_; + + // Shaders + GLuint main_program_ = 0; + GLuint pick_program_ = 0; + GLuint axis_program_ = 0; + + // Axis gizmo + 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_; + + // 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 + // 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; + + // 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_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 + 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; + 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. + // 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 + // 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; + + // 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; + + // 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; + uint32_t gl_draw_calls_ = 0; + uint32_t indirect_sub_draws_ = 0; + + // 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; + bool projection_ortho_ = false; + QMatrix4x4 view_matrix_; + QMatrix4x4 proj_matrix_; + + // Mouse + 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 + // 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; + + // 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_; + 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; + + // 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; + float last_fps_ = 0.0f; +}; + +#endif // VIEWPORTWINDOW_H diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt new file mode 100644 index 0000000000..b32154f3aa --- /dev/null +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -0,0 +1,81 @@ +################################################################################ +# # +# 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) + +# 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}) + +find_package(Eigen3 REQUIRED) + +add_executable(test_federation + test_federation.cpp + ${IFCVIEWER_SRC}/Federation.cpp + # 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.). 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}) +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 + 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_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_federation.cpp b/src/ifcviewer/tests/test_federation.cpp new file mode 100644 index 0000000000..a93ddec56b --- /dev/null +++ b/src/ifcviewer/tests/test_federation.cpp @@ -0,0 +1,712 @@ +/******************************************************************************** + * * + * 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("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; + 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()); + + // 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; + 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()[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)); + 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()); +} + +TEST_CASE("config / federated_false_origin / model_transformation 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); + + FederatedFalseOrigin org; + org.xyz = Eigen::Vector3d(100.0, 200.0, 30.0); + org.rz_deg = 45.0; + src.setFederatedFalseOrigin(org); + + 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.setModelTransformation(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.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.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 ModelTransformation 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("model_transformation")); +} + +TEST_CASE("composeFederatedFalseOrigin moves the nominated point to the origin", + "[federation][compose]") { + FederationConfig cfg; // METRE, no prefix + FederatedFalseOrigin org; + org.xyz = Eigen::Vector3d(10.0, 20.0, 5.0); + org.rz_deg = 0.0; + + 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); + 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("composeFederatedFalseOrigin scales by federation unit", + "[federation][compose]") { + FederationConfig cfg; + cfg.unit_name = "FOOT"; // 1 ft = 0.3048 m + FederatedFalseOrigin org; + org.xyz = Eigen::Vector3d(1.0, 0.0, 0.0); // 1 foot in fed coords + + 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("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. + FederationConfig fed_cfg; // METRE + ModelUnits mu; // 1.0 / 1.0 (already in metres) + Eigen::Matrix4d coord_op = Eigen::Matrix4d::Identity(); + + 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 = 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; + 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); +} 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..f592a37772 --- /dev/null +++ b/src/ifcviewer/tests/test_sidecar_cache.cpp @@ -0,0 +1,256 @@ +/******************************************************************************** + * * + * 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.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); + 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; + } + + // 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) { + 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; + } + + // 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; +} + +} // namespace + +TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") { + REQUIRE(sizeof(MeshInfo) == 56); + REQUIRE(sizeof(InstanceGpu) == 80); + REQUIRE(SIDECAR_VERSION == 11); + 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)); +} diff --git a/src/interface/CMakeLists.txt b/src/interface/CMakeLists.txt new file mode 100644 index 0000000000..4ccf79cdb6 --- /dev/null +++ b/src/interface/CMakeLists.txt @@ -0,0 +1,97 @@ +# 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}/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 + ${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/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 + ${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}/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 +) + +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/ElementRegistry.cpp b/src/interface/ElementRegistry.cpp new file mode 100644 index 0000000000..724c5ad987 --- /dev/null +++ b/src/interface/ElementRegistry.cpp @@ -0,0 +1,115 @@ +// 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) { + loader_ = loader; + connect(loader, &SceneLoader::sidecarElementsReady, + this, &ElementRegistry::onSidecarElementsReady); + connect(loader, &SceneLoader::streamedElementsReady, + this, &ElementRegistry::onStreamedElementsReady); +} + +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; + 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) { + 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..abad8451da --- /dev/null +++ b/src/interface/ElementRegistry.h @@ -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 . * + * * + ********************************************************************************/ + +#ifndef IFCINTERFACE_ELEMENTREGISTRY_H +#define IFCINTERFACE_ELEMENTREGISTRY_H + +#include +#include +#include "../ifcparse/express.h" +#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); + void clear(); + void removeModel(uint32_t model_id); + std::optional findBasicElementInfo(uint32_t object_id) const; + std::optional findEntity(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); + + SceneLoader* loader_ = nullptr; + std::unordered_map elements_; +}; + +} // namespace ifcinterface + +#endif diff --git a/src/interface/MainWindow.cpp b/src/interface/MainWindow.cpp new file mode 100644 index 0000000000..78fac76f51 --- /dev/null +++ b/src/interface/MainWindow.cpp @@ -0,0 +1,745 @@ +// 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 "MainWindow.h" + +#include "../ifcviewer/AppSettings.h" +#include "../ifcviewer/Federation.h" +#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" +#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 "panels/viewport/Controller.h" +#include "panels/viewport/Widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ifcinterface::shell { + +MainWindow::MainWindow(QWidget* parent) + : QMainWindow(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(); + }); + setupChrome(); + setupViewport(); + setupPanels(); + setupStatus(); + setupLoader(); + setupRibbon(); + resize(1720, 980); +} + +void MainWindow::setupChrome() { + setObjectName("appWindow"); + setDockOptions(QMainWindow::AllowNestedDocks | + QMainWindow::AllowTabbedDocks | + QMainWindow::GroupedDragging); + updateWindowTitle(); +} + +QToolButton* MainWindow::makeRibbonAction(const QString& text, const QString& icon_path) { + return components::buttons::makeButton(text, icon_path, this, QSize(90, 54)); +} + +QWidget* MainWindow::makeRibbonGroup(const QString& title, const QList& buttons) { + return components::buttons::makeButtonGroup(title, buttons, this); +} + +QToolButton* MainWindow::makePanelToggle(const QString& text, QDockWidget* dock) { + auto* button = makeRibbonAction(text, ":/icons/sidebar-expand.svg"); + 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* MainWindow::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, &MainWindow::onNewProject); + auto* open_project = makeRibbonAction("Open Project", ":/icons/download-square.svg"); + 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]() { + 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]() { + 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); + auto* save_project_as = makeRibbonAction("Save As", ":/icons/floppy-disk-arrow-in.svg"); + 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); + auto* sync_models = makeRibbonAction("Sync Models", ":/icons/refresh-double.svg"); + connect(sync_models, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Models", "Sync models coming soon"); + }); + + auto* settings_button = makeRibbonAction("Settings", ":/icons/settings.svg"); + connect(settings_button, &QToolButton::clicked, this, [this]() { + panels::settings::SettingsDialog dialog(this); + dialog.exec(); + }); + + 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* MainWindow::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, &MainWindow::onSetHomeView); + auto* go_home = makeRibbonAction("Go Home", ":/icons/home-alt.svg"); + 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_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_widget_) viewport_widget_->viewport()->focusOnSelectedObject(); + }); + + auto* plan_view = makeRibbonAction("Plan", ":/icons/planimetry.svg"); + connect(plan_view, &QToolButton::clicked, this, [this]() { + 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_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_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]() { + 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]() { + 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"); + connect(orbit_mode, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Mode", "Orbit mode active"); + }); + auto* fly_mode = makeRibbonAction("Fly", ":/icons/drone.svg"); + connect(fly_mode, &QToolButton::clicked, this, [this]() { + session_state_->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* MainWindow::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]() { + session_state_->setStatusMessage("Selection", "Hide selected coming soon"); + }); + auto* isolate_selected = makeRibbonAction("Isolate", ":/icons/eye-solid.svg"); + connect(isolate_selected, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Selection", "Isolate selected coming soon"); + }); + auto* show_all = makeRibbonAction("Show All", ":/icons/eye.svg"); + connect(show_all, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Selection", "Show all coming soon"); + }); + auto* invert_selection = makeRibbonAction("Invert", ":/icons/intersect.svg"); + connect(invert_selection, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Selection", "Invert selection coming soon"); + }); + + auto* distance = makeRibbonAction("Distance", ":/icons/select-edge3d.svg"); + connect(distance, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Measure", "Distance coming soon"); + }); + auto* area = makeRibbonAction("Area", ":/icons/select-face3d.svg"); + connect(area, &QToolButton::clicked, this, [this]() { + session_state_->setStatusMessage("Measure", "Area coming soon"); + }); + auto* volume = makeRibbonAction("Volume", ":/icons/select-point3d.svg"); + connect(volume, &QToolButton::clicked, this, [this]() { + session_state_->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* MainWindow::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_panel_), + makePanelToggle("Spatial", spatial_panel_), + makePanelToggle("Layers", layers_panel_), + makePanelToggle("Properties", properties_panel_) + })); + row->addWidget(makeRibbonGroup("QUERY", { + makePanelToggle("Views", stored_views_panel_), + makePanelToggle("Search", search_panel_), + makePanelToggle("Sheets", spreadsheet_panel_), + makePanelToggle("Audit", audit_panel_) + })); + row->addWidget(makeRibbonGroup("COLLABORATE", { + makePanelToggle("Clash", clash_panel_), + makePanelToggle("Issues", issues_panel_) + })); + row->addStretch(1); + return page; +} + +void MainWindow::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 components::TabBar(shell); + ribbon_tabs_->addTab("Home"); + ribbon_tabs_->addTab("Navigate"); + ribbon_tabs_->addTab("Inspect"); + ribbon_tabs_->addTab("Panels"); + ribbon_tabs_->setCurrentIndex(0); + + 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_, &components::TabBar::currentChanged, + ribbon_pages_, &QStackedWidget::setCurrentIndex); + + setMenuWidget(shell); +} + +void MainWindow::setupViewport() { + viewport_widget_ = new panels::viewport::ViewportWidget(this); + setCentralWidget(viewport_widget_); +} + +void MainWindow::setupPanels() { + models_panel_ = new panels::models::ModelsPanelWidget(this); + spatial_panel_ = new panels::spatial_hierarchy::SpatialHierarchyPanelWidget(this); + properties_panel_ = new panels::properties::PropertiesPanelWidget(this); + + 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( + "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_panel_); + addDockWidget(Qt::LeftDockWidgetArea, spatial_panel_); + splitDockWidget(models_panel_, spatial_panel_, Qt::Vertical); + + 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_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_panel_->hide(); + stored_views_panel_->hide(); + search_panel_->hide(); + spreadsheet_panel_->hide(); + audit_panel_->hide(); + clash_panel_->hide(); + issues_panel_->hide(); + + resizeDocks({models_panel_, properties_panel_}, {290, 330}, Qt::Horizontal); + resizeDocks({models_panel_, spatial_panel_}, {280, 240}, Qt::Vertical); +} + +void MainWindow::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(); + }); + 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( + 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); + connect(loader_, &SceneLoader::loadCancelled, this, &MainWindow::onLoadCancelled); + connect(loader_, &SceneLoader::loadError, this, &MainWindow::onLoadError); + connect(loader_, &SceneLoader::allLoadsFinished, this, &MainWindow::onAllLoadsFinished); + + connect(viewport_widget_->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)); + }); + 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) { + 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 { + return (ms >= 1000) + ? QString::number(ms / 1000.0, 'f', 2) + " s" + : QString::number(ms) + " ms"; +} + +void MainWindow::onAddFiles() { + 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_widget_) viewport_widget_->viewport()->setSelectedObjectId(0); + session_state_->setSelectedObjectId(0); + session_state_->notifySelectionChanged(); + + const auto model_ids = session_state_->modelIds(); + for (uint32_t mid : model_ids) { + viewport_widget_->viewport()->removeModel(mid); + loader_->removeModel(mid); + } + + session_state_->clearModelMappings(); + element_registry_->clear(); + session_state_->notifyModelsChanged(); +} + +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) { + session_state_->setModelMapping(fed_ids[i], ids[i]); + } + session_state_->notifyModelsChanged(); +} + +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(); + viewport_controller_->applyFederatedFalseOrigin(); + if (federation_->hasHomeView()) { + const auto& hv = federation_->homeView(); + viewport_widget_->viewport()->setCamera( + hv.target.x(), hv.target.y(), hv.target.z(), hv.distance, hv.yaw, hv.pitch); + } + updateWindowTitle(); + session_state_->setStatusMessage("Project", QFileInfo(path).fileName()); + session_state_->notifyProjectOpened(path); + 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(); + session_state_->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(); + session_state_->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(); + viewport_controller_->applyFederatedFalseOrigin(); + updateWindowTitle(); + session_state_->setStatusMessage("Project", "Untitled"); + session_state_->notifyProjectReset(); +} + +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::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(); + session_state_->setStatusMessage("Camera", "Home view updated"); +} + +void MainWindow::onGoHomeView() { + if (!federation_->hasHomeView()) { + session_state_->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); + session_state_->setStatusMessage("Camera", "Home view restored"); +} + +void MainWindow::onLoadStarted(uint32_t /*mid*/, QString display_name) { + session_state_->setStatusMessage("Loading", display_name); +} + +void MainWindow::onLoadedFromSidecar(uint32_t mid, qint64 elapsed_ms) { + 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) { + session_state_->setStatusMessage( + "Loaded", + QString("%1 streamed in %2") + .arg(loader_->displayName(mid)) + .arg(formatElapsed(elapsed_ms))); +} + +void MainWindow::onLoadCancelled(uint32_t mid) { + session_state_->setStatusMessage("Cancelled", loader_->displayName(mid)); +} + +void MainWindow::onLoadError(uint32_t /*mid*/, QString message) { + session_state_->setStatusMessage("Error", message); + QMessageBox::warning(this, "IfcInterfaceMockup", message); +} + +void MainWindow::onAllLoadsFinished() { + 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 new file mode 100644 index 0000000000..c42612576d --- /dev/null +++ b/src/interface/MainWindow.h @@ -0,0 +1,123 @@ +// 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_SHELL_MAINWINDOW_H +#define IFCINTERFACE_SHELL_MAINWINDOW_H + +#include +#include +#include + +class QLabel; +class QDockWidget; +class QStackedWidget; +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; } +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 { + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget* parent = nullptr); + +private: + void setupChrome(); + void setupRibbon(); + void setupViewport(); + void setupPanels(); + void setupStatus(); + void setupLoader(); + QWidget* buildHomeRibbonPage(); + 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); + 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(); + void onSetHomeView(); + void onGoHomeView(); + 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; + ifcinterface::components::TabBar* ribbon_tabs_ = nullptr; + QStackedWidget* ribbon_pages_ = nullptr; + ifcinterface::panels::viewport::ViewportWidget* viewport_widget_ = nullptr; + 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; + ifcinterface::panels::properties::PropertiesPanelWidget* 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::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; +}; + +} // namespace ifcinterface::shell + +#endif 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/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..410e3a581b --- /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(style::metrics::section_body_padding); + + 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(style::metrics::section_body_padding); + 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/KeyValueTable.cpp b/src/interface/components/KeyValueTable.cpp new file mode 100644 index 0000000000..48c84acc36 --- /dev/null +++ b/src/interface/components/KeyValueTable.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 "KeyValueTable.h" + +#include "SvgIcon.h" + +#include +#include + +namespace ifcinterface::components { + +KeyValueTable::KeyValueTable(const QList& rows, QWidget* parent) + : QWidget(parent) +{ + setObjectName("keyValueTable"); + + auto* layout = new QGridLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setHorizontalSpacing(12); + layout->setVerticalSpacing(6); + layout->setColumnStretch(1, 1); + + int row_index = 0; + for (const auto& row_data : rows) { + 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, this); + value->setObjectName(row_data.value_object_name.isEmpty() + ? "keyValueValueLabel" + : row_data.value_object_name); + value->setWordWrap(true); + value->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + + 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(this); + icon->setObjectName(row_data.trailing_icon_object_name.isEmpty() + ? "keyValueTrailingIconLabel" + : row_data.trailing_icon_object_name); + 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); + } + ++row_index; + } +} + +} // namespace ifcinterface::components diff --git a/src/interface/components/KeyValueTable.h b/src/interface/components/KeyValueTable.h new file mode 100644 index 0000000000..807376ce94 --- /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 = "keyValueValueLabel"; + 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/Panel.cpp b/src/interface/components/Panel.cpp new file mode 100644 index 0000000000..7ba96631d6 --- /dev/null +++ b/src/interface/components/Panel.cpp @@ -0,0 +1,136 @@ +// 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 "Panel.h" + +#include "Style.h" +#include "SvgIcon.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ifcinterface::components { + +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("panelTitleText"); + + layout->addWidget(text); + layout->addStretch(1); + if (has_settings) { + auto* settings = new QToolButton(this); + settings->setIcon(icons::makeSvgIcon(":/icons/settings.svg")); + settings->setAutoRaise(true); + settings->setCursor(Qt::ArrowCursor); + settings->setFixedSize(18, 18); + settings->setObjectName("panelTitleButton"); + 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 + +Panel::Panel(const QString& title, QWidget* content, QWidget* parent, bool has_settings, bool scrollable) + : QDockWidget(title, parent) +{ + auto* outer = new QFrame(); + auto* outer_layout = new QVBoxLayout(outer); + 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("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(style::metrics::section_body_padding); + + 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(style::metrics::section_body_padding); + frame_layout->addWidget(body); + } + + if (content) { + body_layout_->addWidget(content); + } + outer_layout->addWidget(frame); + + setObjectName(title); + setFeatures(QDockWidget::DockWidgetMovable | + QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetClosable); + setTitleBarWidget(new DockTitleBar(title, has_settings, this)); + setWidget(outer); +} + +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 new file mode 100644 index 0000000000..fe48df51a5 --- /dev/null +++ b/src/interface/components/Panel.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_COMPONENTS_PANEL_PANELCHROME_H +#define IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H + +#include + +class QWidget; +class QVBoxLayout; + +namespace ifcinterface::components { + +class Panel : public QDockWidget { + Q_OBJECT + +public: + explicit Panel(const QString& title, + QWidget* content = nullptr, + QWidget* parent = nullptr, + bool has_settings = false, + bool scrollable = false); + + void addBodyWidget(QWidget* widget); + void clearBodyWidgets(); + +private: + QVBoxLayout* body_layout_ = nullptr; +}; + +} // namespace ifcinterface::components + +#endif diff --git a/src/interface/components/Section.cpp b/src/interface/components/Section.cpp new file mode 100644 index 0000000000..2837dee1dc --- /dev/null +++ b/src/interface/components/Section.cpp @@ -0,0 +1,101 @@ +// 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 "Style.h" + +#include +#include +#include +#include + +namespace ifcinterface::components { + +Section::Section(const QString& title, SectionHeaderMode header_mode, QWidget* parent) + : QWidget(parent) +{ + setObjectName("panelSection"); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(style::metrics::padding); + + if (header_mode == SectionHeaderMode::Visible) { + auto* header = new QFrame(this); + header->setObjectName("panelSectionHeader"); + header_layout_ = new QHBoxLayout(header); + header_layout_->setContentsMargins(0, 0, 0, 0); + header_layout_->setSpacing(style::metrics::padding); + + 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); + + connect(toggle_button_, &QToolButton::toggled, this, [this](bool expanded) { + toggle_button_->setArrowType(expanded ? Qt::DownArrow : Qt::RightArrow); + body_->setVisible(expanded); + }); + } + + body_ = new QWidget(this); + body_->setObjectName("panelSectionBody"); + body_layout_ = new QVBoxLayout(body_); + body_layout_->setContentsMargins(style::metrics::section_body_padding, + 0, + style::metrics::section_body_padding, + 0); + body_layout_->setSpacing(style::metrics::padding); + layout->addWidget(body_); +} + +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 new file mode 100644 index 0000000000..bf206b500a --- /dev/null +++ b/src/interface/components/Section.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_COMPONENTS_SECTION_H +#define IFCINTERFACE_COMPONENTS_SECTION_H + +#include + +class QHBoxLayout; +class QToolButton; +class QVBoxLayout; + +namespace ifcinterface::components { + +enum class SectionHeaderMode { + Visible, + Hidden, +}; + +class Section : public QWidget { + Q_OBJECT +public: + explicit Section(const QString& title, + SectionHeaderMode header_mode = SectionHeaderMode::Visible, + 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; + QHBoxLayout* header_layout_ = nullptr; + QToolButton* toggle_button_ = nullptr; +}; + +} // namespace ifcinterface::components + +#endif diff --git a/src/interface/components/Style.cpp b/src/interface/components/Style.cpp new file mode 100644 index 0000000000..a115247716 --- /dev/null +++ b/src/interface/components/Style.cpp @@ -0,0 +1,432 @@ +// 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() { + QString stylesheet = QStringLiteral(R"( + QMainWindow#appWindow { + background: ${app_background}; + color: ${primary_text}; + selection-background-color: ${selection_background}; + selection-color: ${selection_text}; + } + QDialog#appDialog { + background: ${app_background}; + color: ${primary_text}; + } + QMessageBox, + QMessageBox QWidget, + QMessageBox QLabel { + 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#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#appTabBar::tab:selected { + color: ${primary_text}; + border-bottom: 2px solid ${selection_background}; + } + QTabBar#appTabBar::tab:hover { + color: ${ribbon_tab_hover_text}; + } + QFrame#ribbonBand { + background: ${ribbon_band_background}; + border-top: 1px solid ${border}; + } + QTabWidget#appTabWidget::pane { + border: none; + background: transparent; + } + QFrame#ribbonPage { + background: transparent; + } + QFrame#ribbonGroup { + background: transparent; + border-right: 1px solid ${border}; + } + QFrame#ribbonGroup[separator="false"] { + border-right: none; + } + QLabel#ribbonGroupLabel { + 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: ${primary_text}; + } + QToolButton#ribbonButton:hover { + background: ${ribbon_button_hover}; + } + QToolButton#ribbonButton:pressed { + background: ${ribbon_button_pressed}; + } + QFrame#viewportShell { + background: ${viewport_shell_background}; + border-top: none; + } + QFrame#viewportFrame { + background: ${viewport_background}; + border: 1px solid ${border}; + } + QDockWidget { + color: ${primary_text}; + } + QLabel { + color: ${primary_text}; + background: transparent; + } + QAbstractItemView, + QTreeWidget, + QListWidget, + QTableWidget, + QLineEdit, + QComboBox, + QSpinBox, + QDoubleSpinBox, + QCheckBox, + QPushButton, + QToolButton { + color: ${primary_text}; + } + QLabel[textRole="secondary"] { + color: ${secondary_text}; + } + QLabel[textRole="disabled"] { + color: ${disabled_text}; + } + QLabel[textRole="warning"] { + color: ${warning_text}; + } + QLabel#panelTitleText { + color: ${primary_text}; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + } + QToolButton#panelTitleButton { + color: ${panel_title_button}; + border: none; + background: transparent; + } + QToolButton#panelTitleButton:hover { + color: ${ribbon_tab_hover_text}; + background: ${panel_title_button_hover}; + } + QFrame#panel { + background: ${panel_background}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + } + QTreeWidget, QListWidget, QTableWidget, QAbstractScrollArea { + background: ${panel_background}; + border: none; + outline: none; + gridline-color: ${border}; + } + QTreeWidget::viewport, QListWidget::viewport, QTableWidget::viewport { + background: ${panel_background}; + } + QHeaderView::section { + background: ${control_background}; + color: ${primary_text}; + border: none; + border-bottom: 1px solid ${border}; + padding: 7px 8px; + font-weight: 600; + } + QTableCornerButton::section { + background: ${control_background}; + border: none; + } + QScrollArea { + background: ${panel_background}; + border: none; + } + QScrollArea > QWidget > QWidget { + background: ${panel_background}; + } + QLineEdit { + 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 ${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}; + 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}; + } + 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}; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + } + QLabel#entityClassLabel { + color: ${primary_text}; + font-weight: 700; + background: transparent; + } + QWidget#keyValueTable { + 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: ${scroll_handle}; + border-radius: ${panel_radius}px; + min-height: 24px; + min-width: 24px; + } + QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover { + background: ${scroll_handle_hover}; + } + QScrollBar::add-line, QScrollBar::sub-line, + QScrollBar::add-page, QScrollBar::sub-page { + background: transparent; + border: none; + } + QStatusBar { + background: ${status_background}; + } + QStatusBar QLabel { + color: ${secondary_text}; + background: transparent; + border: none; + padding: 2px 8px; + } + QGroupBox { + background: transparent; + border: 1px solid ${border}; + border-radius: ${panel_radius}px; + margin-top: 10px; + padding-top: 10px; + } + QGroupBox#propertySetBox::title { + subcontrol-origin: margin; + left: ${padding}px; + padding: 0 4px; + color: ${primary_text}; + } + QGroupBox#propertySetBox > QWidget { + background: ${box_background}; + } + QWidget#panelSection { + background: transparent; + } + QWidget#panelSectionFilterWrapper { + background: transparent; + } + QLabel#keyValueTrailingIconLabel { + background: transparent; + } + QWidget#panelScrollBody { + background: ${panel_background}; + } + QFrame#panelSectionHeader { + background: ${section_header_background}; + } + QToolButton#panelSectionHeaderButton { + background: transparent; + border: none; + color: ${primary_text}; + font-weight: 700; + text-align: left; + padding: ${section_header_padding}px; + margin: 0; + } + QToolButton#panelSectionHeaderButton:hover { + color: ${ribbon_tab_hover_text}; + } + QToolButton#panelSectionHeaderButton::menu-indicator { + image: none; + width: 0; + } + QToolButton#panelSectionFilterToggle { + background: transparent; + border: none; + padding: ${section_header_padding}px; + } + QToolButton#panelSectionFilterToggle:hover { + background: ${panel_title_button_hover}; + } + QWidget#panelSectionBody { + background: transparent; + } + QLabel#keyValueValueLabel { + color: ${key_value_value_text}; + background: transparent; + } + )"); + + 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 new file mode 100644 index 0000000000..84f6426d3e --- /dev/null +++ b/src/interface/components/Style.h @@ -0,0 +1,73 @@ +// 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_body_padding = 10; +inline constexpr int section_header_padding = 2; +inline constexpr int panel_radius = 3; + +} // namespace ifcinterface::components::style::metrics + +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 + +namespace ifcinterface::components::style { + +QString buildAppStyleSheet(); + +} // namespace ifcinterface::components::style + +#endif diff --git a/src/interface/components/SvgIcon.cpp b/src/interface/components/SvgIcon.cpp new file mode 100644 index 0000000000..2c6470f240 --- /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 makeSvgIcon(const QString& icon_path) { + return makeTintedSvgIcon(icon_path, "#e7ebf2", "#ffffff", "#6f7988"); +} + +QPixmap makeSvgPixmap(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..c8d3bb949a --- /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 makeSvgIcon(const QString& icon_path); +QPixmap makeSvgPixmap(const QString& icon_path, const QSize& size); + +} // namespace ifcinterface::components::icons + +#endif 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/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/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/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-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/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/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/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/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/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/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 new file mode 100644 index 0000000000..feb89ef781 --- /dev/null +++ b/src/interface/interface_resources.qrc @@ -0,0 +1,50 @@ + + + + ../ifctester/webapp/public/fonts/dmsans/DMSans-VariableFont_opsz,wght.ttf + + + 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 + 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 new file mode 100644 index 0000000000..353d587f01 --- /dev/null +++ b/src/interface/main.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 "MainWindow.h" +#include "components/Style.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(); + app.setStyleSheet(ifcinterface::components::style::buildAppStyleSheet()); + + ifcinterface::shell::MainWindow window; + window.show(); + return app.exec(); +} 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/Types.h b/src/interface/panels/properties/Types.h new file mode 100644 index 0000000000..9a1f8da9e7 --- /dev/null +++ b/src/interface/panels/properties/Types.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/View.cpp b/src/interface/panels/properties/View.cpp new file mode 100644 index 0000000000..aa93b85422 --- /dev/null +++ b/src/interface/panels/properties/View.cpp @@ -0,0 +1,123 @@ +// 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 "View.h" + +#include "Widget.h" + +#include "../../ElementRegistry.h" +#include "../../SessionState.h" +#include "../../../ifcviewer/AppSettings.h" + +namespace ifcinterface::panels::properties { + +PropertiesPanelView::PropertiesPanelView(PropertiesPanelWidget* widget, + ifcinterface::SessionState* session_state, + QObject* parent) + : QObject(parent), widget_(widget), session_state_(session_state) +{ + 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::refresh(uint32_t object_id) { + auto* registry = session_state_->elementRegistry(); + PropertiesPanelState state; + 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"}}}, + }; + + 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()) { + 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); + 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); +} + +} // namespace ifcinterface::panels::properties diff --git a/src/interface/panels/properties/View.h b/src/interface/panels/properties/View.h new file mode 100644 index 0000000000..8ed49e1bfd --- /dev/null +++ b/src/interface/panels/properties/View.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_PANELS_PROPERTIESPANELVIEW_H +#define IFCINTERFACE_PANELS_PROPERTIESPANELVIEW_H + +#include "Types.h" + +#include + +namespace ifcinterface { class SessionState; } +namespace ifcinterface::panels::properties { + +class PropertiesPanelWidget; + +class PropertiesPanelView : public QObject { + Q_OBJECT +public: + explicit PropertiesPanelView(PropertiesPanelWidget* widget, + ifcinterface::SessionState* session_state, + QObject* parent = nullptr); + +private: + void refresh(uint32_t object_id); + + PropertiesPanelWidget* widget_ = nullptr; + ifcinterface::SessionState* session_state_ = nullptr; +}; + +} // namespace ifcinterface::panels::properties + +#endif diff --git a/src/interface/panels/properties/Widget.cpp b/src/interface/panels/properties/Widget.cpp new file mode 100644 index 0000000000..515da7554a --- /dev/null +++ b/src/interface/panels/properties/Widget.cpp @@ -0,0 +1,237 @@ +// 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; +} + +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 { + +PropertiesPanelWidget::PropertiesPanelWidget(QWidget* parent) + : components::Panel("Properties", nullptr, parent, false, true) +{ +} + +void PropertiesPanelWidget::render(const PropertiesPanelState& state) { + clearBodyWidgets(); + + 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(makeEntityBox(state.entity, this)); + + 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; + }); + } + + 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 new file mode 100644 index 0000000000..752b339a19 --- /dev/null +++ b/src/interface/panels/properties/Widget.h @@ -0,0 +1,56 @@ +// 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 "Types.h" + +#include "../../components/Panel.h" + +#include + +class QLabel; +class QLineEdit; +class QToolButton; + +namespace ifcinterface::panels::properties { + +class PropertiesPanelWidget : public components::Panel { + Q_OBJECT +public: + explicit PropertiesPanelWidget(QWidget* parent = nullptr); + + void render(const PropertiesPanelState& state); + +private: + 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 + +#endif diff --git a/src/interface/panels/settings/Dialog.cpp b/src/interface/panels/settings/Dialog.cpp new file mode 100644 index 0000000000..33c6ee75cb --- /dev/null +++ b/src/interface/panels/settings/Dialog.cpp @@ -0,0 +1,214 @@ +// 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/Dialog.h" +#include "../../components/Section.h" +#include "../../components/Style.h" +#include "../../components/SvgIcon.h" +#include "../../components/Tabs.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ifcinterface::panels::settings { + +SettingsDialog::SettingsDialog(QWidget* parent) + : components::Dialog(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* 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(components::style::metrics::padding); + + 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(components::style::metrics::padding); + + 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, this); + 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); + + auto* actions_section = new components::Section("", components::SectionHeaderMode::Hidden, this); + actions_section->addBodyWidget(buttons); + + addBodyWidget(tabs); + addBodyWidget(actions_section); +} + +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..d97fab3db6 --- /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 "../../components/Dialog.h" + +class QCheckBox; +class QDoubleSpinBox; +class QLineEdit; +class QShowEvent; +class QSpinBox; + +namespace ifcinterface::panels::settings { + +class SettingsDialog : public components::Dialog { + 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/Types.h b/src/interface/panels/spatial_hierarchy/Types.h new file mode 100644 index 0000000000..90dd67b8fd --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/Types.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/View.cpp b/src/interface/panels/spatial_hierarchy/View.cpp new file mode 100644 index 0000000000..de78b3db31 --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/View.cpp @@ -0,0 +1,75 @@ +// 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 "View.h" + +#include "Widget.h" + +#include "../../SessionState.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, + ifcinterface::SessionState* session_state, + QObject* parent) + : QObject(parent), widget_(widget), session_state_(session_state) +{ + 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(); + session_state_->setStatusMessage("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/View.h b/src/interface/panels/spatial_hierarchy/View.h new file mode 100644 index 0000000000..123db712ef --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/View.h @@ -0,0 +1,51 @@ +// 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 "Types.h" + +#include + +namespace ifcinterface { class SessionState; } +namespace ifcinterface::panels::spatial_hierarchy { + +class SpatialHierarchyPanelWidget; + +class SpatialHierarchyPanelView : public QObject { + Q_OBJECT +public: + 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_; +}; + +} // namespace ifcinterface::panels::spatial_hierarchy + +#endif diff --git a/src/interface/panels/spatial_hierarchy/Widget.cpp b/src/interface/panels/spatial_hierarchy/Widget.cpp new file mode 100644 index 0000000000..39f1efe4d9 --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/Widget.cpp @@ -0,0 +1,95 @@ +// 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/Section.h" +#include "../../components/SvgIcon.h" + +#include +#include +#include + +namespace ifcinterface::panels::spatial_hierarchy { + +SpatialHierarchyPanelWidget::SpatialHierarchyPanelWidget(QWidget* parent) + : components::Panel("Spatial Hierarchy", nullptr, parent) +{ + 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)); + 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(); + section->addBodyWidget(tree_); + addBodyWidget(section); + + 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, 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); + } +} + +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/Widget.h b/src/interface/panels/spatial_hierarchy/Widget.h new file mode 100644 index 0000000000..5cb9c9610c --- /dev/null +++ b/src/interface/panels/spatial_hierarchy/Widget.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 "Types.h" + +#include "../../components/Panel.h" + +class QTreeWidget; +class QTreeWidgetItem; + +namespace ifcinterface::panels::spatial_hierarchy { + +class SpatialHierarchyPanelWidget : public components::Panel { + 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 diff --git a/src/interface/panels/todo/Widget.cpp b/src/interface/panels/todo/Widget.cpp new file mode 100644 index 0000000000..1f2e58d3dc --- /dev/null +++ b/src/interface/panels/todo/Widget.cpp @@ -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 . * + * * + ********************************************************************************/ + +#include "Widget.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); + + auto* content = new QLabel("Coming soon", body); + content->setProperty("textRole", "disabled"); + 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/Widget.h b/src/interface/panels/todo/Widget.h new file mode 100644 index 0000000000..7ab3bba29b --- /dev/null +++ b/src/interface/panels/todo/Widget.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 diff --git a/src/interface/panels/viewport/Controller.cpp b/src/interface/panels/viewport/Controller.cpp new file mode 100644 index 0000000000..acbe20e896 --- /dev/null +++ b/src/interface/panels/viewport/Controller.cpp @@ -0,0 +1,169 @@ +// 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 "../../SessionState.h" +#include "../../../ifcviewer/AppSettings.h" +#include "../../../ifcviewer/Federation.h" +#include "../../../ifcviewer/SceneLoader.h" +#include "../../../ifcviewer/ViewportWindow.h" + +#include + +namespace ifcinterface::panels::viewport { + +ViewportController::ViewportController(ifcinterface::SessionState* session_state, + ViewportWindow* viewport, + QObject* parent) + : QObject(parent) + , session_state_(session_state) + , viewport_(viewport) +{ + Federation* federation = session_state_->federation(); + SceneLoader* loader = session_state_->loader(); + connect(federation, &Federation::federatedFalseOriginChanged, + this, &ViewportController::applyFederatedFalseOrigin); + connect(federation, &Federation::configChanged, this, [this]() { + applyFederatedFalseOrigin(); + for (uint32_t mid : session_state_->modelIds()) { + applyModelTransformation(mid); + } + }); + connect(federation, &Federation::modelTransformationChanged, + this, [this](const QString& fed_id) { + 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, + [this](uint32_t mid, qint64 /*elapsed_ms*/) { + applyCoordinateOperation(mid); + applyModelVisibility(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); + applyModelVisibility(mid); + maybeGuessFederatedFalseOrigin(mid); + }); + connect(&AppSettings::instance(), + &AppSettings::applyCoordinateOperationChanged, + this, [this](bool /*enabled*/) { + 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 (georef->has_coordinate_operation) { + matrix = georef->coordinate_operation_meters; + } + } + } + viewport_->setModelCoordinateOperation(mid, matrix); + applyModelTransformation(mid); +} + +void ViewportController::applyModelTransformation(uint32_t mid) { + Federation* federation = session_state_->federation(); + SceneLoader* loader = session_state_->loader(); + Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); + 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)) { + 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::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())); +} + +void ViewportController::maybeGuessFederatedFalseOrigin(uint32_t mid) { + Federation* federation = session_state_->federation(); + SceneLoader* loader = session_state_->loader(); + 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..69aff80b4d --- /dev/null +++ b/src/interface/panels/viewport/Controller.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_VIEWPORT_CONTROLLER_H +#define IFCINTERFACE_PANELS_VIEWPORT_CONTROLLER_H + +#include + +namespace ifcinterface { class SessionState; } +class ViewportWindow; + +namespace ifcinterface::panels::viewport { + +class ViewportController : public QObject { + Q_OBJECT + +public: + explicit ViewportController(ifcinterface::SessionState* session_state, + ViewportWindow* viewport, + QObject* parent = nullptr); + + void applyFederatedFalseOrigin(); + +private: + void applyCoordinateOperation(uint32_t mid); + void applyModelTransformation(uint32_t mid); + void applyModelVisibility(uint32_t mid); + void maybeGuessFederatedFalseOrigin(uint32_t mid); + + ifcinterface::SessionState* session_state_ = nullptr; + ViewportWindow* viewport_ = 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 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() {}