Merge remote-tracking branch 'origin/ifcviewer' into datamodel-v1.0

This commit is contained in:
Thomas Krijnen
2026-05-07 21:01:56 +02:00
151 changed files with 20686 additions and 18 deletions
Executable
+30
View File
@@ -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/
+26 -1
View File
@@ -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)
+317
View File
@@ -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<T>(...)` 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<express::Base>(attr_index)) {
| ^
```
Six identical errors at lines 1856, 1865, 1896, 1905, 1934, 1943.
## Fix
`src/ifcparse/IfcParse.cpp`:
```diff
-storage->has_attribute_value<express::Base>(attr_index)
+storage->template has_attribute_value<express::Base>(attr_index)
-storage->has_attribute_value<Blank>(attr_index)
+storage->template has_attribute_value<Blank>(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<FileReader<FullBufferImpl>>::InstanceStreamer(FileReader<FullBufferImpl>*, 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<FileReader<FullBufferImpl>>::InstanceStreamer(IfcParse::IfcFile*);
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(const std::string&, bool, IfcParse::IfcFile*);
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(void*, int, IfcParse::IfcFile*);
template IfcParse::InstanceStreamer<FileReader<FullBufferImpl>>::InstanceStreamer(FileReader<FullBufferImpl>*, 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<FileReader<FullBufferImpl>>(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<FileReader<FullBufferImpl>>::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, FullBufferImpl>) {
+ impl_ = std::make_shared<Impl>(data, length);
+ } else {
+ static_assert(...);
+ }
+ }
```
`src/ifcparse/FileReader.cpp` — implement the constructor:
```cpp
FullBufferImpl::FullBufferImpl(void* data, size_t length)
: buf_(static_cast<char*>(data), static_cast<char*>(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<Reader, FileReader<FullBufferImpl>>) {
+ owned_stream_ = std::make_unique<Reader>(nullptr, (size_t)0);
// InstanceStreamer(void*, int, IfcFile*):
+ } else if constexpr (std::is_same_v<Reader, FileReader<FullBufferImpl>>) {
+ owned_stream_ = std::make_unique<Reader>(data, (size_t)length);
```
# Runtime fix: segfault in `parse_context::push()` due to vector reallocation
`parse_context_pool` stores nodes in a `std::vector<parse_context>`. 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 <deque>
struct parse_context_pool {
- std::vector<parse_context> nodes_;
+ std::deque<parse_context> 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 0142) ──────────────────────────
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<const IfcUtil::IfcBaseEntity*>` and `get_single_material_association` returned `nullptr`. Inserting `nullptr` into a `std::set<T*>` is a plain pointer comparison — no dereference, no throw. The refactoring to `std::set<express::Base>` 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
```
+1 -1
View File
@@ -1,6 +1,6 @@
message(STATUS "GEOMETRY_KERNELS ${GEOMETRY_KERNELS}")
set(kernel_plugin_runtime_dir "${CMAKE_BINARY_DIR}/ifcgeom/$<CONFIG>")
set(kernel_plugin_runtime_dir "$<TARGET_FILE_DIR:IfcGeom>")
if (NOT WASM_BUILD)
# wasm-ld (?) trips up on multiple defined symbols. IfcParse already brings
+1 -1
View File
@@ -1,6 +1,6 @@
find_package(Eigen3 REQUIRED)
set(mapping_plugin_runtime_dir "${CMAKE_BINARY_DIR}/ifcgeom/$<CONFIG>")
set(mapping_plugin_runtime_dir "$<TARGET_FILE_DIR:IfcGeom>")
if (NOT WASM_BUILD)
# wasm-ld (?) trips up on multiple defined symbols. IfcParse already brings
@@ -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()
@@ -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
+14 -9
View File
@@ -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
}
+1 -1
View File
@@ -30,7 +30,7 @@ class file;
class IFC_PARSE_API spf_header {
private:
file* file_;
ifcopenshell::file* file_;
std::array<std::shared_ptr<instance_data>, 3> header_entities_;
+3
View File
@@ -31,6 +31,7 @@ namespace rocksdb {
#include <iterator>
#include <type_traits>
#include <iostream>
#include <deque>
#include <vector>
#include <deque>
#include <list>
@@ -389,6 +390,8 @@ namespace ifcopenshell {
typedef rocksdb_map_adapter<inverse_attr_record, std::vector<uint32_t>> 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();
+36
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
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})
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "FederationSettingsDialog.h"
#include "Federation.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QShowEvent>
#include <QStringList>
#include <QVBoxLayout>
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();
}
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef FEDERATIONSETTINGSDIALOG_H
#define FEDERATIONSETTINGSDIALOG_H
#include <QDialog>
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
File diff suppressed because it is too large Load Diff
+206
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
#include <QTreeWidget>
#include <QTableWidget>
#include <QProgressBar>
#include <QLabel>
#include <QSplitter>
#include <QElapsedTimer>
#include <map>
#include <unordered_map>
#include <vector>
#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<PackedElementInfo> 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<ElementInfo> 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<QString> 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<uint32_t, QTreeWidgetItem*> tree_roots_;
// Per-group tree items, keyed by Federation group id.
std::unordered_map<QString, QTreeWidgetItem*> group_tree_items_;
// Bidirectional federation_id <-> model_id map. Federation owns the
// persistent ids; SceneLoader owns the runtime model_ids.
std::unordered_map<QString, uint32_t> fed_id_to_model_id_;
std::unordered_map<uint32_t, QString> model_id_to_fed_id_;
// Display-side element registry for tree + property lookup.
std::unordered_map<uint32_t, ElementInfo> element_map_;
std::unordered_map<uint32_t, QTreeWidgetItem*> tree_items_;
// Scoped (model_id, ifc_id) -> object_id
std::unordered_map<uint64_t, uint32_t> scoped_ifc_id_to_object_id_;
static uint64_t scopedKey(uint32_t model_id, int ifc_id) {
return (static_cast<uint64_t>(model_id) << 32) | static_cast<uint32_t>(ifc_id);
}
QString pending_camera_;
int pending_benchmark_ = 0;
AreaMeasurement area_measurement_;
};
#endif // MAINWINDOW_H
+367
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Measurement.h"
#include "ViewportWindow.h"
#include <QtGlobal>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <queue>
#include <unordered_map>
#include <unordered_set>
#include <vector>
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<uint32_t>& 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<uint64_t, std::vector<double>> 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<float> 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<double>::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<uint32_t> patch;
if (alt) {
patch.push_back(seed);
} else {
const float* sn = &cache->tri_normals[3 * seed];
std::unordered_set<uint32_t> visited;
visited.insert(seed);
std::queue<uint32_t> 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());
}
+107
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_FULL_MEASUREMENT_H
#define IFCVIEWER_FULL_MEASUREMENT_H
#include <cstdint>
#include <unordered_map>
#include <vector>
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<uint32_t>& 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<float> positions; // 3 * N_verts
std::vector<uint32_t> indices; // 3 * N_tris
std::vector<float> tri_normals; // 3 * N_tris (unit, mesh-local)
std::vector<float> tri_areas; // N_tris
// edge_key (min<<32 | max) → list of triangle indices touching it.
std::unordered_map<uint64_t, std::vector<uint32_t>> 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<uint64_t, MeshCache> mesh_cache_;
std::unordered_map<uint64_t, SelectedTri> selected_;
double total_area_m2_ = 0.0;
};
#endif // IFCVIEWER_FULL_MEASUREMENT_H
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "ModelTransformationDialog.h"
#include "Federation.h"
#include <QButtonGroup>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QRadioButton>
#include <QShowEvent>
#include <QVBoxLayout>
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<int>::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();
}
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef MODELTRANSFORMATIONDIALOG_H
#define MODELTRANSFORMATIONDIALOG_H
#include <QDialog>
#include <QString>
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
+138
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SettingsWindow.h"
#include "AppSettings.h"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QShowEvent>
#include <QSpinBox>
#include <QVBoxLayout>
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();
}
+56
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef SETTINGSWINDOW_H
#define SETTINGSWINDOW_H
#include <QDialog>
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
+76
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include <QApplication>
#include <QSurfaceFormat>
#include <QCommandLineParser>
#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();
}
+36
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
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})
+149
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "MinimalWindow.h"
#include "AppSettings.h"
#include <QStatusBar>
#include <QDebug>
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;
}
}
+60
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef MINIMALWINDOW_H
#define MINIMALWINDOW_H
#include <QMainWindow>
#include <QLabel>
#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
+65
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include <QApplication>
#include <QSurfaceFormat>
#include <QCommandLineParser>
#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();
}
+165
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "AppSettings.h"
#include <QSettings>
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_);
}
+100
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef APPSETTINGS_H
#define APPSETTINGS_H
#include <QObject>
#include <QString>
// 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
+145
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "BvhAccel.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <limits>
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<BvhItem>& 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<float>::max();
out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits<float>::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<BvhItem>& items,
uint32_t start, uint32_t count) {
uint32_t node_idx = static_cast<uint32_t>(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<uint16_t>(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<uint16_t>(axis);
buildRecursive(mbvh, items, start, mid);
uint32_t right_child_idx = static_cast<uint32_t>(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<BvhItem>& items,
const std::vector<uint32_t>& 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<uint32_t>(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<BvhItem>& items, uint32_t model_id) {
std::vector<uint32_t> idxs(items.size());
for (uint32_t i = 0; i < items.size(); ++i) idxs[i] = i;
return buildModelBvh(items, idxs, model_id);
}
std::shared_ptr<BvhSet> buildBvhSet(const std::vector<BvhItem>& items) {
auto bvh_set = std::make_shared<BvhSet>();
std::unordered_map<uint32_t, std::vector<uint32_t>> model_items;
for (uint32_t i = 0; i < static_cast<uint32_t>(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;
}
+70
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef BVHACCEL_H
#define BVHACCEL_H
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <memory>
// 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<BvhNode> nodes;
std::vector<uint32_t> item_indices; // indices into the model's InstanceCpu array
};
struct BvhSet {
std::unordered_map<uint32_t, ModelBvh> models;
std::unordered_set<uint32_t> 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<BvhSet> buildBvhSet(const std::vector<BvhItem>& 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<BvhItem>& items, uint32_t model_id);
#endif // BVHACCEL_H
+76
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
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()
+808
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Federation.h"
#include "Geolocation.h"
#include "Unit.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QUuid>
#include <algorithm>
#include <cmath>
#include <functional>
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<Group>();
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<QString> 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<QString> 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<Federation*>(this)->findGroupByIdMutable(group_id);
}
Federation::Group* Federation::findGroupByIdMutable(const QString& group_id) {
if (group_id.isEmpty()) return nullptr;
std::vector<Group*> 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<const Federation::Group*> Federation::allGroups() const {
std::vector<const Group*> out;
for (const auto& g : root_groups_) appendDfs(g.get(), out);
return out;
}
void Federation::appendDfs(const Group* g, std::vector<const Group*>& out) {
if (!g) return;
out.push_back(g);
for (const auto& c : g->children) appendDfs(c.get(), out);
}
std::unique_ptr<Federation::Group> 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<Group>& up) { return up.get() == group; });
if (it == siblings.end()) return nullptr;
std::unique_ptr<Group> 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<void(const QJsonArray&,
std::vector<std::unique_ptr<Group>>&,
Group*)> load_groups;
load_groups = [&](const QJsonArray& arr,
std::vector<std::unique_ptr<Group>>& 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<Group>();
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<QJsonArray(const std::vector<std::unique_ptr<Group>>&)> dump;
dump = [&](const std::vector<std::unique_ptr<Group>>& 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;
}
+331
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef FEDERATION_H
#define FEDERATION_H
#include <Eigen/Dense>
#include <QObject>
#include <QString>
#include <QStringList>
#include <QDateTime>
#include <QVector3D>
#include <memory>
#include <optional>
#include <string>
#include <vector>
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<std::unique_ptr<Group>> 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<Model>& 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<std::unique_ptr<Group>>& 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<const Group*> 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<Group> 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<const Group*>& out);
QString file_path_;
QString name_;
QDateTime created_;
QDateTime modified_;
std::vector<Model> models_;
std::vector<std::unique_ptr<Group>> root_groups_;
FederationConfig config_;
FederatedFalseOrigin federated_false_origin_;
bool has_home_view_ = false;
HomeView home_view_;
bool dirty_ = false;
};
#endif // FEDERATION_H
+270
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Geolocation.h"
#include "Placement.h"
#include "../ifcparse/express.h"
#include "../ifcparse/file.h"
#include "../ifcparse/instance_data.h"
#include "../ifcparse/schema.h"
#include <cmath>
#include <string>
#include <vector>
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<double> readPropertyValueDouble(const express::Base& property) {
if (!property.declaration().is("IfcPropertySingleValue")) return std::nullopt;
auto pe = property.as<express::Entity>();
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<HelmertTransformation>
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<express::Entity>().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<express::Entity>();
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<express::Base> props = pset.get("HasProperties");
for (const auto& prop : props) {
if (!prop.declaration().is("IfcPropertySingleValue")) continue;
auto pe = prop.as<express::Entity>();
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<express::Base> 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<express::Entity>();
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<Eigen::Matrix4d> 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<express::Entity>();
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<express::Base> getMapUnit(ifcopenshell::file* ifc_file) {
std::vector<express::Base> 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<express::Entity>().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<express::Entity>().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);
}
+108
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <Eigen/Dense>
#include <optional>
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<HelmertTransformation>
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<Eigen::Matrix4d> 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<express::Base> 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
+715
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "GeometryStreamer.h"
#include "AppSettings.h"
#include "../ifcgeom/hybrid_kernel.h"
#include "../ifcgeom/taxonomy.h"
#include "../ifcgeom/IfcGeomFilter.h"
#include "../ifcparse/express.h"
#include <Eigen/Dense>
#include <thread>
#include <unordered_map>
#include <cmath>
#include <cstring>
#include <algorithm>
#include <limits>
#include <set>
#include <QDebug>
#include <QElapsedTimer>
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<float>(color.r());
m.g = static_cast<float>(color.g());
m.b = static_cast<float>(color.b());
}
if (!std::isnan(style->transparency)) {
m.a = 1.0f - static_cast<float>(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<uint32_t>(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<ifcopenshell::file> 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<std::mutex> lock(elements_mutex_);
pending_elements_.clear();
}
if (num_threads <= 0) {
num_threads = std::max(1u, std::thread::hardware_concurrency());
}
worker_thread_ = std::make_unique<QThread>();
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<ElementInfo> GeometryStreamer::drainElements() {
std::lock_guard<std::mutex> lock(elements_mutex_);
std::vector<ElementInfo> 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<uint64_t>(orig_idx) << 32) | static_cast<uint32_t>(mat_id);
};
std::unordered_map<uint64_t, uint32_t> 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<float>::max(),
std::numeric_limits<float>::max(),
std::numeric_limits<float>::max() };
float amax[3] = { -std::numeric_limits<float>::max(),
-std::numeric_limits<float>::max(),
-std::numeric_limits<float>::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<uint32_t>(
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<float>(verts[orig_idx * 3 + 0] - offset.x());
float py = static_cast<float>(verts[orig_idx * 3 + 1] - offset.y());
float pz = static_cast<float>(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<float>(normals[orig_idx * 3 + 0]));
chunk.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 1]));
chunk.vertices.push_back(static_cast<float>(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<int>(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<uint32_t>(faces[t * 3 + 0]), mat_id));
chunk.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 1]), mat_id));
chunk.indices.push_back(emit_vertex(static_cast<uint32_t>(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<int> prioritisedContextIds(ifcopenshell::file* ifc_file) {
static const std::vector<std::string> 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<std::string> identifier_order = {
"Body", "Body-FallBack", "Facetation", "FootPrint", "Profile",
"Surface", "Reference", "Axis", "Clearance", "Box", "Lighting",
"Annotation", "CoG",
};
static const std::vector<std::string> 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<std::string>& 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<int>(order.size() - (it - order.begin()));
};
struct ContextInfo {
int id;
int type_priority;
int identifier_priority;
int target_view_priority;
double target_scale;
};
std::vector<ContextInfo> 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<express::Entity>();
const std::string ctype =
entity.get_value<std::string>("ContextType", "");
const std::string cident =
entity.get_value<std::string>("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<double>(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<int> 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<float>::max();
out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits<float>::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<ifcopenshell::file>(
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<ifcopenshell::impl::rocks_db_file_storage>(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<int> net_ids;
std::set<int> gross_ids;
{
const std::string& schema_name = ifc_file_->schema()->name();
std::vector<express::Base> 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<int>(
e.as<express::Entity>().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<std::string, uint32_t> 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<MeshAabb> mesh_aabbs;
uint32_t total_shapes = 0;
uint32_t total_meshes = 0;
QElapsedTimer stream_timer;
stream_timer.start();
// Split the 0100 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<int>(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<int> prioritised_contexts =
prioritisedContextIds(ifc_file_.get());
auto run_pass = [&](const std::set<int>& 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<int> 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<ifcopenshell::geometry::filter_t> filters;
IfcGeom::instance_id_filter idf{
/*include=*/true, /*traverse=*/false, remaining};
filters.push_back(idf);
std::unique_ptr<IfcGeom::Iterator> 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<IfcGeom::Iterator>(
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<const IfcGeom::TriangulationElement*>(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<std::mutex> 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<float>(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<int>(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<int>{ 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<double>(total_shapes) / static_cast<double>(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();
}
+96
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef GEOMETRYSTREAMER_H
#define GEOMETRYSTREAMER_H
#include <QObject>
#include <QThread>
#include <string>
#include <vector>
#include <atomic>
#include <memory>
#include <mutex>
#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<ifcopenshell::file> 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<ElementInfo> 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<ifcopenshell::file> ifc_file_;
std::unique_ptr<QThread> worker_thread_;
std::atomic<bool> running_{false};
std::atomic<bool> cancel_requested_{false};
std::atomic<bool> succeeded_{false};
std::atomic<int> progress_{0};
std::mutex elements_mutex_;
std::vector<ElementInfo> pending_elements_;
uint32_t next_object_id_ = 1;
uint32_t model_id_ = 0;
};
#endif // GEOMETRYSTREAMER_H
+147
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef INSTANCEDGEOMETRY_H
#define INSTANCEDGEOMETRY_H
#include <cstdint>
#include <string>
#include <vector>
// 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<float> vertices; // 7 floats * N_verts (pos3+norm3+color1_packed)
std::vector<uint32_t> 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
+182
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "LodBuilder.h"
#include <meshoptimizer.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
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=<float> override target_error (default 0.05 → 0.2).
// IFC_LOD_RATIO=<float> 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<float>(std::atof(env_err));
if (env_ratio) target_ratio = static_cast<float>(std::atof(env_ratio));
float min_savings = 0.25f;
if (env_savings) min_savings = static_cast<float>(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<uint32_t> simplified;
std::vector<float> 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<int>(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<size_t>(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<const uint16_t*>(
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<size_t>(
3, static_cast<size_t>(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<uint32_t>(new_index_count);
if (static_cast<float>(saved) < min_savings * static_cast<float>(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<uint32_t>(append_offset_bytes);
mesh.lod1_index_count = static_cast<uint32_t>(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<uint32_t>(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;
}
+57
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#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
+199
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "OverlayRenderer.h"
#include <QFont>
#include <QFontMetrics>
#include <QPainter>
#include <QtGlobal>
#include <QtOpenGL/QOpenGLPaintDevice>
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<float>& 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_);
}
}
+75
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCVIEWER_OVERLAYRENDERER_H
#define IFCVIEWER_OVERLAYRENDERER_H
#include <QString>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
#include <vector>
// 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<float>& 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
+144
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Placement.h"
#include "../ifcparse/instance_data.h"
#include "../ifcparse/schema.h"
#include <vector>
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<double> readDirectionRatios(const express::Base& dir) {
if (!dir) return {};
auto attr = dir.as<express::Entity>().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<express::Entity>();
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<express::Entity>().get("Coordinates");
if (coords_attr.isNull()) return Eigen::Matrix4d::Identity();
std::vector<double> 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<express::Entity>().get("Coordinates");
if (coords_attr.isNull()) return Eigen::Matrix4d::Identity();
std::vector<double> 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<express::Entity>().get("Coordinates");
if (coords_attr.isNull()) return Eigen::Matrix4d::Identity();
std::vector<double> 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<express::Entity>();
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);
}
+50
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <Eigen/Dense>
// 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
+993
View File
@@ -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 100600 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, 8095 % 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 (89 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.991.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 1015 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 47 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
1015 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 4863 ms despite only 2447 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 |
| 34 | 1,127 | 0.9% | 3,873 | 1.6 M |
| 58 | 1,106 | 0.9% | 6,407 | 1.9 M |
| 916 | 376 | 0.3% | 4,315 | 0.8 M |
| 1764 | 264 | 0.2% | 7,766 | 8.0 M |
| 65256 | 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 |
| 34 | 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.** 95120 k sub_draws at
~20 fps = 4850 ms/frame, but only 2433 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
2561024 spatially-coherent meshes would collapse 91115 k
sub_draws into 100450, a 2001000× 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
100k500k 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
+412
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SceneLoader.h"
#include "AppSettings.h"
#include <QFileInfo>
#include <QTimer>
#include <QDebug>
#include <QElapsedTimer>
#include <memory>
#include <optional>
#include <utility>
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<uint32_t> SceneLoader::addFiles(const QStringList& paths) {
std::vector<uint32_t> 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::optional<SidecarData>>(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<const Eigen::Matrix<double, 4, 4, Eigen::ColMajor>> 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<float, 4, 4, Eigen::ColMajor>;
model.first_placement =
Eigen::Map<const Mat4fCol>(data.instances[0].placement_transformation)
.cast<double>();
model.has_first_placement = true;
}
std::vector<PackedElementInfo> 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<ifcopenshell::file> file;
try {
file = std::make_unique<ifcopenshell::file>(
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::unique_ptr<ifcopenshell::file>>(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<float, 4, 4, Eigen::ColMajor>;
it->second.first_placement =
Eigen::Map<const Mat4fCol>(chunk.transform).cast<double>();
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);
}
+172
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef SCENELOADER_H
#define SCENELOADER_H
#include <QObject>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <QElapsedTimer>
#include <cstdint>
#include <deque>
#include <map>
#include <string>
#include <thread>
#include <vector>
#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<uint32_t> 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<PackedElementInfo> 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<ElementInfo> 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<uint32_t, Model> models_;
std::deque<uint32_t> 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<std::thread> data_source_threads_;
QTimer element_poll_timer_;
};
#endif // SCENELOADER_H
+161
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <cstdio>
#include <cstring>
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<typename T>
static bool writeVec(FILE* f, const std::vector<T>& v) {
uint32_t n = static_cast<uint32_t>(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<typename T>
static bool readVec(FILE* f, std::vector<T>& 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<uint32_t>(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<SidecarData> 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<SidecarData> { 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;
}
+117
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <cstdint>
#include <optional>
#include <string>
#include <vector>
#include <memory>
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<uint8_t> vertices;
std::vector<uint32_t> indices;
// Mesh dictionary and per-instance data.
std::vector<MeshInfo> meshes; // indexed by local_mesh_id
std::vector<InstanceCpu> 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<PackedElementInfo> 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<SidecarData> readSidecar(const std::string& ifc_path);
#endif // SIDECARCACHE_H
+329
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Unit.h"
#include "../ifcparse/file.h"
#include "../ifcparse/instance_data.h"
#include "../ifcparse/schema.h"
#include <algorithm>
#include <cctype>
const std::unordered_map<std::string, double> 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<std::string, std::string> 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<std::string, double> 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<std::string, std::string> 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<std::string, std::string> 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<double> siScaleFromNamedUnit(express::Base unit) {
double scale = 1.0;
while (unit && unit.declaration().is("IfcConversionBasedUnit")) {
auto e = unit.as<express::Entity>();
// 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<express::Entity>();
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<express::Entity>();
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<express::Base> 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<express::Entity>().get("UnitsInContext");
if (ua_attr.isNull()) return std::nullopt;
return (express::Base) ua_attr;
}
std::optional<express::Base> 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<express::Entity>().get("Units");
if (units_attr.isNull()) return std::nullopt;
std::vector<express::Base> 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<express::Entity>().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<express::Entity>();
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);
}
+89
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <optional>
#include <string>
#include <unordered_map>
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<std::string, double> kSiPrefixes;
// SI prefix display symbols, e.g. "MILLI" -> "m".
extern const std::unordered_map<std::string, std::string> 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<std::string, double> kSiConversions;
// Conversion-based unit name -> IFC unit type, e.g. "foot" -> "LENGTHUNIT".
extern const std::unordered_map<std::string, std::string> kImperialTypes;
// Display symbol per unit name. Covers IfcSIUnit names ("METRE" -> "m") and
// IfcConversionBasedUnit names ("foot" -> "ft").
extern const std::unordered_map<std::string, std::string> 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<double> siScaleFromNamedUnit(express::Base named_unit);
// IfcProject.UnitsInContext (the IfcUnitAssignment). Returns nullopt if
// the file has no project or no assignment.
std::optional<express::Base> 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<express::Base> 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
File diff suppressed because it is too large Load Diff
+700
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef VIEWPORTWINDOW_H
#define VIEWPORTWINDOW_H
#include <QWindow>
#include <QOpenGLContext>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
#include <QElapsedTimer>
#include <QMatrix4x4>
#include <QVector3D>
#include <QSet>
QT_BEGIN_NAMESPACE
class QTimer;
QT_END_NAMESPACE
#include <vector>
#include <unordered_map>
#include <cstdint>
#include <mutex>
#include <memory>
#include <atomic>
#include <future>
#include <deque>
#include <Eigen/Dense>
#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<MeshInfo> meshes;
std::vector<InstanceCpu> 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<uint8_t> 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<BvhItem> 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<std::vector<uint32_t>> vis_fwd_lod0;
std::vector<std::vector<uint32_t>> vis_fwd_lod1;
std::vector<std::vector<uint32_t>> vis_rev_lod0;
std::vector<std::vector<uint32_t>> vis_rev_lod1;
std::vector<uint32_t> visible_flat;
std::vector<DrawElementsIndirectCommand> indirect_scratch;
std::vector<uint32_t> 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<float> positions; // 3 * vertex_count
std::vector<uint32_t> 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<float>& 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<PendingOperation> 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<uint32_t, ModelGpuData> 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<float> hiz_depth_readback_; // hiz_base_w_ * hiz_base_h_ floats
std::vector<float> hiz_pyramid_; // concatenated mip levels
std::vector<uint32_t> hiz_mip_offset_; // into hiz_pyramid_
std::vector<uint32_t> hiz_mip_w_;
std::vector<uint32_t> hiz_mip_h_;
QMatrix4x4 hiz_vp_;
bool hiz_vp_valid_ = false;
std::atomic<uint32_t> hiz_reject_count_{0}; // per-frame stat
// Cull-phase timers. Accumulated across all frames in the current
// 1-second stats window; divided by frame_count_ at print time to
// give per-frame average ms. Reset each window. Lets us see where
// CPU time actually goes: bucket clears vs BVH traversal vs emit vs
// GPU upload.
// 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<uint64_t> cull_clear_ns_{0};
std::atomic<uint64_t> cull_traverse_ns_{0};
std::atomic<uint64_t> cull_emit_ns_{0};
std::atomic<uint64_t> 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<float> 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<int> 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<SectionPlane> 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
+81
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
# 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)
+184
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "BvhAccel.h"
#include <catch2/catch_test_macros.hpp>
#include <random>
#include <vector>
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<BvhItem>& 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<BvhItem> 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<BvhItem> 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<float> coord(-100.0f, 100.0f);
std::uniform_real_distribution<float> radius(0.1f, 1.0f);
constexpr int N = 256;
std::vector<BvhItem> 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<int> 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<BvhItem> 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<BvhItem> 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());
}
+712
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Federation.h"
#include <catch2/catch_test_macros.hpp>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QVector3D>
#include <atomic>
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:
// <root>/fed_root/project.ifcfed
// <root>/fed_root/sub/inside.ifc (under fed_dir)
// <root>/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);
}
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <catch2/catch_test_macros.hpp>
#include <cmath>
#include <cstdint>
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);
}
+191
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "InstancedGeometry.h"
#include "LodBuilder.h"
#include "SidecarCache.h"
#include <catch2/catch_test_macros.hpp>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <vector>
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<uint8_t>& 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<uint16_t>(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);
}
+256
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "InstancedGeometry.h"
#include "SidecarCache.h"
#include <catch2/catch_test_macros.hpp>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <random>
#include <string>
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<uint64_t> 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));
}
+97
View File
@@ -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 <http://www.gnu.org/licenses/>. #
# #
################################################################################
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})
+115
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#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<BasicElementInfo> 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<express::Base> 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<PackedElementInfo> 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<ElementInfo> 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
+71
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_ELEMENTREGISTRY_H
#define IFCINTERFACE_ELEMENTREGISTRY_H
#include <QObject>
#include <QString>
#include "../ifcparse/express.h"
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
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<BasicElementInfo> findBasicElementInfo(uint32_t object_id) const;
std::optional<express::Base> findEntity(uint32_t object_id) const;
private:
void onSidecarElementsReady(uint32_t mid,
std::vector<PackedElementInfo> elements,
std::string string_table);
void onStreamedElementsReady(uint32_t mid, std::vector<ElementInfo> elements);
SceneLoader* loader_ = nullptr;
std::unordered_map<uint32_t, BasicElementInfo> elements_;
};
} // namespace ifcinterface
#endif
+745
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#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 <QDockWidget>
#include <QFileDialog>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QListView>
#include <QMessageBox>
#include <QSignalBlocker>
#include <QStackedWidget>
#include <QStatusBar>
#include <QTreeView>
#include <QToolButton>
#include <QVBoxLayout>
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<QToolButton*>& 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<QListView*>("listView")) {
list->setSelectionMode(QAbstractItemView::ExtendedSelection);
}
if (auto* tree = database_dialog.findChild<QTreeView*>()) {
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<int>(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
+123
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_SHELL_MAINWINDOW_H
#define IFCINTERFACE_SHELL_MAINWINDOW_H
#include <QHash>
#include <QMainWindow>
#include <QStringList>
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<QToolButton*>& 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
+107
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#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<uint32_t> 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
+91
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_SESSIONSTATE_H
#define IFCINTERFACE_SESSIONSTATE_H
#include <QHash>
#include <QObject>
#include <QString>
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<uint32_t> 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<QString, uint32_t> fed_id_to_model_id_;
QHash<uint32_t, QString> model_id_to_fed_id_;
};
} // namespace ifcinterface
#endif
+78
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Buttons.h"
#include "SvgIcon.h"
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QToolButton>
#include <QVBoxLayout>
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<QToolButton*>& 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
+45
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_BUTTONS_H
#define IFCINTERFACE_COMPONENTS_BUTTONS_H
#include <QList>
#include <QSize>
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<QToolButton*>& buttons,
QWidget* parent,
bool trailing_separator = true,
int vertical_spacing = 4);
} // namespace ifcinterface::components::buttons
#endif
+79
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Dialog.h"
#include "Style.h"
#include <QDialog>
#include <QFrame>
#include <QScrollArea>
#include <QVBoxLayout>
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
+46
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_DIALOG_H
#define IFCINTERFACE_COMPONENTS_DIALOG_H
#include <QDialog>
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
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "KeyValueTable.h"
#include "SvgIcon.h"
#include <QGridLayout>
#include <QLabel>
namespace ifcinterface::components {
KeyValueTable::KeyValueTable(const QList<KeyValueTableRow>& 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
+47
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H
#define IFCINTERFACE_COMPONENTS_KEYVALUETABLE_H
#include <QList>
#include <QString>
#include <QWidget>
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<KeyValueTableRow>& rows, QWidget* parent = nullptr);
};
} // namespace ifcinterface::components
#endif
+136
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Panel.h"
#include "Style.h"
#include "SvgIcon.h"
#include <QDockWidget>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMenu>
#include <QScrollArea>
#include <QToolButton>
#include <QVBoxLayout>
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
+50
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H
#define IFCINTERFACE_COMPONENTS_PANEL_PANELCHROME_H
#include <QDockWidget>
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
+101
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "Section.h"
#include "Style.h"
#include <QFrame>
#include <QHBoxLayout>
#include <QToolButton>
#include <QVBoxLayout>
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
+59
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_SECTION_H
#define IFCINTERFACE_COMPONENTS_SECTION_H
#include <QWidget>
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
+432
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#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
+73
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_STYLE_H
#define IFCINTERFACE_COMPONENTS_STYLE_H
#include <QString>
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
+72
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "SvgIcon.h"
#include <QFile>
#include <QPainter>
#include <QRegularExpression>
#include <QSvgRenderer>
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
+41
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_ICONS_SVGICON_H
#define IFCINTERFACE_COMPONENTS_ICONS_SVGICON_H
#include <QIcon>
#include <QPixmap>
#include <QString>
#include <QSize>
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
+40
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#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
+45
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef IFCINTERFACE_COMPONENTS_TABS_H
#define IFCINTERFACE_COMPONENTS_TABS_H
#include <QTabBar>
#include <QTabWidget>
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
+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.5 16.7222L12.2914 12.1618C12.1102 12.0612 11.8898 12.0612 11.7086 12.1618L3.5 16.7222" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.52844 7.29363L11.7086 11.8382C11.8898 11.9388 12.1102 11.9388 12.2914 11.8382L20.5 7.27783" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 3L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 19.5V22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 23C12.5523 23 13 22.5523 13 22C13 21.4477 12.5523 21 12 21C11.4477 21 11 21.4477 11 22C11 22.5523 11.4477 23 12 23Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 8C3.55228 8 4 7.55228 4 7C4 6.44772 3.55228 6 3 6C2.44772 6 2 6.44772 2 7C2 7.55228 2.44772 8 3 8Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 18C3.55228 18 4 17.5523 4 17C4 16.4477 3.55228 16 3 16C2.44772 16 2 16.4477 2 17C2 17.5523 2.44772 18 3 18Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.52844 7.29363L11.7086 11.8382C11.8898 11.9388 12.1102 11.9388 12.2914 11.8382L20.5 7.27783" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 21L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 9.01L10.01 8.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 9.01L14.01 8.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 13.01L10.01 12.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 13.01L14.01 12.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 17.01L10.01 16.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 17.01L14.01 16.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 20.4V5.6C6 5.26863 6.26863 5 6.6 5H12V3.6C12 3.26863 12.2686 3 12.6 3H17.4C17.7314 3 18 3.26863 18 3.6V20.4C18 20.7314 17.7314 21 17.4 21H6.6C6.26863 21 6 20.7314 6 20.4Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1005 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 21H21V12C21 9.61305 20.0518 7.32387 18.364 5.63604C16.6761 3.94821 14.3869 3 12 3C9.61305 3 7.32387 3.94821 5.63604 5.63604C3.94821 7.32387 3 9.61305 3 12V21Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 17L21 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 17V13H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 13V9H20" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 644 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12.5L9.5 17L19 7.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

+9
View File
@@ -0,0 +1,9 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9.01L7.01 8.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 9.01L11.01 8.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 13.01L7.01 12.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 13.01L11.01 12.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 17.01L7.01 16.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 17.01L11.01 16.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 21H3.6C3.26863 21 3 20.7314 3 20.4V5.6C3 5.26863 3.26863 5 3.6 5H9V3.6C9 3.26863 9.26863 3 9.6 3H14.4C14.7314 3 15 3.26863 15 3.6V9M15 21H20.4C20.7314 21 21 20.7314 21 20.4V9.6C21 9.26863 20.7314 9 20.4 9H15M15 21V17M15 9V13M15 13H17M15 13V17M15 17H17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6L12 12L18 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.8883 10.5C21.1645 5.68874 17.013 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C16.1006 22 19.6248 19.5318 21.1679 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 16H21.4C21.7314 16 22 16.2686 22 16.6V21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 8C8.72727 8 8.72727 10 8.72727 11C7.81818 11 6 11.5 6 13.5C6 15.5 7.81818 16 8.72727 16H15.2727C16.1818 16 18 15.5 18 13.5C18 11.5 16.1818 11 15.2727 11C15.2727 10 15.2727 8 12 8Z" stroke="currentColor" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

+10
View File
@@ -0,0 +1,10 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7.35304V16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647V7.35304C3 7.13514 3.11814 6.93437 3.30861 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8819 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.52844 7.29357L11.7086 11.8381C11.8898 11.9388 12.1102 11.9388 12.2914 11.8381L20.5 7.27777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 21V12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 8.5L15.5 15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 15.5L15.5 8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 8.5L8.5 10.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 13.5L13.5 15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.4961 19.7165L18.4961 16.2879C18.8077 16.1099 19 15.7785 19 15.4197V9.58032C19 9.22147 18.8077 8.89012 18.4961 8.71208L12.4961 5.28351C12.1887 5.10783 11.8113 5.10783 11.5039 5.28351L5.50386 8.71208C5.19229 8.89012 5 9.22147 5 9.58032V15.4197C5 15.7785 5.19229 16.1099 5.50386 16.2879L11.5039 19.7165C11.8113 19.8922 12.1887 19.8922 12.4961 19.7165Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 9.5L12 13M12 13L18.5 9.5M12 13V19.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 3.01013L3.01 2.99902" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 21.0101L3.01 20.999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 3.01013L21.01 2.99902" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 21.0101L21.01 20.999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H6C6.41421 2.25 6.75 2.58579 6.75 3C6.75 3.41421 6.41421 3.75 6 3.75H3.75V6C3.75 6.41421 3.41421 6.75 3 6.75C2.58579 6.75 2.25 6.41421 2.25 6V3Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.25 3C17.25 2.58579 17.5858 2.25 18 2.25H21C21.4142 2.25 21.75 2.58579 21.75 3V6C21.75 6.41421 21.4142 6.75 21 6.75C20.5858 6.75 20.25 6.41421 20.25 6V3.75H18C17.5858 3.75 17.25 3.41421 17.25 3Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 17.25C3.41421 17.25 3.75 17.5858 3.75 18V20.25H6C6.41421 20.25 6.75 20.5858 6.75 21C6.75 21.4142 6.41421 21.75 6 21.75H3C2.58579 21.75 2.25 21.4142 2.25 21V18C2.25 17.5858 2.58579 17.25 3 17.25Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 17.25C21.4142 17.25 21.75 17.5858 21.75 18V21C21.75 21.4142 21.4142 21.75 21 21.75H18C17.5858 21.75 17.25 21.4142 17.25 21C17.25 20.5858 17.5858 20.25 18 20.25H20.25V18C20.25 17.5858 20.5858 17.25 21 17.25Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9004 6.6654C12.3462 6.33289 11.6538 6.33289 11.0996 6.6654L7.09963 9.0654C6.57252 9.38167 6.25 9.95131 6.25 10.566V14.4336C6.25 15.0483 6.57252 15.618 7.09963 15.9342L11.0996 18.3342C11.6538 18.6668 12.3462 18.6668 12.9004 18.3342L16.9004 15.9342C17.4275 15.618 17.75 15.0483 17.75 14.4336V10.566C17.75 9.95131 17.4275 9.38167 16.9004 9.0654L12.9004 6.6654ZM9.3642 10.6785C9.00209 10.4773 8.5455 10.6078 8.34437 10.9699C8.14324 11.332 8.27373 11.7886 8.63583 11.9898L11.25 13.4418V16.0001C11.25 16.4144 11.5858 16.7501 12 16.7501C12.4142 16.7501 12.75 16.4144 12.75 16.0001V13.4456C12.9152 13.3554 13.1243 13.241 13.3607 13.1115C13.9447 12.7916 14.6961 12.3787 15.3642 12.0077C15.7263 11.8066 15.8568 11.35 15.6557 10.9879C15.4546 10.6257 14.998 10.4952 14.6359 10.6964C13.9716 11.0653 13.223 11.4766 12.6401 11.796C12.3908 11.9325 12.172 12.0521 12.0032 12.1443L9.3642 10.6785Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 3H3V6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 3H21V6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 21H3V18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 21H21V18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.5145 17.6913L16.5145 15.2913C16.8157 15.1106 17 14.7851 17 14.4338V10.5662C17 10.2149 16.8157 9.88942 16.5145 9.7087L12.5145 7.3087C12.1978 7.11869 11.8022 7.11869 11.4855 7.3087L7.4855 9.7087C7.1843 9.88942 7 10.2149 7 10.5662V14.4338C7 14.7851 7.1843 15.1106 7.4855 15.2913L11.4855 17.6913C11.8022 17.8813 12.1978 17.8813 12.5145 17.6913Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 10.5L12 12.9995M12 12.9995C12 12.9995 15.7637 10.9492 16.5 10.5M12 12.9995V17.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7.35304L21 16.647C21 16.8649 20.8819 17.0656 20.6914 17.1715L12.2914 21.8381C12.1102 21.9388 11.8898 21.9388 11.7086 21.8381L3.30861 17.1715C3.11814 17.0656 3 16.8649 3 16.647L2.99998 7.35304C2.99998 7.13514 3.11812 6.93437 3.3086 6.82855L11.7086 2.16188C11.8898 2.06121 12.1102 2.06121 12.2914 2.16188L20.6914 6.82855C20.8818 6.93437 21 7.13514 21 7.35304Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.52844 7.29357L11.7086 11.8381C11.8898 11.9388 12.1102 11.9388 12.2914 11.8381L20.5 7.27777" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 21L12 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 834 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.5027 9.96958C20.7073 10.4588 20.6154 12.1941 19.3658 12.5533L13.0605 14.3658L10.1807 20.2606C9.60996 21.4288 7.88499 21.218 7.6124 19.9468L4.67677 6.25646C4.44638 5.18204 5.5121 4.2878 6.53019 4.70126L19.5027 9.96958Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6V12C4 12 4 15 11 15C18 15 18 12 18 12V6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 3C18 3 18 6 18 6C18 6 18 9 11 9C4 9 4 6 4 6C4 6 4 3 11 3Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 21C4 21 4 18 4 18V12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 22V16M19 16L22 19M19 16L16 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 611 B

+6
View File
@@ -0,0 +1,6 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 6.5C5 4.567 8.13401 3 12 3C15.866 3 19 4.567 19 6.5C19 8.433 15.866 10 12 10C8.13401 10 5 8.433 5 6.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 6.5V12.5C19 14.433 15.866 16 12 16C8.13401 16 5 14.433 5 12.5V6.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 12.5V17.5C19 19.433 15.866 21 12 21C8.13401 21 5 19.433 5 17.5V12.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 688 B

+5
View File
@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 18L18 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 6V14M12 14L15.5 10.5M12 14L8.5 10.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 528 B

+11
View File
@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.463 17H10.537C10.2313 17 9.97446 16.7701 9.9407 16.4663L9.07403 8.66626C9.03454 8.31084 9.31275 8 9.67036 8L14.3296 8C14.6872 8 14.9655 8.31084 14.926 8.66626L14.0593 16.4663C14.0255 16.7701 13.7687 17 13.463 17Z" stroke="currentColor" stroke-linecap="round"/>
<path d="M4.5 7C5.88071 7 7 5.88071 7 4.5C7 3.11929 5.88071 2 4.5 2C3.11929 2 2 3.11929 2 4.5C2 5.88071 3.11929 7 4.5 7Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 4.5L9 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.49988 19.5L9.5 15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 4.5L15 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 19.5L14.5 15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 22C5.88071 22 7 20.8807 7 19.5C7 18.1193 5.88071 17 4.5 17C3.11929 17 2 18.1193 2 19.5C2 20.8807 3.11929 22 4.5 22Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 7C20.8807 7 22 5.88071 22 4.5C22 3.11929 20.8807 2 19.5 2C18.1193 2 17 3.11929 17 4.5C17 5.88071 18.1193 7 19.5 7Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.5 22C20.8807 22 22 20.8807 22 19.5C22 18.1193 20.8807 17 19.5 17C18.1193 17 17 18.1193 17 19.5C17 20.8807 18.1193 22 19.5 22Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More