From 75c9da50981e6f80e408259b39b4c0d768449d11 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 14:10:05 +1000 Subject: [PATCH 01/20] ifcviewer: overhaul model/object ID tracking Rename the two overloaded model identifiers and make object_id assignment single-authority, fixing a pick -> properties mismatch. Identifiers: - Per-model UUID fed_id -> model_id; the uint32 runtime handle model_id -> session_model_id (SessionState accessors + mirror hashes renamed to match). "fed_id" was a misnomer -- the federation is the whole collection, not one model. object_id assignment (fixes wrong class on click): - Producers (GeometryStreamer, .ifcview sidecar) now stamp model-LOCAL object_ids; ViewportCore::applyCachedModel is the sole authority that assigns the session-global id (base + local). Removed SceneLoader::next_object_id_, GeometryStreamer::lastObjectId(), and the streamer's start_object_id parameter. - The element table is stamped by the same base on both load paths (applySidecarData and onStreamerFinished), so registry ids match the ids pick returns. Previously the sidecar path double-rebased instances vs the registry (click IfcSite -> showed IfcDoor); the live-stream path had the same latent mismatch. Both closed. Naming / cleanup: - SceneLoader::addFiles -> queueModels; startStreamLoadFor -> loadFromGeometryStreamer; readSidecarMetadataOnly -> readSidecarMetadata. - Federation::addModel takes an explicit display_name (no QFileInfo fallback); callers pass QFileInfo(path).fileName(). - Disambiguate cryptic short locals (d->sidecar, m->model, c->chunk, ...) in SceneLoader, Federation, ViewportWindow, AreaMeasurement, SectionGizmoRenderer, and the SidecarData/SidecarReadPlan spots in ViewportCore. Tests: 125/125 pass. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/ElementRegistry.cpp | 18 +- src/bonsaiviewer/ElementRegistry.h | 10 +- src/bonsaiviewer/Measurement.cpp | 30 +- src/bonsaiviewer/Measurement.h | 6 +- src/bonsaiviewer/SessionState.cpp | 64 ++-- src/bonsaiviewer/SessionState.h | 24 +- .../docs/viewport_architecture.rst | 2 +- src/bonsaiviewer/modules/models/Commands.cpp | 99 +++--- src/bonsaiviewer/modules/models/Commands.h | 8 +- .../modules/models/FederationItemModel.cpp | 42 +-- .../modules/models/FederationItemModel.h | 16 +- .../modules/models/SettingsDialog.cpp | 6 +- .../modules/models/SettingsDialog.h | 2 +- .../modules/models/SettingsView.cpp | 10 +- .../modules/models/SettingsView.h | 2 +- src/bonsaiviewer/modules/project/Commands.cpp | 54 ++-- src/bonsaiviewer/modules/viewport/View.cpp | 50 +-- src/bonsaiviewer/modules/viewport/View.h | 8 +- src/ifcviewer/AreaMeasurement.cpp | 58 ++-- src/ifcviewer/AreaMeasurement.h | 6 +- src/ifcviewer/Federation.cpp | 276 +++++++++-------- src/ifcviewer/Federation.h | 36 +-- src/ifcviewer/GeometryStreamer.cpp | 16 +- src/ifcviewer/GeometryStreamer.h | 11 +- src/ifcviewer/InstanceCompose.cpp | 4 +- src/ifcviewer/InstanceCompose.h | 4 +- src/ifcviewer/InstancedGeometry.h | 6 +- src/ifcviewer/LengthMeasurement.cpp | 2 +- src/ifcviewer/ModelGpuData.h | 2 +- src/ifcviewer/SceneLoader.cpp | 287 +++++++++--------- src/ifcviewer/SceneLoader.h | 51 ++-- src/ifcviewer/SectionGizmoRenderer.cpp | 12 +- src/ifcviewer/SidecarBuilder.cpp | 7 +- src/ifcviewer/SidecarCache.cpp | 2 +- src/ifcviewer/SidecarCache.h | 2 +- src/ifcviewer/StreamingLoader.cpp | 2 +- src/ifcviewer/StreamingLoader.h | 2 +- src/ifcviewer/StreamingThread.cpp | 2 +- src/ifcviewer/StreamingThread.h | 4 +- src/ifcviewer/ViewportCore.cpp | 253 +++++++-------- src/ifcviewer/ViewportCore.h | 48 +-- src/ifcviewer/ViewportWindow.cpp | 120 ++++---- src/ifcviewer/ViewportWindow.h | 33 +- src/ifcviewer/tests/test_federation.cpp | 72 +++-- src/ifcviewer/tests/test_instance_compose.cpp | 2 +- .../tests/test_instanced_geometry.cpp | 4 +- src/ifcviewer/tests/test_sidecar_cache.cpp | 4 +- src/ifcviewer/tests/test_sidecar_layout.cpp | 2 +- src/ifcviewer/tests/test_streaming_loader.cpp | 18 +- 49 files changed, 914 insertions(+), 885 deletions(-) diff --git a/src/bonsaiviewer/ElementRegistry.cpp b/src/bonsaiviewer/ElementRegistry.cpp index 6305f9d743..84221f4be9 100644 --- a/src/bonsaiviewer/ElementRegistry.cpp +++ b/src/bonsaiviewer/ElementRegistry.cpp @@ -43,9 +43,9 @@ void ElementRegistry::clear() { elements_.clear(); } -void ElementRegistry::removeModel(uint32_t model_id) { +void ElementRegistry::removeModel(uint32_t session_model_id) { for (auto it = elements_.begin(); it != elements_.end();) { - if (it->second.model_id == model_id) { + if (it->second.session_model_id == session_model_id) { it = elements_.erase(it); } else { ++it; @@ -53,12 +53,12 @@ void ElementRegistry::removeModel(uint32_t model_id) { } } -std::vector ElementRegistry::basicElementInfoForModel(uint32_t model_id) const { +std::vector ElementRegistry::basicElementInfoForModel(uint32_t session_model_id) const { std::vector result; result.reserve(elements_.size()); for (const auto& [object_id, info] : elements_) { (void)object_id; - if (info.model_id != model_id) continue; + if (info.session_model_id != session_model_id) continue; result.push_back(info); } return result; @@ -76,7 +76,7 @@ std::optional ElementRegistry::findEntity(uint32_t object_id) con auto info = findBasicElementInfo(object_id); if (!info) return std::nullopt; - auto* file = loader_->ifcFile(info->model_id); + auto* file = loader_->ifcFile(info->session_model_id); if (!file) return std::nullopt; try { @@ -88,7 +88,7 @@ std::optional ElementRegistry::findEntity(uint32_t object_id) con } } -void ElementRegistry::onSidecarElementsReady(uint32_t /*model_id*/, +void ElementRegistry::onSidecarElementsReady(uint32_t /*session_model_id*/, std::vector elements, std::string string_table) { auto string_from_table = [&](uint32_t offset, uint32_t length) -> QString { @@ -99,7 +99,7 @@ void ElementRegistry::onSidecarElementsReady(uint32_t /*model_id*/, for (const auto& packed_element : elements) { BasicElementInfo info; info.object_id = packed_element.object_id; - info.model_id = packed_element.model_id; + info.session_model_id = packed_element.session_model_id; info.ifc_id = packed_element.ifc_id; info.guid = string_from_table(packed_element.guid_offset, packed_element.guid_length); info.name = string_from_table(packed_element.name_offset, packed_element.name_length); @@ -108,11 +108,11 @@ void ElementRegistry::onSidecarElementsReady(uint32_t /*model_id*/, } } -void ElementRegistry::onStreamedElementsReady(uint32_t /*model_id*/, std::vector elements) { +void ElementRegistry::onStreamedElementsReady(uint32_t /*session_model_id*/, std::vector elements) { for (const auto& element : elements) { BasicElementInfo info; info.object_id = element.object_id; - info.model_id = element.model_id; + info.session_model_id = element.session_model_id; info.ifc_id = element.ifc_id; info.guid = QString::fromStdString(element.guid); info.name = QString::fromStdString(element.name); diff --git a/src/bonsaiviewer/ElementRegistry.h b/src/bonsaiviewer/ElementRegistry.h index 173f3a6aa4..409554d643 100644 --- a/src/bonsaiviewer/ElementRegistry.h +++ b/src/bonsaiviewer/ElementRegistry.h @@ -37,7 +37,7 @@ namespace bonsaiviewer { struct BasicElementInfo { uint32_t object_id = 0; - uint32_t model_id = 0; + uint32_t session_model_id = 0; int ifc_id = 0; QString guid; QString name; @@ -51,16 +51,16 @@ public: void bindLoader(SceneLoader* loader); void clear(); - void removeModel(uint32_t model_id); - std::vector basicElementInfoForModel(uint32_t model_id) const; + void removeModel(uint32_t session_model_id); + std::vector basicElementInfoForModel(uint32_t session_model_id) const; std::optional findBasicElementInfo(uint32_t object_id) const; std::optional findEntity(uint32_t object_id) const; private: - void onSidecarElementsReady(uint32_t model_id, + void onSidecarElementsReady(uint32_t session_model_id, std::vector elements, std::string string_table); - void onStreamedElementsReady(uint32_t model_id, std::vector elements); + void onStreamedElementsReady(uint32_t session_model_id, std::vector elements); SceneLoader* loader_ = nullptr; std::unordered_map elements_; diff --git a/src/bonsaiviewer/Measurement.cpp b/src/bonsaiviewer/Measurement.cpp index dd3175df25..2f99954d92 100644 --- a/src/bonsaiviewer/Measurement.cpp +++ b/src/bonsaiviewer/Measurement.cpp @@ -71,7 +71,7 @@ double volumeOfObjects(ViewportWindow& vp, const std::vector& object_ids) { if (object_ids.empty()) return 0.0; - // Group selected instances by (model_id, mesh_id) so each unique mesh + // Group selected instances by (session_model_id, mesh_id) so each unique mesh // is read back at most once per call. Each entry stores the |det| of // every instance of that mesh in the request. std::unordered_map> by_mesh; @@ -79,16 +79,16 @@ double volumeOfObjects(ViewportWindow& vp, 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; + const uint64_t key = (uint64_t(lk.session_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 session_model_id = uint32_t(key >> 32); const uint32_t mesh_id = uint32_t(key & 0xffffffffu); - if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) continue; + if (!vp.readbackMeshTriangles(session_model_id, mesh_id, tris)) continue; const double v = meshLocalVolume(tris); for (double d : dets) total += v * d; } @@ -102,7 +102,7 @@ volumesPerObject(ViewportWindow& vp, if (object_ids.empty()) return out; out.reserve(object_ids.size()); - // Cache the local-frame volume per unique (model_id, mesh_id) so each + // Cache the local-frame volume per unique (session_model_id, mesh_id) so each // mesh is read back at most once even when many instances share it // (common for repeated families like windows / columns). std::unordered_map mesh_vol_local; @@ -113,11 +113,11 @@ volumesPerObject(ViewportWindow& vp, ViewportWindow::InstanceLookup lk; if (!vp.findInstance(oid, lk)) continue; - const uint64_t key = (uint64_t(lk.model_id) << 32) | lk.mesh_id; + const uint64_t key = (uint64_t(lk.session_model_id) << 32) | lk.mesh_id; auto it = mesh_vol_local.find(key); double v_local = 0.0; if (it == mesh_vol_local.end()) { - if (vp.readbackMeshTriangles(lk.model_id, lk.mesh_id, tris)) { + if (vp.readbackMeshTriangles(lk.session_model_id, lk.mesh_id, tris)) { v_local = meshLocalVolume(tris); } mesh_vol_local.emplace(key, v_local); @@ -252,7 +252,7 @@ void AreaMeasurement::rebuildHighlight(ViewportWindow& vp) { std::vector world_xyz; world_xyz.reserve(selected_.size() * 9); for (const auto& [key, sel] : selected_) { - const uint64_t cache_key = (uint64_t(sel.model_id) << 32) + const uint64_t cache_key = (uint64_t(sel.session_model_id) << 32) | uint64_t(sel.mesh_id); auto cit = mesh_cache_.find(cache_key); if (cit == mesh_cache_.end()) continue; @@ -292,7 +292,7 @@ void AreaMeasurement::rebuildHighlight(ViewportWindow& vp) { if (sels.empty()) continue; // All tris belonging to one object share its mesh + transform. const SelectedTri& any = *sels[0]; - const uint64_t cache_key = (uint64_t(any.model_id) << 32) + const uint64_t cache_key = (uint64_t(any.session_model_id) << 32) | uint64_t(any.mesh_id); auto cit = mesh_cache_.find(cache_key); if (cit == mesh_cache_.end()) continue; @@ -362,14 +362,14 @@ void AreaMeasurement::rebuildHighlight(ViewportWindow& vp) { } AreaMeasurement::MeshCache* AreaMeasurement::meshCache(ViewportWindow& vp, - uint32_t model_id, + uint32_t session_model_id, uint32_t mesh_id) { - const uint64_t key = (uint64_t(model_id) << 32) | uint64_t(mesh_id); + const uint64_t key = (uint64_t(session_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; + if (!vp.readbackMeshTriangles(session_model_id, mesh_id, tris)) return nullptr; MeshCache c; c.positions = std::move(tris.positions); @@ -401,7 +401,7 @@ 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); + MeshCache* cache = meshCache(vp, pick.session_model_id, pick.mesh_id); if (!cache) return; const size_t n_tris = cache->indices.size() / 3; if (n_tris == 0) return; @@ -471,7 +471,7 @@ void AreaMeasurement::onPick(ViewportWindow& vp, int x, int y, bool alt) { } } else { SelectedTri sel; - sel.model_id = pick.model_id; + sel.session_model_id = pick.session_model_id; sel.mesh_id = pick.mesh_id; sel.tri = t; std::memcpy(sel.composed_transform, pick.composed_transform, @@ -876,7 +876,7 @@ void LengthMeasurement::rebuildLaserOverlay(ViewportWindow& vp) { ViewportWindow::MeshTriangles tris; bool have_extent = false; double min_t1 = 0.0, max_t1 = 0.0, min_t2 = 0.0, max_t2 = 0.0; - if (vp.readbackMeshTriangles(first_pick_.model_id, first_pick_.mesh_id, tris)) { + if (vp.readbackMeshTriangles(first_pick_.session_model_id, first_pick_.mesh_id, tris)) { const size_t n_verts = tris.positions.size() / 3; const size_t n_tris = tris.indices.size() / 3; if (n_tris > 0) { diff --git a/src/bonsaiviewer/Measurement.h b/src/bonsaiviewer/Measurement.h index 253eee279b..67a0beb986 100644 --- a/src/bonsaiviewer/Measurement.h +++ b/src/bonsaiviewer/Measurement.h @@ -79,7 +79,7 @@ public: private: // Cached per-mesh data: triangles + edge→triangles adjacency. Keyed - // by (model_id << 32) | mesh_id. Filled lazily on first pick of that + // by (session_model_id << 32) | mesh_id. Filled lazily on first pick of that // mesh, dropped on clear(). struct MeshCache { std::vector positions; // 3 * N_verts @@ -89,14 +89,14 @@ private: // edge_key (min<<32 | max) → list of triangle indices touching it. std::unordered_map> edges; }; - MeshCache* meshCache(ViewportWindow& vp, uint32_t model_id, uint32_t mesh_id); + MeshCache* meshCache(ViewportWindow& vp, uint32_t session_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 session_model_id; uint32_t mesh_id; uint32_t tri; float composed_transform[16]; diff --git a/src/bonsaiviewer/SessionState.cpp b/src/bonsaiviewer/SessionState.cpp index ee0b5617c7..418b8c2982 100644 --- a/src/bonsaiviewer/SessionState.cpp +++ b/src/bonsaiviewer/SessionState.cpp @@ -64,25 +64,25 @@ void SessionState::createLoader(ViewportWindow* viewport) { }); connect(loader_, &SceneLoader::progressChanged, this, &SessionState::setProgress); connect(loader_, &SceneLoader::loadedFromSidecar, this, - [this, format_elapsed](uint32_t model_id, qint64 elapsed_ms) { + [this, format_elapsed](uint32_t session_model_id, qint64 elapsed_ms) { setStatusMessage("Loaded", QString("%1 from cache in %2") - .arg(loader_->displayName(model_id)) + .arg(loader_->displayName(session_model_id)) .arg(format_elapsed(elapsed_ms))); endProgress(); - emit modelGeometryReady(model_id); + emit modelGeometryReady(session_model_id); }); connect(loader_, &SceneLoader::loadedFromStream, this, - [this, format_elapsed](uint32_t model_id, qint64 elapsed_ms) { + [this, format_elapsed](uint32_t session_model_id, qint64 elapsed_ms) { setStatusMessage("Loaded", QString("%1 streamed in %2") - .arg(loader_->displayName(model_id)) + .arg(loader_->displayName(session_model_id)) .arg(format_elapsed(elapsed_ms))); endProgress(); - emit modelGeometryReady(model_id); + emit modelGeometryReady(session_model_id); }); - connect(loader_, &SceneLoader::loadCancelled, this, [this](uint32_t model_id) { - setStatusMessage("Cancelled", loader_->displayName(model_id)); + connect(loader_, &SceneLoader::loadCancelled, this, [this](uint32_t session_model_id) { + setStatusMessage("Cancelled", loader_->displayName(session_model_id)); endProgress(); }); connect(loader_, &SceneLoader::loadError, this, @@ -118,44 +118,44 @@ void SessionState::endProgress() { emit progressEnded(); } -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::setModelMapping(const QString& model_id, uint32_t session_model_id) { + model_id_to_session_model_id_[model_id] = session_model_id; + session_model_id_to_model_id_[session_model_id] = model_id; } -void SessionState::removeModelMappingByFedId(const QString& fed_id) { - cloud_metadata_.remove(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::removeModelMappingByModelId(const QString& model_id) { + cloud_metadata_.remove(model_id); + auto it = model_id_to_session_model_id_.find(model_id); + if (it == model_id_to_session_model_id_.end()) return; + session_model_id_to_model_id_.remove(it.value()); + model_id_to_session_model_id_.erase(it); } void SessionState::clearModelMappings() { - fed_id_to_model_id_.clear(); - model_id_to_fed_id_.clear(); + model_id_to_session_model_id_.clear(); + session_model_id_to_model_id_.clear(); cloud_metadata_.clear(); } -void SessionState::setCloudMetadata(const QString& fed_id, const QVariantMap& metadata) { - if (metadata.isEmpty()) cloud_metadata_.remove(fed_id); - else cloud_metadata_.insert(fed_id, metadata); +void SessionState::setCloudMetadata(const QString& model_id, const QVariantMap& metadata) { + if (metadata.isEmpty()) cloud_metadata_.remove(model_id); + else cloud_metadata_.insert(model_id, metadata); } -QVariantMap SessionState::cloudMetadata(const QString& fed_id) const { - return cloud_metadata_.value(fed_id); +QVariantMap SessionState::cloudMetadata(const QString& model_id) const { + return cloud_metadata_.value(model_id); } -uint32_t SessionState::modelIdForFedId(const QString& fed_id) const { - return fed_id_to_model_id_.value(fed_id, 0); +uint32_t SessionState::sessionModelIdForModelId(const QString& model_id) const { + return model_id_to_session_model_id_.value(model_id, 0); } -QString SessionState::fedIdForModelId(uint32_t model_id) const { - return model_id_to_fed_id_.value(model_id); +QString SessionState::modelIdForSessionModelId(uint32_t session_model_id) const { + return session_model_id_to_model_id_.value(session_model_id); } -QList SessionState::modelIds() const { - return model_id_to_fed_id_.keys(); +QList SessionState::sessionModelIds() const { + return session_model_id_to_model_id_.keys(); } void SessionState::notifySelectionChanged() { @@ -174,8 +174,8 @@ void SessionState::notifyVisibilityChanged() { emit visibilityChanged(); } -void SessionState::notifyModelGeometryReady(uint32_t model_id) { - emit modelGeometryReady(model_id); +void SessionState::notifyModelGeometryReady(uint32_t session_model_id) { + emit modelGeometryReady(session_model_id); } void SessionState::notifyProjectOpened(const QString& path) { diff --git a/src/bonsaiviewer/SessionState.h b/src/bonsaiviewer/SessionState.h index 0ae486912e..638e810129 100644 --- a/src/bonsaiviewer/SessionState.h +++ b/src/bonsaiviewer/SessionState.h @@ -64,25 +64,25 @@ public: void setProgress(int percent); void endProgress(); - void setModelMapping(const QString& fed_id, uint32_t model_id); - void removeModelMappingByFedId(const QString& fed_id); + void setModelMapping(const QString& model_id, uint32_t session_model_id); + void removeModelMappingByModelId(const QString& model_id); void clearModelMappings(); // Per-session cloud metadata returned by connectors (revision/date/ // author/...). Not persisted to the .ifcfed; display only. Lifetime - // is tied to the fed_id — removeModelMappingByFedId and + // is tied to the model_id — removeModelMappingByModelId and // clearModelMappings drop the matching entries. - void setCloudMetadata(const QString& fed_id, const QVariantMap& metadata); - QVariantMap cloudMetadata(const QString& fed_id) const; - uint32_t modelIdForFedId(const QString& fed_id) const; - QString fedIdForModelId(uint32_t model_id) const; - QList modelIds() const; + void setCloudMetadata(const QString& model_id, const QVariantMap& metadata); + QVariantMap cloudMetadata(const QString& model_id) const; + uint32_t sessionModelIdForModelId(const QString& model_id) const; + QString modelIdForSessionModelId(uint32_t session_model_id) const; + QList sessionModelIds() const; void notifySelectionChanged(); void notifyModelsChanged(); void notifyFederationChanged(); void notifyVisibilityChanged(); - void notifyModelGeometryReady(uint32_t model_id); + void notifyModelGeometryReady(uint32_t session_model_id); void notifyProjectOpened(const QString& path); void notifyProjectSaved(const QString& path); void notifyProjectReset(); @@ -100,7 +100,7 @@ signals: // Fires when a model's geometry has been pushed to the viewport. Fires // for both sidecar-cache and stream loads; subscribers that just need to // re-derive view state (e.g. ViewportView::refresh) listen to this. - void modelGeometryReady(uint32_t model_id); + void modelGeometryReady(uint32_t session_model_id); // Fires when SceneLoader reports a load failure. SessionState turns the // raw loader signal into a session-level one so views (e.g. the MessageBox) // can subscribe without touching the loader directly. @@ -119,8 +119,8 @@ private: uint32_t selected_object_id_ = 0; QString status_mode_; QString status_detail_; - QHash fed_id_to_model_id_; - QHash model_id_to_fed_id_; + QHash model_id_to_session_model_id_; + QHash session_model_id_to_model_id_; QHash cloud_metadata_; }; diff --git a/src/bonsaiviewer/docs/viewport_architecture.rst b/src/bonsaiviewer/docs/viewport_architecture.rst index c910f78960..a23ee7dd2f 100644 --- a/src/bonsaiviewer/docs/viewport_architecture.rst +++ b/src/bonsaiviewer/docs/viewport_architecture.rst @@ -84,7 +84,7 @@ The fast path starts when ``SceneLoader`` finds a readable ``.ifcview`` cache. The reader validates the sidecar header, skips the compressed geometry section, and reads the metadata blocks. -For desktop loading, ``readSidecarMetadataOnly()`` returns a +For desktop loading, ``readSidecarMetadata()`` returns a ``StreamingSidecar`` containing: - the sidecar file path diff --git a/src/bonsaiviewer/modules/models/Commands.cpp b/src/bonsaiviewer/modules/models/Commands.cpp index 8ed35b4b07..c098c77eb5 100644 --- a/src/bonsaiviewer/modules/models/Commands.cpp +++ b/src/bonsaiviewer/modules/models/Commands.cpp @@ -166,31 +166,31 @@ void removeGroup(SessionState& session, QWidget& host, const QString& group_id) session.setStatusMessage("Models", "Group removed"); } -void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& fed_id) { - const Federation::Model* model = session.federation()->findById(fed_id); - const QString label = model ? model->display_name : fed_id; +void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& model_id) { + const Federation::Model* model = session.federation()->findById(model_id); + const QString label = model ? model->display_name : model_id; const auto choice = QMessageBox::question( &host, "Remove Model", QString("Remove model '%1' from the federation?").arg(label), QMessageBox::Yes | QMessageBox::No, QMessageBox::No); if (choice != QMessageBox::Yes) return; - const uint32_t model_id = session.modelIdForFedId(fed_id); - if (model_id == 0) { - session.federation()->removeModel(fed_id); + const uint32_t session_model_id = session.sessionModelIdForModelId(model_id); + if (session_model_id == 0) { + session.federation()->removeModel(model_id); session.notifyFederationChanged(); session.setStatusMessage("Models", "Model removed"); return; } - if (session.loader()->isLoadingModel(model_id)) return; + if (session.loader()->isLoadingModel(session_model_id)) return; viewport.setSelectedObjectId(0); session.setSelectedObjectId(0); - session.federation()->removeModel(fed_id); - viewport.removeModel(model_id); - session.loader()->removeModel(model_id); - session.elementRegistry()->removeModel(model_id); - session.removeModelMappingByFedId(fed_id); + session.federation()->removeModel(model_id); + viewport.removeModel(session_model_id); + session.loader()->removeModel(session_model_id); + session.elementRegistry()->removeModel(session_model_id); + session.removeModelMappingByModelId(model_id); session.notifySelectionChanged(); session.notifyModelsChanged(); session.setStatusMessage("Models", "Model removed"); @@ -198,12 +198,12 @@ void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, namespace detail { -void loadModels(SessionState& session, const QStringList& paths, const QStringList& fed_ids) { +void loadModels(SessionState& session, const QStringList& paths, const QStringList& model_ids) { if (paths.isEmpty()) return; - const auto model_ids = session.loader()->addFiles(paths); - for (int i = 0; i < paths.size() && i < static_cast(model_ids.size()) && i < fed_ids.size(); ++i) { - session.setModelMapping(fed_ids[i], model_ids[i]); + const auto session_model_ids = session.loader()->queueModels(paths); + for (int i = 0; i < paths.size() && i < static_cast(session_model_ids.size()) && i < model_ids.size(); ++i) { + session.setModelMapping(model_ids[i], session_model_ids[i]); } } @@ -269,20 +269,21 @@ void addModel(SessionState& session, QWidget& host) { // models yet — the first model that finishes loading will set the // origin via ViewportView. Checked here (before federation->addModel) // because federation->addModel doesn't yet populate SessionState's - // model mapping; modelIds() reflects pre-add state at this point. - if (session.modelIds().isEmpty()) { + // model mapping; sessionModelIds() reflects pre-add state at this point. + if (session.sessionModelIds().isEmpty()) { armFederatedFalseOriginGuess(); } QStringList accepted_paths; - QStringList accepted_fed_ids; + QStringList accepted_model_ids; for (const auto& path : paths) { - const QString fed_id = session.federation()->addModel(path); - if (fed_id.isEmpty()) continue; + const QString model_id = + session.federation()->addModel(path, QFileInfo(path).fileName()); + if (model_id.isEmpty()) continue; accepted_paths << path; - accepted_fed_ids << fed_id; + accepted_model_ids << model_id; } - detail::loadModels(session, accepted_paths, accepted_fed_ids); + detail::loadModels(session, accepted_paths, accepted_model_ids); session.notifyModelsChanged(); } @@ -317,15 +318,15 @@ void addModelFromCloud(SessionState& session, QWidget& host) { proc->call("pull_models_interactive", QJsonValue(), [sguard, connector_id](const QJsonValue& result) { if (!sguard) return; - // Arm before the first addCloudModel — modelIds() reflects the + // Arm before the first addCloudModel — sessionModelIds() reflects the // session state at the moment the connector returns, which is // when the user's "add into empty session" intent applies. - if (sguard->modelIds().isEmpty()) { + if (sguard->sessionModelIds().isEmpty()) { armFederatedFalseOriginGuess(); } const QJsonArray arr = result.toArray(); QStringList paths; - QStringList fed_ids; + QStringList model_ids; int added = 0; for (const QJsonValue& value : arr) { if (value.isNull() || !value.isObject()) continue; @@ -337,19 +338,19 @@ void addModelFromCloud(SessionState& session, QWidget& host) { QString src_connector = source.value("connector").toString(); if (src_connector.isEmpty()) src_connector = connector_id; - const QString fed_id = sguard->federation()->addCloudModel( + const QString model_id = sguard->federation()->addCloudModel( display_name, src_connector, source); - if (fed_id.isEmpty()) continue; + if (model_id.isEmpty()) continue; const QJsonObject meta = entry.value("metadata").toObject(); - sguard->setCloudMetadata(fed_id, meta.toVariantMap()); + sguard->setCloudMetadata(model_id, meta.toVariantMap()); paths << path; - fed_ids << fed_id; + model_ids << model_id; ++added; } if (!paths.isEmpty()) { - detail::loadModels(*sguard, paths, fed_ids); + detail::loadModels(*sguard, paths, model_ids); sguard->notifyModelsChanged(); } sguard->setStatusMessage("Cloud", @@ -368,28 +369,28 @@ void addModelFromCloud(SessionState& session, QWidget& host) { namespace { // Shared "local path on disk" lookup for the right-click cloud commands: -// the loader keeps the path keyed by model_id (set when a file or pull_models +// the loader keeps the path keyed by session_model_id (set when a file or pull_models // path was queued). Both local-sourced and resolved cloud-sourced models // have one; only un-resolved cloud models (where pull_models hasn't // returned yet) won't. -QString localPathForModel(SessionState& session, const QString& fed_id) { - const uint32_t model_id = session.modelIdForFedId(fed_id); - if (model_id == 0 || !session.loader()) return {}; - return session.loader()->filePath(model_id); +QString localPathForModel(SessionState& session, const QString& model_id) { + const uint32_t session_model_id = session.sessionModelIdForModelId(model_id); + if (session_model_id == 0 || !session.loader()) return {}; + return session.loader()->filePath(session_model_id); } } // namespace -void saveModelToCloud(SessionState& session, QWidget& host, const QString& fed_id) { +void saveModelToCloud(SessionState& session, QWidget& host, const QString& model_id) { auto* federation = session.federation(); - const Federation::Model* model = federation->findById(fed_id); + const Federation::Model* model = federation->findById(model_id); if (!model) return; if (model->source_connector == "local") { QMessageBox::information(&host, "Save Model To Cloud", "This model has no cloud target. Use \"Save As To Cloud\" first."); return; } - const QString local_path = localPathForModel(session, fed_id); + const QString local_path = localPathForModel(session, model_id); if (local_path.isEmpty()) { QMessageBox::warning(&host, "Save Model To Cloud", "Cannot find a local copy of this model to push."); @@ -416,15 +417,15 @@ void saveModelToCloud(SessionState& session, QWidget& host, const QString& fed_i QPointer sguard(&session); proc->call("push_model", params, - [sguard, fed_id, connector_id](const QJsonValue& result) { + [sguard, model_id, connector_id](const QJsonValue& result) { if (!sguard) return; const QJsonObject obj = result.toObject(); const QJsonObject new_source = obj.value("source").toObject(); QString new_connector = new_source.value("connector").toString(); if (new_connector.isEmpty()) new_connector = connector_id; - sguard->federation()->setModelSource(fed_id, new_connector, new_source); + sguard->federation()->setModelSource(model_id, new_connector, new_source); const QJsonObject meta = obj.value("metadata").toObject(); - sguard->setCloudMetadata(fed_id, meta.toVariantMap()); + sguard->setCloudMetadata(model_id, meta.toVariantMap()); sguard->setStatusMessage("Cloud", QString("Saved to %1").arg(new_connector)); }, @@ -438,10 +439,10 @@ void saveModelToCloud(SessionState& session, QWidget& host, const QString& fed_i }); } -void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& fed_id) { - const Federation::Model* model = session.federation()->findById(fed_id); +void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& model_id) { + const Federation::Model* model = session.federation()->findById(model_id); if (!model) return; - const QString local_path = localPathForModel(session, fed_id); + const QString local_path = localPathForModel(session, model_id); if (local_path.isEmpty()) { QMessageBox::warning(&host, "Save Model As To Cloud", "Cannot find a local copy of this model to push."); @@ -480,20 +481,20 @@ void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& fed QPointer sguard(&session); proc->call("push_model_interactive", params, - [sguard, fed_id, connector_id](const QJsonValue& result) { + [sguard, model_id, connector_id](const QJsonValue& result) { if (!sguard) return; const QJsonObject obj = result.toObject(); const QJsonObject new_source = obj.value("source").toObject(); QString new_connector = new_source.value("connector").toString(); if (new_connector.isEmpty()) new_connector = connector_id; - sguard->federation()->setModelSource(fed_id, new_connector, new_source); + sguard->federation()->setModelSource(model_id, new_connector, new_source); const QString new_name = obj.value("display_name").toString(); if (!new_name.isEmpty()) { - sguard->federation()->setModelDisplayName(fed_id, new_name); + sguard->federation()->setModelDisplayName(model_id, new_name); } const QJsonObject meta = obj.value("metadata").toObject(); - sguard->setCloudMetadata(fed_id, meta.toVariantMap()); + sguard->setCloudMetadata(model_id, meta.toVariantMap()); sguard->setStatusMessage("Cloud", QString("Pushed to %1").arg(new_connector)); }, diff --git a/src/bonsaiviewer/modules/models/Commands.h b/src/bonsaiviewer/modules/models/Commands.h index 1904ad66de..12233598fc 100644 --- a/src/bonsaiviewer/modules/models/Commands.h +++ b/src/bonsaiviewer/modules/models/Commands.h @@ -58,7 +58,7 @@ void renameGroup(SessionState& session, QWidget& host, const QString& group_id); void moveGroup(SessionState& session, const QString& id, const QString& parent_group_id); void moveModels(SessionState& session, const QStringList& ids, const QString& parent_group_id); void removeGroup(SessionState& session, QWidget& host, const QString& group_id); -void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& fed_id); +void removeModel(SessionState& session, ViewportWindow& viewport, QWidget& host, const QString& model_id); void addModel(SessionState& session, QWidget& host); // Connector picker → pull_models_interactive → addCloudModel + load. // Reachable from AddModelDialog's CloudModel button; the underlying call @@ -66,10 +66,10 @@ void addModel(SessionState& session, QWidget& host); void addModelFromCloud(SessionState& session, QWidget& host); // push_model: push a cloud-sourced model back to its existing target. // Only valid when model.source_connector != "local". Async. -void saveModelToCloud(SessionState& session, QWidget& host, const QString& fed_id); +void saveModelToCloud(SessionState& session, QWidget& host, const QString& model_id); // push_model_interactive: pick a connector and push to a fresh cloud // target. Valid for any model (local or already cloud-sourced). Async. -void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& fed_id); +void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& model_id); void convertIfcToDatabase(SessionState& session, QWidget& host); void exportGeometryDatabase(SessionState& session, QWidget& host); void openSettings(SessionState& session, QWidget& host); @@ -80,7 +80,7 @@ void openSettings(SessionState& session, QWidget& host); namespace detail { // Queues already-federated models on the loader and maps their federation-ids to mids. -void loadModels(SessionState& session, const QStringList& paths, const QStringList& fed_ids); +void loadModels(SessionState& session, const QStringList& paths, const QStringList& model_ids); } // namespace detail diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.cpp b/src/bonsaiviewer/modules/models/FederationItemModel.cpp index 290052eead..2b9eda7ed3 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.cpp +++ b/src/bonsaiviewer/modules/models/FederationItemModel.cpp @@ -87,9 +87,9 @@ QStandardItem* FederationItemModel::makeGroupNameItem(const QString& group_id, c return item; } -QStandardItem* FederationItemModel::makeModelNameItem(const QString& fed_id, const QString& display_name) const { +QStandardItem* FederationItemModel::makeModelNameItem(const QString& model_id, const QString& display_name) const { auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/cube.svg"), display_name); - item->setData(fed_id, IdRole); + item->setData(model_id, IdRole); item->setData(int(ItemKind::Model), KindRole); item->setEditable(false); return item; @@ -129,14 +129,14 @@ QStandardItem* FederationItemModel::parentItemForGroup(const QString& parent_gro return found ? found : invisibleRootItem(); } -void FederationItemModel::appendModelTo(QStandardItem* parent_item, const QString& fed_id) { - const Federation::Model* model = federation_->findById(fed_id); +void FederationItemModel::appendModelTo(QStandardItem* parent_item, const QString& model_id) { + const Federation::Model* model = federation_->findById(model_id); if (!model) return; - auto* name_item = makeModelNameItem(fed_id, model->display_name); - auto* vis_item = makeVisibilityItem(ItemKind::Model, federation_->isModelEffectivelyVisible(fed_id)); + auto* name_item = makeModelNameItem(model_id, model->display_name); + auto* vis_item = makeVisibilityItem(ItemKind::Model, federation_->isModelEffectivelyVisible(model_id)); parent_item->appendRow({name_item, vis_item}); - id_to_name_item_.insert(fed_id, name_item); - styleRowVisibility(name_item, federation_->isModelEffectivelyVisible(fed_id)); + id_to_name_item_.insert(model_id, name_item); + styleRowVisibility(name_item, federation_->isModelEffectivelyVisible(model_id)); } void FederationItemModel::appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id) { @@ -228,38 +228,38 @@ void FederationItemModel::onGroupVisibilityChanged(const QString& group_id, bool refreshSubtreeVisibility(item); } -void FederationItemModel::onModelAdded(const QString& fed_id) { - const Federation::Model* model = federation_->findById(fed_id); +void FederationItemModel::onModelAdded(const QString& model_id) { + const Federation::Model* model = federation_->findById(model_id); if (!model) return; QStandardItem* parent_item = parentItemForGroup(model->group_id); - appendModelTo(parent_item, fed_id); + appendModelTo(parent_item, model_id); } -void FederationItemModel::onModelRemoved(const QString& fed_id) { - QStandardItem* item = findItem(fed_id); +void FederationItemModel::onModelRemoved(const QString& model_id) { + QStandardItem* item = findItem(model_id); if (!item) return; - id_to_name_item_.remove(fed_id); + id_to_name_item_.remove(model_id); QStandardItem* parent_item = item->parent(); if (!parent_item) parent_item = invisibleRootItem(); parent_item->removeRow(item->row()); } -void FederationItemModel::onModelVisibilityChanged(const QString& fed_id, bool /*visible*/) { - QStandardItem* item = findItem(fed_id); +void FederationItemModel::onModelVisibilityChanged(const QString& model_id, bool /*visible*/) { + QStandardItem* item = findItem(model_id); if (!item) return; refreshSubtreeVisibility(item); } -void FederationItemModel::onModelChanged(const QString& fed_id) { - QStandardItem* item = findItem(fed_id); +void FederationItemModel::onModelChanged(const QString& model_id) { + QStandardItem* item = findItem(model_id); if (!item) return; - const Federation::Model* model = federation_->findById(fed_id); + const Federation::Model* model = federation_->findById(model_id); if (!model) return; item->setText(model->display_name); } -void FederationItemModel::onModelGroupChanged(const QString& fed_id, const QString& new_group_id) { - QStandardItem* item = findItem(fed_id); +void FederationItemModel::onModelGroupChanged(const QString& model_id, const QString& new_group_id) { + QStandardItem* item = findItem(model_id); if (!item) return; QStandardItem* current_parent = item->parent(); if (!current_parent) current_parent = invisibleRootItem(); diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.h b/src/bonsaiviewer/modules/models/FederationItemModel.h index 4d8ece84f8..e2d007e681 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.h +++ b/src/bonsaiviewer/modules/models/FederationItemModel.h @@ -58,27 +58,27 @@ private slots: void onGroupRemoved(const QString& group_id); void onGroupChanged(const QString& group_id); void onGroupVisibilityChanged(const QString& group_id, bool visible); - void onModelAdded(const QString& fed_id); - void onModelRemoved(const QString& fed_id); - void onModelVisibilityChanged(const QString& fed_id, bool visible); - void onModelGroupChanged(const QString& fed_id, const QString& new_group_id); - void onModelChanged(const QString& fed_id); + void onModelAdded(const QString& model_id); + void onModelRemoved(const QString& model_id); + void onModelVisibilityChanged(const QString& model_id, bool visible); + void onModelGroupChanged(const QString& model_id, const QString& new_group_id); + void onModelChanged(const QString& model_id); private: QStandardItem* makeGroupNameItem(const QString& group_id, const QString& display_name) const; - QStandardItem* makeModelNameItem(const QString& fed_id, const QString& display_name) const; + QStandardItem* makeModelNameItem(const QString& model_id, const QString& display_name) const; QStandardItem* makeVisibilityItem(ItemKind kind, bool visible) const; void styleRowVisibility(QStandardItem* name_item, bool visible) const; QStandardItem* findItem(const QString& id) const; QStandardItem* parentItemForGroup(const QString& parent_group_id) const; - void appendModelTo(QStandardItem* parent_item, const QString& fed_id); + void appendModelTo(QStandardItem* parent_item, const QString& model_id); void appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id); void refreshSubtreeVisibility(QStandardItem* root); Federation* federation_ = nullptr; - QHash id_to_name_item_; // both group_ids and fed_ids + QHash id_to_name_item_; // both group_ids and model_ids }; } // namespace bonsaiviewer::modules::models diff --git a/src/bonsaiviewer/modules/models/SettingsDialog.cpp b/src/bonsaiviewer/modules/models/SettingsDialog.cpp index 998eac6310..9c507bf7a5 100644 --- a/src/bonsaiviewer/modules/models/SettingsDialog.cpp +++ b/src/bonsaiviewer/modules/models/SettingsDialog.cpp @@ -378,7 +378,7 @@ void SettingsDialog::populateModelTable() { model_table_->setItem(row, 0, model_item); ModelRowWidgets widgets; - widgets.fed_id = model.id; + widgets.model_id = model.id; widgets.frame = new QComboBox(model_table_); widgets.frame->addItem("Local", static_cast(AFrame::ModelLocal)); @@ -448,7 +448,7 @@ void SettingsDialog::updateSelectedModelGeoref() { return; } - settings_view_->refresh(model_rows_[row].fed_id); + settings_view_->refresh(model_rows_[row].model_id); } void SettingsDialog::onAccepted() { @@ -473,7 +473,7 @@ void SettingsDialog::onAccepted() { transformation.b = parseVector3(row.to_point->text()); transformation.rxyz_deg = parseVector3(row.rotate->text()); transformation.pivot = parseVector3(row.pivot->text()); - federation_->setModelTransformation(row.fed_id, transformation); + federation_->setModelTransformation(row.model_id, transformation); } if (session_state_) { session_state_->notifyFederationChanged(); diff --git a/src/bonsaiviewer/modules/models/SettingsDialog.h b/src/bonsaiviewer/modules/models/SettingsDialog.h index 70a0ff5b7f..84b7968b29 100644 --- a/src/bonsaiviewer/modules/models/SettingsDialog.h +++ b/src/bonsaiviewer/modules/models/SettingsDialog.h @@ -55,7 +55,7 @@ protected: private: struct ModelRowWidgets { - QString fed_id; + QString model_id; QComboBox* frame = nullptr; QTableWidgetItem* from_point = nullptr; QTableWidgetItem* to_point = nullptr; diff --git a/src/bonsaiviewer/modules/models/SettingsView.cpp b/src/bonsaiviewer/modules/models/SettingsView.cpp index fdcbdf4901..b8c0f74cf8 100644 --- a/src/bonsaiviewer/modules/models/SettingsView.cpp +++ b/src/bonsaiviewer/modules/models/SettingsView.cpp @@ -212,7 +212,7 @@ SettingsView::SettingsView(SettingsDialog* widget, { } -void SettingsView::refresh(const QString& fed_id) const { +void SettingsView::refresh(const QString& model_id) const { if (!widget_) { return; } @@ -228,18 +228,18 @@ void SettingsView::refresh(const QString& fed_id) const { return; } - const uint32_t model_id = session_state_->modelIdForFedId(fed_id); - if (model_id == 0) { + const uint32_t session_model_id = session_state_->sessionModelIdForModelId(model_id); + if (session_model_id == 0) { widget_->renderSelectedModelGeoref(unknownState("Not loaded", "No live model")); return; } - if (auto* ifc_file = loader->ifcFile(model_id)) { + if (auto* ifc_file = loader->ifcFile(session_model_id)) { widget_->renderSelectedModelGeoref(stateFromLiveFile(ifc_file)); return; } - const ModelGeoref* georef = loader->modelGeoref(model_id); + const ModelGeoref* georef = loader->modelGeoref(session_model_id); if (!georef) { widget_->renderSelectedModelGeoref(unknownState("Not available yet", "No data source")); return; diff --git a/src/bonsaiviewer/modules/models/SettingsView.h b/src/bonsaiviewer/modules/models/SettingsView.h index 346d1014db..a03b76f4b8 100644 --- a/src/bonsaiviewer/modules/models/SettingsView.h +++ b/src/bonsaiviewer/modules/models/SettingsView.h @@ -36,7 +36,7 @@ public: explicit SettingsView(SettingsDialog* widget, bonsaiviewer::SessionState* session_state); - void refresh(const QString& fed_id) const; + void refresh(const QString& model_id) const; private: SettingsDialog* widget_ = nullptr; diff --git a/src/bonsaiviewer/modules/project/Commands.cpp b/src/bonsaiviewer/modules/project/Commands.cpp index 2a2e5122fe..c4c10e0ed0 100644 --- a/src/bonsaiviewer/modules/project/Commands.cpp +++ b/src/bonsaiviewer/modules/project/Commands.cpp @@ -56,9 +56,9 @@ namespace { void clearScene(SessionState& session, ViewportWindow& viewport) { viewport.setSelectedObjectId(0); session.setSelectedObjectId(0); - for (uint32_t model_id : session.modelIds()) { - viewport.removeModel(model_id); - session.loader()->removeModel(model_id); + for (uint32_t session_model_id : session.sessionModelIds()) { + viewport.removeModel(session_model_id); + session.loader()->removeModel(session_model_id); } session.clearModelMappings(); session.elementRegistry()->clear(); @@ -81,7 +81,7 @@ bool confirmDiscardIfDirty(SessionState& session, QWidget& host) { // Fire-and-forget async resolution of any non-local models in the // federation. Groups by source_connector and issues one pull_models per // group. For each returned entry: -// - if the fed_id already has a scene entry pointed at the same path, +// - if the model_id already has a scene entry pointed at the same path, // just refresh cloud metadata (no reload, preserves view state); // - if the path differs, tear down the stale scene entry and queue a // fresh load (federation entry is preserved either way); @@ -90,21 +90,21 @@ bool confirmDiscardIfDirty(SessionState& session, QWidget& host) { // has already shown its own UI. void resolveCloudModels(SessionState& session, ViewportWindow& viewport) { auto* federation = session.federation(); - QHash connector_to_fed_ids; + QHash connector_to_model_ids; for (const auto& model : federation->models()) { if (model.source_connector == "local") continue; - connector_to_fed_ids[model.source_connector].push_back(model.id); + connector_to_model_ids[model.source_connector].push_back(model.id); } - if (connector_to_fed_ids.isEmpty()) return; + if (connector_to_model_ids.isEmpty()) return; auto* registry = session.connectorRegistry(); QPointer sguard(&session); QPointer vguard(&viewport); - for (auto it = connector_to_fed_ids.constBegin(); - it != connector_to_fed_ids.constEnd(); ++it) { + for (auto it = connector_to_model_ids.constBegin(); + it != connector_to_model_ids.constEnd(); ++it) { const QString connector_id = it.key(); - const QStringList fed_ids = it.value(); + const QStringList model_ids = it.value(); auto* proc = registry->get(connector_id); if (!proc) { @@ -114,8 +114,8 @@ void resolveCloudModels(SessionState& session, ViewportWindow& viewport) { } QJsonArray params; - for (const QString& fed_id : fed_ids) { - const Federation::Model* model = federation->findById(fed_id); + for (const QString& model_id : model_ids) { + const Federation::Model* model = federation->findById(model_id); if (!model) continue; QJsonObject source = model->source_data; source["connector"] = model->source_connector; @@ -127,25 +127,25 @@ void resolveCloudModels(SessionState& session, ViewportWindow& viewport) { } proc->call("pull_models", params, - [sguard, vguard, fed_ids](const QJsonValue& result) { + [sguard, vguard, model_ids](const QJsonValue& result) { if (!sguard) return; const QJsonArray arr = result.toArray(); QStringList paths_to_load; - QStringList fed_ids_to_load; + QStringList model_ids_to_load; bool any_detached = false; - for (int i = 0; i < arr.size() && i < fed_ids.size(); ++i) { + for (int i = 0; i < arr.size() && i < model_ids.size(); ++i) { if (arr[i].isNull()) continue; const QJsonObject obj = arr[i].toObject(); const QString new_path = obj.value("path").toString(); if (new_path.isEmpty()) continue; - const QString fed_id = fed_ids[i]; + const QString model_id = model_ids[i]; const QJsonObject meta = obj.value("metadata").toObject(); - const uint32_t existing_mid = sguard->modelIdForFedId(fed_id); + const uint32_t existing_mid = sguard->sessionModelIdForModelId(model_id); if (existing_mid != 0 && sguard->loader()) { const QString existing_path = sguard->loader()->filePath(existing_mid); if (QDir::cleanPath(existing_path) == QDir::cleanPath(new_path)) { - sguard->setCloudMetadata(fed_id, meta.toVariantMap()); + sguard->setCloudMetadata(model_id, meta.toVariantMap()); continue; } // Path changed (new revision lives in a fresh cache dir). @@ -153,13 +153,13 @@ void resolveCloudModels(SessionState& session, ViewportWindow& viewport) { if (vguard) vguard->removeModel(existing_mid); sguard->loader()->removeModel(existing_mid); sguard->elementRegistry()->removeModel(existing_mid); - sguard->removeModelMappingByFedId(fed_id); + sguard->removeModelMappingByModelId(model_id); any_detached = true; } - sguard->setCloudMetadata(fed_id, meta.toVariantMap()); + sguard->setCloudMetadata(model_id, meta.toVariantMap()); paths_to_load << new_path; - fed_ids_to_load << fed_id; + model_ids_to_load << model_id; } if (any_detached) { if (vguard) vguard->setSelectedObjectId(0); @@ -168,7 +168,7 @@ void resolveCloudModels(SessionState& session, ViewportWindow& viewport) { } if (!paths_to_load.isEmpty()) { modules::models::commands::detail::loadModels( - *sguard, paths_to_load, fed_ids_to_load); + *sguard, paths_to_load, model_ids_to_load); sguard->notifyModelsChanged(); } }, @@ -183,8 +183,8 @@ void resolveCloudModels(SessionState& session, ViewportWindow& viewport) { } int total = 0; - for (auto it = connector_to_fed_ids.constBegin(); - it != connector_to_fed_ids.constEnd(); ++it) { + for (auto it = connector_to_model_ids.constBegin(); + it != connector_to_model_ids.constEnd(); ++it) { total += it.value().size(); } session.setStatusMessage("Cloud", QString("Resolving %1 model(s)...").arg(total)); @@ -224,7 +224,7 @@ bool openProjectAt(SessionState& session, QWidget& host, ViewportWindow& viewpor clearScene(session, viewport); QStringList paths; - QStringList fed_ids; + QStringList model_ids; for (const auto& model : session.federation()->models()) { if (model.source_connector != "local") continue; if (!QFileInfo::exists(model.source_path)) { @@ -232,9 +232,9 @@ bool openProjectAt(SessionState& session, QWidget& host, ViewportWindow& viewpor continue; } paths << model.source_path; - fed_ids << model.id; + model_ids << model.id; } - modules::models::commands::detail::loadModels(session, paths, fed_ids); + modules::models::commands::detail::loadModels(session, paths, model_ids); if (!warnings.isEmpty()) { QMessageBox::warning(&host, "Open Project", diff --git a/src/bonsaiviewer/modules/viewport/View.cpp b/src/bonsaiviewer/modules/viewport/View.cpp index 60640b4cd1..ec2b242fc7 100644 --- a/src/bonsaiviewer/modules/viewport/View.cpp +++ b/src/bonsaiviewer/modules/viewport/View.cpp @@ -67,9 +67,9 @@ ViewportView::ViewportView(bonsaiviewer::SessionState* session_state, // first geometry-ready consumes the arm). refresh() stays terminal — // any federation mutation from the guess propagates through // SessionState's federatedFalseOriginChanged relay. - connect(session_state_, &SessionState::modelGeometryReady, this, [this](uint32_t model_id) { + connect(session_state_, &SessionState::modelGeometryReady, this, [this](uint32_t session_model_id) { if (modules::models::consumeFederatedFalseOriginGuess()) { - guessFederatedFalseOriginFromFirstModel(model_id); + guessFederatedFalseOriginFromFirstModel(session_model_id); } refresh(); }); @@ -136,34 +136,34 @@ void ViewportView::refresh() { viewport_->setFederatedFalseOrigin( composeFederatedFalseOrigin(federation->federatedFalseOrigin(), federation->config())); - for (uint32_t model_id : session_state_->modelIds()) { - applyCoordinateOperation(model_id); - applyModelVisibility(model_id); + for (uint32_t session_model_id : session_state_->sessionModelIds()) { + applyCoordinateOperation(session_model_id); + applyModelVisibility(session_model_id); } } -void ViewportView::applyCoordinateOperation(uint32_t model_id) { +void ViewportView::applyCoordinateOperation(uint32_t session_model_id) { SceneLoader* loader = session_state_->loader(); Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); - if (const ModelGeoref* georef = loader->modelGeoref(model_id)) { + if (const ModelGeoref* georef = loader->modelGeoref(session_model_id)) { if (georef->has_coordinate_operation) { matrix = georef->coordinate_operation_meters; } } - viewport_->setModelCoordinateOperation(model_id, matrix); - applyModelTransformation(model_id); + viewport_->setModelCoordinateOperation(session_model_id, matrix); + applyModelTransformation(session_model_id); } -void ViewportView::applyModelTransformation(uint32_t model_id) { +void ViewportView::applyModelTransformation(uint32_t session_model_id) { Federation* federation = session_state_->federation(); SceneLoader* loader = session_state_->loader(); Eigen::Matrix4d matrix = Eigen::Matrix4d::Identity(); - const QString fed_id = session_state_->fedIdForModelId(model_id); - if (!fed_id.isEmpty()) { - if (const Federation::Model* model = federation->findById(fed_id)) { + const QString model_id = session_state_->modelIdForSessionModelId(session_model_id); + if (!model_id.isEmpty()) { + if (const Federation::Model* model = federation->findById(model_id)) { ModelUnits units; Eigen::Matrix4d coordinate_operation = Eigen::Matrix4d::Identity(); - if (const ModelGeoref* georef = loader->modelGeoref(model_id)) { + if (const ModelGeoref* georef = loader->modelGeoref(session_model_id)) { units = georef->units; if (georef->has_coordinate_operation) { coordinate_operation = georef->coordinate_operation_meters; @@ -173,18 +173,18 @@ void ViewportView::applyModelTransformation(uint32_t model_id) { model->model_transformation, federation->config(), units, coordinate_operation); } } - viewport_->setModelTransformation(model_id, matrix); + viewport_->setModelTransformation(session_model_id, matrix); } -void ViewportView::applyModelVisibility(uint32_t model_id) { +void ViewportView::applyModelVisibility(uint32_t session_model_id) { Federation* federation = session_state_->federation(); - const QString fed_id = session_state_->fedIdForModelId(model_id); - if (fed_id.isEmpty()) return; + const QString model_id = session_state_->modelIdForSessionModelId(session_model_id); + if (model_id.isEmpty()) return; - if (federation->isModelEffectivelyVisible(fed_id)) { - viewport_->showModel(model_id); + if (federation->isModelEffectivelyVisible(model_id)) { + viewport_->showModel(session_model_id); } else { - viewport_->hideModel(model_id); + viewport_->hideModel(session_model_id); } } @@ -209,7 +209,7 @@ void ViewportView::applyModelVisibility(uint32_t model_id) { // mutation here propagates through SessionState's federation relay // (federatedFalseOriginChanged → notifyFederationChanged) without // re-entering this function. -void ViewportView::guessFederatedFalseOriginFromFirstModel(uint32_t model_id) { +void ViewportView::guessFederatedFalseOriginFromFirstModel(uint32_t session_model_id) { Federation* federation = session_state_->federation(); if (!federation->filePath().isEmpty()) return; @@ -218,10 +218,10 @@ void ViewportView::guessFederatedFalseOriginFromFirstModel(uint32_t model_id) { if (current.xyz != defaults.xyz || current.rz_deg != defaults.rz_deg) return; Eigen::Vector3d first_geometry_point_m; - if (!viewport_->firstGeometryPointWorldM(model_id, first_geometry_point_m)) return; + if (!viewport_->firstGeometryPointWorldM(session_model_id, first_geometry_point_m)) return; SceneLoader* loader = session_state_->loader(); - const ModelGeoref* georef = loader->modelGeoref(model_id); + const ModelGeoref* georef = loader->modelGeoref(session_model_id); if (georef == nullptr) return; federation->setFederatedFalseOrigin(::guessFederatedFalseOrigin( @@ -236,7 +236,7 @@ void ViewportView::guessFederatedFalseOriginFromFirstModel(uint32_t model_id) { // (0,0,0) — the federated false origin in render space — capped at // 100 m so a model with crazy-coord geometry can't pull the camera // back into nothing. - viewport_->frameOnFederatedOrigin(model_id, 100.0f); + viewport_->frameOnFederatedOrigin(session_model_id, 100.0f); } void ViewportView::updateVolumeReadout() { diff --git a/src/bonsaiviewer/modules/viewport/View.h b/src/bonsaiviewer/modules/viewport/View.h index be36e6987f..c0f18dce33 100644 --- a/src/bonsaiviewer/modules/viewport/View.h +++ b/src/bonsaiviewer/modules/viewport/View.h @@ -50,10 +50,10 @@ public: private: void refresh(); - void applyCoordinateOperation(uint32_t model_id); - void applyModelTransformation(uint32_t model_id); - void applyModelVisibility(uint32_t model_id); - void guessFederatedFalseOriginFromFirstModel(uint32_t model_id); + void applyCoordinateOperation(uint32_t session_model_id); + void applyModelTransformation(uint32_t session_model_id); + void applyModelVisibility(uint32_t session_model_id); + void guessFederatedFalseOriginFromFirstModel(uint32_t session_model_id); void updateVolumeReadout(); bonsaiviewer::SessionState* session_state_ = nullptr; diff --git a/src/ifcviewer/AreaMeasurement.cpp b/src/ifcviewer/AreaMeasurement.cpp index 747499fc43..d59ee165ae 100644 --- a/src/ifcviewer/AreaMeasurement.cpp +++ b/src/ifcviewer/AreaMeasurement.cpp @@ -150,8 +150,8 @@ void AreaMeasurement::clear(ViewportWindow& vp) { AreaMeasurement::MeshAdj* AreaMeasurement::meshAdj(ViewportWindow& vp, - uint32_t model_id, uint32_t mesh_id) { - const uint64_t key = (uint64_t(model_id) << 32) | uint64_t(mesh_id); + uint32_t session_model_id, uint32_t mesh_id) { + const uint64_t key = (uint64_t(session_model_id) << 32) | uint64_t(mesh_id); auto it = mesh_cache_.find(key); if (it != mesh_cache_.end()) return &it->second; @@ -160,7 +160,7 @@ AreaMeasurement::meshAdj(ViewportWindow& vp, // and live in the viewport already), so just look them up freshly // each time the user picks a brand-new mesh. ViewportWindow::MeshTriangles tris; - if (!vp.readbackMeshTriangles(model_id, mesh_id, tris)) return nullptr; + if (!vp.readbackMeshTriangles(session_model_id, mesh_id, tris)) return nullptr; if (tris.indices.size() < 3) return nullptr; MeshAdj a; @@ -196,11 +196,11 @@ void AreaMeasurement::onPick(ViewportWindow& vp, if (!vp.pickMeshLocalAt(x_phys, y_phys, pick)) return; ViewportWindow::MeshTriangles tris; - if (!vp.readbackMeshTriangles(pick.model_id, pick.mesh_id, tris)) return; + if (!vp.readbackMeshTriangles(pick.session_model_id, pick.mesh_id, tris)) return; const size_t n_tris = tris.indices.size() / 3; if (n_tris == 0) return; - MeshAdj* adj = meshAdj(vp, pick.model_id, pick.mesh_id); + MeshAdj* adj = meshAdj(vp, pick.session_model_id, pick.mesh_id); if (!adj) return; // Seed: the triangle whose interior (or boundary) is closest to the @@ -266,7 +266,7 @@ void AreaMeasurement::onPick(ViewportWindow& vp, } } else { SelectedTri sel; - sel.model_id = pick.model_id; + sel.session_model_id = pick.session_model_id; sel.mesh_id = pick.mesh_id; sel.tri = t; std::memcpy(sel.composed_transform, pick.composed_transform, @@ -298,25 +298,25 @@ void AreaMeasurement::rebuildHighlightAndLabels(ViewportWindow& vp) { // to avoid repeated viewport lookups when many tris share a mesh. std::unordered_map tris_cache; - auto get_tris = [&](uint32_t model_id, uint32_t mesh_id) + auto get_tris = [&](uint32_t session_model_id, uint32_t mesh_id) -> ViewportWindow::MeshTriangles* { - const uint64_t k = (uint64_t(model_id) << 32) | uint64_t(mesh_id); + const uint64_t k = (uint64_t(session_model_id) << 32) | uint64_t(mesh_id); auto it = tris_cache.find(k); if (it != tris_cache.end()) return &it->second; - ViewportWindow::MeshTriangles t; - if (!vp.readbackMeshTriangles(model_id, mesh_id, t)) return nullptr; - return &tris_cache.emplace(k, std::move(t)).first->second; + ViewportWindow::MeshTriangles tris; + if (!vp.readbackMeshTriangles(session_model_id, mesh_id, tris)) return nullptr; + return &tris_cache.emplace(k, std::move(tris)).first->second; }; for (const auto& [key, sel] : selected_) { - ViewportWindow::MeshTriangles* t = get_tris(sel.model_id, sel.mesh_id); - if (!t) continue; - if (size_t(sel.tri) * 3 + 2 >= t->indices.size()) continue; + ViewportWindow::MeshTriangles* tris = get_tris(sel.session_model_id, sel.mesh_id); + if (!tris) continue; + if (size_t(sel.tri) * 3 + 2 >= tris->indices.size()) continue; const float* M = sel.composed_transform; // column-major for (int e = 0; e < 3; ++e) { - const uint32_t vi = t->indices[3 * sel.tri + e]; - if (3 * vi + 2 >= t->positions.size()) continue; - const float* p = &t->positions[3 * vi]; + const uint32_t vi = tris->indices[3 * sel.tri + e]; + if (3 * vi + 2 >= tris->positions.size()) continue; + const float* p = &tris->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]; @@ -342,9 +342,9 @@ void AreaMeasurement::rebuildHighlightAndLabels(ViewportWindow& vp) { for (const auto& [obj_id, sels] : by_object) { if (sels.empty()) continue; const SelectedTri& any = *sels[0]; - ViewportWindow::MeshTriangles* t = get_tris(any.model_id, any.mesh_id); - if (!t) continue; - MeshAdj* adj = meshAdj(vp, any.model_id, any.mesh_id); + ViewportWindow::MeshTriangles* tris = get_tris(any.session_model_id, any.mesh_id); + if (!tris) continue; + MeshAdj* adj = meshAdj(vp, any.session_model_id, any.mesh_id); if (!adj) continue; std::unordered_set remaining; @@ -360,10 +360,10 @@ void AreaMeasurement::rebuildHighlightAndLabels(ViewportWindow& vp) { while (!frontier.empty()) { const uint32_t tri = frontier.front(); frontier.pop(); component.push_back(tri); - if (size_t(tri) * 3 + 2 >= t->indices.size()) continue; + if (size_t(tri) * 3 + 2 >= tris->indices.size()) continue; for (int e = 0; e < 3; ++e) { - const uint32_t ia = t->indices[3 * tri + e]; - const uint32_t ib = t->indices[3 * tri + (e + 1) % 3]; + const uint32_t ia = tris->indices[3 * tri + e]; + const uint32_t ib = tris->indices[3 * tri + (e + 1) % 3]; auto eit = adj->edges.find(edgeKey(ia, ib)); if (eit == adj->edges.end()) continue; for (uint32_t nt : eit->second) { @@ -380,12 +380,12 @@ void AreaMeasurement::rebuildHighlightAndLabels(ViewportWindow& vp) { if (size_t(tri) >= adj->tri_areas.size()) continue; const double a = adj->tri_areas[tri]; area += a; - const uint32_t ia = t->indices[3 * tri + 0]; - const uint32_t ib = t->indices[3 * tri + 1]; - const uint32_t ic = t->indices[3 * tri + 2]; - const float* va = &t->positions[3 * ia]; - const float* vb = &t->positions[3 * ib]; - const float* vc = &t->positions[3 * ic]; + const uint32_t ia = tris->indices[3 * tri + 0]; + const uint32_t ib = tris->indices[3 * tri + 1]; + const uint32_t ic = tris->indices[3 * tri + 2]; + const float* va = &tris->positions[3 * ia]; + const float* vb = &tris->positions[3 * ib]; + const float* vc = &tris->positions[3 * ic]; cx += a * (double(va[0]) + vb[0] + vc[0]) / 3.0; cy += a * (double(va[1]) + vb[1] + vc[1]) / 3.0; cz += a * (double(va[2]) + vb[2] + vc[2]) / 3.0; diff --git a/src/ifcviewer/AreaMeasurement.h b/src/ifcviewer/AreaMeasurement.h index a54096fb5e..b055e4353e 100644 --- a/src/ifcviewer/AreaMeasurement.h +++ b/src/ifcviewer/AreaMeasurement.h @@ -64,16 +64,16 @@ private: // edge_key (min<<32 | max) → list of triangle indices touching it. std::unordered_map> edges; }; - // Keyed by (model_id << 32) | mesh_id. + // Keyed by (session_model_id << 32) | mesh_id. MeshAdj* meshAdj(ViewportWindow& vp, - uint32_t model_id, uint32_t mesh_id); + uint32_t session_model_id, uint32_t mesh_id); // Per-selected-triangle record. The composed transform is captured // at pick time so highlight rebuilds don'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 session_model_id; uint32_t mesh_id; uint32_t tri; float composed_transform[16]; diff --git a/src/ifcviewer/Federation.cpp b/src/ifcviewer/Federation.cpp index 92243c07aa..25f2b868f9 100644 --- a/src/ifcviewer/Federation.cpp +++ b/src/ifcviewer/Federation.cpp @@ -237,76 +237,76 @@ void Federation::setFederatedFalseOrigin(const FederatedFalseOrigin& o) { emit federatedFalseOriginChanged(); } -void Federation::setModelTransformation(const QString& fed_id, +void Federation::setModelTransformation(const QString& model_id, const ModelTransformation& xf) { - for (auto& m : models_) { - if (m.id != fed_id) continue; - m.model_transformation = xf; + for (auto& model : models_) { + if (model.id != model_id) continue; + model.model_transformation = xf; setDirty(true); - emit modelTransformationChanged(fed_id); + emit modelTransformationChanged(model_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; +void Federation::setModelVisible(const QString& model_id, bool visible) { + for (auto& model : models_) { + if (model.id != model_id) continue; + if (model.visible == visible) return; + model.visible = visible; setDirty(true); - emit modelVisibilityChanged(fed_id, visible); + emit modelVisibilityChanged(model_id, visible); return; } } -void Federation::setModelGroup(const QString& fed_id, const QString& group_id) { +void Federation::setModelGroup(const QString& model_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; + for (auto& model : models_) { + if (model.id != model_id) continue; + if (model.group_id == group_id) return; + model.group_id = group_id; setDirty(true); - emit modelGroupChanged(fed_id, group_id); + emit modelGroupChanged(model_id, group_id); return; } } -void Federation::setModelDisplayName(const QString& fed_id, const QString& display_name) { +void Federation::setModelDisplayName(const QString& model_id, const QString& display_name) { if (display_name.isEmpty()) return; - for (auto& m : models_) { - if (m.id != fed_id) continue; - if (m.display_name == display_name) return; - m.display_name = display_name; + for (auto& model : models_) { + if (model.id != model_id) continue; + if (model.display_name == display_name) return; + model.display_name = display_name; setDirty(true); - emit modelChanged(fed_id); + emit modelChanged(model_id); return; } } -void Federation::setModelSource(const QString& fed_id, +void Federation::setModelSource(const QString& model_id, const QString& connector_id, const QJsonObject& source_data) { if (connector_id.isEmpty()) return; - for (auto& m : models_) { - if (m.id != fed_id) continue; - m.source_connector = connector_id; - m.source_data = source_data; - m.source_data.remove("connector"); + for (auto& model : models_) { + if (model.id != model_id) continue; + model.source_connector = connector_id; + model.source_data = source_data; + model.source_data.remove("connector"); if (connector_id == "local") { // Round-trip the path through source_data when caller chooses - // to encode it there; otherwise leave m.source_path untouched. + // to encode it there; otherwise leave model.source_path untouched. const QString path_field = source_data.value("path").toString(); if (!path_field.isEmpty()) { - m.source_path = QDir::cleanPath(QFileInfo(path_field).absoluteFilePath()); - m.source_data.remove("path"); + model.source_path = QDir::cleanPath(QFileInfo(path_field).absoluteFilePath()); + model.source_data.remove("path"); } } else { // Cloud sources don't track a source_path — local file lives in // the connector's cache, looked up via SceneLoader. - m.source_path.clear(); + model.source_path.clear(); } setDirty(true); - emit modelChanged(fed_id); + emit modelChanged(model_id); return; } } @@ -319,14 +319,14 @@ QString Federation::addGroup(const QString& display_name, if (!parent) return {}; } - auto g = std::make_unique(); - g->id = generateId(); - g->display_name = display_name.isEmpty() ? QString("Group") : display_name; - g->parent = parent; - const QString new_id = g->id; + auto group = std::make_unique(); + group->id = generateId(); + group->display_name = display_name.isEmpty() ? QString("Group") : display_name; + group->parent = parent; + const QString new_id = group->id; - if (parent) parent->children.push_back(std::move(g)); - else root_groups_.push_back(std::move(g)); + if (parent) parent->children.push_back(std::move(group)); + else root_groups_.push_back(std::move(group)); setDirty(true); emit groupAdded(new_id); @@ -356,10 +356,10 @@ void Federation::removeGroup(const QString& group_id) { // Reparent direct child models up one level. std::vector moved_model_ids; - for (auto& m : models_) { - if (m.group_id == group_id) { - m.group_id = new_parent_id; - moved_model_ids.push_back(m.id); + for (auto& model : models_) { + if (model.group_id == group_id) { + model.group_id = new_parent_id; + moved_model_ids.push_back(model.id); } } @@ -369,16 +369,16 @@ void Federation::removeGroup(const QString& group_id) { 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); + for (const auto& model_id : moved_model_ids) emit modelGroupChanged(model_id, 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; + Group* group = findGroupByIdMutable(group_id); + if (!group) return; + if (group->display_name == display_name) return; + group->display_name = display_name; setDirty(true); emit groupChanged(group_id); } @@ -411,10 +411,10 @@ void Federation::setGroupParent(const QString& 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; + Group* group = findGroupByIdMutable(group_id); + if (!group) return; + if (group->visible == visible) return; + group->visible = visible; setDirty(true); emit groupVisibilityChanged(group_id, visible); } @@ -426,26 +426,26 @@ const Federation::Group* Federation::findGroupById(const QString& group_id) cons Federation::Group* Federation::findGroupByIdMutable(const QString& group_id) { if (group_id.isEmpty()) return nullptr; std::vector stack; - for (auto& g : root_groups_) stack.push_back(g.get()); + for (auto& group : root_groups_) stack.push_back(group.get()); while (!stack.empty()) { - Group* g = stack.back(); + Group* group = stack.back(); stack.pop_back(); - if (g->id == group_id) return g; - for (auto& c : g->children) stack.push_back(c.get()); + if (group->id == group_id) return group; + for (auto& c : group->children) stack.push_back(c.get()); } return nullptr; } std::vector Federation::allGroups() const { std::vector out; - for (const auto& g : root_groups_) appendDfs(g.get(), out); + for (const auto& group : root_groups_) appendDfs(group.get(), out); return out; } -void Federation::appendDfs(const Group* g, std::vector& out) { - if (!g) return; - out.push_back(g); - for (const auto& c : g->children) appendDfs(c.get(), out); +void Federation::appendDfs(const Group* group, std::vector& out) { + if (!group) return; + out.push_back(group); + for (const auto& c : group->children) appendDfs(c.get(), out); } std::unique_ptr Federation::detachGroup(Group* group) { @@ -471,19 +471,19 @@ bool Federation::isDescendantOrSelf(const Group* group, 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; + const Group* group = findGroupById(group_id); + while (group != nullptr) { + if (!group->visible) return false; + group = group->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); +bool Federation::isModelEffectivelyVisible(const QString& model_id) const { + const Model* model = findById(model_id); + if (!model) return false; + if (!model->visible) return false; + return isGroupChainVisible(model->group_id); } void Federation::markClean() { @@ -496,9 +496,9 @@ void Federation::setDirty(bool 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; +const Federation::Model* Federation::findById(const QString& model_id) const { + for (const auto& model : models_) { + if (model.id == model_id) return &model; } return nullptr; } @@ -508,14 +508,12 @@ QString Federation::addModel(const QString& source_path, 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_connector = "local"; - m.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); - models_.push_back(std::move(m)); + Model model; + model.id = generateId(); + model.display_name = display_name; + model.source_connector = "local"; + model.source_path = QDir::cleanPath(QFileInfo(source_path).absoluteFilePath()); + models_.push_back(std::move(model)); const QString new_id = models_.back().id; setDirty(true); emit modelAdded(new_id); @@ -527,25 +525,25 @@ QString Federation::addCloudModel(const QString& display_name, const QJsonObject& source_data) { if (connector_id.isEmpty() || connector_id == "local") return {}; - Model m; - m.id = generateId(); - m.display_name = display_name.isEmpty() ? m.id : display_name; - m.source_connector = connector_id; - m.source_data = source_data; - m.source_data.remove("connector"); // canonicalize: never duplicated - models_.push_back(std::move(m)); + Model model; + model.id = generateId(); + model.display_name = display_name.isEmpty() ? model.id : display_name; + model.source_connector = connector_id; + model.source_data = source_data; + model.source_data.remove("connector"); // canonicalize: never duplicated + models_.push_back(std::move(model)); const QString new_id = models_.back().id; setDirty(true); emit modelAdded(new_id); return new_id; } -void Federation::removeModel(const QString& fed_id) { +void Federation::removeModel(const QString& model_id) { for (auto it = models_.begin(); it != models_.end(); ++it) { - if (it->id == fed_id) { + if (it->id == model_id) { models_.erase(it); setDirty(true); - emit modelRemoved(fed_id); + emit modelRemoved(model_id); return; } } @@ -632,18 +630,18 @@ bool Federation::load(const QString& path, continue; } QJsonObject go = arr[i].toObject(); - auto g = std::make_unique(); - g->id = go.value("id").toString(); - if (g->id.isEmpty()) g->id = generateId(); - g->display_name = go.value("display_name").toString(); + auto group = std::make_unique(); + group->id = go.value("id").toString(); + if (group->id.isEmpty()) group->id = generateId(); + group->display_name = go.value("display_name").toString(); if (QJsonValue vv = go.value("visible"); vv.isBool()) - g->visible = vv.toBool(); - g->parent = parent; + group->visible = vv.toBool(); + group->parent = parent; if (QJsonValue cv = go.value("groups"); cv.isArray()) { - load_groups(cv.toArray(), g->children, g.get()); + load_groups(cv.toArray(), group->children, group.get()); } - sink.push_back(std::move(g)); + sink.push_back(std::move(group)); } }; load_groups(root.value("groups").toArray(), root_groups_, nullptr); @@ -657,60 +655,60 @@ bool Federation::load(const QString& path, } 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(); + Model model; + model.id = mo.value("id").toString(); + if (model.id.isEmpty()) model.id = generateId(); + model.display_name = mo.value("display_name").toString(); QJsonObject so = mo.value("source").toObject(); - m.source_connector = so.value("connector").toString("local"); - if (m.source_connector == "local") { + model.source_connector = so.value("connector").toString("local"); + if (model.source_connector == "local") { 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(); + model.source_path = resolvePath(fed_dir, stored); + if (model.display_name.isEmpty()) + model.display_name = QFileInfo(model.source_path).fileName(); } else { // Cloud source: keep every key except "connector" itself; the // connector resolves these to a local path on demand. QJsonObject data = so; data.remove("connector"); - m.source_data = data; - if (m.display_name.isEmpty()) - m.display_name = m.id; + model.source_data = data; + if (model.display_name.isEmpty()) + model.display_name = model.id; } 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 = + model.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()); + model.model_transformation.a = readVec3(to.value("a").toArray()); + model.model_transformation.b = readVec3(to.value("b").toArray()); + model.model_transformation.rxyz_deg = readVec3(to.value("rxyz_deg").toArray()); + model.model_transformation.pivot = readVec3(to.value("pivot").toArray()); } QJsonValue vv = mo.value("visible"); - if (vv.isBool()) m.visible = vv.toBool(); + if (vv.isBool()) model.visible = vv.toBool(); - m.group_id = mo.value("group_id").toString(); - if (!m.group_id.isEmpty() && findGroupById(m.group_id) == nullptr) { + model.group_id = mo.value("group_id").toString(); + if (!model.group_id.isEmpty() && findGroupById(model.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(); + .arg(i).arg(model.group_id); + model.group_id.clear(); } - models_.push_back(std::move(m)); + models_.push_back(std::move(model)); } QJsonValue hv = root.value("home_view"); @@ -842,12 +840,12 @@ bool Federation::writeJsonAt(const QString& abs_path, std::function>&)> dump; dump = [&](const std::vector>& src) { QJsonArray out; - for (const auto& g : src) { + for (const auto& group : 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); + go["id"] = group->id; + go["display_name"] = group->display_name; + if (!group->visible) go["visible"] = false; + if (!group->children.empty()) go["groups"] = dump(group->children); out.append(go); } return out; @@ -856,18 +854,18 @@ bool Federation::writeJsonAt(const QString& abs_path, } QJsonArray arr; - for (const auto& m : models_) { + for (const auto& model : models_) { QJsonObject mo; - mo["id"] = m.id; - mo["display_name"] = m.display_name; + mo["id"] = model.id; + mo["display_name"] = model.display_name; QJsonObject so; - so["connector"] = m.source_connector; - if (m.source_connector == "local") { - so["path"] = relativizePath(fed_dir, m.source_path); + so["connector"] = model.source_connector; + if (model.source_connector == "local") { + so["path"] = relativizePath(fed_dir, model.source_path); } else { // Round-trip connector-specific keys verbatim. - for (auto it = m.source_data.begin(); it != m.source_data.end(); ++it) { + for (auto it = model.source_data.begin(); it != model.source_data.end(); ++it) { so[it.key()] = it.value(); } } @@ -875,7 +873,7 @@ bool Federation::writeJsonAt(const QString& abs_path, // Skip model_transformation when it's at defaults (identity placement). const ModelTransformation def; - const ModelTransformation& xf = m.model_transformation; + const ModelTransformation& xf = model.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; @@ -895,8 +893,8 @@ bool Federation::writeJsonAt(const QString& abs_path, mo["model_transformation"] = to; } - if (!m.visible) mo["visible"] = false; - if (!m.group_id.isEmpty()) mo["group_id"] = m.group_id; + if (!model.visible) mo["visible"] = false; + if (!model.group_id.isEmpty()) mo["group_id"] = model.group_id; arr.append(mo); } diff --git a/src/ifcviewer/Federation.h b/src/ifcviewer/Federation.h index 362d419d32..98a66df35e 100644 --- a/src/ifcviewer/Federation.h +++ b/src/ifcviewer/Federation.h @@ -244,8 +244,10 @@ public: // Mutations void clear(); + // display_name is stored verbatim — callers decide the label (typically + // QFileInfo(source_path).fileName() for local files). No implicit fallback. QString addModel(const QString& source_path, - const QString& display_name = QString()); + const QString& display_name); // Add a model whose source is a cloud connector (anything other than // "local"). `source_data` holds the connector-specific keys; the // top-level "connector" field, if present, is overwritten with @@ -253,28 +255,28 @@ public: QString addCloudModel(const QString& display_name, const QString& connector_id, const QJsonObject& source_data); - void removeModel(const QString& fed_id); + void removeModel(const QString& model_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); - // Rename a model. No-op when fed_id is unknown, name is empty, or + void setModelTransformation(const QString& model_id, const ModelTransformation&); + void setModelVisible(const QString& model_id, bool visible); + // Rename a model. No-op when model_id is unknown, name is empty, or // name is unchanged. - void setModelDisplayName(const QString& fed_id, const QString& display_name); + void setModelDisplayName(const QString& model_id, const QString& display_name); // Replace a model's source. Used after push_model[_interactive] when // the connector reports a fresh source (e.g. a new version_id) or when // a previously-local model gets uploaded for the first time. The // top-level "connector" key in `source_data`, if any, is dropped — // it's expressed via `connector_id`. - void setModelSource(const QString& fed_id, + void setModelSource(const QString& model_id, const QString& connector_id, const QJsonObject& source_data); // 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); + // No-op when model_id is unknown or group_id is unknown-and-non-empty. + void setModelGroup(const QString& model_id, const QString& group_id); // Group mutations. All return / accept stable group ids. QString addGroup(const QString& display_name = QString(), @@ -292,7 +294,7 @@ public: // Accessors const std::vector& models() const { return models_; } - const Model* findById(const QString& fed_id) const; + const Model* findById(const QString& model_id) const; // Top-level groups in insertion order; descend via Group::children. const std::vector>& rootGroups() const { return root_groups_; } const Group* findGroupById(const QString& group_id) const; @@ -304,7 +306,7 @@ public: 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 isModelEffectivelyVisible(const QString& model_id) const; bool isDirty() const { return dirty_; } void markClean(); QString filePath() const { return file_path_; } @@ -332,14 +334,14 @@ signals: // to dirtyChanged from the corresponding setters. void configChanged(); void federatedFalseOriginChanged(); - void modelAdded(const QString& fed_id); - void modelRemoved(const QString& fed_id); - 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 modelAdded(const QString& model_id); + void modelRemoved(const QString& model_id); + void modelTransformationChanged(const QString& model_id); + void modelVisibilityChanged(const QString& model_id, bool visible); + void modelGroupChanged(const QString& model_id, const QString& group_id); // Emitted on rename / source change — anything that affects how the // model is displayed but is not covered by the other granular signals. - void modelChanged(const QString& fed_id); + void modelChanged(const QString& model_id); void groupAdded(const QString& group_id); void groupRemoved(const QString& group_id); diff --git a/src/ifcviewer/GeometryStreamer.cpp b/src/ifcviewer/GeometryStreamer.cpp index ccc76ff210..f51fe786ca 100644 --- a/src/ifcviewer/GeometryStreamer.cpp +++ b/src/ifcviewer/GeometryStreamer.cpp @@ -86,7 +86,7 @@ void GeometryStreamer::setIfcFile(std::unique_ptr file) { ifc_file_ = std::move(file); } -void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_id, uint32_t model_id, int num_threads) { +void GeometryStreamer::loadFile(const std::string& path, uint32_t session_model_id, int num_threads) { if (running_.load()) { cancel(); if (worker_thread_ && worker_thread_->isRunning()) { @@ -99,8 +99,8 @@ void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_i succeeded_ = false; running_ = true; progress_ = 0; - next_object_id_ = start_object_id; - model_id_ = model_id; + next_object_id_ = 1; // model-local; globalized at applyCachedModel install time + session_model_id_ = session_model_id; { std::lock_guard lock(elements_mutex_); @@ -153,12 +153,12 @@ std::vector GeometryStreamer::drainElements() { // 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 StreamedMesh buildStreamedMesh(uint32_t model_id, +static StreamedMesh buildStreamedMesh(uint32_t session_model_id, uint32_t local_mesh_id, const IfcGeom::TriangulationElement* elem, const Eigen::Vector3d& offset) { StreamedMesh mesh; - mesh.model_id = model_id; + mesh.session_model_id = session_model_id; mesh.local_mesh_id = local_mesh_id; const auto& geom = elem->geometry(); @@ -557,7 +557,7 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { ElementInfo info; info.object_id = object_id; - info.model_id = model_id_; + info.session_model_id = session_model_id_; info.ifc_id = tri_elem->id(); info.guid = tri_elem->guid(); info.name = tri_elem->name(); @@ -604,7 +604,7 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } StreamedMesh streamed_mesh = - buildStreamedMesh(model_id_, local_mesh_id, tri_elem, offset); + buildStreamedMesh(session_model_id_, local_mesh_id, tri_elem, offset); MeshAabb mesh_aabb; for (int a = 0; a < 3; ++a) { mesh_aabb.lmin[a] = streamed_mesh.local_aabb_min[a]; @@ -635,7 +635,7 @@ void GeometryStreamer::run(const std::string& path, int num_threads) { } StreamedInstance inst; - inst.model_id = model_id_; + inst.session_model_id = session_model_id_; inst.local_mesh_id = local_mesh_id; inst.object_id = object_id; inst.color_override_rgba8 = 0; diff --git a/src/ifcviewer/GeometryStreamer.h b/src/ifcviewer/GeometryStreamer.h index 41fe3e1b53..ded9b79631 100644 --- a/src/ifcviewer/GeometryStreamer.h +++ b/src/ifcviewer/GeometryStreamer.h @@ -36,7 +36,7 @@ struct ElementInfo { uint32_t object_id; - uint32_t model_id; + uint32_t session_model_id; int ifc_id; std::string guid; std::string name; @@ -49,7 +49,9 @@ 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); + // Streamer stamps model-LOCAL object_ids (1..N, reset each load). + // ViewportCore::applyCachedModel assigns the session-global ids at install. + void loadFile(const std::string& path, uint32_t session_model_id, int num_threads = 0); void cancel(); // Adopt an externally-opened ifcopenshell::file as the data source @@ -59,8 +61,7 @@ public: 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_; } + uint32_t sessionModelId() const { return session_model_id_; } ifcopenshell::file* ifcFile() const { return ifc_file_.get(); } @@ -89,7 +90,7 @@ private: std::vector pending_elements_; uint32_t next_object_id_ = 1; - uint32_t model_id_ = 0; + uint32_t session_model_id_ = 0; }; #endif // GEOMETRYSTREAMER_H diff --git a/src/ifcviewer/InstanceCompose.cpp b/src/ifcviewer/InstanceCompose.cpp index 9fb4e5b7c8..15fc167e87 100644 --- a/src/ifcviewer/InstanceCompose.cpp +++ b/src/ifcviewer/InstanceCompose.cpp @@ -79,13 +79,13 @@ bool findInstanceInModels( const std::unordered_map& models, InstanceLookup& out) { if (object_id == 0) return false; - for (const auto& [model_id, model_data] : models) { + for (const auto& [session_model_id, model_data] : models) { auto it = model_data.object_id_to_instance.find(object_id); if (it == model_data.object_id_to_instance.end()) continue; const uint32_t instance_index = it->second; if (instance_index >= model_data.instances.size()) continue; const InstanceInfo& instance = model_data.instances[instance_index]; - out.model_id = model_id; + out.session_model_id = session_model_id; out.mesh_id = instance.mesh_id; std::memcpy(out.placement_transformation, instance.placement_transformation, diff --git a/src/ifcviewer/InstanceCompose.h b/src/ifcviewer/InstanceCompose.h index 4cfc667041..302e16e49b 100644 --- a/src/ifcviewer/InstanceCompose.h +++ b/src/ifcviewer/InstanceCompose.h @@ -69,13 +69,13 @@ void composeInstance( // / ModelTransformation) — the same convention as InstanceInfo so the // measurement / picking tools can re-compose at need. struct InstanceLookup { - uint32_t model_id = 0; + uint32_t session_model_id = 0; uint32_t mesh_id = 0; double placement_transformation[16]{}; }; // Walk a map of models looking for the one that owns `object_id`, -// fill `out` with that instance's (model_id, mesh_id, placement) and +// fill `out` with that instance's (session_model_id, mesh_id, placement) and // return true. Returns false for object_id == 0 (the sentinel for // "no object") or when no model owns the id. Defensive: skips // instances whose stored index is out-of-range for the model's diff --git a/src/ifcviewer/InstancedGeometry.h b/src/ifcviewer/InstancedGeometry.h index bf9972816f..81b6edde33 100644 --- a/src/ifcviewer/InstancedGeometry.h +++ b/src/ifcviewer/InstancedGeometry.h @@ -113,7 +113,7 @@ struct InstanceInfo { uint32_t mesh_id = 0; // index into meshes array uint32_t object_id = 0; uint32_t color_override_rgba8 = 0; - uint32_t model_id = 0; + uint32_t session_model_id = 0; double placement_transformation[16]{}; float transform[16]{}; float world_aabb_min[3]{}; @@ -126,7 +126,7 @@ struct InstanceInfo { // geometry in local coords. `local_mesh_id` is the streamer-assigned id // within this model. struct StreamedMesh { - uint32_t model_id = 0; + uint32_t session_model_id = 0; uint32_t local_mesh_id = 0; std::vector vertices; // 7 floats * N_verts (pos3+norm3+color1_packed) std::vector indices; @@ -138,7 +138,7 @@ struct StreamedMesh { // iterator). For the first instance of a mesh, the StreamedMesh is emitted // just before this. struct StreamedInstance { - uint32_t model_id = 0; + uint32_t session_model_id = 0; uint32_t local_mesh_id = 0; uint32_t object_id = 0; uint32_t color_override_rgba8 = 0; diff --git a/src/ifcviewer/LengthMeasurement.cpp b/src/ifcviewer/LengthMeasurement.cpp index 7e1c532388..d1191184fd 100644 --- a/src/ifcviewer/LengthMeasurement.cpp +++ b/src/ifcviewer/LengthMeasurement.cpp @@ -535,7 +535,7 @@ void LengthMeasurement::rebuildLaserOverlay(ViewportWindow& vp) { ViewportWindow::MeshTriangles tris; bool have_extent = false; double min_t1 = 0.0, max_t1 = 0.0, min_t2 = 0.0, max_t2 = 0.0; - if (vp.readbackMeshTriangles(first_pick_.model_id, first_pick_.mesh_id, tris)) { + if (vp.readbackMeshTriangles(first_pick_.session_model_id, first_pick_.mesh_id, tris)) { const size_t n_verts = tris.positions.size() / 3; const size_t n_tris = tris.indices.size() / 3; if (n_tris > 0) { diff --git a/src/ifcviewer/ModelGpuData.h b/src/ifcviewer/ModelGpuData.h index 3f7a44a5cb..aa5b312013 100644 --- a/src/ifcviewer/ModelGpuData.h +++ b/src/ifcviewer/ModelGpuData.h @@ -263,7 +263,7 @@ struct ModelGpuData { // for chunks that were never evicted or were LRU-evicted (the // latter doesn't have an obvious "evictor" — just a slot // pressure event). - uint32_t last_evicted_by_model_id = 0; + uint32_t last_evicted_by_session_model_id = 0; uint32_t last_evicted_by_chunk_idx = UINT32_MAX; float last_evicted_by_priority = 0.0f; // Frame at which this chunk was most recently evicted, so the diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index 6f370c60de..a78fee1adb 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -63,38 +63,38 @@ void SceneLoader::joinDataSourceThreads() { data_source_threads_.clear(); } -QString SceneLoader::filePath(uint32_t mid) const { - auto it = models_.find(mid); +QString SceneLoader::filePath(uint32_t session_model_id) const { + auto it = models_.find(session_model_id); return it == models_.end() ? QString() : it->second.file_path; } -QString SceneLoader::displayName(uint32_t mid) const { - auto it = models_.find(mid); +QString SceneLoader::displayName(uint32_t session_model_id) const { + auto it = models_.find(session_model_id); return it == models_.end() ? QString() : it->second.display_name; } -ifcopenshell::file* SceneLoader::ifcFile(uint32_t mid) const { - auto it = models_.find(mid); +ifcopenshell::file* SceneLoader::ifcFile(uint32_t session_model_id) const { + auto it = models_.find(session_model_id); return it == models_.end() ? nullptr : it->second.streamer->ifcFile(); } -const ModelGeoref* SceneLoader::modelGeoref(uint32_t mid) { - auto it = models_.find(mid); +const ModelGeoref* SceneLoader::modelGeoref(uint32_t session_model_id) { + auto it = models_.find(session_model_id); 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; + auto& model = it->second; + if (model.has_georef) return &model.georef; + auto* file = model.streamer ? model.streamer->ifcFile() : nullptr; if (!file) return nullptr; - m.georef = computeModelGeoref(file); - m.has_georef = true; - return &m.georef; + model.georef = computeModelGeoref(file); + model.has_georef = true; + return &model.georef; } -std::vector SceneLoader::addFiles(const QStringList& paths) { +std::vector SceneLoader::queueModels(const QStringList& paths) { std::vector assigned; assigned.reserve(paths.size()); for (const auto& path : paths) { - uint32_t id = next_model_id_++; + uint32_t id = next_session_model_id_++; Model model; model.id = id; model.file_path = path; @@ -105,7 +105,7 @@ std::vector SceneLoader::addFiles(const QStringList& paths) { assigned.push_back(id); } - if (loading_model_id_ == 0) { + if (loading_session_model_id_ == 0) { QTimer::singleShot(0, this, &SceneLoader::startNextLoad); } return assigned; @@ -126,18 +126,18 @@ void SceneLoader::connectStreamer(GeometryStreamer* streamer) { this, &SceneLoader::onStreamerError, Qt::QueuedConnection); } -void SceneLoader::removeModel(uint32_t mid) { +void SceneLoader::removeModel(uint32_t session_model_id) { // 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; + if (loading_session_model_id_ == session_model_id) return; for (auto it = load_queue_.begin(); it != load_queue_.end();) { - if (*it == mid) it = load_queue_.erase(it); + if (*it == session_model_id) it = load_queue_.erase(it); else ++it; } - auto it = models_.find(mid); + auto it = models_.find(session_model_id); if (it == models_.end()) return; if (it->second.streamer) { it->second.streamer->deleteLater(); @@ -146,29 +146,29 @@ void SceneLoader::removeModel(uint32_t mid) { } void SceneLoader::cancelCurrentLoad() { - if (loading_model_id_ == 0) return; - auto it = models_.find(loading_model_id_); + if (loading_session_model_id_ == 0) return; + auto it = models_.find(loading_session_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; + loading_session_model_id_ = 0; emit allLoadsFinished(); return; } - loading_model_id_ = load_queue_.front(); + loading_session_model_id_ = load_queue_.front(); load_queue_.pop_front(); - auto& model = models_[loading_model_id_]; + auto& model = models_[loading_session_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_; + uint32_t session_model_id = loading_session_model_id_; const bool is_sidecar_source = QFileInfo(model.file_path).suffix().compare("ifcview", Qt::CaseInsensitive) == 0; @@ -176,24 +176,24 @@ void SceneLoader::startNextLoad() { // .ifcview file directly). Skip the background thread and go straight // to a stream load. if (!is_sidecar_source && !should_read_sidecar_) { - startStreamLoadFor(mid); + loadFromGeometryStreamer(session_model_id); return; } // Sidecar read on a background thread so the UI stays responsive. joinSidecarThread(); - sidecar_read_thread_ = std::thread([this, ifc_path, mid, is_sidecar_source]() { - QElapsedTimer rt; rt.start(); - auto cached = readSidecarMetadataOnly(ifc_path); + sidecar_read_thread_ = std::thread([this, ifc_path, session_model_id, is_sidecar_source]() { + QElapsedTimer read_timer; read_timer.start(); + auto cached = readSidecarMetadata(ifc_path); std::fprintf(stderr, "[info] Sidecar metadata read: %lld ms (%s)\n", - (long long)rt.elapsed(), ifc_path.c_str()); + (long long)read_timer.elapsed(), ifc_path.c_str()); auto result = std::make_shared>(std::move(cached)); - QMetaObject::invokeMethod(this, [this, mid, result, is_sidecar_source]() { - auto it = models_.find(mid); + QMetaObject::invokeMethod(this, [this, session_model_id, result, is_sidecar_source]() { + auto it = models_.find(session_model_id); if (*result && !(*result)->meta.instances.empty()) { - applySidecarData(mid, std::move(**result)); + applySidecarData(session_model_id, std::move(**result)); if (!is_sidecar_source) { - startDataSourceLoad(mid); + startDataSourceLoad(session_model_id); } return; } @@ -201,105 +201,102 @@ void SceneLoader::startNextLoad() { if (it == models_.end()) return; if (is_sidecar_source) { - loading_model_id_ = 0; - emit loadError(mid, QString("Failed to read IFC Viewer cache:\n%1").arg(it->second.file_path)); + loading_session_model_id_ = 0; + emit loadError(session_model_id, QString("Failed to read IFC Viewer cache:\n%1").arg(it->second.file_path)); QTimer::singleShot(0, this, &SceneLoader::startNextLoad); return; } - startStreamLoadFor(mid); + loadFromGeometryStreamer(session_model_id); }, Qt::QueuedConnection); }); } -void SceneLoader::startStreamLoadFor(uint32_t mid) { - auto it = models_.find(mid); +void SceneLoader::loadFromGeometryStreamer(uint32_t session_model_id) { + auto it = models_.find(session_model_id); if (it == models_.end()) return; - auto& m = it->second; + auto& model = it->second; // Accumulate sidecar data alongside the GPU upload so the first load // naturally produces a cache for the next one — no GPU readback at // finish time. Skipped when caching writes are off. if (should_write_sidecar_) { - m.sidecar_builder = std::make_unique(); - m.streamed_elements.clear(); + model.sidecar_builder = std::make_unique(); } - connectStreamer(m.streamer); + // Elements are buffered here and emitted to the registry once at finalize, + // after applyCachedModel assigns this model's global object_id base. + model.streamed_elements.clear(); + connectStreamer(model.streamer); element_poll_timer_.start(); - m.streamer->loadFile( - m.file_path.toStdString(), next_object_id_, loading_model_id_); + model.streamer->loadFile(model.file_path.toStdString(), loading_session_model_id_); } -void SceneLoader::applySidecarData(uint32_t mid, StreamingSidecar metadata) { - auto it = models_.find(mid); +void SceneLoader::applySidecarData(uint32_t session_model_id, StreamingSidecar metadata) { + auto it = models_.find(session_model_id); if (it == models_.end()) return; auto& model = it->second; - SidecarData& d = metadata.meta; + SidecarData& sidecar = metadata.meta; std::fprintf(stderr, "[info] Sidecar hit: %s (%zu chunks, %zu meshes, %zu instances, %zu elements)\n", model.file_path.toStdString().c_str(), - d.chunks.size(), - d.meshes.size(), - d.instances.size(), - d.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 : d.elements) { - if (pe.object_id < min_oid) min_oid = pe.object_id; - } - uint32_t oid_offset = 0; - if (!d.elements.empty() && min_oid < UINT32_MAX) { - oid_offset = next_object_id_ - min_oid; - } - for (auto& pe : d.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 : d.instances) { - inst.object_id += oid_offset; - inst.model_id = mid; - } + sidecar.chunks.size(), + sidecar.meshes.size(), + sidecar.instances.size(), + sidecar.elements.size()); // Restore the cached CoordinateOperation into the model so - // modelGeoref(mid) returns it without needing the IFC source. Prevents + // modelGeoref(session_model_id) returns it without needing the IFC source. Prevents // sidecar-loaded models from silently losing their georef when the // .ifc/.rdb sibling is absent. { - ModelGeoref& gr = model.georef; - gr.has_coordinate_operation = d.has_coordinate_operation != 0; - Eigen::Map> M( - d.coordinate_operation_meters); - gr.coordinate_operation_meters = M; - gr.units.project_length_to_meters = d.project_length_to_meters; - gr.units.map_unit_to_meters = d.map_unit_to_meters; - model.has_georef = true; + ModelGeoref& georef = model.georef; + georef.has_coordinate_operation = sidecar.has_coordinate_operation != 0; + Eigen::Map> coord_op( + sidecar.coordinate_operation_meters); + georef.coordinate_operation_meters = coord_op; + georef.units.project_length_to_meters = sidecar.project_length_to_meters; + georef.units.map_unit_to_meters = sidecar.map_unit_to_meters; + model.has_georef = true; } - std::vector elements = std::move(d.elements); - std::string stbl = std::move(d.string_table); + // Pull the element table out before applyCachedModel consumes the metadata. + // The geometry upload doesn't touch elements; it only reads/moves meshes and + // instances. + std::vector elements = std::move(sidecar.elements); + std::string string_table = std::move(sidecar.string_table); - viewport_->applyCachedModel(mid, std::move(metadata)); + // applyCachedModel is the sole authority for the global object_id space: the + // sidecar stores model-LOCAL ids, and it assigns each instance's global id as + // base + local, storing the base on the model. The element table gets the + // same base below — one authority, two halves, no separate id assignment. + viewport_->applyCachedModel(session_model_id, std::move(metadata)); - emit sidecarElementsReady(mid, std::move(elements), std::move(stbl)); + // Stamp the element records with the same base applyCachedModel gave the + // instances, so registry ids match the ids pick/selection return. Mirrors + // ViewportCore::loadElementMetadataWeb and the live-stream path + // (onStreamerFinished). + const uint32_t base = viewport_->modelObjectIdBase(session_model_id); + for (auto& element : elements) { + element.object_id += base; + element.session_model_id = session_model_id; + } - qint64 ms = model.load_timer.elapsed(); - emit loadedFromSidecar(mid, ms); + emit sidecarElementsReady(session_model_id, std::move(elements), std::move(string_table)); - loading_model_id_ = 0; + qint64 elapsed_ms = model.load_timer.elapsed(); + emit loadedFromSidecar(session_model_id, elapsed_ms); + + loading_session_model_id_ = 0; QTimer::singleShot(0, this, &SceneLoader::startNextLoad); } -void SceneLoader::startDataSourceLoad(uint32_t mid) { - auto it = models_.find(mid); +void SceneLoader::startDataSourceLoad(uint32_t session_model_id) { + auto it = models_.find(session_model_id); if (it == models_.end()) return; std::string data_path_std = it->second.file_path.toStdString(); - data_source_threads_.emplace_back([this, mid, data_path_std]() { - QElapsedTimer t; t.start(); + data_source_threads_.emplace_back([this, session_model_id, data_path_std]() { + QElapsedTimer timer; timer.start(); std::unique_ptr file; try { file = std::make_unique( @@ -310,11 +307,11 @@ void SceneLoader::startDataSourceLoad(uint32_t mid) { return; } std::fprintf(stderr, "[info] Data source load: %lld ms (%s)\n", - (long long)t.elapsed(), data_path_std.c_str()); + (long long)timer.elapsed(), data_path_std.c_str()); auto shared = std::make_shared>(std::move(file)); - QMetaObject::invokeMethod(this, [this, mid, shared]() { - auto it = models_.find(mid); + QMetaObject::invokeMethod(this, [this, session_model_id, shared]() { + auto it = models_.find(session_model_id); if (it == models_.end()) return; auto* streamer = it->second.streamer; if (streamer == nullptr) return; @@ -322,7 +319,7 @@ void SceneLoader::startDataSourceLoad(uint32_t mid) { // path somehow populated it), don't clobber it. if (streamer->ifcFile() != nullptr) return; streamer->setIfcFile(std::move(*shared)); - emit dataSourceReady(mid); + emit dataSourceReady(session_model_id); }, Qt::QueuedConnection); }); } @@ -333,8 +330,8 @@ void SceneLoader::onStreamerProgressChanged(int percent) { void SceneLoader::onStreamerMeshReady(StreamedMesh mesh) { viewport_->uploadStreamedMesh(mesh); - if (loading_model_id_ != 0) { - auto it = models_.find(loading_model_id_); + if (loading_session_model_id_ != 0) { + auto it = models_.find(loading_session_model_id_); if (it != models_.end() && it->second.sidecar_builder) { it->second.sidecar_builder->onMeshReady(mesh); } @@ -342,8 +339,8 @@ void SceneLoader::onStreamerMeshReady(StreamedMesh mesh) { } void SceneLoader::onStreamerInstanceReady(StreamedInstance instance_record) { - if (loading_model_id_ != 0) { - auto it = models_.find(loading_model_id_); + if (loading_session_model_id_ != 0) { + auto it = models_.find(loading_session_model_id_); if (it != models_.end() && it->second.sidecar_builder) { it->second.sidecar_builder->onInstanceReady(instance_record); } @@ -352,76 +349,86 @@ void SceneLoader::onStreamerInstanceReady(StreamedInstance instance_record) { } void SceneLoader::onElementPollTick() { - if (loading_model_id_ == 0) return; - auto it = models_.find(loading_model_id_); + if (loading_session_model_id_ == 0) return; + auto it = models_.find(loading_session_model_id_); if (it == models_.end()) return; auto batch = it->second.streamer->drainElements(); if (batch.empty()) return; - // Mirror into the per-model accumulator so finalize() has the full set - // without re-draining (the streamer's queue is consumed by this drain). - if (it->second.sidecar_builder) { - auto& buf = it->second.streamed_elements; - buf.insert(buf.end(), batch.begin(), batch.end()); - } - emit streamedElementsReady(loading_model_id_, std::move(batch)); + // Buffer the whole set. The streamer stamps model-LOCAL object_ids, so we + // can't hand these to the registry yet — they're globalized and emitted + // once at finalize (onStreamerFinished), after applyCachedModel assigns + // this model's object_id base. The sidecar builder also reads this buffer. + auto& buf = it->second.streamed_elements; + buf.insert(buf.end(), batch.begin(), batch.end()); } 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); + uint32_t session_model_id = loading_session_model_id_; + if (session_model_id != 0) { + auto it = models_.find(session_model_id); if (it != models_.end()) { - auto& m = it->second; - next_object_id_ = m.streamer->lastObjectId(); - viewport_->finalizeModel(mid); + auto& model = it->second; + viewport_->finalizeModel(session_model_id); // Sidecar finalize + disk write. Wgpu has no live LOD1 apply — // LOD1 indices land in the on-disk sidecar and are picked up // on the *next* open of this file; first-session view is // LOD0-only. Acceptable trade-off vs reallocating chunk index // slices live to splice LOD1 in. - if (m.sidecar_builder) { + // + // The sidecar is written from the LOCAL element/instance ids (the + // globalization below happens after), so a re-opened .ifcview + // stores model-local ids exactly like a freshly-streamed one. + if (model.sidecar_builder) { ModelGeoref georef; - if (auto* file = m.streamer->ifcFile()) { + if (auto* file = model.streamer->ifcFile()) { georef = computeModelGeoref(file); } - QElapsedTimer wt; wt.start(); - SidecarData data = m.sidecar_builder->finalize(georef, m.streamed_elements); + QElapsedTimer write_timer; write_timer.start(); + SidecarData data = model.sidecar_builder->finalize(georef, model.streamed_elements); // Lay geometry out in streaming-chunk order + bake the chunk TOC // (v14) so it streams as one contiguous range per chunk. reorderSidecarByMorton(data); - const bool ok = writeSidecar(m.file_path.toStdString(), data); + const bool ok = writeSidecar(model.file_path.toStdString(), data); std::fprintf(stderr, "[info] Sidecar finalize + write: %lld ms (%s)\n", - (long long)wt.elapsed(), ok ? "ok" : "FAILED"); - m.sidecar_builder.reset(); - m.streamed_elements.clear(); - m.streamed_elements.shrink_to_fit(); + (long long)write_timer.elapsed(), ok ? "ok" : "FAILED"); + model.sidecar_builder.reset(); } - qint64 ms = m.load_timer.elapsed(); - emit loadedFromStream(mid, ms); + // Globalize the buffered element ids by the base applyCachedModel + // assigned to this model's instances, then hand them to the + // registry — one emit, ids matching the GPU/pick space. Mirrors the + // sidecar-hit path (applySidecarData). + const uint32_t base = viewport_->modelObjectIdBase(session_model_id); + for (auto& element : model.streamed_elements) element.object_id += base; + emit streamedElementsReady(session_model_id, std::move(model.streamed_elements)); + model.streamed_elements.clear(); + model.streamed_elements.shrink_to_fit(); + + qint64 elapsed_ms = model.load_timer.elapsed(); + emit loadedFromStream(session_model_id, elapsed_ms); } } - loading_model_id_ = 0; + loading_session_model_id_ = 0; startNextLoad(); } void SceneLoader::onStreamerCancelled() { element_poll_timer_.stop(); - const uint32_t mid = loading_model_id_; - loading_model_id_ = 0; + const uint32_t session_model_id = loading_session_model_id_; + loading_session_model_id_ = 0; - if (mid != 0) { - viewport_->removeModel(mid); - emit loadCancelled(mid); + if (session_model_id != 0) { + viewport_->removeModel(session_model_id); + emit loadCancelled(session_model_id); } QTimer::singleShot(0, this, &SceneLoader::startNextLoad); } @@ -429,12 +436,12 @@ void SceneLoader::onStreamerCancelled() { void SceneLoader::onStreamerError(const QString& msg) { element_poll_timer_.stop(); - const uint32_t mid = loading_model_id_; - loading_model_id_ = 0; + const uint32_t session_model_id = loading_session_model_id_; + loading_session_model_id_ = 0; - if (mid != 0) { - viewport_->removeModel(mid); + if (session_model_id != 0) { + viewport_->removeModel(session_model_id); } - emit loadError(mid, msg); + emit loadError(session_model_id, msg); QTimer::singleShot(0, this, &SceneLoader::startNextLoad); } diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 11bb929c4f..4a03247de4 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -69,61 +69,61 @@ public: bool shouldReadSidecar() const { return should_read_sidecar_; } bool shouldWriteSidecar() const { return should_write_sidecar_; } - // Returns the model_ids assigned to the enqueued paths, in order. + // Returns the session_model_ids assigned to the enqueued paths, in order. // Callers can use these to set up per-model UI state (tree roots, etc.) // before any load signal fires. - std::vector addFiles(const QStringList& paths); + std::vector queueModels(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; } + bool isLoading() const { return loading_session_model_id_ != 0 || !load_queue_.empty(); } + bool isLoadingModel(uint32_t session_model_id) const { return loading_session_model_id_ == session_model_id; } size_t modelCount() const { return models_.size(); } - // Drop the loader's tracking for `mid` — its streamer, file path, georef + // Drop the loader's tracking for `session_model_id` — 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); + void removeModel(uint32_t session_model_id); - QString filePath(uint32_t mid) const; - QString displayName(uint32_t mid) const; - ifcopenshell::file* ifcFile(uint32_t mid) const; + QString filePath(uint32_t session_model_id) const; + QString displayName(uint32_t session_model_id) const; + ifcopenshell::file* ifcFile(uint32_t session_model_id) 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); + const ModelGeoref* modelGeoref(uint32_t session_model_id); signals: void progressChanged(int percent); - void loadStarted(uint32_t mid, QString display_name); + void loadStarted(uint32_t session_model_id, 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, + void sidecarElementsReady(uint32_t session_model_id, std::vector elements, std::string string_table); - void loadedFromSidecar(uint32_t mid, qint64 elapsed_ms); + void loadedFromSidecar(uint32_t session_model_id, 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); + // any UI that queries ifcFile(session_model_id) for attributes/properties. + void dataSourceReady(uint32_t session_model_id); // Fired repeatedly while streaming, as the worker thread produces // elements. Each batch contains whatever accumulated since the last // poll tick. - void streamedElementsReady(uint32_t mid, std::vector elements); + void streamedElementsReady(uint32_t session_model_id, std::vector elements); // Fired once after the streamer finishes and the viewport has been // finalized. Consumer may synchronously perform work that needs all // elements to be known (e.g. sidecar write) — SceneLoader will only // start the next queued load after all slots return. - void loadedFromStream(uint32_t mid, qint64 elapsed_ms); - void loadCancelled(uint32_t mid); + void loadedFromStream(uint32_t session_model_id, qint64 elapsed_ms); + void loadCancelled(uint32_t session_model_id); - void loadError(uint32_t mid, QString message); + void loadError(uint32_t session_model_id, QString message); void allLoadsFinished(); private slots: @@ -143,7 +143,7 @@ private: GeometryStreamer* streamer = nullptr; QElapsedTimer load_timer; - // Cached on first SceneLoader::modelGeoref(mid) call once the + // Cached on first SceneLoader::modelGeoref(session_model_id) call once the // streamer has its IFC file loaded. ModelGeoref georef; bool has_georef = false; @@ -158,21 +158,20 @@ private: }; void startNextLoad(); - void startStreamLoadFor(uint32_t mid); + void loadFromGeometryStreamer(uint32_t session_model_id); void connectStreamer(GeometryStreamer* streamer); void joinSidecarThread(); void joinDataSourceThreads(); - void applySidecarData(uint32_t mid, StreamingSidecar metadata); - void startDataSourceLoad(uint32_t mid); + void applySidecarData(uint32_t session_model_id, StreamingSidecar metadata); + void startDataSourceLoad(uint32_t session_model_id); ViewportWindow* viewport_ = nullptr; bool should_read_sidecar_ = false; bool should_write_sidecar_ = false; std::map models_; std::deque load_queue_; - uint32_t next_model_id_ = 1; - uint32_t next_object_id_ = 1; - uint32_t loading_model_id_ = 0; + uint32_t next_session_model_id_ = 1; + uint32_t loading_session_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 diff --git a/src/ifcviewer/SectionGizmoRenderer.cpp b/src/ifcviewer/SectionGizmoRenderer.cpp index fc0796d000..bd9ca4969f 100644 --- a/src/ifcviewer/SectionGizmoRenderer.cpp +++ b/src/ifcviewer/SectionGizmoRenderer.cpp @@ -313,16 +313,16 @@ void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matri const float vh = float(viewport_h_px); const int n = std::min(int(planes.size()), kMaxPlanes); for (int i = 0; i < n; ++i) { - const SectionPlane& p = planes[i]; + const SectionPlane& plane = planes[i]; Eigen::Vector3f nn, tangent, bitangent; - planeBasis(p.n, nn, tangent, bitangent); + planeBasis(plane.n, nn, tangent, bitangent); // Fixed 1 m gizmo (matches the desktop OverlayRenderer / GL constant). // NOT visual_radius: the normal is flipped toward the camera, so a large // arrow would shoot past the eye (clip.w<0) and vanish. const float half = 1.0f; uint8_t slot[256]; - packSectionUniform(slot, view_proj, p.origin, half, tangent, line_w, + packSectionUniform(slot, view_proj, plane.origin, half, tangent, line_w, bitangent, nn, 1.0f, 1.0f, 1.0f, 1.0f, vw, vh); const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlot; wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot)); @@ -340,12 +340,12 @@ int SectionGizmoRenderer::hitTest(int x, int y, const std::vector& float best_d = tolerance_px; const int n = std::min(int(planes.size()), kMaxPlanes); for (int i = 0; i < n; ++i) { - const SectionPlane& p = planes[i]; + const SectionPlane& plane = planes[i]; // The arrow runs origin → origin + n * 1 m (visual radius scales the // gizmo, but hit-test the unit arrow to mirror the desktop). Eigen::Vector2f s_origin, s_tip; - if (!projectWorldToLogicalScreen(vp, p.origin, viewport_w_px, viewport_h_px, s_origin)) continue; - if (!projectWorldToLogicalScreen(vp, p.origin + p.n * 1.0f, viewport_w_px, viewport_h_px, s_tip)) continue; + if (!projectWorldToLogicalScreen(vp, plane.origin, viewport_w_px, viewport_h_px, s_origin)) continue; + if (!projectWorldToLogicalScreen(vp, plane.origin + plane.n * 1.0f, viewport_w_px, viewport_h_px, s_tip)) continue; const Eigen::Vector2f ab = s_tip - s_origin; const float ab_len2 = ab.squaredNorm(); if (ab_len2 < 1e-3f) continue; diff --git a/src/ifcviewer/SidecarBuilder.cpp b/src/ifcviewer/SidecarBuilder.cpp index 8a5459a46d..9292a81350 100644 --- a/src/ifcviewer/SidecarBuilder.cpp +++ b/src/ifcviewer/SidecarBuilder.cpp @@ -103,7 +103,7 @@ void SidecarBuilder::onInstanceReady(const StreamedInstance& instance_record) { instance.mesh_id = instance_record.local_mesh_id; instance.object_id = instance_record.object_id; instance.color_override_rgba8 = instance_record.color_override_rgba8; - instance.model_id = instance_record.model_id; + instance.session_model_id = instance_record.session_model_id; // The streamer's instance transform is the double-precision // placement_transformation. The cached float transform/world_aabb is only @@ -142,7 +142,7 @@ SidecarData SidecarBuilder::finalize(const ModelGeoref& georef, for (const auto& info : elements) { ElementTableRecord packed; packed.object_id = info.object_id; - packed.model_id = info.model_id; + packed.session_model_id = info.session_model_id; packed.ifc_id = info.ifc_id; packed.guid_offset = static_cast(sidecar_data_.string_table.size()); @@ -191,8 +191,7 @@ bool SidecarBuilder::build(const QString& ifc_path, }); streamer.loadFile(ifc_path.toStdString(), - /*start_object_id*/ 1, - /*model_id*/ 1, + /*session_model_id*/ 1, num_threads); loop.exec(); diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index 582329b69f..4c894f44e0 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -254,7 +254,7 @@ struct BufReader { } // namespace // Full read: reconstruct the whole SidecarData (test/tooling path — the runtime -// streams via readSidecarMetadataOnly + per-chunk loads and never calls this). +// streams via readSidecarMetadata + per-chunk loads and never calls this). // Decompresses the metadata blocks, then scatters each chunk's decompressed // geometry back into the whole-model vertex/index arrays using the mesh offsets. std::optional readSidecar(const std::string& ifc_path) { diff --git a/src/ifcviewer/SidecarCache.h b/src/ifcviewer/SidecarCache.h index 6035bd1733..ee49fb894e 100644 --- a/src/ifcviewer/SidecarCache.h +++ b/src/ifcviewer/SidecarCache.h @@ -115,7 +115,7 @@ struct SidecarChunk { // into a separate string table. struct ElementTableRecord { uint32_t object_id; - uint32_t model_id; + uint32_t session_model_id; int32_t ifc_id; uint32_t guid_offset; uint32_t guid_length; diff --git a/src/ifcviewer/StreamingLoader.cpp b/src/ifcviewer/StreamingLoader.cpp index b93b20db63..641de1cd5d 100644 --- a/src/ifcviewer/StreamingLoader.cpp +++ b/src/ifcviewer/StreamingLoader.cpp @@ -125,7 +125,7 @@ bool parseSidecarElementMetadata(const uint8_t* data, size_t n, SidecarData& out return true; } -std::optional readSidecarMetadataOnly(const std::string& ifc_path) { +std::optional readSidecarMetadata(const std::string& ifc_path) { const std::string path = sidecarPath(ifc_path); FILE* f = std::fopen(path.c_str(), "rb"); if (!f) return std::nullopt; diff --git a/src/ifcviewer/StreamingLoader.h b/src/ifcviewer/StreamingLoader.h index 4f6c89c330..92a38b4c90 100644 --- a/src/ifcviewer/StreamingLoader.h +++ b/src/ifcviewer/StreamingLoader.h @@ -69,7 +69,7 @@ struct StreamingSidecar { // Read just the metadata + section offsets. Returns nullopt on any I/O or // version error (same failure modes as readSidecar). The file is closed // before return — callers re-open for per-chunk reads. -std::optional readSidecarMetadataOnly(const std::string& ifc_path); +std::optional readSidecarMetadata(const std::string& ifc_path); // Read + decompress one chunk's geometry (v16) from disk: the vertex zstd frame // at [geometry_section_offset + v_comp_off, +v_comp_size) → out_vbytes (v_raw diff --git a/src/ifcviewer/StreamingThread.cpp b/src/ifcviewer/StreamingThread.cpp index 39b136c0aa..5591829cfc 100644 --- a/src/ifcviewer/StreamingThread.cpp +++ b/src/ifcviewer/StreamingThread.cpp @@ -95,7 +95,7 @@ void StreamingThread::workerLoop() { // thread — they cross back to the main thread when the result // is drained and applied (pool.alloc + queueWriteBuffer). Result result; - result.model_id = req.model_id; + result.session_model_id = req.session_model_id; result.chunk_idx = req.chunk_idx; result.success = readChunkGeometryCompressed( req.file_path, req.geometry_section_offset, diff --git a/src/ifcviewer/StreamingThread.h b/src/ifcviewer/StreamingThread.h index d019ccf70b..90f473fa3b 100644 --- a/src/ifcviewer/StreamingThread.h +++ b/src/ifcviewer/StreamingThread.h @@ -44,7 +44,7 @@ class StreamingThread { public: struct Request { - uint32_t model_id; + uint32_t session_model_id; std::size_t chunk_idx; std::string file_path; // v16: the chunk's two zstd frames in the geometry section. The reader @@ -56,7 +56,7 @@ public: }; struct Result { - uint32_t model_id; + uint32_t session_model_id; std::size_t chunk_idx; bool success; std::vector vbytes; diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 16dec8df77..f37e547cf0 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -102,8 +102,8 @@ void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) { // ---- Scene mutators ------------------------------------------------------- -void ViewportCore::removeModel(uint32_t model_id) { - auto it = models_gpu_.find(model_id); +void ViewportCore::removeModel(uint32_t session_model_id) { + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; releaseWgpuModelGpuData(it->second, pool_); models_gpu_.erase(it); @@ -111,7 +111,7 @@ void ViewportCore::removeModel(uint32_t model_id) { } void ViewportCore::resetScene() { - for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); + for (auto& [session_model_id, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); models_gpu_.clear(); // A fresh scene should auto-frame its first model. Without this the flag // stays set from the previous scene (on web, the embedded sample sets it at @@ -121,15 +121,15 @@ void ViewportCore::resetScene() { host_->requestFrame(); } -void ViewportCore::hideModel(uint32_t model_id) { - auto it = models_gpu_.find(model_id); +void ViewportCore::hideModel(uint32_t session_model_id) { + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end() || it->second.hidden) return; it->second.hidden = true; host_->requestFrame(); } -void ViewportCore::showModel(uint32_t model_id) { - auto it = models_gpu_.find(model_id); +void ViewportCore::showModel(uint32_t session_model_id) { + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end() || !it->second.hidden) return; it->second.hidden = false; host_->requestFrame(); @@ -141,22 +141,22 @@ void ViewportCore::setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters) for (auto& kv : models_gpu_) recomposeAndUploadModel(kv.first); } -void ViewportCore::setModelCoordinateOperation(uint32_t model_id, +void ViewportCore::setModelCoordinateOperation(uint32_t session_model_id, const Eigen::Matrix4d& matrix_meters) { - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; if (it->second.coordinate_operation_meters == matrix_meters) return; it->second.coordinate_operation_meters = matrix_meters; - recomposeAndUploadModel(model_id); + recomposeAndUploadModel(session_model_id); } -void ViewportCore::setModelTransformation(uint32_t model_id, +void ViewportCore::setModelTransformation(uint32_t session_model_id, const Eigen::Matrix4d& matrix_meters) { - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; if (it->second.model_transformation_meters == matrix_meters) return; it->second.model_transformation_meters = matrix_meters; - recomposeAndUploadModel(model_id); + recomposeAndUploadModel(session_model_id); } // ---- Camera math ---------------------------------------------------------- @@ -199,7 +199,7 @@ bool ViewportCore::computeSceneAabb(float mn[3], float mx[3]) const { mn[i] = std::numeric_limits::infinity(); mx[i] = -std::numeric_limits::infinity(); } - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& inst : m.instances) { for (int i = 0; i < 3; ++i) { @@ -265,9 +265,9 @@ float ViewportCore::chunkScreenAreaPx(const ModelGpuData::Chunk& c, return (xmax - xmin) * (ymax - ymin); } -void ViewportCore::recomposeAndUploadModel(uint32_t model_id) { +void ViewportCore::recomposeAndUploadModel(uint32_t session_model_id) { if (!wgpu_initialized_) return; - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; ModelGpuData& m = it->second; if (m.instances.empty() || m.instance_storage == nullptr) return; @@ -316,9 +316,14 @@ bool ViewportCore::findInstance(uint32_t object_id, return InstanceCompose::findInstanceInModels(object_id, models_gpu_, out); } -bool ViewportCore::firstGeometryPointWorldM(uint32_t model_id, +uint32_t ViewportCore::modelObjectIdBase(uint32_t session_model_id) const { + auto it = models_gpu_.find(session_model_id); + return it == models_gpu_.end() ? 0u : it->second.object_id_base; +} + +bool ViewportCore::firstGeometryPointWorldM(uint32_t session_model_id, Eigen::Vector3d& out) const { - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return false; const ModelGpuData& m = it->second; if (m.instances.empty()) return false; @@ -636,7 +641,7 @@ bool ViewportCore::computeObjectAabb(uint32_t object_id, mn[i] = std::numeric_limits::infinity(); mx[i] = -std::numeric_limits::infinity(); } - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { for (const auto& inst : m.instances) { if (inst.object_id != object_id) continue; for (int i = 0; i < 3; ++i) { @@ -678,7 +683,7 @@ double ViewportCore::volumeOfObjects( if (object_ids.empty()) return 0.0; double total = 0.0; for (uint32_t oid : object_ids) { - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { auto it = m.object_id_to_instance.find(oid); if (it == m.object_id_to_instance.end()) continue; const InstanceInfo& inst = m.instances[it->second]; @@ -699,7 +704,7 @@ ViewportCore::volumesPerObject( if (object_ids.empty()) return out; out.reserve(object_ids.size()); for (uint32_t oid : object_ids) { - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { auto it = m.object_id_to_instance.find(oid); if (it == m.object_id_to_instance.end()) continue; const InstanceInfo& inst = m.instances[it->second]; @@ -842,11 +847,11 @@ fn find_draw(vid: u32) -> u32 { var lo: u32 = 0u; var hi: u32 = u_model.draw_count; while (lo + 1u < hi) { - let mid = (lo + hi) >> 1u; - if (prefix_sums[mid] <= vid) { - lo = mid; + let session_model_id = (lo + hi) >> 1u; + if (prefix_sums[session_model_id] <= vid) { + lo = session_model_id; } else { - hi = mid; + hi = session_model_id; } } return lo; @@ -1784,7 +1789,7 @@ void ViewportCore::shutdown() { // we've torn down model state. Worker drains its queue then joins. streaming_thread_.stop(); - for (auto& [mid, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); + for (auto& [session_model_id, m] : models_gpu_) releaseWgpuModelGpuData(m, pool_); models_gpu_.clear(); if (frame_bind_group_) { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; } @@ -2035,10 +2040,10 @@ bool ViewportCore::applyStreamedChunk( StreamingThread::Request ViewportCore::makeChunkRequest( const ModelGpuData& m, std::size_t chunk_idx, - std::uint32_t model_id) { + std::uint32_t session_model_id) { const auto& c = m.chunks[chunk_idx]; StreamingThread::Request req; - req.model_id = model_id; + req.session_model_id = session_model_id; req.chunk_idx = chunk_idx; req.file_path = m.streaming_file_path; // v16: one compressed vertex frame + one compressed index frame per chunk. @@ -2130,7 +2135,7 @@ void ViewportCore::driveStreamingLoads() { // the chunk has *actually* contributed pixels (post-HiZ) over the // last ~30 frames. constexpr float HISTORY_ALPHA = 1.0f / 30.0f; - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (auto& c : m.chunks) { if (c.is_resident && c.frustum_visible_count > 0) { @@ -2199,7 +2204,7 @@ void ViewportCore::driveStreamingLoads() { ModelGpuData* victim_m = nullptr; std::size_t victim_ci = 0; std::uint64_t victim_lru = std::numeric_limits::max(); - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { auto& c = m.chunks[ci]; if (!c.is_resident) continue; @@ -2233,7 +2238,7 @@ void ViewportCore::driveStreamingLoads() { ModelGpuData* victim_m = nullptr; std::size_t victim_ci = 0; float victim_priority = threshold; - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { auto& c = m.chunks[ci]; if (!c.is_resident) continue; @@ -2256,7 +2261,7 @@ void ViewportCore::driveStreamingLoads() { // 2-cycle detection: this victim was previously evicted by // THIS exact candidate — the smoking gun for a swap loop. const bool is_2_cycle = - victim.last_evicted_by_model_id == cand_mid + victim.last_evicted_by_session_model_id == cand_mid && victim.last_evicted_by_chunk_idx == cand_ci && victim.load_count > 1; Log::info() @@ -2271,7 +2276,7 @@ void ViewportCore::driveStreamingLoads() { << ", threshold=" << int(threshold) << ")"; } - victim.last_evicted_by_model_id = cand_mid; + victim.last_evicted_by_session_model_id = cand_mid; victim.last_evicted_by_chunk_idx = cand_ci; victim.last_evicted_by_priority = cand_priority; victim.last_evicted_frame_idx = streaming_frame_idx_; @@ -2287,7 +2292,7 @@ void ViewportCore::driveStreamingLoads() { { auto results = streaming_thread_.drainResults(); for (auto& res : results) { - auto it = models_gpu_.find(res.model_id); + auto it = models_gpu_.find(res.session_model_id); if (it == models_gpu_.end()) continue; // model unloaded auto& m = it->second; if (res.chunk_idx >= m.chunks.size()) continue; @@ -2295,7 +2300,7 @@ void ViewportCore::driveStreamingLoads() { c.is_loading = false; if (!res.success) { Log::warn() << "[wgpu stream] worker read failed for model " - << res.model_id << " chunk " << res.chunk_idx; + << res.session_model_id << " chunk " << res.chunk_idx; continue; } if (!applyStreamedChunk(m, res.chunk_idx, res.vbytes, res.idx)) { @@ -2330,12 +2335,12 @@ void ViewportCore::driveStreamingLoads() { struct Candidate { ModelGpuData* m; std::size_t ci; - std::uint32_t mid; + std::uint32_t session_model_id; float priority; }; std::vector candidates; candidates.reserve(64); - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { if (m.streaming_file_path.empty() || m.hidden) continue; for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { auto& c = m.chunks[ci]; @@ -2348,7 +2353,7 @@ void ViewportCore::driveStreamingLoads() { // what's resolvable now; the rest stream in as you approach. if (c.contribution_visible_count == 0) continue; if (c.blocked_cooldown_until_frame_idx > streaming_frame_idx_) continue; - candidates.push_back({&m, ci, mid, candidate_priority(c)}); + candidates.push_back({&m, ci, session_model_id, candidate_priority(c)}); } } streaming_candidates_this_frame_ = int(candidates.size()); @@ -2394,7 +2399,7 @@ void ViewportCore::driveStreamingLoads() { // once it exceeds the memory budget (highest-contribution chunks win). while (pool_.total_free_bytes() < streaming_web_inflight_bytes_ + need) { if (evict_one_lru()) continue; - if (evict_lowest_priority_than(cand.mid, std::uint32_t(cand.ci), + if (evict_lowest_priority_than(cand.session_model_id, std::uint32_t(cand.ci), cand.priority)) continue; break; } @@ -2412,7 +2417,7 @@ void ViewportCore::driveStreamingLoads() { && !pool_can_fit(c.index_count * sizeof(std::uint32_t))) || pool_.total_free_bytes() < need) { if (evict_one_lru()) continue; - if (evict_lowest_priority_than(cand.mid, std::uint32_t(cand.ci), + if (evict_lowest_priority_than(cand.session_model_id, std::uint32_t(cand.ci), cand.priority)) continue; break; } @@ -2474,7 +2479,7 @@ void ViewportCore::driveStreamingLoads() { c.is_loading = true; c.last_visible_frame_idx = streaming_frame_idx_; ++streaming_web_inflight_count_; - beginWebChunkLoad(cand.mid, cand.ci); + beginWebChunkLoad(cand.session_model_id, cand.ci); ++enqueued; continue; } @@ -2490,7 +2495,7 @@ void ViewportCore::driveStreamingLoads() { continue; } - if (streaming_thread_.enqueue(makeChunkRequest(*cand.m, cand.ci, cand.mid))) { + if (streaming_thread_.enqueue(makeChunkRequest(*cand.m, cand.ci, cand.session_model_id))) { c.is_loading = true; ++enqueued; } @@ -2507,7 +2512,7 @@ void ViewportCore::driveStreamingLoads() { // next few frames so an on-demand render loop doesn't stall before the // geometry actually appears. Bounded, so the loop still quiesces at idle. bool visible_pending = false; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.streaming_file_path.empty() || m.hidden) continue; for (const auto& c : m.chunks) { if (!c.is_resident && (c.frustum_visible_count > 0 || c.is_loading)) { @@ -2578,7 +2583,7 @@ void ViewportCore::driveStreamingLoads() { << " ev_pri=" << streaming_evictions_pri_this_frame_ << " blocked=" << streaming_blocked_oom_this_frame_; - struct Stat { std::uint32_t mid; std::size_t ci; float area; }; + struct Stat { std::uint32_t session_model_id; std::size_t ci; float area; }; std::vector all; all.reserve(64); for (const auto& [mid2, m2] : models_gpu_) { @@ -2594,7 +2599,7 @@ void ViewportCore::driveStreamingLoads() { const std::size_t n = std::min(5, all.size()); for (std::size_t i = 0; i < n; ++i) { Log::info() - << " top cand #" << i << ": model " << all[i].mid + << " top cand #" << i << ": model " << all[i].session_model_id << " chunk " << all[i].ci << " area=" << int(all[i].area) << "px2"; } @@ -2607,7 +2612,7 @@ void ViewportCore::driveStreamingLoads() { std::size_t resident = 0; std::uint32_t max_load_count = 0; std::size_t cycled = 0; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { for (const auto& c : m.chunks) { if (c.is_resident) ++resident; if (c.load_count > max_load_count) max_load_count = c.load_count; @@ -2897,11 +2902,11 @@ WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue, // Holds a unique_ptr so address stability is preserved as the map grows. SidecarData& getOrCreateDirectStaging( std::unordered_map>& staging, - std::uint32_t model_id) { - auto it = staging.find(model_id); + std::uint32_t session_model_id) { + auto it = staging.find(session_model_id); if (it == staging.end()) { auto [it_new, _] = staging.emplace( - model_id, std::make_unique()); + session_model_id, std::make_unique()); return *it_new->second; } return *it->second; @@ -2909,7 +2914,7 @@ SidecarData& getOrCreateDirectStaging( } // namespace -void ViewportCore::applyCachedModel(std::uint32_t model_id, +void ViewportCore::applyCachedModel(std::uint32_t session_model_id, StreamingSidecar metadata) { if (!device_ || !queue_) { Log::warn() << "applyCachedModel without an initialised device"; @@ -2917,7 +2922,7 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, } // Replace any existing state for this id. - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it != models_gpu_.end()) { releaseWgpuModelGpuData(it->second, pool_); models_gpu_.erase(it); @@ -3203,11 +3208,11 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, } } - auto [inserted, _] = models_gpu_.emplace(model_id, std::move(model_gpu_data)); + auto [inserted, _] = models_gpu_.emplace(session_model_id, std::move(model_gpu_data)); ModelGpuData& inserted_model = inserted->second; Log::info() - << "[wgpu stream] applyCachedModel mid=" << model_id + << "[wgpu stream] applyCachedModel session_model_id=" << session_model_id << " verts=" << inserted_model.vertex_bytes << "B (deferred)" << " idx=" << inserted_model.index_count << " meshes=" << inserted_model.mesh_count @@ -3224,7 +3229,7 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, void ViewportCore::uploadStreamedMesh(const StreamedMesh& mesh) { if (mesh.vertices.empty() || mesh.indices.empty()) return; - SidecarData& s = getOrCreateDirectStaging(pending_direct_loads_, mesh.model_id); + SidecarData& staging = getOrCreateDirectStaging(pending_direct_loads_, mesh.session_model_id); // Streamer format: 7 floats / vertex (pos3 + normal3 + color-as-float). // Same quantisation as SidecarBuilder::onMeshReady so direct-load and @@ -3250,17 +3255,17 @@ void ViewportCore::uploadStreamedMesh(const StreamedMesh& mesh) { extent_recip[a] = ext > 0.0f ? 1.0f / ext : 0.0f; } - const std::size_t vb_offset = s.vertices.size(); - s.vertices.resize(vb_offset + n_verts * INSTANCED_VERTEX_STRIDE_BYTES); + const std::size_t vb_offset = staging.vertices.size(); + staging.vertices.resize(vb_offset + n_verts * INSTANCED_VERTEX_STRIDE_BYTES); for (std::size_t i = 0; i < n_verts; ++i) { quantizeVertex(mesh.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS, bmin, extent_recip, - s.vertices.data() + vb_offset + staging.vertices.data() + vb_offset + i * INSTANCED_VERTEX_STRIDE_BYTES); } - const std::size_t ib_offset = s.indices.size(); - s.indices.insert(s.indices.end(), + const std::size_t ib_offset = staging.indices.size(); + staging.indices.insert(staging.indices.end(), mesh.indices.begin(), mesh.indices.end()); MeshInfo info{}; @@ -3277,20 +3282,20 @@ void ViewportCore::uploadStreamedMesh(const StreamedMesh& mesh) { info.lod1_ebo_byte_offset = 0; info.lod1_index_count = 0; - if (s.meshes.size() <= mesh.local_mesh_id) { - s.meshes.resize(mesh.local_mesh_id + 1); + if (staging.meshes.size() <= mesh.local_mesh_id) { + staging.meshes.resize(mesh.local_mesh_id + 1); } - s.meshes[mesh.local_mesh_id] = info; + staging.meshes[mesh.local_mesh_id] = info; } void ViewportCore::uploadStreamedInstance(const StreamedInstance& instance_record) { - SidecarData& s = getOrCreateDirectStaging(pending_direct_loads_, instance_record.model_id); + SidecarData& staging = getOrCreateDirectStaging(pending_direct_loads_, instance_record.session_model_id); InstanceInfo instance{}; instance.mesh_id = instance_record.local_mesh_id; instance.object_id = instance_record.object_id; instance.color_override_rgba8 = instance_record.color_override_rgba8; - instance.model_id = instance_record.model_id; + instance.session_model_id = instance_record.session_model_id; std::memcpy(instance.placement_transformation, instance_record.transform, sizeof(instance.placement_transformation)); for (int i = 0; i < 16; ++i) { @@ -3299,7 +3304,7 @@ void ViewportCore::uploadStreamedInstance(const StreamedInstance& instance_recor std::memcpy(instance.world_aabb_min, instance_record.world_aabb_min, sizeof(instance.world_aabb_min)); std::memcpy(instance.world_aabb_max, instance_record.world_aabb_max, sizeof(instance.world_aabb_max)); - s.instances.push_back(instance); + staging.instances.push_back(instance); } std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) { @@ -3307,14 +3312,14 @@ std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) { Log::warn() << "loadSidecarFromPath: wgpu not initialised"; return 0; } - auto meta_opt = readSidecarMetadataOnly(path); + auto meta_opt = readSidecarMetadata(path); if (!meta_opt) { Log::warn() << "loadSidecarFromPath: could not read sidecar metadata from " << path; return 0; } - const std::uint32_t mid = next_model_id_++; - applyCachedModel(mid, std::move(*meta_opt)); - return mid; + const std::uint32_t session_model_id = next_session_model_id_++; + applyCachedModel(session_model_id, std::move(*meta_opt)); + return session_model_id; } #if defined(__EMSCRIPTEN__) @@ -3407,9 +3412,9 @@ void webIssueCurrentPlan(int id) { if (done) done(true, std::move(out)); return; } - const SidecarReadPlan& p = r.plans[r.plan_idx]; - r.scratch.assign(std::size_t(p.read_size), 0); - ifcvReadRangeInto(r.source_id, id, double(p.file_offset), double(p.read_size), + const SidecarReadPlan& plan = r.plans[r.plan_idx]; + r.scratch.assign(std::size_t(plan.read_size), 0); + ifcvReadRangeInto(r.source_id, id, double(plan.file_offset), double(plan.read_size), r.scratch.data()); } @@ -3456,8 +3461,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_on_range_done(int reqId, int ok) { if (done) done(false, {}); return; } - const SidecarReadPlan& p = r.plans[r.plan_idx]; - for (const auto& s : p.slices) { + const SidecarReadPlan& plan = r.plans[r.plan_idx]; + for (const auto& s : plan.slices) { std::memcpy(r.out.data() + s.dst_offset, r.scratch.data() + s.src_offset, std::size_t(s.bytes)); } @@ -3465,8 +3470,8 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_on_range_done(int reqId, int ok) { webIssueCurrentPlan(reqId); } -void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx) { - auto it = models_gpu_.find(model_id); +void ViewportCore::beginWebChunkLoad(std::uint32_t session_model_id, std::size_t chunk_idx) { + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; ModelGpuData& m = it->second; if (chunk_idx >= m.chunks.size()) return; @@ -3493,13 +3498,13 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i }; auto join = std::make_shared(); std::function finish = - [this, model_id, chunk_idx, need, v_raw, i_raw, join]() { + [this, session_model_id, chunk_idx, need, v_raw, i_raw, join]() { if (!join->v_done || !join->i_done) return; // wait for the other frame streaming_web_inflight_bytes_ -= std::min(streaming_web_inflight_bytes_, need); if (streaming_web_inflight_count_ > 0) --streaming_web_inflight_count_; host_->requestFrame(); - auto mit = models_gpu_.find(model_id); + auto mit = models_gpu_.find(session_model_id); if (mit == models_gpu_.end()) return; ModelGpuData& mm = mit->second; if (chunk_idx >= mm.chunks.size()) return; @@ -3608,15 +3613,15 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe } const std::size_t n_meshes = sc.meta.meshes.size(); const std::size_t n_instances = sc.meta.instances.size(); - const std::uint32_t mid = next_model_id_++; - applyCachedModel(mid, std::move(sc)); + const std::uint32_t session_model_id = next_session_model_id_++; + applyCachedModel(session_model_id, std::move(sc)); // Mark web-streamed + set the source IMMEDIATELY — the // model now has non-resident chunks and the RAF loop's // driveStreamingLoads will run before the element metadata header // read below returns. If streaming_from_web weren't set // yet it would take the sync fopen path and fail // ("failed to read/decompress chunk 0"). - if (auto m0 = models_gpu_.find(mid); m0 != models_gpu_.end()) { + if (auto m0 = models_gpu_.find(session_model_id); m0 != models_gpu_.end()) { m0->second.streaming_from_web = true; m0->second.web_source_id = source_id; } @@ -3625,10 +3630,10 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe const std::uint64_t element_metadata_hdr_off = geometry_metadata_off + geometry_metadata_comp; webReadRangesAsync(source_id, 0, {{element_metadata_hdr_off, 16}}, - [this, mid, element_metadata_hdr_off, source_id, source_label, + [this, session_model_id, element_metadata_hdr_off, source_id, source_label, n_meshes, n_instances] (bool ok4, std::vector&& dh) { - auto mit = models_gpu_.find(mid); + auto mit = models_gpu_.find(session_model_id); if (mit != models_gpu_.end()) { if (ok4 && dh.size() >= 16) { std::uint64_t dc = 0, dr = 0; @@ -3646,7 +3651,7 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe // as each federated model streams in. host_->requestFrame(); Log::info() << "ifcviewer-web: loaded sidecar (" << source_label - << ", id " << mid << ", " << n_meshes << " meshes, " + << ", id " << session_model_id << ", " << n_meshes << " meshes, " << n_instances << " instances)"; }); }); @@ -3654,14 +3659,14 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe }); } -void ViewportCore::loadElementMetadataWeb(std::uint32_t model_id, +void ViewportCore::loadElementMetadataWeb(std::uint32_t session_model_id, std::function done) { // On-demand fetch of the v15 element metadata block (elements + string table) // for a web-streamed model — the property data a UI needs (selected- // object name, search) but rendering doesn't. Fetches at most once. Reads // from the model's own registered byte-source, so it works per-model even // with several federated files loaded. - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) { if (done) done(false); return; } ModelGpuData& m = it->second; if (m.element_metadata_loaded || m.element_metadata_comp_size == 0) { @@ -3672,8 +3677,8 @@ void ViewportCore::loadElementMetadataWeb(std::uint32_t model_id, const std::uint64_t raw_size = m.element_metadata_raw_size; webReadRangesAsync(m.web_source_id, 0, {{m.element_metadata_comp_offset, m.element_metadata_comp_size}}, - [this, model_id, raw_size, done](bool ok, std::vector&& cz) { - auto mit = models_gpu_.find(model_id); + [this, session_model_id, raw_size, done](bool ok, std::vector&& cz) { + auto mit = models_gpu_.find(session_model_id); if (mit == models_gpu_.end()) { if (done) done(false); return; } std::vector buf(static_cast(raw_size)); SidecarData tmp; @@ -3700,13 +3705,13 @@ void ViewportCore::loadElementMetadataWeb(std::uint32_t model_id, void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) { InstanceCompose::InstanceLookup lk; if (!findInstance(object_id, lk)) return; // empty pick / unknown id - const std::uint32_t model_id = lk.model_id; - loadElementMetadataWeb(model_id, [this, object_id, model_id](bool ok) { + const std::uint32_t session_model_id = lk.session_model_id; + loadElementMetadataWeb(session_model_id, [this, object_id, session_model_id](bool ok) { if (!ok) { Log::warn() << "pick: element metadata fetch failed for object " << object_id; return; } - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; const ModelGpuData& m = it->second; for (const auto& e : m.elements) { @@ -3727,7 +3732,7 @@ void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) { void ViewportCore::streamingProgress(int& resident_chunks, int& total_chunks) const { resident_chunks = 0; total_chunks = 0; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { for (const auto& c : m.chunks) { ++total_chunks; if (c.is_resident) ++resident_chunks; @@ -3744,11 +3749,11 @@ void ViewportCore::streamingModelProgress(int idx, int& resident_chunks, resident_chunks = 0; total_chunks = 0; if (idx < 0 || idx >= int(models_gpu_.size())) return; - // Order by model_id (= load order) so a model keeps the same UI slot as it + // Order by session_model_id (= load order) so a model keeps the same UI slot as it // streams, instead of hopping with unordered_map iteration order. std::vector ids; ids.reserve(models_gpu_.size()); - for (const auto& [mid, m] : models_gpu_) ids.push_back(mid); + for (const auto& [session_model_id, m] : models_gpu_) ids.push_back(session_model_id); std::sort(ids.begin(), ids.end()); auto it = models_gpu_.find(ids[std::size_t(idx)]); if (it == models_gpu_.end()) return; @@ -3767,7 +3772,7 @@ void ViewportCore::streamingByteProgress(std::uint64_t& total_bytes, // So loaded/needed = how done this view is; needed/total = how much of the // whole model this view even requires. total_bytes = needed_bytes = loaded_bytes = 0; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& c : m.chunks) { // Report COMPRESSED bytes — what actually crosses the network. Fall @@ -3784,11 +3789,11 @@ void ViewportCore::streamingByteProgress(std::uint64_t& total_bytes, } } -void ViewportCore::finalizeModel(std::uint32_t model_id) { - auto it = pending_direct_loads_.find(model_id); +void ViewportCore::finalizeModel(std::uint32_t session_model_id) { + auto it = pending_direct_loads_.find(session_model_id); if (it == pending_direct_loads_.end()) { Log::warn() - << "[wgpu direct] finalizeModel(" << model_id + << "[wgpu direct] finalizeModel(" << session_model_id << ") with no staged data; skipping"; return; } @@ -3801,7 +3806,7 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { return; } if (sidecar_data.meshes.empty() || sidecar_data.instances.empty()) { - Log::info() << "[wgpu direct] finalizeModel(" << model_id + Log::info() << "[wgpu direct] finalizeModel(" << session_model_id << "): empty staging (meshes=" << sidecar_data.meshes.size() << " instances=" << sidecar_data.instances.size() << ")"; return; @@ -3822,12 +3827,12 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { std::vector raw_vertices = std::move(metadata.meta.vertices); std::vector raw_indices = std::move(metadata.meta.indices); - applyCachedModel(model_id, std::move(metadata)); + applyCachedModel(session_model_id, std::move(metadata)); - auto model_it = models_gpu_.find(model_id); + auto model_it = models_gpu_.find(session_model_id); if (model_it == models_gpu_.end()) { Log::warn() - << "[wgpu direct] finalizeModel(" << model_id + << "[wgpu direct] finalizeModel(" << session_model_id << "): applyCachedModel produced no model entry"; return; } @@ -3863,7 +3868,7 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { if (!applyStreamedChunk(model_gpu_data, chunk_index, vbytes, indices)) { Log::warn() - << "[wgpu direct] finalizeModel(" << model_id + << "[wgpu direct] finalizeModel(" << session_model_id << "): applyStreamedChunk failed on chunk " << chunk_index << " (pool OOM?)"; continue; @@ -3872,7 +3877,7 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { } Log::info() - << "[wgpu direct] finalizeModel mid=" << model_id + << "[wgpu direct] finalizeModel session_model_id=" << session_model_id << " meshes=" << model_gpu_data.meshes.size() << " instances=" << model_gpu_data.instances.size() << " chunks=" << chunks_uploaded << "/" << model_gpu_data.chunks.size() @@ -4923,7 +4928,7 @@ void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels, WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); wgpuRenderPassEncoderSetPipeline(pass, pick_pipeline_); wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& c : m.chunks) { if (!c.bind_group || c.total_visible_vertices == 0) continue; @@ -5122,7 +5127,7 @@ void ViewportCore::isolateSelected() { // objects stay model-hidden (element-level hiding on top is redundant), and // object_id 0 (unpickable) is skipped. const auto& sel_ids = selection_.selectionIds(); - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const InstanceInfo& inst : m.instances) { if (inst.object_id == 0) continue; @@ -5273,7 +5278,7 @@ bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h, WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); wgpuRenderPassEncoderSetPipeline(pass, pick_pipeline_); wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& c : m.chunks) { if (!c.bind_group || c.total_visible_vertices == 0) continue; @@ -5428,7 +5433,7 @@ bool ViewportCore::raycastSurfaceForObject(std::uint32_t object_id, int x_pixels Eigen::Vector3f best_normal; float best_radius = 0.0f; bool found = false; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& inst : m.instances) { if (inst.object_id != object_id) continue; @@ -5604,9 +5609,9 @@ bool ViewportCore::pickMeshLocalAt(int x, int y, MeshLocalPick& out) { Eigen::Vector3f world_pos, world_normal; if (!pickSurfaceAt(x, y, obj_id, world_pos, world_normal)) return false; - // Use the OUTER mid (the live map key) rather than inst.model_id — - // InstanceInfo::model_id is stale across sessions. - for (const auto& [mid, m] : models_gpu_) { + // Use the OUTER session_model_id (the live map key) rather than inst.session_model_id — + // InstanceInfo::session_model_id is stale across sessions. + for (const auto& [session_model_id, m] : models_gpu_) { auto it = m.object_id_to_instance.find(obj_id); if (it == m.object_id_to_instance.end()) continue; const InstanceInfo& inst = m.instances[it->second]; @@ -5714,7 +5719,7 @@ bool ViewportCore::pickMeshLocalAt(int x, int y, MeshLocalPick& out) { refined_world_pos.z(), 1.0f); out.object_id = obj_id; - out.model_id = mid; + out.session_model_id = session_model_id; out.mesh_id = inst.mesh_id; out.mesh_local[0] = mp.x(); out.mesh_local[1] = mp.y(); @@ -5744,7 +5749,7 @@ bool ViewportCore::raycast(const float origin[3], const float dir[3], std::uint32_t best_oid = 0; float best_normal[3] = {0, 0, 0}; - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(m.instances.size()); ++inst_idx) { const InstanceInfo& inst = m.instances[inst_idx]; @@ -6275,10 +6280,10 @@ void ViewportCore::render() { #endif std::vector>> futures; futures.reserve(models_gpu_.size()); - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; auto& m_ref = m; - futures.emplace_back(mid, std::async(std::launch::async, + futures.emplace_back(session_model_id, std::async(std::launch::async, [this, &m_ref, &planes, &eye_a, &fwd_a, &right_a, &up_a, focal_px, effective_min_px, &hiz_occluded]() { return cullModelCpuCompute( @@ -6288,11 +6293,11 @@ void ViewportCore::render() { hiz_occluded); })); } - for (auto& [mid, fut] : futures) { + for (auto& [session_model_id, fut] : futures) { hiz_reject_count_ += fut.get(); } } else { - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; hiz_reject_count_ += cullModelCpuCompute( m, planes, eye_a, fwd_a, right_a, up_a, focal_px, @@ -6304,7 +6309,7 @@ void ViewportCore::render() { const double cull_compute_ms = double(cull_timer.nsecsElapsed()) / 1e6; Stopwatch upload_timer; upload_timer.start(); - for (auto& [mid, m] : models_gpu_) { + for (auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; cullModelCpuUpload(m); for (const auto& c : m.chunks) { @@ -6377,7 +6382,7 @@ void ViewportCore::render() { wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_); wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& c : m.chunks) { if (!c.bind_group || c.opaque_visible_vertices == 0) continue; @@ -6388,7 +6393,7 @@ void ViewportCore::render() { } wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_transparent_); - for (const auto& [mid, m] : models_gpu_) { + for (const auto& [session_model_id, m] : models_gpu_) { if (m.hidden) continue; for (const auto& c : m.chunks) { if (!c.bind_group) continue; @@ -6477,7 +6482,7 @@ void ViewportCore::render() { : 0.0; std::uint32_t total_obj = 0, total_tri = 0, total_meshes = 0; - for (const auto& [mid, mm] : models_gpu_) { + for (const auto& [session_model_id, mm] : models_gpu_) { total_obj += std::uint32_t(mm.instances.size()); total_tri += mm.index_count / 3; total_meshes += std::uint32_t(mm.meshes.size()); @@ -6492,7 +6497,7 @@ void ViewportCore::render() { stats.visible_triangles = last_visible_triangles_; stats.unique_meshes = total_meshes; std::uint32_t draw_calls = 0; - for (const auto& [mid, mm] : models_gpu_) { + for (const auto& [session_model_id, mm] : models_gpu_) { if (mm.hidden) continue; for (const auto& c : mm.chunks) { if (c.is_resident && c.total_visible_draws > 0) ++draw_calls; @@ -6534,7 +6539,7 @@ void ViewportCore::render() { std::uint32_t total_instances = 0; std::size_t chunks_total = 0, chunks_resident = 0; std::size_t chunks_frustum_vis = 0, chunks_missing = 0; - for (const auto& [mid, mo] : models_gpu_) { + for (const auto& [session_model_id, mo] : models_gpu_) { total_vbo += mo.vram_bytes_vbo; total_ebo += mo.vram_bytes_ebo; total_ssbo += mo.vram_bytes_ssbo; @@ -6610,7 +6615,7 @@ void ViewportCore::render() { if ((bench_count_ % 50) == 0) { std::uint64_t total_vbo = 0, total_ebo = 0, total_ssbo = 0; std::uint32_t total_instances = 0; - for (const auto& [mid, mo] : models_gpu_) { + for (const auto& [session_model_id, mo] : models_gpu_) { total_vbo += mo.vram_bytes_vbo; total_ebo += mo.vram_bytes_ebo; total_ssbo += mo.vram_bytes_ssbo; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 8f23a04f9e..29b21a3593 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -123,9 +123,15 @@ public: // A point that actually lies on the model's first instance — used // by the federation false-origin guess on first geometry. Pure // read of models_gpu_; no GPU touch. - bool firstGeometryPointWorldM(uint32_t model_id, + bool firstGeometryPointWorldM(uint32_t session_model_id, Eigen::Vector3d& out) const; + // The global-id base applyCachedModel added to this model's instance + // object_ids. Callers that hold the element table separately (the desktop + // sidecar path) rebase their element records by the same base so registry + // ids match the ids pick/selection return. 0 if the model is unknown. + uint32_t modelObjectIdBase(uint32_t session_model_id) const; + // ---- Scene mutators ----------------------------------------------------- // // All of these flip scene state (or post a recompose) and ask the @@ -133,26 +139,26 @@ public: // is responsible for coalescing those requests (Qt's requestUpdate // does it natively; the web host wraps requestAnimationFrame). - void removeModel(uint32_t model_id); + void removeModel(uint32_t session_model_id); void resetScene(); - void hideModel(uint32_t model_id); - void showModel(uint32_t model_id); + void hideModel(uint32_t session_model_id); + void showModel(uint32_t session_model_id); // Federation matrix setters. Each writes to model state and posts // a recompose so per-instance world matrices stay consistent with // the configured georef + transformation pipeline. void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters); - void setModelCoordinateOperation(uint32_t model_id, + void setModelCoordinateOperation(uint32_t session_model_id, const Eigen::Matrix4d& matrix_meters); - void setModelTransformation(uint32_t model_id, + void setModelTransformation(uint32_t session_model_id, const Eigen::Matrix4d& matrix_meters); - // Walk every instance of `model_id`, recompose its transform from + // Walk every instance of `session_model_id`, recompose its transform from // the current federation matrices, refresh per-chunk world AABBs, // and re-upload InstanceGpu[] into m.instance_storage. No-op if // the model is unknown, has no instances, or wgpu init hasn't // completed. - void recomposeAndUploadModel(uint32_t model_id); + void recomposeAndUploadModel(uint32_t session_model_id); // ---- Camera math -------------------------------------------------------- // @@ -384,7 +390,7 @@ public: // sidecar offsets. Pure function of model + chunk metadata; safe to // call from the main thread. static StreamingThread::Request makeChunkRequest( - const ModelGpuData& m, std::size_t chunk_idx, std::uint32_t model_id); + const ModelGpuData& m, std::size_t chunk_idx, std::uint32_t session_model_id); // Per-frame streaming driver. Called from render() after cull. Walks // every model's chunks once for residency bookkeeping, drains the @@ -397,20 +403,20 @@ public: // ---- Sidecar / direct load (#84-q) ----------------------------------- // // Apply a parsed sidecar's metadata + planned chunk layout to - // models_gpu_[model_id]. Builds the per-chunk small buffers + // models_gpu_[session_model_id]. Builds the per-chunk small buffers // (visible_draws / prefix_sums / per_chunk_uniform), the per-model // mesh + instance storage SSBOs, and the spatial chunk plan; chunk // vertex/index slices stay non-resident until the streaming loader // brings them in. Triggers an auto-viewAll on the first model (so a // freshly-loaded scene frames itself). - void applyCachedModel(std::uint32_t model_id, StreamingSidecar metadata); + void applyCachedModel(std::uint32_t session_model_id, StreamingSidecar metadata); - // Qt-free sidecar load: readSidecarMetadataOnly + applyCachedModel. + // Qt-free sidecar load: readSidecarMetadata + applyCachedModel. // Used by the web build (and any other non-Qt embedder) so the // public ViewportWindow::loadSidecar's QString + QFile triage // tilde-expansion doesn't have to be replicated. Returns 0 on // any failure (device not ready, file missing, magic / version - // mismatch) and the freshly-assigned model_id on success. + // mismatch) and the freshly-assigned session_model_id on success. std::uint32_t loadSidecarFromPath(const std::string& path); #if defined(__EMSCRIPTEN__) @@ -431,7 +437,7 @@ public: // / search) needs, fetched only when asked so first paint never waits on // it. Populates ModelGpuData.elements/string_table; fires done(ok). At most // one fetch per model. - void loadElementMetadataWeb(std::uint32_t model_id, + void loadElementMetadataWeb(std::uint32_t session_model_id, std::function done = {}); // Demo consumer of the element metadata fetch: on pick, ensure the owning model's @@ -444,7 +450,7 @@ public: // the active web source). applyStreamedChunk runs in the JS completion // callback; c.is_loading is held until then. No-op if the model/chunk // vanished mid-flight (e.g. a resetScene landed between issue and done). - void beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx); + void beginWebChunkLoad(std::uint32_t session_model_id, std::size_t chunk_idx); #endif // Streaming progress for a loading UI: resident vs total streaming chunks @@ -454,7 +460,7 @@ public: // Per-model progress for a federation loading UI. count() is how many // models have metadata (are in the scene); progress(idx,…) gives the - // idx-th model's resident/total chunks, ordered by model_id (= load order) + // idx-th model's resident/total chunks, ordered by session_model_id (= load order) // so each model keeps a stable UI slot as it streams. int streamingModelCount() const; void streamingModelProgress(int idx, int& resident_chunks, @@ -474,7 +480,7 @@ public: // ViewportCore so both halves can share it. void uploadStreamedMesh(const StreamedMesh& mesh); void uploadStreamedInstance(const StreamedInstance& instance_record); - void finalizeModel(std::uint32_t model_id); + void finalizeModel(std::uint32_t session_model_id); // ---- Cross-chunk + screenshot capture (#84-v) ------------------------- // @@ -774,7 +780,7 @@ public: // round-trip from mesh-local back to world without re-deriving it. struct MeshLocalPick { std::uint32_t object_id = 0; - std::uint32_t model_id = 0; + std::uint32_t session_model_id = 0; std::uint32_t mesh_id = 0; float mesh_local [3] = {0, 0, 0}; float world_pos [3] = {0, 0, 0}; @@ -1082,9 +1088,9 @@ private: // on subsequent frames. StreamingThread streaming_thread_; - // Per-model GPU + CPU state, keyed by viewport-assigned model_id. + // Per-model GPU + CPU state, keyed by viewport-assigned session_model_id. std::unordered_map models_gpu_; - uint32_t next_model_id_ = 1; + uint32_t next_session_model_id_ = 1; // Globally-unique object_id allocator. Each applyCachedModel rebases // the sidecar's local object_ids by base_object_id_so_far so picks // are unambiguous across models. @@ -1163,7 +1169,7 @@ private: std::string pending_screenshot_path_; // Bonsai direct-load staging map. uploadStreamedMesh + - // uploadStreamedInstance append into entries keyed by model_id; the + // uploadStreamedInstance append into entries keyed by session_model_id; the // finalizeModel call moves the entry out, hands it to // applyCachedModel, and uploads the chunk slices synchronously. std::unordered_map> diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 2264aa0dcc..555d097d48 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -215,7 +215,7 @@ ViewportWindow::ViewportWindow(QWindow* parent) streaming_thread_(core_.streaming_thread_), streaming_frame_idx_(core_.streaming_frame_idx_), models_gpu_ (core_.models_gpu_), - next_model_id_ (core_.next_model_id_), + next_session_model_id_ (core_.next_session_model_id_), next_object_id_ (core_.next_object_id_), federated_false_origin_meters_(core_.federated_false_origin_meters_), wgpu_initialized_(core_.wgpu_initialized_), @@ -511,7 +511,7 @@ uint32_t ViewportWindow::loadSidecar(const std::string& path_std) { // Metadata-only read: mesh dict + instance dict + georef. Per-chunk // vertex/index bytes are deferred to the per-frame loader as chunks // become frustum-visible. - auto meta_opt = readSidecarMetadataOnly(resolved.toStdString()); + auto meta_opt = readSidecarMetadata(resolved.toStdString()); if (!meta_opt) { // Triage: distinguish missing file from magic/version mismatch by // peeking the header ourselves, so users know which to fix. @@ -549,13 +549,13 @@ uint32_t ViewportWindow::loadSidecar(const std::string& path_std) { return 0; } - const uint32_t mid = next_model_id_++; - applyCachedModel(mid, std::move(*meta_opt)); - return mid; + const uint32_t session_model_id = next_session_model_id_++; + applyCachedModel(session_model_id, std::move(*meta_opt)); + return session_model_id; } -void ViewportWindow::applyCachedModel(uint32_t model_id, StreamingSidecar metadata) { - core_.applyCachedModel(model_id, std::move(metadata)); +void ViewportWindow::applyCachedModel(uint32_t session_model_id, StreamingSidecar metadata) { + core_.applyCachedModel(session_model_id, std::move(metadata)); } // ----------------------------------------------------------------------------- @@ -573,7 +573,7 @@ void ViewportWindow::uploadStreamedInstance(const StreamedInstance& instance_rec core_.uploadStreamedInstance(instance_record); } -void ViewportWindow::finalizeModel(uint32_t model_id) { core_.finalizeModel(model_id); } +void ViewportWindow::finalizeModel(uint32_t session_model_id) { core_.finalizeModel(session_model_id); } // removeModel / resetScene / hideModel / showModel / // setFederatedFalseOrigin / setModelCoordinateOperation / @@ -581,41 +581,45 @@ void ViewportWindow::finalizeModel(uint32_t model_id) { core_.finalizeModel(mode // ViewportCore (#84-f). The public-API entry points below forward // so existing bonsai-side callers don't have to change. -void ViewportWindow::removeModel(uint32_t model_id) { core_.removeModel(model_id); } +void ViewportWindow::removeModel(uint32_t session_model_id) { core_.removeModel(session_model_id); } void ViewportWindow::resetScene() { core_.resetScene(); } -void ViewportWindow::hideModel(uint32_t model_id) { core_.hideModel(model_id); } -void ViewportWindow::showModel(uint32_t model_id) { core_.showModel(model_id); } +void ViewportWindow::hideModel(uint32_t session_model_id) { core_.hideModel(session_model_id); } +void ViewportWindow::showModel(uint32_t session_model_id) { core_.showModel(session_model_id); } void ViewportWindow::setFederatedFalseOrigin(const Eigen::Matrix4d& m) { core_.setFederatedFalseOrigin(m); } -void ViewportWindow::setModelCoordinateOperation(uint32_t mid, +void ViewportWindow::setModelCoordinateOperation(uint32_t session_model_id, const Eigen::Matrix4d& m) { - core_.setModelCoordinateOperation(mid, m); + core_.setModelCoordinateOperation(session_model_id, m); } -void ViewportWindow::setModelTransformation(uint32_t mid, +void ViewportWindow::setModelTransformation(uint32_t session_model_id, const Eigen::Matrix4d& m) { - core_.setModelTransformation(mid, m); + core_.setModelTransformation(session_model_id, m); } -void ViewportWindow::recomposeAndUploadModel(uint32_t mid) { - core_.recomposeAndUploadModel(mid); +void ViewportWindow::recomposeAndUploadModel(uint32_t session_model_id) { + core_.recomposeAndUploadModel(session_model_id); } bool ViewportWindow::findInstance(uint32_t object_id, InstanceLookup& out) const { return core_.findInstance(object_id, out); } -bool ViewportWindow::firstGeometryPointWorldM(uint32_t model_id, +bool ViewportWindow::firstGeometryPointWorldM(uint32_t session_model_id, Eigen::Vector3d& out) const { - return core_.firstGeometryPointWorldM(model_id, out); + return core_.firstGeometryPointWorldM(session_model_id, out); } -void ViewportWindow::frameOnFederatedOrigin(uint32_t model_id, +uint32_t ViewportWindow::modelObjectIdBase(uint32_t session_model_id) const { + return core_.modelObjectIdBase(session_model_id); +} + +void ViewportWindow::frameOnFederatedOrigin(uint32_t session_model_id, float max_distance_m) { - auto it = models_gpu_.find(model_id); + auto it = models_gpu_.find(session_model_id); if (it == models_gpu_.end()) return; - const ModelGpuData& m = it->second; - if (m.instances.empty()) return; + const ModelGpuData& model = it->second; + if (model.instances.empty()) return; float mn[3] = { std::numeric_limits::infinity(), std::numeric_limits::infinity(), @@ -623,7 +627,7 @@ void ViewportWindow::frameOnFederatedOrigin(uint32_t model_id, float mx[3] = { -std::numeric_limits::infinity(), -std::numeric_limits::infinity(), -std::numeric_limits::infinity() }; - for (const auto& inst : m.instances) { + for (const auto& inst : model.instances) { for (int a = 0; a < 3; ++a) { mn[a] = std::min(mn[a], inst.world_aabb_min[a]); mx[a] = std::max(mx[a], inst.world_aabb_max[a]); @@ -659,7 +663,7 @@ void ViewportWindow::frameOnFederatedOrigin(uint32_t model_id, } Log::info().noquote().nospace() - << "[wgpu] frameOnFederatedOrigin model=" << model_id + << "[wgpu] frameOnFederatedOrigin model=" << session_model_id << " distance=" << camera_distance_ << " (cap=" << max_distance_m << "m, model radius=" << radius << ")"; @@ -1023,13 +1027,13 @@ void ViewportWindow::setHighlightTriangles(const std::vector& world_xyz, if (isExposed()) requestUpdate(); } -bool ViewportWindow::readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id, +bool ViewportWindow::readbackMeshTriangles(uint32_t session_model_id, uint32_t mesh_id, MeshTriangles& out) const { - auto mit = models_gpu_.find(model_id); + auto mit = models_gpu_.find(session_model_id); if (mit == models_gpu_.end()) return false; - const ModelGpuData& m = mit->second; - if (mesh_id >= m.mesh_triangles_cache.size()) return false; - const auto& src = m.mesh_triangles_cache[mesh_id]; + const ModelGpuData& model = mit->second; + if (mesh_id >= model.mesh_triangles_cache.size()) return false; + const auto& src = model.mesh_triangles_cache[mesh_id]; if (src.indices.empty() || src.positions.empty()) return false; // Copy out — callers iterate freely without worrying about lifetime // (streaming may evict a chunk and rebuild the shadow on next load). @@ -1049,12 +1053,12 @@ bool ViewportWindow::meshLocalToGlobal(uint32_t object_id, const float mesh_local[3], double global_out[3]) const { // Find the instance via the per-model object_id_to_instance map. - // Use the live map key (`mid`) — see pickMeshLocalAt comment about - // stale InstanceInfo::model_id from sidecar writes. - for (const auto& [mid, m] : models_gpu_) { - auto it = m.object_id_to_instance.find(object_id); - if (it == m.object_id_to_instance.end()) continue; - const InstanceInfo& inst = m.instances[it->second]; + // Use the live map key (`session_model_id`) — see pickMeshLocalAt comment about + // stale InstanceInfo::session_model_id from sidecar writes. + for (const auto& [session_model_id, model] : models_gpu_) { + auto it = model.object_id_to_instance.find(object_id); + if (it == model.object_id_to_instance.end()) continue; + const InstanceInfo& inst = model.instances[it->second]; // CoordinateOperation · placement · local — gives the IFC's own // georeferenced world frame (ENH). Excludes FederatedFalseOrigin // and ModelTransformation, matching the GL meshLocalToGlobal @@ -1072,7 +1076,7 @@ bool ViewportWindow::meshLocalToGlobal(uint32_t object_id, static_cast(mesh_local[2]), 1.0); const Eigen::Vector3d global = - (m.coordinate_operation_meters * P * local).head<3>(); + (model.coordinate_operation_meters * P * local).head<3>(); global_out[0] = global.x(); global_out[1] = global.y(); global_out[2] = global.z(); @@ -1148,9 +1152,9 @@ void ViewportWindow::invertElementVisibility() { // don't mutate the set we're iterating over. std::vector to_hide; to_hide.reserve(1024); - for (const auto& [mid, m] : models_gpu_) { - if (m.hidden) continue; - for (const InstanceInfo& inst : m.instances) { + for (const auto& [session_model_id, model] : models_gpu_) { + if (model.hidden) continue; + for (const InstanceInfo& inst : model.instances) { if (inst.object_id == 0) continue; if (!visibility_.isHidden(inst.object_id)) { to_hide.push_back(inst.object_id); @@ -1251,10 +1255,10 @@ void ViewportWindow::updateVolumeReadout() { // first matching instance. For label placement at the AABB // centre this is identical-looking; only the rare multi- // representation object_id sees a slightly smaller union. - for (const auto& [mid, m] : models_gpu_) { - auto it = m.object_id_to_instance.find(oid); - if (it == m.object_id_to_instance.end()) continue; - const InstanceInfo& inst = m.instances[it->second]; + for (const auto& [session_model_id, model] : models_gpu_) { + auto it = model.object_id_to_instance.find(oid); + if (it == model.object_id_to_instance.end()) continue; + const InstanceInfo& inst = model.instances[it->second]; OverlayRenderer::Label lbl; lbl.world_pos[0] = (inst.world_aabb_min[0] + inst.world_aabb_max[0]) * 0.5f; lbl.world_pos[1] = (inst.world_aabb_min[1] + inst.world_aabb_max[1]) * 0.5f; @@ -1335,7 +1339,7 @@ void ViewportWindow::ensureSelectionFlagsBuffer() { core_.ensureSelectionFlagsBu // uploadSelectionFlagsIfDirty moved to ViewportCore (#84-k). void ViewportWindow::uploadSelectionFlagsIfDirty() { core_.uploadSelectionFlagsIfDirty(); } -void ViewportWindow::buildModelBindGroup(ModelGpuData& m) { core_.buildModelBindGroup(m); } +void ViewportWindow::buildModelBindGroup(ModelGpuData& model) { core_.buildModelBindGroup(model); } // buildChunkBindGroup moved to ViewportCore (#84-n). @@ -1738,15 +1742,15 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) { std::set> seen; Log::info().noquote().nospace() << "[track] object " << id << " — enumerating chunks:"; - for (auto& [mid, m] : models_gpu_) { - for (const auto& inst : m.instances) { + for (auto& [session_model_id, model] : models_gpu_) { + for (const auto& inst : model.instances) { if (inst.object_id != id) continue; - if (inst.mesh_id >= m.mesh_chunk_idx.size()) continue; - const size_t ci = m.mesh_chunk_idx[inst.mesh_id]; - if (!seen.insert({mid, ci}).second) continue; - const auto& c = m.chunks[ci]; + if (inst.mesh_id >= model.mesh_chunk_idx.size()) continue; + const size_t ci = model.mesh_chunk_idx[inst.mesh_id]; + if (!seen.insert({session_model_id, ci}).second) continue; + const auto& chunk = model.chunks[ci]; Log::info().noquote().nospace() - << " model " << mid << " chunk " << ci + << " model " << session_model_id << " chunk " << ci << " inst_aabb " << QString::number(inst.world_aabb_max[0] - inst.world_aabb_min[0], 'f', 1) << "×" @@ -1754,17 +1758,17 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) { << "×" << QString::number(inst.world_aabb_max[2] - inst.world_aabb_min[2], 'f', 1) << "m" << " chunk_aabb " - << QString::number(c.aabb_max[0] - c.aabb_min[0], 'f', 1) << "×" - << QString::number(c.aabb_max[1] - c.aabb_min[1], 'f', 1) << "×" - << QString::number(c.aabb_max[2] - c.aabb_min[2], 'f', 1) << "m" - << " resident=" << (c.is_resident ? "Y" : "N"); + << QString::number(chunk.aabb_max[0] - chunk.aabb_min[0], 'f', 1) << "×" + << QString::number(chunk.aabb_max[1] - chunk.aabb_min[1], 'f', 1) << "×" + << QString::number(chunk.aabb_max[2] - chunk.aabb_min[2], 'f', 1) << "m" + << " resident=" << (chunk.is_resident ? "Y" : "N"); // First hit becomes the "primary" slot the // eviction watcher uses. Good enough until we wire // a multi-chunk watcher. if (tracked_chunk_idx_ == SIZE_MAX) { - tracked_chunk_mid_ = mid; + tracked_chunk_mid_ = session_model_id; tracked_chunk_idx_ = ci; - tracked_was_resident_ = c.is_resident; + tracked_was_resident_ = chunk.is_resident; } } } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 18b918a04a..82a77cc892 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -108,7 +108,7 @@ public: // Synchronous metadata load + GPU upload. Requires wgpu init to have // completed (i.e. the window has been exposed at least once). Returns - // the assigned model_id, or 0 on failure. Reads metadata only (mesh + // the assigned session_model_id, or 0 on failure. Reads metadata only (mesh // dict + instance dict + georef); per-chunk vertex / index bytes are // read on demand by the per-frame loader as chunks become visible. uint32_t loadSidecar(const std::string& path); @@ -118,7 +118,7 @@ public: // unclaimed and is_resident=false. The per-frame loader // (driveStreamingLoads) sub-allocates the chunk's vertex + index // ranges from pool_ on demand as cull flags them visible. - void applyCachedModel(uint32_t model_id, + void applyCachedModel(uint32_t session_model_id, struct StreamingSidecar metadata); // Direct-IFC ingestion (mirrors GL ViewportWindow). The host (typically @@ -129,20 +129,20 @@ public: // staged data, allocates pool slices, and uploads — same render path // as a sidecar load. Bytes are gathered from memory (no disk I/O), so // every chunk lands `is_resident=true` immediately. The streamer's - // model_id is passed through unchanged; the viewport's globally-unique + // session_model_id is passed through unchanged; the viewport's globally-unique // object_id rebasing happens at finalize time. void uploadStreamedMesh(const struct StreamedMesh& mesh); void uploadStreamedInstance(const struct StreamedInstance& instance_record); - void finalizeModel(uint32_t model_id); + void finalizeModel(uint32_t session_model_id); - void removeModel(uint32_t model_id); + void removeModel(uint32_t session_model_id); void resetScene(); // Model-level visibility. Mirrors the GL ViewportWindow API — flips // ModelGpuData::hidden, which every render/pick/cull pass already // consults. requestUpdate() so the change is visible immediately. - void hideModel(uint32_t model_id); - void showModel(uint32_t model_id); + void hideModel(uint32_t session_model_id); + void showModel(uint32_t session_model_id); // Federation pipeline: composed instance transform = // FederatedFalseOrigin · ModelTransformation · CoordinateOperation @@ -153,9 +153,9 @@ public: // integration compiles against these signatures; visual georef parity // arrives with the recompose+SSBO-rewrite work tracked separately. void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters); - void setModelCoordinateOperation(uint32_t model_id, + void setModelCoordinateOperation(uint32_t session_model_id, const Eigen::Matrix4d& matrix_meters); - void setModelTransformation(uint32_t model_id, + void setModelTransformation(uint32_t session_model_id, const Eigen::Matrix4d& matrix_meters); size_t modelCount() const { return models_gpu_.size(); } @@ -378,11 +378,11 @@ public: // CPU mesh shadow: positions (3 floats/vert, mesh-local) + indices // (LOD0). Populated at applyCachedModel / applyStreamedChunk — // returns false if the mesh isn't loaded yet (streaming) or the - // (model_id, mesh_id) pair doesn't resolve. Matches the GL + // (session_model_id, mesh_id) pair doesn't resolve. Matches the GL // ViewportWindow::MeshTriangles + readbackMeshTriangles shape so // the measure tools port verbatim. using MeshTriangles = ModelGpuData::MeshTriangles; - bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id, + bool readbackMeshTriangles(uint32_t session_model_id, uint32_t mesh_id, MeshTriangles& out) const; // Pure CPU lookup: object_id → owning model + mesh + raw placement @@ -401,7 +401,8 @@ public: // (ViewportView::guessFederatedFalseOriginFromFirstModel) consumes // this lazily on modelGeometryReady. Returns false when the model // is unknown or has no instances. - bool firstGeometryPointWorldM(uint32_t model_id, + uint32_t modelObjectIdBase(uint32_t session_model_id) const; + bool firstGeometryPointWorldM(uint32_t session_model_id, Eigen::Vector3d& out) const; // Re-frame the camera onto the federated false origin in post-shift @@ -421,7 +422,7 @@ public: // Unlike viewAll() this *never* iterates all loaded models — it // frames around the specific model the guess fired for, ignoring // models with bad coordinates elsewhere in the session. - void frameOnFederatedOrigin(uint32_t model_id, float max_distance_m); + void frameOnFederatedOrigin(uint32_t session_model_id, float max_distance_m); // Selection accessor. Exposed for callers (bonsai's volume readout) // that need to read selectionIds() / activeObjectId(). Mutation goes @@ -578,11 +579,11 @@ private: // stayed during the move and forwards to core_ — once every internal // caller routes through ViewportCore directly the forwarder goes away. - // Walk every instance of `model_id`, recompose its transform from the + // Walk every instance of `session_model_id`, recompose its transform from the // current federation matrices, refresh per-chunk world AABBs, and // re-upload InstanceGpu[] into m.instance_storage. No-op if the model // is unknown, has no instances, or wgpu init hasn't completed. - void recomposeAndUploadModel(uint32_t model_id); + void recomposeAndUploadModel(uint32_t session_model_id); bool& wgpu_initialized_; int& configured_w_; @@ -900,7 +901,7 @@ private: // Per-model state aliases (storage in core_). std::unordered_map& models_gpu_; - uint32_t& next_model_id_; + uint32_t& next_session_model_id_; uint32_t& next_object_id_; // Sidecar paths queued before init completes. diff --git a/src/ifcviewer/tests/test_federation.cpp b/src/ifcviewer/tests/test_federation.cpp index 1c39aa8e6f..9916e32cb9 100644 --- a/src/ifcviewer/tests/test_federation.cpp +++ b/src/ifcviewer/tests/test_federation.cpp @@ -60,6 +60,12 @@ QString writeStubFile(const QString& path) { return QDir::cleanPath(fi.absoluteFilePath()); } +// addModel with the filename as its label — mirrors how the app +// (models/Commands.cpp) calls it now that addModel takes the label explicitly. +QString addLocalModel(Federation& fed, const QString& path) { + return fed.addModel(path, QFileInfo(path).fileName()); +} + QJsonObject readJsonFile(const QString& path) { QFile f(path); REQUIRE(f.open(QIODevice::ReadOnly)); @@ -88,7 +94,7 @@ TEST_CASE("addModel emits dirty=true; markClean clears it; remove re-dirties", " QSignalSpy spy(&fed, &Federation::dirtyChanged); QString abs = writeStubFile(tmp.filePath("a.ifc")); - QString id = fed.addModel(abs); + QString id = addLocalModel(fed, abs); REQUIRE_FALSE(id.isEmpty()); REQUIRE(fed.isDirty()); REQUIRE(spy.count() == 1); @@ -108,9 +114,9 @@ TEST_CASE("addModel emits dirty=true; markClean clears it; remove re-dirties", " 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(addLocalModel(fed, "").isEmpty()); + REQUIRE(addLocalModel(fed, "nested.ifcfed").isEmpty()); + REQUIRE(addLocalModel(fed, "nested.IfcFed").isEmpty()); // case-insensitive REQUIRE(fed.models().empty()); REQUIRE_FALSE(fed.isDirty()); } @@ -152,7 +158,7 @@ TEST_CASE("setModelVisible toggles flag, dirty, and signal; idempotent", "[feder REQUIRE(tmp.isValid()); Federation fed; - QString id = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString id = addLocalModel(fed, writeStubFile(tmp.filePath("a.ifc"))); REQUIRE_FALSE(id.isEmpty()); REQUIRE(fed.findById(id)->visible); // visible by default fed.markClean(); @@ -176,7 +182,7 @@ TEST_CASE("setModelVisible toggles flag, dirty, and signal; idempotent", "[feder REQUIRE(dirty_spy.count() == 0); REQUIRE(vis_spy.count() == 0); - // Unknown fed_id is a no-op (no crash, no signal). + // Unknown model_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); @@ -199,7 +205,7 @@ TEST_CASE("save then load round-trips models, transform, visibility, home view", Federation src; QString id1 = src.addModel(src1, "Wall"); - QString id2 = src.addModel(src2); // default display_name from filename + QString id2 = addLocalModel(src, src2); // filename as label REQUIRE_FALSE(id1.isEmpty()); REQUIRE_FALSE(id2.isEmpty()); @@ -261,8 +267,8 @@ TEST_CASE("save stores paths relative when under fed_dir, absolute otherwise", " QString outside = writeStubFile(root.filePath("elsewhere/outside.ifc")); Federation fed; - fed.addModel(inside); - fed.addModel(outside); + addLocalModel(fed, inside); + addLocalModel(fed, outside); QString err; REQUIRE(fed.save(fed_path, &err)); @@ -304,7 +310,7 @@ TEST_CASE("Save-As to a different directory recomputes path relativity", "[feder QString fed_b = fed_dir_b + "/proj.ifcfed"; Federation fed; - fed.addModel(src); + addLocalModel(fed, src); QString err; REQUIRE(fed.save(fed_a, &err)); @@ -514,31 +520,31 @@ TEST_CASE("setModelGroup assigns and reassigns; rejects unknown group", ensureQApp(); QTemporaryDir tmp; Federation fed; - QString mid = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString model_id = addLocalModel(fed, 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); + fed.setModelGroup(model_id, gid); + REQUIRE(fed.findById(model_id)->group_id == gid); REQUIRE(fed.isDirty()); REQUIRE(spy.count() == 1); // Idempotent. fed.markClean(); spy.clear(); - fed.setModelGroup(mid, gid); + fed.setModelGroup(model_id, 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); + fed.setModelGroup(model_id, "no-such-group"); + REQUIRE(fed.findById(model_id)->group_id == gid); REQUIRE_FALSE(fed.isDirty()); // Reassign back to root. - fed.setModelGroup(mid, QString()); - REQUIRE(fed.findById(mid)->group_id.isEmpty()); + fed.setModelGroup(model_id, QString()); + REQUIRE(fed.findById(model_id)->group_id.isEmpty()); REQUIRE(spy.count() == 1); } @@ -547,12 +553,12 @@ TEST_CASE("setGroupVisible affects effective visibility cascade", ensureQApp(); QTemporaryDir tmp; Federation fed; - QString mid = fed.addModel(writeStubFile(tmp.filePath("a.ifc"))); + QString model_id = addLocalModel(fed, writeStubFile(tmp.filePath("a.ifc"))); QString outer = fed.addGroup("Outer"); QString inner = fed.addGroup("Inner", outer); - fed.setModelGroup(mid, inner); + fed.setModelGroup(model_id, inner); - REQUIRE(fed.isModelEffectivelyVisible(mid)); + REQUIRE(fed.isModelEffectivelyVisible(model_id)); REQUIRE(fed.isGroupChainVisible(inner)); // Hide the outer group: inner chain visibility flips, model effective @@ -560,21 +566,21 @@ TEST_CASE("setGroupVisible affects effective visibility cascade", fed.setGroupVisible(outer, false); REQUIRE_FALSE(fed.isGroupChainVisible(outer)); REQUIRE_FALSE(fed.isGroupChainVisible(inner)); - REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); - REQUIRE(fed.findById(mid)->visible); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(model_id)); + REQUIRE(fed.findById(model_id)->visible); // Hiding a model directly while its group is also hidden — still // effectively hidden. - fed.setModelVisible(mid, false); - REQUIRE_FALSE(fed.isModelEffectivelyVisible(mid)); + fed.setModelVisible(model_id, false); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(model_id)); // 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)); + REQUIRE_FALSE(fed.isModelEffectivelyVisible(model_id)); - fed.setModelVisible(mid, true); - REQUIRE(fed.isModelEffectivelyVisible(mid)); + fed.setModelVisible(model_id, true); + REQUIRE(fed.isModelEffectivelyVisible(model_id)); } TEST_CASE("setGroupParent rejects cycles and self-parenting", @@ -610,9 +616,9 @@ TEST_CASE("removeGroup reparents direct children + models up one level", 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"))); + QString m_outer = addLocalModel(fed, writeStubFile(tmp.filePath("a.ifc"))); + QString m_mid = addLocalModel(fed, writeStubFile(tmp.filePath("b.ifc"))); + QString m_inner = addLocalModel(fed, writeStubFile(tmp.filePath("c.ifc"))); fed.setModelGroup(m_outer, outer); fed.setModelGroup(m_mid, mid_outer); fed.setModelGroup(m_inner, inner); @@ -653,8 +659,8 @@ TEST_CASE("groups + model.group_id round-trip through nested JSON save/load", 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"))); + m_root = addLocalModel(src, writeStubFile(tmp.filePath("root.ifc"))); + m_bldg = addLocalModel(src, writeStubFile(tmp.filePath("bldg.ifc"))); src.setModelGroup(m_bldg, bldg_id); src.setGroupVisible(bldg_id, false); diff --git a/src/ifcviewer/tests/test_instance_compose.cpp b/src/ifcviewer/tests/test_instance_compose.cpp index f184af1dee..3cbcf7b3f4 100644 --- a/src/ifcviewer/tests/test_instance_compose.cpp +++ b/src/ifcviewer/tests/test_instance_compose.cpp @@ -323,7 +323,7 @@ TEST_CASE("findInstanceInModels fills the correct lookup for an owned id", "[ins InstanceCompose::InstanceLookup out; REQUIRE(InstanceCompose::findInstanceInModels(8u, models, out)); - REQUIRE(out.model_id == 2u); + REQUIRE(out.session_model_id == 2u); REQUIRE(out.mesh_id == 4u); REQUIRE(out.placement_transformation[12] == 22.0); REQUIRE(out.placement_transformation[0] == 1.0); diff --git a/src/ifcviewer/tests/test_instanced_geometry.cpp b/src/ifcviewer/tests/test_instanced_geometry.cpp index 9993709a6a..aea1562215 100644 --- a/src/ifcviewer/tests/test_instanced_geometry.cpp +++ b/src/ifcviewer/tests/test_instanced_geometry.cpp @@ -247,13 +247,13 @@ TEST_CASE("quantizeVertex passes the packed color through unchanged", "[instgeom TEST_CASE("StreamedMesh and StreamedInstance default-init to zeroed metadata", "[instgeom]") { StreamedMesh mc; - REQUIRE(mc.model_id == 0); + REQUIRE(mc.session_model_id == 0); REQUIRE(mc.local_mesh_id == 0); REQUIRE(mc.vertices.empty()); REQUIRE(mc.indices.empty()); StreamedInstance ic; - REQUIRE(ic.model_id == 0); + REQUIRE(ic.session_model_id == 0); REQUIRE(ic.local_mesh_id == 0); REQUIRE(ic.object_id == 0); REQUIRE(ic.color_override_rgba8 == 0); diff --git a/src/ifcviewer/tests/test_sidecar_cache.cpp b/src/ifcviewer/tests/test_sidecar_cache.cpp index feb8d380dc..41395ca948 100644 --- a/src/ifcviewer/tests/test_sidecar_cache.cpp +++ b/src/ifcviewer/tests/test_sidecar_cache.cpp @@ -87,7 +87,7 @@ SidecarData buildFixture() { 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; + inst.session_model_id = 1; for (int k = 0; k < 16; ++k) { inst.placement_transformation[k] = double(i) * 0.25 + double(k); inst.transform[k] = float(i) * 0.5f + float(k); @@ -111,7 +111,7 @@ SidecarData buildFixture() { for (size_t i = 0; i < sd.elements.size(); ++i) { ElementTableRecord& e = sd.elements[i]; e.object_id = uint32_t(100 + i); - e.model_id = 1; + e.session_model_id = 1; e.ifc_id = int32_t(1000 + i); e.guid_offset = 0; e.guid_length = 0; e.name_offset = 1; e.name_length = 4; // "Wall" diff --git a/src/ifcviewer/tests/test_sidecar_layout.cpp b/src/ifcviewer/tests/test_sidecar_layout.cpp index c6729baa5c..baad4ca8d3 100644 --- a/src/ifcviewer/tests/test_sidecar_layout.cpp +++ b/src/ifcviewer/tests/test_sidecar_layout.cpp @@ -84,7 +84,7 @@ SidecarData buildFixture() { InstanceInfo ic; ic.mesh_id = uint32_t(i); // authoritative ic.object_id = obj++; - ic.model_id = 1; + ic.session_model_id = 1; const float x = float((i * 13 + k * 5) % 11); const float y = float((i * 7 + k * 3) % 9); const float z = float((i * 5 + k * 2) % 7); diff --git a/src/ifcviewer/tests/test_streaming_loader.cpp b/src/ifcviewer/tests/test_streaming_loader.cpp index 60b344682b..4ee8dff859 100644 --- a/src/ifcviewer/tests/test_streaming_loader.cpp +++ b/src/ifcviewer/tests/test_streaming_loader.cpp @@ -70,7 +70,7 @@ SidecarData buildFixture() { for (size_t i = 0; i < sd.instances.size(); ++i) { sd.instances[i].mesh_id = (i < 2) ? 0u : 1u; sd.instances[i].object_id = uint32_t(100 + i); - sd.instances[i].model_id = 1; + sd.instances[i].session_model_id = 1; } sd.has_coordinate_operation = 1; @@ -82,7 +82,7 @@ SidecarData buildFixture() { sd.elements.resize(2); for (size_t i = 0; i < sd.elements.size(); ++i) { sd.elements[i].object_id = uint32_t(100 + i); - sd.elements[i].model_id = 1; + sd.elements[i].session_model_id = 1; sd.elements[i].ifc_id = int32_t(1000 + i); } // v16 stores geometry per-chunk (compressed); a fixture with geometry needs @@ -93,14 +93,14 @@ SidecarData buildFixture() { } // namespace -TEST_CASE("readSidecarMetadataOnly returns metadata, skips bulk geometry", +TEST_CASE("readSidecarMetadata returns metadata, skips bulk geometry", "[streaming]") { fs::path dir = makeScratchDir("metaonly"); fs::path ifc = dir / "model.ifc"; SidecarData sd = buildFixture(); REQUIRE(writeSidecar(ifc.string(), sd)); - auto meta = readSidecarMetadataOnly(ifc.string()); + auto meta = readSidecarMetadata(ifc.string()); REQUIRE(meta.has_value()); // Bulk geometry is skipped, not loaded. @@ -127,9 +127,9 @@ TEST_CASE("readSidecarMetadataOnly returns metadata, skips bulk geometry", REQUIRE(std::memcmp(&meta->meta.meshes[1], &sd.meshes[1], sizeof(MeshInfo)) == 0); } -TEST_CASE("readSidecarMetadataOnly rejects missing / corrupt files", "[streaming]") { +TEST_CASE("readSidecarMetadata rejects missing / corrupt files", "[streaming]") { fs::path dir = makeScratchDir("reject"); - REQUIRE_FALSE(readSidecarMetadataOnly((dir / "absent.ifc").string()).has_value()); + REQUIRE_FALSE(readSidecarMetadata((dir / "absent.ifc").string()).has_value()); // Truncated head (under 16 bytes). fs::path bad = dir / "bad.ifc"; @@ -140,7 +140,7 @@ TEST_CASE("readSidecarMetadataOnly rejects missing / corrupt files", "[streaming std::fwrite(junk, 1, sizeof(junk), f); std::fclose(f); } - REQUIRE_FALSE(readSidecarMetadataOnly(bad.string()).has_value()); + REQUIRE_FALSE(readSidecarMetadata(bad.string()).has_value()); } TEST_CASE("readChunkGeometryCompressed decompresses a chunk's blobs", "[streaming]") { @@ -148,7 +148,7 @@ TEST_CASE("readChunkGeometryCompressed decompresses a chunk's blobs", "[streamin fs::path ifc = dir / "model.ifc"; SidecarData sd = buildFixture(); REQUIRE(writeSidecar(ifc.string(), sd)); - auto meta = readSidecarMetadataOnly(ifc.string()); + auto meta = readSidecarMetadata(ifc.string()); REQUIRE(meta.has_value()); REQUIRE(meta->meta.chunks.size() == 2); @@ -202,7 +202,7 @@ TEST_CASE("v16 element metadata block: fetch via locator, decompress, parse", "[ SidecarData sd = buildFixture(); REQUIRE(writeSidecar(ifc.string(), sd)); - auto meta = readSidecarMetadataOnly(ifc.string()); + auto meta = readSidecarMetadata(ifc.string()); REQUIRE(meta.has_value()); REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); // geometry metadata REQUIRE(meta->meta.chunks.size() == sd.chunks.size()); From c5ea61211087ed0292e64cd55cec5949266e1cc4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 14:48:09 +1000 Subject: [PATCH 02/20] helpers: port util.element.get_predefined_type; show it in properties Add src/helpers/element.{h,cpp}, a schema-dispatched C++ port of ifcopenshell.util.element.get_predefined_type: prefers the associated type element's predefined type (IsTypedBy / IsDefinedBy), falls back to ElementType / ProcessType when USERDEFINED, then the occurrence's own PredefinedType / ObjectType. Attribute reads are by-name so they work across the IfcElement / IfcType* subtypes that carry these attributes. Wire it into the properties panel entity summary: live IFC entities show their real predefined type; geometry-only elements (a .ifcview loaded without its .ifc/.rdb) show "N/A". Clears the placeholder so a stale predefined type no longer leaks once a project is loaded. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/modules/properties/View.cpp | 12 ++ src/helpers/CMakeLists.txt | 1 + src/helpers/element.cpp | 146 +++++++++++++++++++ src/helpers/element.h | 38 +++++ 4 files changed, 197 insertions(+) create mode 100644 src/helpers/element.cpp create mode 100644 src/helpers/element.h diff --git a/src/bonsaiviewer/modules/properties/View.cpp b/src/bonsaiviewer/modules/properties/View.cpp index 0e23e57c5c..0b992e0f1a 100644 --- a/src/bonsaiviewer/modules/properties/View.cpp +++ b/src/bonsaiviewer/modules/properties/View.cpp @@ -25,6 +25,8 @@ #include "../../ElementRegistry.h" #include "../../SessionState.h" +#include "element.h" // helpers: get_predefined_type + namespace bonsaiviewer::modules::properties { PropertiesPanelView::PropertiesPanelView(PropertiesPanel* widget, @@ -90,9 +92,17 @@ void PropertiesPanelView::refresh(uint32_t object_id) { return; } + // A real project is loaded — don't leak the placeholder predefined type. + // It's populated below from live IFC data, or set to "N/A" for + // geometry-only elements that have no data to read it from. + state.entity.predefined_type.clear(); + auto entity = registry->findEntity(object_id); if (entity) { state.entity.entity_class = QString::fromStdString(entity->declaration().name()); + if (auto predefined_type = get_predefined_type(*entity)) { + state.entity.predefined_type = QString::fromStdString(*predefined_type); + } if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { state.property_sets[1].rows[0].value = state.entity.entity_class; } @@ -104,6 +114,8 @@ void PropertiesPanelView::refresh(uint32_t object_id) { auto info = registry->findBasicElementInfo(object_id); if (info && !info->type.isEmpty()) { state.entity.entity_class = info->type; + // Geometry only — no live IFC entity to read a predefined type from. + state.entity.predefined_type = "N/A"; if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { state.property_sets[1].rows[0].value = info->type; } diff --git a/src/helpers/CMakeLists.txt b/src/helpers/CMakeLists.txt index 7abd7411de..c21c56113f 100644 --- a/src/helpers/CMakeLists.txt +++ b/src/helpers/CMakeLists.txt @@ -32,6 +32,7 @@ message("Running CMakeLists.txt in /src/helpers") # (ifcopenshell.util.placement) # * Pset — property and quantity retrieval # (ifcopenshell.util.element) +# * Element — get_predefined_type (ifcopenshell.util.element) find_package(Eigen3 REQUIRED) diff --git a/src/helpers/element.cpp b/src/helpers/element.cpp new file mode 100644 index 0000000000..b9968a8aba --- /dev/null +++ b/src/helpers/element.cpp @@ -0,0 +1,146 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// This file was generated with the assistance of an AI coding tool. + +#include "element.h" + +#include "../ifcparse/exception.h" +#include "../ifcparse/file.h" +#include "../ifcparse/instance_data.h" +#include "../ifcparse/schema.h" +#include "schema_dispatch.i" + +#include +#include + +namespace { + +template +struct is_ifc4_or_higher : std::false_type {}; + +template +struct is_ifc4_or_higher> : std::true_type {}; + +std::string schema_name(const express::Base& instance) { + return instance.declaration().schema()->name(); +} + +[[noreturn]] void unsupported_schema(const std::string& name) { + throw ifcopenshell::exception("No helper implementation was built for schema " + name); +} + +// getattr(element, name) for a string- or enum-valued attribute. std::nullopt +// when the attribute is not part of this entity's type (Python's absent +// getattr) or when it is IFC null. Reads by name, so it works uniformly across +// the various IfcElement / IfcType* subtypes that carry PredefinedType et al. +std::optional get_string_attribute(const express::Base& element, + const std::string& name) { + const ifcopenshell::entity* declaration = element.declaration().as_entity(); + if (declaration == nullptr) { + return std::nullopt; + } + const std::ptrdiff_t index = declaration->attribute_index(name); + if (index < 0) { + return std::nullopt; + } + const attribute_value value = element.get_attribute_value(static_cast(index)); + if (value.isNull()) { + return std::nullopt; + } + switch (value.type()) { + case ifcopenshell::Argument_ENUMERATION: { + const enumeration_reference enumeration = value; + return enumeration.value() ? std::string(enumeration.value()) : std::string(); + } + case ifcopenshell::Argument_STRING: + return static_cast(value); + default: + return std::nullopt; + } +} + +// ifcopenshell.util.element.get_type: the construction type of an occurrence +// (get_type(type_element) == type_element). +template +express::Base get_type_s(const express::Base& element) { + if (element.template as()) { + return element; + } + const auto object = element.template as(); + if (!object) { + return {}; + } + if constexpr (is_ifc4_or_higher::value) { + const auto relationships = object.IsTypedBy(); + if (!relationships.empty()) { + return relationships.front().RelatingType(); + } + } else { + for (const auto& relationship : object.IsDefinedBy()) { + if (auto by_type = relationship.template as()) { + return by_type.RelatingType(); + } + } + } + return {}; +} + +template +std::optional get_predefined_type_s(const express::Base& element) { + // Prefer the associated type element's predefined type. + if (const express::Base type = get_type_s(element)) { + std::optional predefined_type = get_string_attribute(type, "PredefinedType"); + if (!predefined_type || *predefined_type == "USERDEFINED") { + // ElementType (IfcElementType) or ProcessType (IfcTypeProcess) — the + // two are mutually exclusive by type, so whichever is present wins. + std::optional custom = get_string_attribute(type, "ElementType"); + if (!custom) { + custom = get_string_attribute(type, "ProcessType"); + } + predefined_type = custom; + } + if (predefined_type && !predefined_type->empty() && *predefined_type != "NOTDEFINED") { + return predefined_type; + } + } + + // Fall back to the occurrence's own predefined type / user-defined ObjectType. + std::optional predefined_type = get_string_attribute(element, "PredefinedType"); + if (!predefined_type || *predefined_type == "USERDEFINED") { + predefined_type = get_string_attribute(element, "ObjectType"); + } + return predefined_type; +} + +} // namespace + +std::optional get_predefined_type(const express::Base& element) { + if (!element) { + return std::nullopt; + } + const std::string name = schema_name(element); +#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \ + if (name == Identifier) { \ + return get_predefined_type_s(element); \ + } + IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH) +#undef IFCOPENSHELL_DISPATCH + unsupported_schema(name); +} diff --git a/src/helpers/element.h b/src/helpers/element.h new file mode 100644 index 0000000000..c340c262f4 --- /dev/null +++ b/src/helpers/element.h @@ -0,0 +1,38 @@ +/******************************************************************************** + * * + * This file is part of IfcOpenShell. * + * * + * IfcOpenShell is free software: you can redistribute it and/or modify * + * it under the terms of the Lesser GNU General Public License as published by * + * the Free Software Foundation, either version 3.0 of the License, or * + * (at your option) any later version. * + * * + * IfcOpenShell is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// This file was generated with the assistance of an AI coding tool. + +#ifndef ELEMENT_H +#define ELEMENT_H + +#include "../ifcparse/express.h" + +#include +#include + +// Mirrors ifcopenshell.util.element.get_predefined_type. Returns the element's +// PredefinedType, falling back to the user-defined ObjectType / ElementType / +// ProcessType when it is USERDEFINED or unset, and preferring the predefined +// type of the associated type element (via IsTypedBy / IsDefinedBy) first. +// std::nullopt when there is no such attribute (e.g. the element is not an +// IfcObject, or a geometry-only proxy with no live IFC data). +std::optional get_predefined_type(const express::Base& element); + +#endif // ELEMENT_H From 8ac7b4373e6c578b0a24d2557df4864c5d09044b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 15:14:13 +1000 Subject: [PATCH 03/20] properties: real attributes + relationships; keep selection on deselect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add helpers to src/helpers/element: get_scalar_attributes (primitive EXPRESS attributes only — entity refs / aggregates omitted), get_type and get_container (ports of ifcopenshell.util.element), and a public get_string_attribute for safe by-name reads. Wire them into the properties panel: - Attributes section shows the element's direct primitive attributes for live entities, or cached GlobalId / Name for geometry-only elements. - Relationships section shows the construction Type and spatial Container by name (falling back to the class when unnamed). - Placeholders are cleared once a project is loaded, so no mock data leaks. Also: a deselect (click on empty space -> object_id 0) no longer resets the panel; it keeps showing the last active object. Project reset/open still clear it. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/modules/properties/View.cpp | 47 ++++-- src/helpers/element.cpp | 160 +++++++++++++++++-- src/helpers/element.h | 26 +++ 3 files changed, 203 insertions(+), 30 deletions(-) diff --git a/src/bonsaiviewer/modules/properties/View.cpp b/src/bonsaiviewer/modules/properties/View.cpp index 0b992e0f1a..5fe66566c6 100644 --- a/src/bonsaiviewer/modules/properties/View.cpp +++ b/src/bonsaiviewer/modules/properties/View.cpp @@ -35,6 +35,10 @@ PropertiesPanelView::PropertiesPanelView(PropertiesPanel* widget, : QObject(parent), widget_(widget), session_state_(session_state) { connect(session_state_, &bonsaiviewer::SessionState::selectionChanged, this, [this](uint32_t object_id) { + // A deselect (click on empty space → object_id 0) leaves the panel + // showing the last active object rather than resetting to the empty + // placeholder. Project reset/open below still clear it explicitly. + if (object_id == 0) return; refresh(object_id); }); connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, [this]() { @@ -92,10 +96,12 @@ void PropertiesPanelView::refresh(uint32_t object_id) { return; } - // A real project is loaded — don't leak the placeholder predefined type. - // It's populated below from live IFC data, or set to "N/A" for - // geometry-only elements that have no data to read it from. + // A real project is loaded — don't leak the placeholder attributes / + // relationships / predefined type. They're populated below from live IFC + // data, or from the cached basics for geometry-only elements. state.entity.predefined_type.clear(); + state.attributes.clear(); + state.relationships.clear(); auto entity = registry->findEntity(object_id); if (entity) { @@ -103,14 +109,32 @@ void PropertiesPanelView::refresh(uint32_t object_id) { if (auto predefined_type = get_predefined_type(*entity)) { state.entity.predefined_type = QString::fromStdString(*predefined_type); } + // Direct EXPRESS attributes, primitives only — lists / entity refs omitted. + for (const auto& [name, value] : get_scalar_attributes(*entity)) { + state.attributes.append({QString::fromStdString(name), QString::fromStdString(value)}); + } + // Relationships: the construction type and the spatial container, shown + // by name (falling back to the entity class when unnamed). + auto display_name = [](const express::Base& related) -> QString { + if (auto name = get_string_attribute(related, "Name"); name && !name->empty()) { + return QString::fromStdString(*name); + } + return QString::fromStdString(related.declaration().name()); + }; + if (express::Base type = get_type(*entity)) { + state.relationships.append({"Type", display_name(type)}); + } + if (express::Base container = get_container(*entity)) { + state.relationships.append({"Container", display_name(container)}); + } if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { state.property_sets[1].rows[0].value = state.entity.entity_class; } } else { // No live IFC source for this object — typical when a pure-geometry - // .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall - // back to the basic info cached in the element registry so the - // panel still shows class / name / guid for visible elements. + // .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall back + // to the basic info cached in the element registry so the panel still + // shows class / GlobalId / Name for visible elements. auto info = registry->findBasicElementInfo(object_id); if (info && !info->type.isEmpty()) { state.entity.entity_class = info->type; @@ -120,14 +144,11 @@ void PropertiesPanelView::refresh(uint32_t object_id) { state.property_sets[1].rows[0].value = info->type; } } - if (info && !info->name.isEmpty()) { - state.attributes[1].value = info->name; - if (state.property_sets.size() > 1 && state.property_sets[1].rows.size() > 1) { - state.property_sets[1].rows[1].value = info->name; - } - } if (info && !info->guid.isEmpty()) { - state.attributes[0].value = info->guid; + state.attributes.append({"GlobalId", info->guid}); + } + if (info && !info->name.isEmpty()) { + state.attributes.append({"Name", info->name}); } } widget_->render(state); diff --git a/src/helpers/element.cpp b/src/helpers/element.cpp index b9968a8aba..d6d854a283 100644 --- a/src/helpers/element.cpp +++ b/src/helpers/element.cpp @@ -27,7 +27,10 @@ #include "../ifcparse/schema.h" #include "schema_dispatch.i" +#include + #include +#include #include namespace { @@ -46,31 +49,36 @@ std::string schema_name(const express::Base& instance) { throw ifcopenshell::exception("No helper implementation was built for schema " + name); } -// getattr(element, name) for a string- or enum-valued attribute. std::nullopt -// when the attribute is not part of this entity's type (Python's absent -// getattr) or when it is IFC null. Reads by name, so it works uniformly across -// the various IfcElement / IfcType* subtypes that carry PredefinedType et al. -std::optional get_string_attribute(const express::Base& element, - const std::string& name) { - const ifcopenshell::entity* declaration = element.declaration().as_entity(); - if (declaration == nullptr) { - return std::nullopt; - } - const std::ptrdiff_t index = declaration->attribute_index(name); - if (index < 0) { - return std::nullopt; - } - const attribute_value value = element.get_attribute_value(static_cast(index)); +// A primitive scalar attribute value formatted for display, or std::nullopt for +// IFC null and for non-primitive values (entity references, aggregates/lists, +// binary) — which the properties UI omits. +std::optional format_scalar(const attribute_value& value) { if (value.isNull()) { return std::nullopt; } switch (value.type()) { + case ifcopenshell::Argument_STRING: + return static_cast(value); case ifcopenshell::Argument_ENUMERATION: { const enumeration_reference enumeration = value; return enumeration.value() ? std::string(enumeration.value()) : std::string(); } - case ifcopenshell::Argument_STRING: - return static_cast(value); + case ifcopenshell::Argument_INT: + return std::to_string(static_cast(value)); + case ifcopenshell::Argument_DOUBLE: { + std::ostringstream stream; + stream << static_cast(value); + return stream.str(); + } + case ifcopenshell::Argument_BOOL: + return static_cast(value) ? std::string("True") : std::string("False"); + case ifcopenshell::Argument_LOGICAL: { + const boost::logic::tribool logical = value; + if (boost::logic::indeterminate(logical)) { + return std::string("UNKNOWN"); + } + return static_cast(logical) ? std::string("True") : std::string("False"); + } default: return std::nullopt; } @@ -129,6 +137,45 @@ std::optional get_predefined_type_s(const express::Base& element) { return predefined_type; } +// ifcopenshell.util.element.get_aggregate: the aggregate parent, via the +// Decomposes inverse (IfcRelAggregates.RelatingObject). +template +express::Base get_aggregate_s(const express::Base& element) { + const auto object = element.template as(); + if (!object) { + return {}; + } + const auto decomposes = object.Decomposes(); + if (decomposes.empty()) { + return {}; + } + const auto relationship = decomposes.front(); + if constexpr (!is_ifc4_or_higher::value) { + // IFC2X3 reuses Decomposes for both aggregates and nests. + if (!relationship.template as()) { + return {}; + } + } + return relationship.RelatingObject(); +} + +// ifcopenshell.util.element.get_container (should_get_direct=false, no +// ifc_class): the directly containing spatial element, or the container of the +// aggregate parent for an aggregated part. +template +express::Base get_container_s(const express::Base& element) { + if (const auto product = element.template as()) { + const auto relationships = product.ContainedInStructure(); + if (!relationships.empty()) { + return relationships.front().RelatingStructure(); + } + } + if (const express::Base aggregate = get_aggregate_s(element)) { + return get_container_s(aggregate); + } + return {}; +} + } // namespace std::optional get_predefined_type(const express::Base& element) { @@ -144,3 +191,82 @@ std::optional get_predefined_type(const express::Base& element) { #undef IFCOPENSHELL_DISPATCH unsupported_schema(name); } + +std::vector> get_scalar_attributes(const express::Base& element) { + std::vector> result; + if (!element) { + return result; + } + const ifcopenshell::entity* declaration = element.declaration().as_entity(); + if (declaration == nullptr) { + return result; + } + // all_attributes() is supertype-first, matching get_attribute_value(index). + const auto& attributes = declaration->all_attributes(); + for (std::size_t index = 0; index < attributes.size(); ++index) { + if (auto value = format_scalar(element.get_attribute_value(index))) { + result.emplace_back(attributes[index]->name(), std::move(*value)); + } + } + return result; +} + +express::Base get_type(const express::Base& element) { + if (!element) { + return {}; + } + const std::string name = schema_name(element); +#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \ + if (name == Identifier) { \ + return get_type_s(element); \ + } + IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH) +#undef IFCOPENSHELL_DISPATCH + unsupported_schema(name); +} + +express::Base get_container(const express::Base& element) { + if (!element) { + return {}; + } + const std::string name = schema_name(element); +#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \ + if (name == Identifier) { \ + return get_container_s(element); \ + } + IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH) +#undef IFCOPENSHELL_DISPATCH + unsupported_schema(name); +} + +// getattr(element, name) for a string- or enum-valued attribute. Reads by name, +// so it works uniformly across the various subtypes that carry a given +// attribute (PredefinedType, Name, ...). +std::optional get_string_attribute(const express::Base& element, + const std::string& name) { + if (!element) { + return std::nullopt; + } + const ifcopenshell::entity* declaration = element.declaration().as_entity(); + if (declaration == nullptr) { + return std::nullopt; + } + const std::ptrdiff_t index = declaration->attribute_index(name); + if (index < 0) { + return std::nullopt; + } + const attribute_value value = element.get_attribute_value(static_cast(index)); + if (value.isNull()) { + return std::nullopt; + } + switch (value.type()) { + case ifcopenshell::Argument_ENUMERATION: { + const enumeration_reference enumeration = value; + return enumeration.value() ? std::string(enumeration.value()) : std::string(); + } + case ifcopenshell::Argument_STRING: + return static_cast(value); + default: + return std::nullopt; + } +} diff --git a/src/helpers/element.h b/src/helpers/element.h index c340c262f4..2b15c3424b 100644 --- a/src/helpers/element.h +++ b/src/helpers/element.h @@ -26,6 +26,8 @@ #include #include +#include +#include // Mirrors ifcopenshell.util.element.get_predefined_type. Returns the element's // PredefinedType, falling back to the user-defined ObjectType / ElementType / @@ -35,4 +37,28 @@ // IfcObject, or a geometry-only proxy with no live IFC data). std::optional get_predefined_type(const express::Base& element); +// Mirrors ifcopenshell.util.element.get_type: the construction type element of +// an occurrence (via IsTypedBy on IFC4+, IsDefinedBy on IFC2X3). A type element +// returns itself. Empty express::Base when the element is untyped. +express::Base get_type(const express::Base& element); + +// Mirrors ifcopenshell.util.element.get_container (indirect, no ifc_class +// filter): the spatial element that contains this element — the directly +// containing spatial structure, or, for an aggregated part, the container of its +// aggregate parent. Empty when uncontained. (The nest / filled-void / +// voided-element branches of the Python original are not ported.) +express::Base get_container(const express::Base& element); + +// Safely read a string- or enum-valued attribute by name (Python's getattr). +// std::nullopt when the attribute is absent for this entity's type or IFC null. +std::optional get_string_attribute(const express::Base& element, + const std::string& name); + +// The element's direct EXPRESS attributes that have a primitive scalar value +// (string / enum / integer / real / boolean / logical), as (name, formatted +// value) pairs in declaration order. Attributes that are entity references, +// aggregates / lists, or unset (IFC null) are omitted — so the caller gets a +// flat, display-ready view with no nested objects. +std::vector> get_scalar_attributes(const express::Base& element); + #endif // ELEMENT_H From ec285fc32c49721b96a66d51b82f9bb2bf6b9f7f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 15:22:05 +1000 Subject: [PATCH 04/20] properties: show real property and quantity sets Populate the Properties and Quantities sections from the pset helper: get_psets(psets_only) for Pset_*, get_psets(qtos_only) for Qto_* / BaseQuantities, inheriting occurrence-over-type values. A toPropertySets converter drops the internal "id" key and non-scalar values, formats scalars for single-line cells, and skips empty sets. Placeholders are cleared once a project is loaded. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/modules/properties/View.cpp | 64 +++++++++++++++++--- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/src/bonsaiviewer/modules/properties/View.cpp b/src/bonsaiviewer/modules/properties/View.cpp index 5fe66566c6..fae5095935 100644 --- a/src/bonsaiviewer/modules/properties/View.cpp +++ b/src/bonsaiviewer/modules/properties/View.cpp @@ -25,10 +25,60 @@ #include "../../ElementRegistry.h" #include "../../SessionState.h" -#include "element.h" // helpers: get_predefined_type +#include "element.h" // helpers: get_predefined_type, get_type, get_container +#include "pset.h" // helpers: get_psets + +#include +#include namespace bonsaiviewer::modules::properties { +namespace { + +// A property/quantity value formatted for a single-line cell. std::nullopt for +// IFC null and compound values (entity references, lists, maps), which are +// omitted from the flat property table. +std::optional formatPropertyValue(const property_value& value) { + if (const auto* text = value.get_if()) { + return QString::fromStdString(*text); + } + if (const auto* flag = value.get_if()) { + return *flag ? QStringLiteral("True") : QStringLiteral("False"); + } + if (const auto* integer = value.get_if()) { + return QString::number(static_cast(*integer)); + } + if (const auto* real = value.get_if()) { + return QString::number(*real); + } + return std::nullopt; +} + +// element_properties (set name -> {property name -> value}) into the panel's +// PropertySet list, dropping the internal "id" key and non-scalar values, and +// omitting sets that end up empty. +QList toPropertySets(const element_properties& sets) { + QList result; + for (const auto& [set_name, properties] : sets) { + PropertySet set; + set.title = QString::fromStdString(set_name); + for (const auto& [property_name, value] : properties) { + if (property_name == "id") { + continue; // definition instance id, not a real property + } + if (auto formatted = formatPropertyValue(value)) { + set.rows.append({QString::fromStdString(property_name), *formatted}); + } + } + if (!set.rows.isEmpty()) { + result.append(set); + } + } + return result; +} + +} // namespace + PropertiesPanelView::PropertiesPanelView(PropertiesPanel* widget, bonsaiviewer::SessionState* session_state, QObject* parent) @@ -102,6 +152,8 @@ void PropertiesPanelView::refresh(uint32_t object_id) { state.entity.predefined_type.clear(); state.attributes.clear(); state.relationships.clear(); + state.property_sets.clear(); + state.quantity_sets.clear(); auto entity = registry->findEntity(object_id); if (entity) { @@ -127,9 +179,10 @@ void PropertiesPanelView::refresh(uint32_t object_id) { if (express::Base container = get_container(*entity)) { state.relationships.append({"Container", display_name(container)}); } - if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { - state.property_sets[1].rows[0].value = state.entity.entity_class; - } + // Property sets (Pset_*) and quantity sets (Qto_* / BaseQuantities), + // occurrence values inheriting from the type. + state.property_sets = toPropertySets(get_psets(*entity, /*psets_only=*/true, /*qtos_only=*/false)); + state.quantity_sets = toPropertySets(get_psets(*entity, /*psets_only=*/false, /*qtos_only=*/true)); } else { // No live IFC source for this object — typical when a pure-geometry // .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall back @@ -140,9 +193,6 @@ void PropertiesPanelView::refresh(uint32_t object_id) { state.entity.entity_class = info->type; // Geometry only — no live IFC entity to read a predefined type from. state.entity.predefined_type = "N/A"; - if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) { - state.property_sets[1].rows[0].value = info->type; - } } if (info && !info->guid.isEmpty()) { state.attributes.append({"GlobalId", info->guid}); From 60cab3e7c43ec147f73c1c1a43fd9a15e249342e Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 16:38:42 +1000 Subject: [PATCH 05/20] spatial hierarchy from IFC + active-model concept Build the spatial hierarchy panel from the loaded model's real IFC spatial structure instead of mock data: - helpers/element: add get_spatial_children (IsDecomposedBy -> RelatedObjects, filtered to spatial elements) to walk IfcProject -> IfcSite -> IfcBuilding -> IfcBuildingStorey -> IfcSpace. - SessionState: relay dataSourceReady as modelDataSourceReady (the .ifc for a sidecar hit loads asynchronously, so the tree can only build once it arrives). - spatial_hierarchy/View: walk the active model's IFC file into a TreeNode tree, naming nodes by Name (fallback to class), mapping site/building/storey kinds; siblings sorted with natural (numeric) collation. - spatial_hierarchy/Panel: tree now fills the panel height (setBodyExpanding + Expanding size policy); right-click menu for recursive Expand/Collapse Subtree and Expand/Collapse All. Add the concept of an active model: - SessionState: activeModelId / setActiveModelId / activeModelChanged; the first loaded model is active by default; reassigns/clears on removal. - Models panel: clicking a model makes it active; its cube icon is drawn with the accent colour (makeAccentSvgIcon) via FederationItemModel::setActiveModelId. - The spatial hierarchy reflects only the active model. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/SessionState.cpp | 21 +++++ src/bonsaiviewer/SessionState.h | 13 +++ .../modules/models/FederationItemModel.cpp | 16 +++- .../modules/models/FederationItemModel.h | 7 ++ src/bonsaiviewer/modules/models/Panel.cpp | 11 ++- src/bonsaiviewer/modules/models/View.cpp | 3 + .../modules/spatial_hierarchy/Panel.cpp | 29 ++++++ .../modules/spatial_hierarchy/View.cpp | 93 +++++++++++++++++-- .../modules/spatial_hierarchy/View.h | 1 + src/helpers/element.cpp | 33 +++++++ src/helpers/element.h | 5 + 11 files changed, 221 insertions(+), 11 deletions(-) diff --git a/src/bonsaiviewer/SessionState.cpp b/src/bonsaiviewer/SessionState.cpp index 418b8c2982..203c184583 100644 --- a/src/bonsaiviewer/SessionState.cpp +++ b/src/bonsaiviewer/SessionState.cpp @@ -94,6 +94,15 @@ void SessionState::createLoader(ViewportWindow* viewport) { connect(loader_, &SceneLoader::allLoadsFinished, this, [this]() { setStatusMessage("Loaded", QString("%1 model(s)").arg(loader_->modelCount())); }); + connect(loader_, &SceneLoader::dataSourceReady, this, [this](uint32_t session_model_id) { + emit modelDataSourceReady(session_model_id); + }); + // First model to load becomes the active model by default. + connect(this, &SessionState::modelGeometryReady, this, [this](uint32_t session_model_id) { + if (active_model_id_.isEmpty()) { + setActiveModelId(modelIdForSessionModelId(session_model_id)); + } + }); } void SessionState::setSelectedObjectId(uint32_t object_id) { @@ -129,12 +138,24 @@ void SessionState::removeModelMappingByModelId(const QString& model_id) { if (it == model_id_to_session_model_id_.end()) return; session_model_id_to_model_id_.remove(it.value()); model_id_to_session_model_id_.erase(it); + if (model_id == active_model_id_) { + setActiveModelId(model_id_to_session_model_id_.isEmpty() + ? QString() + : model_id_to_session_model_id_.keys().first()); + } } void SessionState::clearModelMappings() { model_id_to_session_model_id_.clear(); session_model_id_to_model_id_.clear(); cloud_metadata_.clear(); + setActiveModelId(QString()); +} + +void SessionState::setActiveModelId(const QString& model_id) { + if (model_id == active_model_id_) return; + active_model_id_ = model_id; + emit activeModelChanged(active_model_id_); } void SessionState::setCloudMetadata(const QString& model_id, const QVariantMap& metadata) { diff --git a/src/bonsaiviewer/SessionState.h b/src/bonsaiviewer/SessionState.h index 638e810129..672d5dc088 100644 --- a/src/bonsaiviewer/SessionState.h +++ b/src/bonsaiviewer/SessionState.h @@ -78,6 +78,12 @@ public: QString modelIdForSessionModelId(uint32_t session_model_id) const; QList sessionModelIds() const; + // The active model — the single model the spatial hierarchy (and other + // model-scoped views) operate on. Set by clicking a model in the models + // panel; defaults to the first loaded model. Empty when no model is loaded. + QString activeModelId() const { return active_model_id_; } + void setActiveModelId(const QString& model_id); + void notifySelectionChanged(); void notifyModelsChanged(); void notifyFederationChanged(); @@ -101,6 +107,12 @@ signals: // for both sidecar-cache and stream loads; subscribers that just need to // re-derive view state (e.g. ViewportView::refresh) listen to this. void modelGeometryReady(uint32_t session_model_id); + // Fires when a model's live IFC data source (the .ifc/.rdb, opened in the + // background after a sidecar-cache hit) becomes available for queries — + // e.g. so the spatial hierarchy can be built once the file is loaded. + void modelDataSourceReady(uint32_t session_model_id); + // Fires when the active model changes (empty model_id when cleared). + void activeModelChanged(const QString& model_id); // Fires when SceneLoader reports a load failure. SessionState turns the // raw loader signal into a session-level one so views (e.g. the MessageBox) // can subscribe without touching the loader directly. @@ -121,6 +133,7 @@ private: QString status_detail_; QHash model_id_to_session_model_id_; QHash session_model_id_to_model_id_; + QString active_model_id_; QHash cloud_metadata_; }; diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.cpp b/src/bonsaiviewer/modules/models/FederationItemModel.cpp index 2b9eda7ed3..9b30a654c1 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.cpp +++ b/src/bonsaiviewer/modules/models/FederationItemModel.cpp @@ -87,14 +87,28 @@ QStandardItem* FederationItemModel::makeGroupNameItem(const QString& group_id, c return item; } +QIcon FederationItemModel::modelIcon(const QString& model_id) const { + return model_id == active_model_id_ + ? components::icons::makeAccentSvgIcon(":/icons/cube.svg") + : components::icons::makeSvgIcon(":/icons/cube.svg"); +} + QStandardItem* FederationItemModel::makeModelNameItem(const QString& model_id, const QString& display_name) const { - auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/cube.svg"), display_name); + auto* item = new QStandardItem(modelIcon(model_id), display_name); item->setData(model_id, IdRole); item->setData(int(ItemKind::Model), KindRole); item->setEditable(false); return item; } +void FederationItemModel::setActiveModelId(const QString& model_id) { + if (model_id == active_model_id_) return; + const QString previous = active_model_id_; + active_model_id_ = model_id; + if (auto* item = id_to_name_item_.value(previous)) item->setIcon(modelIcon(previous)); + if (auto* item = id_to_name_item_.value(active_model_id_)) item->setIcon(modelIcon(active_model_id_)); +} + QStandardItem* FederationItemModel::makeVisibilityItem(ItemKind kind, bool visible) const { QString icon_path; if (kind == ItemKind::Group) { diff --git a/src/bonsaiviewer/modules/models/FederationItemModel.h b/src/bonsaiviewer/modules/models/FederationItemModel.h index e2d007e681..5e6f00c534 100644 --- a/src/bonsaiviewer/modules/models/FederationItemModel.h +++ b/src/bonsaiviewer/modules/models/FederationItemModel.h @@ -53,6 +53,10 @@ public: // preserving anyway). void rebuildAll(); + // The active model is drawn with an accent-coloured cube icon. Restyles the + // previously- and newly-active model rows. + void setActiveModelId(const QString& model_id); + private slots: void onGroupAdded(const QString& group_id); void onGroupRemoved(const QString& group_id); @@ -77,8 +81,11 @@ private: void appendGroupSubtreeTo(QStandardItem* parent_item, const QString& group_id); void refreshSubtreeVisibility(QStandardItem* root); + QIcon modelIcon(const QString& model_id) const; // accent cube when active, else plain + Federation* federation_ = nullptr; QHash id_to_name_item_; // both group_ids and model_ids + QString active_model_id_; }; } // namespace bonsaiviewer::modules::models diff --git a/src/bonsaiviewer/modules/models/Panel.cpp b/src/bonsaiviewer/modules/models/Panel.cpp index 4a3a630a34..509abf6fac 100644 --- a/src/bonsaiviewer/modules/models/Panel.cpp +++ b/src/bonsaiviewer/modules/models/Panel.cpp @@ -249,8 +249,15 @@ ModelsPanel::ModelsPanel(bonsaiviewer::SessionState* session_state, addBodyWidget(section); connect(tree_, &QTreeView::clicked, this, [this](const QModelIndex& index) { - if (!index.isValid() || index.column() != 1) return; - commands::toggleVisibility(*session_state_, kindOf(index), idOf(index)); + if (!index.isValid()) return; + if (index.column() == 1) { + commands::toggleVisibility(*session_state_, kindOf(index), idOf(index)); + return; + } + // Clicking a model (its cube icon / row) makes it the active model. + if (kindOf(index) == ItemKind::Model) { + session_state_->setActiveModelId(idOf(index)); + } }); connect(tree_, &QTreeView::customContextMenuRequested, this, [this](const QPoint& pos) { diff --git a/src/bonsaiviewer/modules/models/View.cpp b/src/bonsaiviewer/modules/models/View.cpp index 9a8619c682..27ace0bbf6 100644 --- a/src/bonsaiviewer/modules/models/View.cpp +++ b/src/bonsaiviewer/modules/models/View.cpp @@ -70,6 +70,9 @@ ModelsPanelView::ModelsPanelView(ModelsPanel* widget, connect(&bonsaiviewer::ViewerSettings::instance(), &bonsaiviewer::ViewerSettings::themeChanged, this, rebuild); + connect(session_state_, &SessionState::activeModelChanged, this, [this](const QString& model_id) { + model_->setActiveModelId(model_id); + }); } } // namespace bonsaiviewer::modules::models diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp index 927f170e34..2aa30cc95e 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp +++ b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp @@ -24,17 +24,32 @@ #include "../../components/SvgIcon.h" #include +#include +#include #include #include namespace bonsaiviewer::modules::spatial_hierarchy { +namespace { + +void setSubtreeExpanded(QTreeWidgetItem* item, bool expanded) { + item->setExpanded(expanded); + for (int i = 0; i < item->childCount(); ++i) { + setSubtreeExpanded(item->child(i), expanded); + } +} + +} // namespace + SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent) : components::Panel("Spatial Hierarchy", nullptr, parent) { auto* section = new components::Section("", components::SectionHeaderMode::Hidden, this); + section->setBodyExpanding(true); // let the tree fill the panel's height tree_ = new QTreeWidget(section); + tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); tree_->setColumnCount(2); tree_->setHeaderLabels({"Spatial Item", ""}); tree_->setIconSize(QSize(16, 16)); @@ -52,6 +67,20 @@ SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent) if (!item || column != 1) return; emit visibilityToggleRequested(itemPath(item)); }); + + // Right-click: recursive expand/collapse of a subtree or the whole tree. + tree_->setContextMenuPolicy(Qt::CustomContextMenu); + connect(tree_, &QTreeWidget::customContextMenuRequested, this, [this](const QPoint& pos) { + QMenu menu(tree_); + if (QTreeWidgetItem* item = tree_->itemAt(pos); item && item->childCount() > 0) { + menu.addAction("Expand Subtree", tree_, [item]() { setSubtreeExpanded(item, true); }); + menu.addAction("Collapse Subtree", tree_, [item]() { setSubtreeExpanded(item, false); }); + menu.addSeparator(); + } + menu.addAction("Expand All", tree_, [this]() { tree_->expandAll(); }); + menu.addAction("Collapse All", tree_, [this]() { tree_->collapseAll(); }); + menu.exec(tree_->viewport()->mapToGlobal(pos)); + }); } void SpatialHierarchyPanel::setNodes(const QList& nodes) { diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp b/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp index 353fecf7dc..6829e26628 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp +++ b/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp @@ -23,11 +23,33 @@ #include "Panel.h" #include "../../SessionState.h" +#include "../../../ifcviewer/SceneLoader.h" +#include "../../../ifcparse/file.h" +#include "../../../ifcparse/schema.h" + +#include "element.h" // helpers: get_spatial_children, get_string_attribute + +#include + +#include namespace bonsaiviewer::modules::spatial_hierarchy { namespace { +// Sort siblings by name with natural ordering (so "Level 2" precedes "Level 10"). +void sortByName(QList& nodes) { + static const QCollator collator = [] { + QCollator c; + c.setNumericMode(true); + c.setCaseSensitivity(Qt::CaseInsensitive); + return c; + }(); + std::sort(nodes.begin(), nodes.end(), [](const TreeNode& a, const TreeNode& b) { + return collator.compare(a.name, b.name) < 0; + }); +} + TreeNode* findNodeRecursive(QList& nodes, const NodePath& path, int depth) { for (auto& node : nodes) { if (node.name != path.at(depth)) continue; @@ -37,6 +59,33 @@ TreeNode* findNodeRecursive(QList& nodes, const NodePath& path, int de return nullptr; } +ItemKind kindOf(const express::Base& element) { + const auto& declaration = element.declaration(); + if (declaration.is("IfcSite")) return ItemKind::Site; + if (declaration.is("IfcBuilding")) return ItemKind::Building; + if (declaration.is("IfcBuildingStorey")) return ItemKind::Storey; + return ItemKind::Space; // IfcSpace, IfcSpatialZone, … +} + +QString displayName(const express::Base& element) { + if (auto name = get_string_attribute(element, "Name"); name && !name->empty()) { + return QString::fromStdString(*name); + } + return QString::fromStdString(element.declaration().name()); +} + +TreeNode buildNode(const express::Base& element) { + TreeNode node; + node.name = displayName(element); + node.kind = kindOf(element); + node.visible = true; + for (const auto& child : get_spatial_children(element)) { + node.children.append(buildNode(child)); + } + sortByName(node.children); + return node; +} + } // namespace SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widget, @@ -44,14 +93,6 @@ SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widg QObject* parent) : QObject(parent), widget_(widget), session_state_(session_state) { - nodes_ = { - {"Site A", ItemKind::Site, true, - {{"Building 01", ItemKind::Building, true, - {{"Level 02", ItemKind::Storey, true, - {{"Lobby", ItemKind::Space, true, {}}, - {"Core", ItemKind::Space, true, {}}}}}}}}, - }; - connect(widget_, &SpatialHierarchyPanel::visibilityToggleRequested, this, [this](const NodePath& path) { if (auto* node = findNode(path)) { node->visible = !node->visible; @@ -60,6 +101,42 @@ SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widg } }); + // The tree reflects the active model only. Rebuild when it changes, when its + // geometry or its live IFC data source arrives (the .ifc for a sidecar hit + // loads asynchronously), and on project open/reset. + connect(session_state_, &bonsaiviewer::SessionState::activeModelChanged, this, [this](const QString&) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::modelDataSourceReady, this, [this](uint32_t) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::modelGeometryReady, this, [this](uint32_t) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::projectOpened, this, [this](const QString&) { rebuild(); }); + connect(session_state_, &bonsaiviewer::SessionState::projectReset, this, [this]() { rebuild(); }); + + rebuild(); +} + +void SpatialHierarchyPanelView::rebuild() { + nodes_.clear(); + + auto* loader = session_state_->loader(); + const QString active_model_id = session_state_->activeModelId(); + if (loader != nullptr && !active_model_id.isEmpty()) { + const uint32_t session_model_id = session_state_->sessionModelIdForModelId(active_model_id); + ifcopenshell::file* file = session_model_id != 0 ? loader->ifcFile(session_model_id) : nullptr; + if (file != nullptr) { // null for a geometry-only model with no live IFC + try { + // IfcProject → IfcSite → … ; start the tree at the project's + // spatial children (the project itself has no ItemKind). + for (const auto& project : file->instances_by_type("IfcProject")) { + for (const auto& child : get_spatial_children(project)) { + nodes_.append(buildNode(child)); + } + } + } catch (const std::exception&) { + // Unsupported schema or malformed decomposition — show nothing. + } + } + } + + sortByName(nodes_); reload(); } diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/View.h b/src/bonsaiviewer/modules/spatial_hierarchy/View.h index 6e93e2b725..7fad4ed5e4 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/View.h +++ b/src/bonsaiviewer/modules/spatial_hierarchy/View.h @@ -38,6 +38,7 @@ public: QObject* parent = nullptr); private: + void rebuild(); // re-derive nodes_ from the loaded models' IFC spatial structure void reload(); TreeNode* findNode(const NodePath& path); diff --git a/src/helpers/element.cpp b/src/helpers/element.cpp index d6d854a283..820ce7a823 100644 --- a/src/helpers/element.cpp +++ b/src/helpers/element.cpp @@ -159,6 +159,25 @@ express::Base get_aggregate_s(const express::Base& element) { return relationship.RelatingObject(); } +// The spatial-structure children aggregated under this element (IsDecomposedBy → +// RelatedObjects, filtered to spatial elements). +template +std::vector get_spatial_children_s(const express::Base& element) { + std::vector children; + const auto object = element.template as(); + if (!object) { + return children; + } + for (const auto& relationship : object.IsDecomposedBy()) { + for (const auto& related : relationship.RelatedObjects()) { + if (related.template as()) { + children.push_back(related); + } + } + } + return children; +} + // ifcopenshell.util.element.get_container (should_get_direct=false, no // ifc_class): the directly containing spatial element, or the container of the // aggregate parent for an aggregated part. @@ -239,6 +258,20 @@ express::Base get_container(const express::Base& element) { unsupported_schema(name); } +std::vector get_spatial_children(const express::Base& element) { + if (!element) { + return {}; + } + const std::string name = schema_name(element); +#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \ + if (name == Identifier) { \ + return get_spatial_children_s(element); \ + } + IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH) +#undef IFCOPENSHELL_DISPATCH + unsupported_schema(name); +} + // getattr(element, name) for a string- or enum-valued attribute. Reads by name, // so it works uniformly across the various subtypes that carry a given // attribute (PredefinedType, Name, ...). diff --git a/src/helpers/element.h b/src/helpers/element.h index 2b15c3424b..3b477846c4 100644 --- a/src/helpers/element.h +++ b/src/helpers/element.h @@ -54,6 +54,11 @@ express::Base get_container(const express::Base& element); std::optional get_string_attribute(const express::Base& element, const std::string& name); +// The spatial-structure elements aggregated directly under `element` (its +// IsDecomposedBy → RelatedObjects, filtered to spatial elements). Used to walk +// the IfcProject → IfcSite → IfcBuilding → IfcBuildingStorey → IfcSpace tree. +std::vector get_spatial_children(const express::Base& element); + // The element's direct EXPRESS attributes that have a primitive scalar value // (string / enum / integer / real / boolean / logical), as (name, formatted // value) pairs in declaration order. Attributes that are entity references, From 90196dd51d0e055aebbc4bb0797ff0db9da67bfd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 19:43:07 +1000 Subject: [PATCH 06/20] viewport: idle the render loop when only unfetchable chunks remain The streaming settle burst re-armed the render loop whenever a non-resident chunk was frustum-visible, but the enqueue only fetches chunks that are contribution-visible (big enough on screen) and not in a blocked cooldown. A chunk that is in the frustum but sub-pixel is never loaded, so visible_pending stayed true forever and the loop spun at full frame rate with no input. Match visible_pending to the enqueue's eligibility test: a non-resident chunk keeps the loop alive only if it's actively loading, or is contribution-visible and past its cooldown. Sub-pixel / cooldown-blocked chunks no longer prevent idle; they still stream in when a camera move or eviction requests a frame. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer/ViewportCore.cpp | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index f37e547cf0..8a753d26e5 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -2515,7 +2515,17 @@ void ViewportCore::driveStreamingLoads() { for (const auto& [session_model_id, m] : models_gpu_) { if (m.streaming_file_path.empty() || m.hidden) continue; for (const auto& c : m.chunks) { - if (!c.is_resident && (c.frustum_visible_count > 0 || c.is_loading)) { + if (c.is_resident) continue; + // Keep the loop alive only for chunks we're actually loading or that + // are eligible to enqueue — the same test the enqueue below uses + // (contribution-visible and not in a blocked cooldown). A chunk + // that's in the frustum but sub-pixel (contribution_visible_count + // == 0) is never fetched, so it must not keep the render loop + // spinning at idle; likewise a cooldown-blocked chunk only retries + // after real work (an eviction or camera move) requests a frame. + if (c.is_loading + || (c.contribution_visible_count > 0 + && c.blocked_cooldown_until_frame_idx <= streaming_frame_idx_)) { visible_pending = true; break; } From 4afb3892a2de7507b7351570826626c694b4e1ab Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 20:33:29 +1000 Subject: [PATCH 07/20] spatial hierarchy: storey elevation + Long Name column + resizable layout - helpers/placement: port get_storey_elevation (placement Z, falling back to the Elevation attribute), matching ifcopenshell.util.placement. - Add a secondary column: the storey elevation for IfcBuildingStorey, otherwise the LongName when filled. Elevations are right-aligned. - Columns: Name is drag-resizable (interactive) and defaults to 20% of the width, Long Name stretches to fill the rest, and the eye is pinned to the right at a fixed width. Header shown so the divider can be grabbed. Co-Authored-By: Claude Opus 4.8 --- .../modules/spatial_hierarchy/Panel.cpp | 44 ++++++++++++++----- .../modules/spatial_hierarchy/Panel.h | 5 +++ .../modules/spatial_hierarchy/Types.h | 1 + .../modules/spatial_hierarchy/View.cpp | 10 ++++- src/helpers/placement.cpp | 41 +++++++++++++++++ src/helpers/placement.h | 5 +++ 6 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp index 2aa30cc95e..26f535cc5e 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp +++ b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include #include @@ -50,21 +51,26 @@ SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent) tree_ = new QTreeWidget(section); tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - tree_->setColumnCount(2); - tree_->setHeaderLabels({"Spatial Item", ""}); + tree_->setColumnCount(3); + tree_->setHeaderLabels({"Name", "Long Name", ""}); tree_->setIconSize(QSize(16, 16)); tree_->setSelectionMode(QAbstractItemView::ExtendedSelection); tree_->setUniformRowHeights(true); + // Name is drag-resizable, Long Name fills the rest, the eye is pinned to + // the right at a fixed width. Header stays visible so the Name/Long-Name + // divider can be dragged. Initial 20/80 split applied in showEvent once the + // real width is known. tree_->header()->setStretchLastSection(false); - tree_->header()->setSectionResizeMode(0, QHeaderView::Stretch); - tree_->header()->setSectionResizeMode(1, QHeaderView::Fixed); - tree_->header()->resizeSection(1, 28); - tree_->header()->hide(); + tree_->header()->setSectionsMovable(false); + tree_->header()->setSectionResizeMode(0, QHeaderView::Interactive); // name + tree_->header()->setSectionResizeMode(1, QHeaderView::Stretch); // LongName / elevation + tree_->header()->setSectionResizeMode(2, QHeaderView::Fixed); // visibility + tree_->header()->resizeSection(2, 28); section->addBodyWidget(tree_); addBodyWidget(section); connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int column) { - if (!item || column != 1) return; + if (!item || column != 2) return; emit visibilityToggleRequested(itemPath(item)); }); @@ -83,6 +89,20 @@ SpatialHierarchyPanel::SpatialHierarchyPanel(QWidget* parent) }); } +void SpatialHierarchyPanel::showEvent(QShowEvent* event) { + components::Panel::showEvent(event); + // Default the Name column to 20% of the width once the panel has a real + // layout size; Long Name (stretch) takes the rest. Left interactive after, + // so the user's own drag persists. + if (!column_widths_initialized_) { + const int available = tree_->viewport()->width(); + if (available > 100) { + tree_->header()->resizeSection(0, available / 5); + column_widths_initialized_ = true; + } + } +} + void SpatialHierarchyPanel::setNodes(const QList& nodes) { tree_->clear(); for (const auto& node : nodes) { @@ -92,11 +112,15 @@ void SpatialHierarchyPanel::setNodes(const QList& nodes) { } void SpatialHierarchyPanel::addNode(QTreeWidgetItem* parent, const TreeNode& node) { - auto* item = new QTreeWidgetItem(parent, {node.name, ""}); - item->setData(1, Qt::UserRole, node.visible); + auto* item = new QTreeWidgetItem(parent, {node.name, node.detail, ""}); + item->setData(2, Qt::UserRole, node.visible); item->setSizeHint(0, QSize(0, 24)); item->setIcon(0, components::icons::makeSvgIcon(iconPath(node.kind))); - item->setIcon(1, components::icons::makeSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); + // Storey elevations read as right-aligned numbers; LongNames stay left. + if (node.kind == ItemKind::Storey) { + item->setTextAlignment(1, Qt::AlignRight | Qt::AlignVCenter); + } + item->setIcon(2, components::icons::makeSvgIcon(node.visible ? ":/icons/eye.svg" : ":/icons/eye-closed.svg")); for (const auto& child : node.children) { addNode(item, child); } diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.h b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.h index aaa91c858a..1ff951c158 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/Panel.h +++ b/src/bonsaiviewer/modules/spatial_hierarchy/Panel.h @@ -27,6 +27,7 @@ class QTreeWidget; class QTreeWidgetItem; +class QShowEvent; namespace bonsaiviewer::modules::spatial_hierarchy { @@ -40,12 +41,16 @@ public: signals: void visibilityToggleRequested(const NodePath& path); +protected: + void showEvent(QShowEvent* event) override; + private: void addNode(QTreeWidgetItem* parent, const TreeNode& node); NodePath itemPath(QTreeWidgetItem* item) const; QString iconPath(ItemKind kind) const; QTreeWidget* tree_ = nullptr; + bool column_widths_initialized_ = false; }; } // namespace bonsaiviewer::modules::spatial_hierarchy diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/Types.h b/src/bonsaiviewer/modules/spatial_hierarchy/Types.h index 1818944648..719ef2896e 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/Types.h +++ b/src/bonsaiviewer/modules/spatial_hierarchy/Types.h @@ -36,6 +36,7 @@ enum class ItemKind { struct TreeNode { QString name; + QString detail; // secondary column: LongName, or the elevation for storeys ItemKind kind = ItemKind::Space; bool visible = true; QList children; diff --git a/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp b/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp index 6829e26628..765fe7273c 100644 --- a/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp +++ b/src/bonsaiviewer/modules/spatial_hierarchy/View.cpp @@ -27,7 +27,8 @@ #include "../../../ifcparse/file.h" #include "../../../ifcparse/schema.h" -#include "element.h" // helpers: get_spatial_children, get_string_attribute +#include "element.h" // helpers: get_spatial_children, get_string_attribute +#include "placement.h" // helpers: get_storey_elevation #include @@ -79,6 +80,13 @@ TreeNode buildNode(const express::Base& element) { node.name = displayName(element); node.kind = kindOf(element); node.visible = true; + // Secondary column: the storey elevation, else the LongName when filled. + if (node.kind == ItemKind::Storey) { + node.detail = QString::number(get_storey_elevation(element)); + } else if (auto long_name = get_string_attribute(element, "LongName"); + long_name && !long_name->empty()) { + node.detail = QString::fromStdString(*long_name); + } for (const auto& child : get_spatial_children(element)) { node.children.append(buildNode(child)); } diff --git a/src/helpers/placement.cpp b/src/helpers/placement.cpp index f0fdfd75b9..37c0c5d29a 100644 --- a/src/helpers/placement.cpp +++ b/src/helpers/placement.cpp @@ -20,8 +20,11 @@ #include "placement.h" #include "../ifcparse/exception.h" +#include "../ifcparse/instance_data.h" +#include "../ifcparse/schema.h" #include "schema_dispatch.i" +#include #include #include @@ -125,6 +128,31 @@ Eigen::Matrix4d get_local_placement_s(const express::Base& placement) { return get_axis2_placement_s(placement); } +// ifcopenshell.util.placement.get_storey_elevation: the Z of the storey's +// placement in project units, falling back to the Elevation attribute. +template +double get_storey_elevation_s(const express::Base& storey) { + const auto typed = storey.template as(); + if (!typed) { + return 0.0; + } + if (const auto placement = typed.ObjectPlacement()) { + return get_local_placement_s(placement)(2, 3); + } + // Fallback: the optional Elevation attribute (read by name). + const ifcopenshell::entity* declaration = storey.declaration().as_entity(); + if (declaration != nullptr) { + const std::ptrdiff_t index = declaration->attribute_index("Elevation"); + if (index >= 0) { + const attribute_value value = storey.get_attribute_value(static_cast(index)); + if (!value.isNull() && value.type() == ifcopenshell::Argument_DOUBLE) { + return static_cast(value); + } + } + } + return 0.0; +} + } // namespace Eigen::Matrix4d axes_to_placement(const Eigen::Vector3d& origin, @@ -167,3 +195,16 @@ Eigen::Matrix4d get_local_placement(const express::Base& placement) { #undef IFCOPENSHELL_DISPATCH unsupported_schema(name); } + +double get_storey_elevation(const express::Base& storey) { + if (!storey) { + return 0.0; + } + const auto name = storey.declaration().schema()->name(); +#define IFCOPENSHELL_DISPATCH(Schema, Identifier) \ + if (name == Identifier) \ + return get_storey_elevation_s(storey); + IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH) +#undef IFCOPENSHELL_DISPATCH + unsupported_schema(name); +} diff --git a/src/helpers/placement.h b/src/helpers/placement.h index 72dd8a3c1e..1cf2d711f9 100644 --- a/src/helpers/placement.h +++ b/src/helpers/placement.h @@ -47,4 +47,9 @@ Eigen::Matrix4d get_axis2_placement(const express::Base& placement); // identity for a null input. Eigen::Matrix4d get_local_placement(const express::Base& placement); +// ifcopenshell.util.placement.get_storey_elevation: the Z elevation of an +// IfcBuildingStorey in the project's length unit — the Z of its placement, or +// the Elevation attribute as a fallback. 0 for a non-storey or null input. +double get_storey_elevation(const express::Base& storey); + #endif // PLACEMENT_H From 392af501d16dfe435ca548c410da9b919cb5af44 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 8 Jul 2026 20:37:17 +1000 Subject: [PATCH 08/20] properties: real empty states + smaller base UI font - Replace the mock IfcWall placeholder with a "No item selected" empty state; the panel only fills in class/attributes/relationships/psets from a resolved object, and safely stays empty otherwise. - Show "No properties" / "No quantities" placeholders (muted, themed via secondary_text) when those sets are empty. - Drop the base application font 10pt -> 9pt to fit more data. Panel titles keep their own explicit size and are unaffected. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/components/Style.cpp | 4 ++ src/bonsaiviewer/main.cpp | 5 +- src/bonsaiviewer/modules/properties/Panel.cpp | 12 ++++ src/bonsaiviewer/modules/properties/View.cpp | 60 +++---------------- 4 files changed, 27 insertions(+), 54 deletions(-) diff --git a/src/bonsaiviewer/components/Style.cpp b/src/bonsaiviewer/components/Style.cpp index fd1bcffc1a..069197428f 100644 --- a/src/bonsaiviewer/components/Style.cpp +++ b/src/bonsaiviewer/components/Style.cpp @@ -143,6 +143,10 @@ QString buildAppStyleSheet() { font-size: ${font_small}px; font-weight: 600; } + QLabel#panelSectionEmptyLabel { + color: ${secondary_text}; + padding: 4px 10px; + } QToolButton#panelTitleButton { border: none; background: transparent; diff --git a/src/bonsaiviewer/main.cpp b/src/bonsaiviewer/main.cpp index 59ef765e19..a77fe2896a 100644 --- a/src/bonsaiviewer/main.cpp +++ b/src/bonsaiviewer/main.cpp @@ -41,7 +41,10 @@ void installUiFont() { } } if (!family.isEmpty()) { - QApplication::setFont(QFont(family, 10)); + // Slightly smaller base font to fit more data. Panel titles keep their + // own explicit size (QLabel#panelTitleText in Style.cpp), so they're + // unaffected by this. + QApplication::setFont(QFont(family, 9)); } } diff --git a/src/bonsaiviewer/modules/properties/Panel.cpp b/src/bonsaiviewer/modules/properties/Panel.cpp index d45d858681..7820c1b69e 100644 --- a/src/bonsaiviewer/modules/properties/Panel.cpp +++ b/src/bonsaiviewer/modules/properties/Panel.cpp @@ -71,6 +71,12 @@ QWidget* makeRelationshipList(const QListsetObjectName("panelSectionEmptyLabel"); + return label; +} + QWidget* makeFilterWrapper(QLineEdit** field_out, QWidget* parent = nullptr) { auto* wrapper = new QWidget(parent); wrapper->setObjectName("panelSectionFilterWrapper"); @@ -176,6 +182,9 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { }); properties_section->addBodyWidget(properties_filter_wrapper); for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); + if (property_set_widgets.isEmpty()) { + properties_section->addBodyWidget(makeEmptyStateLabel("No properties", this)); + } properties_section->setExpanded(properties_expanded_); properties_filter_toggle->setChecked(properties_filter_visible_); @@ -203,6 +212,9 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { }); quantities_section->addBodyWidget(quantities_filter_wrapper); for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); + if (quantity_set_widgets.isEmpty()) { + quantities_section->addBodyWidget(makeEmptyStateLabel("No quantities", this)); + } quantities_section->setExpanded(quantities_expanded_); quantities_filter_toggle->setChecked(quantities_filter_visible_); diff --git a/src/bonsaiviewer/modules/properties/View.cpp b/src/bonsaiviewer/modules/properties/View.cpp index fae5095935..de9d820b61 100644 --- a/src/bonsaiviewer/modules/properties/View.cpp +++ b/src/bonsaiviewer/modules/properties/View.cpp @@ -102,60 +102,14 @@ PropertiesPanelView::PropertiesPanelView(PropertiesPanel* widget, void PropertiesPanelView::refresh(uint32_t object_id) { auto* registry = session_state_->elementRegistry(); + + // Empty default: nothing selected → "No item selected" with empty sections. + // Real data is filled in below when an object resolves. PropertiesPanelState state; - state.entity = {"IfcWall", "SOLIDWALL"}; - state.attributes = { - {"GlobalId", "2Q$n5SLPP9Q8B7wQKjKfUQ"}, - {"Name", "Core-EXT-204"}, - {"Description", "External load-bearing wall"}, - }; - state.relationships = { - {"Type", "Basic Wall: Exterior - 200mm"}, - {"Container", "Level 02"}, - }; - state.property_sets = { - {"Pset_WallCommon", - {{"Reference", "Core-EXT-204"}, - {"Status", "Reviewed"}, - {"Fire Rating", "120 min"}, - {"LoadBearing", "True"}}}, - {"Identity Data", - {{"Type", "IfcWall"}, - {"Name", "Core-EXT-204"}, - {"Owner", "Architecture"}, - {"Phase", "Construction"}}}, - {"BIM Collaboration", - {{"Issue Count", "2 open"}, - {"Last Review", "2026-04-30"}, - {"Assigned To", "Design Coordination"}}}, - }; - state.quantity_sets = { - {"BaseQuantities", - {{"Length", "6.20 m"}, - {"Height", "3.45 m"}, - {"Width", "0.30 m"}, - {"Volume", "6.42 m3"}}}, - {"Finish Quantities", - {{"NetSideArea", "21.39 m2"}, - {"GrossArea", "22.10 m2"}, - {"Paint Coverage", "42.78 m2"}}}, - }; + state.entity = {"No item selected", ""}; - if (!registry) { - widget_->render(state); - return; - } - - // A real project is loaded — don't leak the placeholder attributes / - // relationships / predefined type. They're populated below from live IFC - // data, or from the cached basics for geometry-only elements. - state.entity.predefined_type.clear(); - state.attributes.clear(); - state.relationships.clear(); - state.property_sets.clear(); - state.quantity_sets.clear(); - - auto entity = registry->findEntity(object_id); + auto entity = registry ? registry->findEntity(object_id) + : std::optional{}; if (entity) { state.entity.entity_class = QString::fromStdString(entity->declaration().name()); if (auto predefined_type = get_predefined_type(*entity)) { @@ -183,7 +137,7 @@ void PropertiesPanelView::refresh(uint32_t object_id) { // occurrence values inheriting from the type. state.property_sets = toPropertySets(get_psets(*entity, /*psets_only=*/true, /*qtos_only=*/false)); state.quantity_sets = toPropertySets(get_psets(*entity, /*psets_only=*/false, /*qtos_only=*/true)); - } else { + } else if (registry) { // No live IFC source for this object — typical when a pure-geometry // .ifcview sidecar was loaded without its .ifc/.rdb sibling. Fall back // to the basic info cached in the element registry so the panel still From 12002ace864d5b673d5a72986612b8462d7b59b6 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 12:21:11 +1000 Subject: [PATCH 09/20] properties: filter psets/quantities by name, property, or value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter field now actually filters. Typing shows only the sets whose name matches, or that contain a matching property name/value — and when it's a property/value match, only the matching rows are kept (neighbouring rows are dropped). Matching is case-insensitive. Set widgets live in a per-section container that's rebuilt from the raw data on each keystroke, so filtering never recreates the filter field (its focus and cursor are preserved). Placeholder reads "No properties/ quantities" with no data, "No matching properties/quantities" when the filter excludes everything. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/modules/properties/Panel.cpp | 110 +++++++++++++++--- src/bonsaiviewer/modules/properties/Panel.h | 17 +++ 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/src/bonsaiviewer/modules/properties/Panel.cpp b/src/bonsaiviewer/modules/properties/Panel.cpp index 7820c1b69e..3343da17e4 100644 --- a/src/bonsaiviewer/modules/properties/Panel.cpp +++ b/src/bonsaiviewer/modules/properties/Panel.cpp @@ -33,6 +33,8 @@ #include #include +#include + namespace { QWidget* makePropertySetPanel(const bonsaiviewer::modules::properties::PropertySet& property_set, QWidget* parent = nullptr) { @@ -50,6 +52,24 @@ QWidget* makePropertySetPanel(const bonsaiviewer::modules::properties::PropertyS return group; } +void clearLayout(QLayout* layout) { + if (!layout) return; + while (QLayoutItem* item = layout->takeAt(0)) { + if (QWidget* w = item->widget()) delete w; + delete item; + } +} + +// A container for a section's set widgets, laid out like the section body so it +// can be swapped/rebuilt in one place without touching the filter field. +QWidget* makeSetContainer(QWidget* parent) { + auto* container = new QWidget(parent); + auto* layout = new QVBoxLayout(container); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(bonsaiviewer::components::style::metrics::padding); + return container; +} + QWidget* makeAttributeList(const QList& rows, QWidget* parent = nullptr) { QList table_rows; for (const auto& row : rows) { @@ -136,16 +156,12 @@ PropertiesPanel::PropertiesPanel(QWidget* parent) void PropertiesPanel::render(const PropertiesPanelState& state) { clearBodyWidgets(); - - QList property_set_widgets; - for (const auto& property_set : state.property_sets) { - property_set_widgets.append(makePropertySetPanel(property_set, this)); - } - - QList quantity_set_widgets; - for (const auto& property_set : state.quantity_sets) { - quantity_set_widgets.append(makePropertySetPanel(property_set, this)); - } + // The previous widgets were just deleted — drop the stale container pointers + // before rebuilding so a stray filter pass can't touch them. + property_sets_data_ = state.property_sets; + quantity_sets_data_ = state.quantity_sets; + properties_container_ = nullptr; + quantities_container_ = nullptr; auto* entity_section = new components::Section("", components::SectionHeaderMode::Hidden, this); entity_section->addBodyWidget(makeEntityBox(state.entity, this)); @@ -179,12 +195,12 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { }); connect(properties_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) { properties_filter_text_ = text; + rebuildPropertyWidgets(); }); properties_section->addBodyWidget(properties_filter_wrapper); - for (auto* widget : property_set_widgets) properties_section->addBodyWidget(widget); - if (property_set_widgets.isEmpty()) { - properties_section->addBodyWidget(makeEmptyStateLabel("No properties", this)); - } + properties_container_ = makeSetContainer(properties_section); + properties_section->addBodyWidget(properties_container_); + rebuildPropertyWidgets(); properties_section->setExpanded(properties_expanded_); properties_filter_toggle->setChecked(properties_filter_visible_); @@ -209,12 +225,12 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { }); connect(quantities_filter_field, &QLineEdit::textChanged, this, [this](const QString& text) { quantities_filter_text_ = text; + rebuildQuantityWidgets(); }); quantities_section->addBodyWidget(quantities_filter_wrapper); - for (auto* widget : quantity_set_widgets) quantities_section->addBodyWidget(widget); - if (quantity_set_widgets.isEmpty()) { - quantities_section->addBodyWidget(makeEmptyStateLabel("No quantities", this)); - } + quantities_container_ = makeSetContainer(quantities_section); + quantities_section->addBodyWidget(quantities_container_); + rebuildQuantityWidgets(); quantities_section->setExpanded(quantities_expanded_); quantities_filter_toggle->setChecked(quantities_filter_visible_); @@ -246,4 +262,62 @@ void PropertiesPanel::render(const PropertiesPanelState& state) { addBodyWidget(quantities_section); } +namespace { + +// Filter one set: keep it if the filter is empty, or its name matches (then all +// rows are kept), or some property name/value matches (then only those rows). +// Returns nullopt when nothing in the set matches. +std::optional filterSet(const PropertySet& set, const QString& text) { + if (text.isEmpty() || set.title.contains(text, Qt::CaseInsensitive)) { + return set; + } + PropertySet filtered; + filtered.title = set.title; + for (const auto& row : set.rows) { + if (row.key.contains(text, Qt::CaseInsensitive) || + row.value.contains(text, Qt::CaseInsensitive)) { + filtered.rows.append(row); + } + } + if (filtered.rows.isEmpty()) return std::nullopt; + return filtered; +} + +// Rebuild a set container's contents from raw data under the current filter, +// dropping non-matching rows, with a placeholder when nothing is shown. +void rebuildSetContainer(QWidget* container, + const QList& sets, + const QString& filter_text, + const QString& empty_text, + const QString& no_match_text) { + if (!container) return; + auto* layout = qobject_cast(container->layout()); + if (!layout) return; + clearLayout(layout); + + const QString text = filter_text.trimmed(); + int shown = 0; + for (const auto& set : sets) { + if (auto filtered = filterSet(set, text)) { + layout->addWidget(makePropertySetPanel(*filtered, container)); + ++shown; + } + } + if (shown == 0) { + layout->addWidget(makeEmptyStateLabel(sets.isEmpty() ? empty_text : no_match_text, container)); + } +} + +} // namespace + +void PropertiesPanel::rebuildPropertyWidgets() { + rebuildSetContainer(properties_container_, property_sets_data_, properties_filter_text_, + "No properties", "No matching properties"); +} + +void PropertiesPanel::rebuildQuantityWidgets() { + rebuildSetContainer(quantities_container_, quantity_sets_data_, quantities_filter_text_, + "No quantities", "No matching quantities"); +} + } // namespace bonsaiviewer::modules::properties diff --git a/src/bonsaiviewer/modules/properties/Panel.h b/src/bonsaiviewer/modules/properties/Panel.h index 1e5558be3a..32a72a9465 100644 --- a/src/bonsaiviewer/modules/properties/Panel.h +++ b/src/bonsaiviewer/modules/properties/Panel.h @@ -30,6 +30,7 @@ class QLabel; class QLineEdit; class QToolButton; +class QWidget; namespace bonsaiviewer::modules::properties { @@ -41,6 +42,14 @@ public: void render(const PropertiesPanelState& state); private: + // Rebuild the set widgets inside their container, applying the current + // (case-insensitive) filter text: a set is shown only if its name or one of + // its property names/values matches, and — when it's a property/value match + // rather than a set-name match — only the matching rows are kept. Toggles a + // "No properties" / "No matching properties" placeholder. + void rebuildPropertyWidgets(); + void rebuildQuantityWidgets(); + bool attributes_expanded_ = true; bool relationships_expanded_ = true; bool properties_expanded_ = true; @@ -49,6 +58,14 @@ private: bool quantities_filter_visible_ = false; QString properties_filter_text_; QString quantities_filter_text_; + + // Raw data + the container the set widgets live in, so a filter change can + // rebuild just the sets without disturbing the filter field. Recreated on + // each render(); the container is owned by its section. + QList property_sets_data_; + QList quantity_sets_data_; + QWidget* properties_container_ = nullptr; + QWidget* quantities_container_ = nullptr; }; } // namespace bonsaiviewer::modules::properties From 6e86072d5a46454d940c1e54ae3a1ee93e6246dd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 12:48:38 +1000 Subject: [PATCH 10/20] viewport: select a section plane, highlight it, delete the selected one Previously Del always removed the most recently added section plane. Now a plane can be picked and deleted individually: - ViewportCore tracks a selected plane index, kept valid as planes are added (the new one becomes selected), removed, or cleared. - Clicking a gizmo with the section tool active selects that plane. - The section gizmo geometry is baked white and coloured via its per-plane tint, so the selected plane draws in a bright amber highlight while the rest stay red (unchanged look). - Del/Backspace removes the selected plane, falling back to the most recent one when nothing is selected. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer/SectionGizmoRenderer.cpp | 33 ++++++++++++++++---------- src/ifcviewer/SectionGizmoRenderer.h | 4 +++- src/ifcviewer/ViewportCore.cpp | 19 ++++++++++++++- src/ifcviewer/ViewportCore.h | 6 +++++ src/ifcviewer/ViewportWindow.cpp | 14 +++++++---- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/ifcviewer/SectionGizmoRenderer.cpp b/src/ifcviewer/SectionGizmoRenderer.cpp index bd9ca4969f..c0ef92ab6f 100644 --- a/src/ifcviewer/SectionGizmoRenderer.cpp +++ b/src/ifcviewer/SectionGizmoRenderer.cpp @@ -172,18 +172,20 @@ bool SectionGizmoRenderer::init(WGPUDevice device, WGPUQueue queue, if (!device_ || !queue_) return false; // ---- Gizmo geometry: 9 line segments (quad outline + normal arrow) ---- + // Baked white so the per-plane `tint` uniform supplies the colour (red + // normally, a highlight colour for the selected plane — see encode()). struct Seg { std::array s, e, c; }; - static constexpr std::array kRed = { 1.000f, 0.200f, 0.322f }; + static constexpr std::array kWhite = { 1.0f, 1.0f, 1.0f }; static const Seg segs[] = { - { {-1, -1, 0}, { 1, -1, 0}, kRed }, // quad outline - { { 1, -1, 0}, { 1, 1, 0}, kRed }, - { { 1, 1, 0}, {-1, 1, 0}, kRed }, - { {-1, 1, 0}, {-1, -1, 0}, kRed }, - { { 0, 0, 0}, { 0, 0, 1}, kRed }, // arrow shaft along +n - { { 0, 0, 1}, {-0.18f, 0, 0.78f}, kRed }, // arrow head - { { 0, 0, 1}, { 0.18f, 0, 0.78f}, kRed }, - { { 0, 0, 1}, { 0, -0.18f, 0.78f}, kRed }, - { { 0, 0, 1}, { 0, 0.18f, 0.78f}, kRed }, + { {-1, -1, 0}, { 1, -1, 0}, kWhite }, // quad outline + { { 1, -1, 0}, { 1, 1, 0}, kWhite }, + { { 1, 1, 0}, {-1, 1, 0}, kWhite }, + { {-1, 1, 0}, {-1, -1, 0}, kWhite }, + { { 0, 0, 0}, { 0, 0, 1}, kWhite }, // arrow shaft along +n + { { 0, 0, 1}, {-0.18f, 0, 0.78f}, kWhite }, // arrow head + { { 0, 0, 1}, { 0.18f, 0, 0.78f}, kWhite }, + { { 0, 0, 1}, { 0, -0.18f, 0.78f}, kWhite }, + { { 0, 0, 1}, { 0, 0.18f, 0.78f}, kWhite }, }; std::vector verts; verts.reserve(std::size(segs) * 6 * 11); @@ -302,7 +304,8 @@ bool SectionGizmoRenderer::init(WGPUDevice device, WGPUQueue queue, void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matrix4f& view_proj, const std::vector& planes, - int viewport_w_px, int viewport_h_px, int device_pixel_ratio) { + int viewport_w_px, int viewport_h_px, int device_pixel_ratio, + int selected_index) { if (!pipeline_ || planes.empty()) return; wgpuRenderPassEncoderSetPipeline(pass, pipeline_); wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE); @@ -321,9 +324,15 @@ void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matri // arrow would shoot past the eye (clip.w<0) and vanish. const float half = 1.0f; + // Red normally; a bright amber highlight for the selected plane. + const bool selected = (i == selected_index); + const float tr = selected ? 1.00f : 1.000f; + const float tg = selected ? 0.75f : 0.200f; + const float tb = selected ? 0.10f : 0.322f; + uint8_t slot[256]; packSectionUniform(slot, view_proj, plane.origin, half, tangent, line_w, - bitangent, nn, 1.0f, 1.0f, 1.0f, 1.0f, vw, vh); + bitangent, nn, tr, tg, tb, 1.0f, vw, vh); const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlot; wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot)); wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset); diff --git a/src/ifcviewer/SectionGizmoRenderer.h b/src/ifcviewer/SectionGizmoRenderer.h index b902953ed3..49ef000eea 100644 --- a/src/ifcviewer/SectionGizmoRenderer.h +++ b/src/ifcviewer/SectionGizmoRenderer.h @@ -51,9 +51,11 @@ public: bool ready() const { return pipeline_ != nullptr; } // Draw one gizmo per plane into an already-open render pass (the main pass). + // `selected_index` (or -1) is drawn with a highlight tint to show selection. void encode(WGPURenderPassEncoder pass, const Eigen::Matrix4f& view_proj, const std::vector& planes, - int viewport_w_px, int viewport_h_px, int device_pixel_ratio); + int viewport_w_px, int viewport_h_px, int device_pixel_ratio, + int selected_index = -1); // Screen-space hit test: index of the plane whose gizmo (arrow segment, // origin→origin+normal) the (x, y) logical-pixel point lies within diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 8a753d26e5..99c3000a0d 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -6439,7 +6439,7 @@ void ViewportCore::render() { // Section-plane gizmo — shared renderer, drawn for desktop + web from here. // (The desktop's OverlayRenderer no longer draws it, to avoid doubling.) section_gizmo_.encode(pass, vp_this_frame, section_planes_, - viewport_w_px, viewport_h_px, dpr_int); + viewport_w_px, viewport_h_px, dpr_int, section_selected_index_); // Remaining in-pass overlays (highlight triangles, pivot, overlay // lines/points). QtViewportHost forwards to overlays_.X(); web host no-ops. @@ -6728,6 +6728,8 @@ bool ViewportCore::addSectionPlaneAtSurface(const Eigen::Vector3f& point, p.d = -n.dot(point); p.visual_radius = (visual_radius > 0.0f) ? visual_radius : 1.0f; section_planes_.push_back(p); + // The freshly added plane becomes the selected one. + section_selected_index_ = int(section_planes_.size()) - 1; Log::info() << "[wgpu section] added plane #" << section_planes_.size() - 1 << " origin=(" << point.x() << "," << point.y() << "," << point.z() << ")" @@ -6736,9 +6738,23 @@ bool ViewportCore::addSectionPlaneAtSurface(const Eigen::Vector3f& point, return true; } +void ViewportCore::setSelectedSectionPlane(int index) { + const int clamped = (index >= 0 && index < int(section_planes_.size())) ? index : -1; + if (clamped == section_selected_index_) return; + section_selected_index_ = clamped; + host_->requestFrame(); +} + void ViewportCore::removeSectionPlane(int index) { if (index < 0 || index >= int(section_planes_.size())) return; section_planes_.erase(section_planes_.begin() + index); + // Keep the selection pointing at the same plane: clear it if it was the one + // removed, shift it down if it sat after the removed index. + if (section_selected_index_ == index) { + section_selected_index_ = -1; + } else if (section_selected_index_ > index) { + --section_selected_index_; + } Log::info() << "[wgpu section] removed plane " << index; host_->requestFrame(); } @@ -6746,6 +6762,7 @@ void ViewportCore::removeSectionPlane(int index) { void ViewportCore::clearSectionPlanes() { if (section_planes_.empty()) return; section_planes_.clear(); + section_selected_index_ = -1; Log::info() << "[wgpu section] cleared all planes"; host_->requestFrame(); } diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 29b21a3593..e03d60d3fe 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -534,6 +534,11 @@ public: // Number of active section planes (0..kMaxSectionPlanes). int sectionPlaneCount() const { return int(section_planes_.size()); } + // The selected section plane (drawn highlighted; the target of a delete), or + // -1 for none. Index is kept valid as planes are added/removed/cleared. + void setSelectedSectionPlane(int index); + int selectedSectionPlane() const { return section_selected_index_; } + // ---- Section gizmo interaction (shared desktop + web) ------------------- // // All coords are LOGICAL (CSS) pixels; the core derives the logical viewport @@ -1057,6 +1062,7 @@ private: // the press point (logical px) so update can slide it along the normal. bool section_drag_active_ = false; int section_drag_index_ = -1; + int section_selected_index_ = -1; Eigen::Vector3f section_drag_start_origin_ = Eigen::Vector3f::Zero(); int section_drag_start_mx_ = 0; int section_drag_start_my_ = 0; diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 555d097d48..b4e95bf78c 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1554,6 +1554,7 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) { const Eigen::Vector2i lp = toV2i(event->position().toPoint()); const int hit = core_.hitTestSectionGizmo(lp.x(), lp.y()); if (hit >= 0 && core_.beginSectionDrag(hit, lp.x(), lp.y())) { + core_.setSelectedSectionPlane(hit); // clicking a gizmo selects it nav_drag_kind_ = NavDrag::Inactive; Log::info().noquote().nospace() << "[wgpu section] drag start: plane=" << hit; @@ -1918,9 +1919,10 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { // Section tool. K toggles the tool; Shift+K clears all planes. When // the tool is active, click adds a plane at the surface (handled in - // mouseReleaseEvent), Esc deactivates, Del/Backspace removes the - // most recently added plane. Mirrors GL ViewportWindow + Bonsai's - // bind_shortcut(K / Shift+K) bindings. + // mouseReleaseEvent) or selects the gizmo under the cursor, Esc + // deactivates, Del/Backspace removes the selected plane (or the most + // recent one when nothing is selected). Mirrors GL ViewportWindow + + // Bonsai's bind_shortcut(K / Shift+K) bindings. if (key == Qt::Key_K && !event->isAutoRepeat()) { if (mods == Qt::ShiftModifier) { clearSectionPlanes(); @@ -1936,7 +1938,11 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { } if ((key == Qt::Key_Delete || key == Qt::Key_Backspace) && !section_planes_.empty()) { - removeSectionPlane(int(section_planes_.size()) - 1); + // Delete the selected plane; fall back to the most recent one when + // nothing is selected. + const int selected = core_.selectedSectionPlane(); + removeSectionPlane(selected >= 0 ? selected + : int(section_planes_.size()) - 1); return; } } From 93fcdc9a8df494e9258a1ad4d6cd667c74e20cf0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 13:17:55 +1000 Subject: [PATCH 11/20] viewport: don't clobber the persisted nav preset at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initWgpu() runs on the first exposeEvent, after MainWindow has already applied the nav preset saved in Settings. It then unconditionally re-applied "blender" whenever WGPU_NAV_PRESET was unset, silently overriding the user's saved choice — so the applied navigation didn't match what Settings showed. Only apply the preset from WGPU_NAV_PRESET when that env override is actually set; otherwise leave the current preset (MainWindow's persisted choice, or the blender default). The startup log now reports the effective orbit/pan bindings rather than a hardcoded name. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer/ViewportWindow.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index b4e95bf78c..7c3fc394c9 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -774,17 +774,20 @@ bool ViewportWindow::initWgpu() { Log::info() << "[wgpu fly] WGPU_FLY_DEBUG=1 — per-frame [fly] dt log enabled"; } } - const char* nav_env = std::getenv("WGPU_NAV_PRESET"); - applyNavPreset(nav_env ? nav_env : "blender"); + // WGPU_NAV_PRESET is a dev override; apply it here. Otherwise leave the + // preset alone — MainWindow applies the persisted Settings choice before the + // window is exposed (initWgpu runs on the first expose), so forcing a + // default here would clobber it and desync the applied preset from Settings. + if (const char* nav_env = std::getenv("WGPU_NAV_PRESET")) { + applyNavPreset(nav_env); + } Log::info().noquote().nospace() - << "[wgpu nav] preset=" << (nav_env ? nav_env : "blender") - << " (orbit " + << "[wgpu nav] orbit " << (orbit_button_ == Qt::RightButton ? "RMB" : "MMB") << (orbit_mods_ & Qt::ShiftModifier ? "+Shift" : "") << ", pan " << (pan_button_ == Qt::RightButton ? "RMB" : "MMB") - << (pan_mods_ & Qt::ShiftModifier ? "+Shift" : "") - << ")"; + << (pan_mods_ & Qt::ShiftModifier ? "+Shift" : ""); // ---- ViewportCore handles instance/adapter/device/queue/pool/format - if (!core_.initWgpu(web_limits_)) return false; From b2ecfab86e11721b9b48dd6d17cb0c94ef94ca25 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 13:17:55 +1000 Subject: [PATCH 12/20] style: theme input/tab-bar, align header height with body rows - Theme QInputDialog (the New Group / Rename Group popup) so it follows the dark theme instead of rendering light. - Theme the generic QTabBar that QMainWindow creates for tabbed docks (previously bright white). The app's own #appTabBar keeps its look via more specific selectors. - Reduce QHeaderView::section vertical padding (7px -> 4px) so table/tree header rows match the body row height throughout the UI. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/components/Style.cpp | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/bonsaiviewer/components/Style.cpp b/src/bonsaiviewer/components/Style.cpp index 069197428f..175c3d8a93 100644 --- a/src/bonsaiviewer/components/Style.cpp +++ b/src/bonsaiviewer/components/Style.cpp @@ -50,6 +50,29 @@ QString buildAppStyleSheet() { background: ${app_background}; color: ${primary_text}; } + QInputDialog, + QInputDialog QWidget { + background: ${app_background}; + color: ${primary_text}; + } + /* Generic tab bar — e.g. the QTabBar QMainWindow creates when docks are + tabbed together. appTabBar's #-selectors below are more specific and + still win for the app's own top tabs. */ + QTabBar { + background: ${app_background}; + } + QTabBar::tab { + background: ${tab_background}; + color: ${secondary_text}; + padding: 5px 12px; + } + QTabBar::tab:selected { + background: ${panel_background}; + color: ${primary_text}; + } + QTabBar::tab:hover { + color: ${hover_text}; + } QTabBar#appTabBar { background: ${tab_bar_background}; } @@ -180,7 +203,9 @@ QString buildAppStyleSheet() { color: ${primary_text}; border: none; border-bottom: 1px solid ${border}; - padding: 7px 8px; + /* Match the body row height: same vertical padding as + QTreeView/QListView/QTableView::item (4px) below. */ + padding: 4px 8px; font-weight: 600; } QTableCornerButton::section { From ed21dd7ecc0de488163fe7916e091476e7ed574f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 16:46:29 +1000 Subject: [PATCH 13/20] web: MODULARIZE build + embedded JS-integration example + selection callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the web viewer so the wasm is a reusable module and add a second example that drives it from ordinary page DOM. Build: - Emit IfcViewerWeb.js (a `createIfcViewer` factory, MODULARIZE) + .wasm instead of a single baked page (dropped --shell-file); copy the static example pages next to it at build time. - Unbreak the web build: CameraMath.h / ViewportCore.cpp used boost::math::constants::pi just for pi, pulling all of boost/math into a header shared with the Emscripten build (no Boost in its sysroot). Replace with a constexpr kPiF — identical value, no dependency, desktop unaffected. JS integration (web/ifcviewer.js): - A small helper wraps the factory: boots the viewer on a canvas, runs the RAF loop from onRuntimeInitialized (NOT a post-await .then, which stalls Dawn-web's device callback and leaves the device half-initialised), and exposes addFile/addUrl, clearScene, model list/progress, and onSelect(...). - ViewportCore/main_web emit each pick to JS via Module.__ifcvOnSelect (object id + IFC GlobalId + model index; empty on deselect); onSelect also dispatches an 'ifcviewer:select' DOM event. - Fix input coords for a non-fullscreen canvas: mousemove/mouseup are window-targeted, so convert their coords to canvas-relative via the canvas client-rect origin (marquee + box-pick were offset when embedded). Examples: - IfcViewerWeb.html: the fullscreen viewer (same DOM/behaviour as before, now loading the module) — the Playwright smoke suite still targets it. - embedded.html: a sized viewer with DOM outside it to add models (file or URL), list loaded models with streaming progress, and show the model + GlobalId of the clicked object. Starts empty (drops the wasm's embedded sample, which the fullscreen page/tests still use). - index.html links both. Federation note: the web viewer already streams multiple models into one scene (a byte-source per file/URL); it doesn't need the desktop Federation document for this. Verified: 11/11 web smoke tests pass; embedded example loads models, reports the picked model + GUID, and the marquee aligns. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer-web/CMakeLists.txt | 37 ++- src/ifcviewer-web/WebViewportHost.h | 2 +- src/ifcviewer-web/main_web.cpp | 77 +++-- src/ifcviewer-web/shell.html | 384 ------------------------ src/ifcviewer-web/web/IfcViewerWeb.html | 246 +++++++++++++++ src/ifcviewer-web/web/embedded.html | 216 +++++++++++++ src/ifcviewer-web/web/ifcviewer.js | 169 +++++++++++ src/ifcviewer-web/web/index.html | 42 +++ src/ifcviewer/CameraMath.h | 9 +- src/ifcviewer/ViewportCore.cpp | 25 +- 10 files changed, 783 insertions(+), 424 deletions(-) delete mode 100644 src/ifcviewer-web/shell.html create mode 100644 src/ifcviewer-web/web/IfcViewerWeb.html create mode 100644 src/ifcviewer-web/web/embedded.html create mode 100644 src/ifcviewer-web/web/ifcviewer.js create mode 100644 src/ifcviewer-web/web/index.html diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index bbd7d70624..1299979f07 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -89,26 +89,32 @@ target_link_options(IfcViewerWeb PRIVATE # also instrument every function reachable from emscripten_sleep, # adding ~30% to wasm size for no win here. # - # EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in shell.html) + # EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in the host page (web/ifcviewer.js)) # keeps wasm alive after main() returns so Dawn-web's # RequestAdapter/RequestDevice promise callbacks land. The # alternative — calling emscripten_set_main_loop_arg early in # main() to set noExitRuntime as a side effect — registers a RAF # that starves the device promise (observed: ~10s delay in Firefox). "-sEXIT_RUNTIME=0" + # Emit a reusable module factory (IfcViewerWeb.js) instead of a baked page, + # so multiple static example pages can load the same wasm. Each page does + # createIfcViewer({ canvas, ... }).then(Module => …) + # (see web/ifcviewer.js, which wraps this into a small integration API). + "-sMODULARIZE=1" + "-sEXPORT_NAME=createIfcViewer" # Expose the C entry points to JS. _raf_tick_c drives the RAF loop - # (shell.html); _load_sidecar_from_blob_c loads a user-picked File via + # (the host page (web/ifcviewer.js)); _load_sidecar_from_blob_c loads a user-picked File via # byte-range Blob.slice reads; _load_sidecar_from_url_c streams a remote # sidecar via HTTP Range; _ifcv_on_range_done / _ifcv_source_ready are the # JS→C completion callbacks for a landed range / a resolved URL size. # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't - # add them to Module. ccall lets shell.html pass a JS string (the ?model + # add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model # URL) to load_sidecar_from_url_c without manual heap marshalling. "-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c']" - # ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c. + # ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large # sidecar streams by range instead of loading whole). Standard, zero-cost. - "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8']" + "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','UTF8ToString']" # Streaming + chunked geometry want a heap that can grow as buffers # arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer # cap; --shared64 / MEMORY64 would lift this later if we need it). @@ -124,7 +130,22 @@ target_link_options(IfcViewerWeb PRIVATE # mounts the file at the virtual path the wasm fopen()s. User-picked # files instead stream via Blob.slice byte ranges (load_sidecar_from_blob_c). "--embed-file=${CMAKE_CURRENT_SOURCE_DIR}/sample.ifcview@/sample.ifcview" - # Shell template wraps the JS output in our canvas page. - "--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/shell.html" ) -set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".html") +# MODULARIZE emits IfcViewerWeb.js (the createIfcViewer factory) + .wasm. +set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".js") + +# Copy the static example pages + the JS integration helper next to the wasm so +# a plain `python3 -m http.server --directory build-web` serves the whole demo: +# /IfcViewerWeb.html fullscreen example +# /embedded.html embedded viewer + DOM model list / selection (JS API) +# /index.html links to both +set(IFCVIEWERWEB_STATIC + "${CMAKE_CURRENT_SOURCE_DIR}/web/ifcviewer.js" + "${CMAKE_CURRENT_SOURCE_DIR}/web/IfcViewerWeb.html" + "${CMAKE_CURRENT_SOURCE_DIR}/web/embedded.html" + "${CMAKE_CURRENT_SOURCE_DIR}/web/index.html" +) +add_custom_command(TARGET IfcViewerWeb POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${IFCVIEWERWEB_STATIC} "$" + COMMENT "Copying web example pages next to IfcViewerWeb.js") diff --git a/src/ifcviewer-web/WebViewportHost.h b/src/ifcviewer-web/WebViewportHost.h index 44661d9bef..633e93cf98 100644 --- a/src/ifcviewer-web/WebViewportHost.h +++ b/src/ifcviewer-web/WebViewportHost.h @@ -35,7 +35,7 @@ class WebViewportHost final : public ViewportHost { public: // `canvas_selector` is the CSS selector for the host (e.g. - // "#viewer-canvas" — matches shell.html). The string is stored; + // "#viewer-canvas" — matches the host page (web/ifcviewer.js)). The string is stored; // it must outlive the host. explicit WebViewportHost(std::string canvas_selector); diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index afab844e8b..c7404f032d 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -20,9 +20,9 @@ // Web entry point. Wires a WebViewportHost to a ViewportCore, brings up // wgpu via emdawnwebgpu (the spec-compatible WebGPU header set that // shipped with Dawn), loads the embedded sample sidecar, and drives -// render() per requestAnimationFrame from JS (shell.html). +// render() per requestAnimationFrame from JS (the host page (web/ifcviewer.js)). // -// The RAF loop lives in shell.html — NOT here — because any call into +// The RAF loop lives in the host page (web/ifcviewer.js) — NOT here — because any call into // Emscripten's main-loop / RAF helpers (or even raw // requestAnimationFrame via EM_ASM) made from inside Dawn-web's wgpu // promise-resolution chain stalls the device callback. Having JS drive @@ -44,7 +44,7 @@ namespace { -// CSS selector for the host ; must match shell.html + the +// CSS selector for the host ; must match the host page (web/ifcviewer.js) + the // WebViewportHost selector below. constexpr const char* kCanvasSelector = "#viewer-canvas"; @@ -78,6 +78,13 @@ struct AppState { float nav_drag_px = 0.0f; long down_x = 0; long down_y = 0; + // The canvas's top-left in window coords, captured on mousedown. The + // mousemove/mouseup handlers are window-targeted (so a drag can leave the + // canvas), so their coords are window-relative; subtracting this maps them + // back to canvas-relative — the space down_x/down_y and the picker use. + // Zero for a fullscreen canvas pinned at (0,0); nonzero when embedded. + double canvas_origin_x = 0.0; + double canvas_origin_y = 0.0; // ---- Fly (first-person) mode ---- // Shift+F enters (pointer-locks the canvas), Esc exits. While flying, held @@ -118,7 +125,7 @@ int canvasCssHeight() { return (h > 1.0) ? int(h) : 1; } -// Marquee rectangle overlay. The rubber-band is a plain DOM
(shell.html) +// Marquee rectangle overlay. The rubber-band is a plain DOM
(the host page (web/ifcviewer.js)) // positioned in CSS px — the canvas fills the viewport, so canvas-relative // coords are viewport coords. Cheaper + pixel-perfect vs a GPU overlay pass // (which the web lib doesn't have anyway). @@ -136,6 +143,19 @@ void hideMarquee() { EM_ASM({ var m = document.getElementById('marquee'); if (m) m.style.display = 'none'; }); } +// The canvas's top-left in window (client) coords. Window-targeted mouse events +// are window-relative; subtract this to convert them to canvas-relative. +void canvasClientOrigin(double& left, double& top) { + left = EM_ASM_DOUBLE({ + var c = document.getElementById('viewer-canvas'); + return c ? c.getBoundingClientRect().left : 0; + }); + top = EM_ASM_DOUBLE({ + var c = document.getElementById('viewer-canvas'); + return c ? c.getBoundingClientRect().top : 0; + }); +} + NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, bool shift, bool ctrl, bool alt) { using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; @@ -151,6 +171,9 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); // In fly mode a click exits (matches the desktop app). if (app->fly_mode) { setFlyMode(app, false); return EM_TRUE; } + // Snapshot the canvas origin for this gesture so the window-targeted + // move/up handlers can map their coords back into canvas space. + canvasClientOrigin(app->canvas_origin_x, app->canvas_origin_y); // Section tool: LMB on a plane's gizmo arrow grabs it to slide (logical px). if (app->section_tool_active && e->button == 0) { const int hit = app->core.hitTestSectionGizmo(int(e->targetX), int(e->targetY)); @@ -181,7 +204,8 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { } // Section gizmo drag: slide the grabbed plane along its normal (logical px). if (app->section_dragging) { - app->core.updateSectionDrag(int(e->targetX), int(e->targetY)); + app->core.updateSectionDrag(int(e->targetX - app->canvas_origin_x), + int(e->targetY - app->canvas_origin_y)); return EM_TRUE; } if (!app->nav_active) return EM_FALSE; @@ -192,12 +216,14 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { if (app->nav_kind == NavKind::Orbit) app->core.orbitBy(dx, dy); else if (app->nav_kind == NavKind::Pan) app->core.panBy(dx, dy, canvasCssHeight()); else if (app->nav_kind == NavKind::Select && app->nav_drag_px > kClickDragThresholdPx) { - // Select-button drag → draw the marquee rubber-band (CSS px). - const long x0 = std::min(app->down_x, e->targetX); - const long y0 = std::min(app->down_y, e->targetY); + // Select-button drag → draw the marquee rubber-band (canvas-relative CSS px). + const long mx = long(e->targetX - app->canvas_origin_x); + const long my = long(e->targetY - app->canvas_origin_y); + const long x0 = std::min(app->down_x, mx); + const long y0 = std::min(app->down_y, my); showMarquee(int(x0), int(y0), - int(std::labs(long(e->targetX) - app->down_x)), - int(std::labs(long(e->targetY) - app->down_y))); + int(std::labs(mx - app->down_x)), + int(std::labs(my - app->down_y))); } return EM_TRUE; } @@ -238,11 +264,13 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { if (!no_drag) { // Marquee drag → box-pick the rect (device px) and apply to selection. hideMarquee(); - const long x0 = std::min(app->down_x, e->targetX); - const long y0 = std::min(app->down_y, e->targetY); + const long mx = long(e->targetX - app->canvas_origin_x); + const long my = long(e->targetY - app->canvas_origin_y); + const long x0 = std::min(app->down_x, mx); + const long y0 = std::min(app->down_y, my); const int rx = int(x0 * dpr), ry = int(y0 * dpr); - const int rw = int(std::labs(long(e->targetX) - app->down_x) * dpr); - const int rh = int(std::labs(long(e->targetY) - app->down_y) * dpr); + const int rw = int(std::labs(mx - app->down_x) * dpr); + const int rh = int(std::labs(my - app->down_y) * dpr); app->core.picksInRectAsync(rx, ry, rw, rh, [app, add, remove](std::vector ids) { app->core.applyMarqueeToSelection(ids, add, remove); @@ -253,8 +281,13 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { const int px = int(app->down_x * dpr), py = int(app->down_y * dpr); app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) { app->core.applyPickToSelection(id, add, remove); - // v15 on-demand element metadata fetch: log the picked object's IFC GUID. - if (id != 0) app->core.logSelectedObjectGuidWeb(id); + // Surface the pick to JS: resolve + emit the GUID for a real hit; + // emit an empty selection when a plain click deselects (id 0). + if (id != 0) { + app->core.logSelectedObjectGuidWeb(id); + } else if (!add && !remove) { + EM_ASM({ if (Module.__ifcvOnSelect) Module.__ifcvOnSelect(0, '', -1); }); + } app->host.requestFrame(); }); } @@ -409,7 +442,7 @@ void installInputHandlers(AppState* app) { } // namespace -// Called from shell.html's RAF tick (via Module._raf_tick_c). Exported +// Called from the host page (web/ifcviewer.js)'s RAF tick (via Module._raf_tick_c). Exported // to JS by EXPORTED_FUNCTIONS in CMakeLists.txt; EMSCRIPTEN_KEEPALIVE // also keeps the symbol alive under -O*. extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) { @@ -443,7 +476,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) { } // Stream a sidecar from a registered JS byte-source and APPEND it to the scene -// (federation). shell.html registers the source first — a picked File or a +// (federation). the host page (web/ifcviewer.js) registers the source first — a picked File or a // remote URL, sized up front — into Module.__ifcvSources[source_id], then calls // this. Byte-range: the file is never copied whole into the wasm heap; metadata // is read via ranges and chunks stream per-chunk, so a 500 MB sidecar stays in @@ -454,14 +487,14 @@ extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_source_c(int source_id) { g_app->core.loadSidecarMetadataWeb(source_id, "source"); } -// Drop all loaded models (used by shell.html to replace the embedded sample / +// Drop all loaded models (used by the host page (web/ifcviewer.js) to replace the embedded sample / // a prior federation before loading a fresh set). extern "C" EMSCRIPTEN_KEEPALIVE void clear_scene_c() { if (!g_app || !g_app->ready) return; g_app->core.resetScene(); } -// Viewport-navigation entry points for the shell.html toolbar (buttons that +// Viewport-navigation entry points for the the host page (web/ifcviewer.js) toolbar (buttons that // mirror the keyboard hotkeys). Each schedules a frame. extern "C" EMSCRIPTEN_KEEPALIVE void view_all_c() { if (!g_app || !g_app->ready) return; @@ -518,7 +551,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) { g_app->host.requestFrame(); } -// Streaming progress for the loading bar (shell.html polls these each frame). +// Streaming progress for the loading bar (the host page (web/ifcviewer.js) polls these each frame). // total == 0 while still fetching metadata; resident climbs to total as // geometry chunks arrive. extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_resident_c() { @@ -613,7 +646,7 @@ int main(int /*argc*/, char** /*argv*/) { installInputHandlers(g_app); // Hand the app pointer to the JS-side RAF loop (set up in - // shell.html's onRuntimeInitialized). The loop polls for + // the host page (web/ifcviewer.js)'s onRuntimeInitialized). The loop polls for // Module._app_ptr before invoking _raf_tick_c. EM_ASM({ Module._app_ptr = $0; }, (void*)g_app); }); diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html deleted file mode 100644 index 47b42ccafe..0000000000 --- a/src/ifcviewer-web/shell.html +++ /dev/null @@ -1,384 +0,0 @@ - - - - -IfcViewer (web) - - - - -
-
-
-
-
-
-
-
-
- - - - -
Starting…
- - -{{{ SCRIPT }}} - - diff --git a/src/ifcviewer-web/web/IfcViewerWeb.html b/src/ifcviewer-web/web/IfcViewerWeb.html new file mode 100644 index 0000000000..8ac7799053 --- /dev/null +++ b/src/ifcviewer-web/web/IfcViewerWeb.html @@ -0,0 +1,246 @@ + + + + +IfcViewer (web) — fullscreen + + + + +
+↗ embedded / JS-integration example +
+
+
+
+
+
+
+
+ + + + +
Starting…
+ + + + + + diff --git a/src/ifcviewer-web/web/embedded.html b/src/ifcviewer-web/web/embedded.html new file mode 100644 index 0000000000..9dee6e6454 --- /dev/null +++ b/src/ifcviewer-web/web/embedded.html @@ -0,0 +1,216 @@ + + + + +IfcViewer (web) — embedded / JS integration + + + +
+

IfcOpenShell web viewer — JavaScript integration

+

The viewer is an ordinary page element; the model list and selected GUID are + plain DOM updated from JS.  ↗ fullscreen example

+
+ +
+ +
+
+ +
+
+
Drag to orbit · scroll to zoom · right-click to select (drag right-click to box-select)
+
+ + +
+ + + + + + diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js new file mode 100644 index 0000000000..4f76bf1f8e --- /dev/null +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -0,0 +1,169 @@ +// ifcviewer.js — a small JavaScript integration layer over the Emscripten +// module (IfcViewerWeb.js). Load this AFTER IfcViewerWeb.js, which defines the +// global `createIfcViewer` factory. +// +// +// +// +// +// The canvas element MUST have id="viewer-canvas" — the wasm side hard-codes +// that selector for its WebGPU surface and input handlers. +(function (global) { + 'use strict'; + + // Resolve a remote sidecar's total size so the loader can bound its ranged + // reads: HEAD Content-Length, falling back to a 0-0 Range's Content-Range. + async function sizeUrl(url) { + const head = await fetch(url, { method: 'HEAD' }); + const len = head.ok ? parseInt(head.headers.get('Content-Length') || '0', 10) : 0; + if (len > 0) return len; + const probe = await fetch(url, { headers: { Range: 'bytes=0-0' } }); + const cr = probe.headers.get('Content-Range'); // "bytes 0-0/12345" + return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0; + } + + // Boot a viewer bound to `opts.canvas`. Resolves to the API object once the + // wasm runtime is initialised; `api.ready` resolves once the GPU app is live. + async function create(opts) { + opts = opts || {}; + const factory = opts.moduleFactory || global.createIfcViewer; + if (typeof factory !== 'function') { + throw new Error('createIfcViewer not found — load IfcViewerWeb.js first'); + } + + const selectListeners = []; + let api = null; // built below; the RAF loop only reads it after that + let live = false; + let resolveReady; + const ready = new Promise(function (r) { resolveReady = r; }); + + // The per-frame loop: poll for the app pointer (published once the GPU + // device is ready), then drive the C tick. It is registered from + // onRuntimeInitialized (a clean callback context) rather than after + // `await factory(...)` — that Promise.then continuation is exactly the + // nesting that stalls Dawn-web's device callback and leaves the GPU device + // half-initialised (every buffer then reports "invalid"). Learned during + // the original web bring-up; kept here deliberately. + function startLoop(Module) { + function tick() { + if (Module._app_ptr && Module._raf_tick_c) { + if (!live) { + live = true; + resolveReady(api); + if (opts.onReady) opts.onReady(api); + } + Module._raf_tick_c(Module._app_ptr); + if (opts.onFrame) opts.onFrame(api); + } + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + } + + const Module = await factory({ + canvas: opts.canvas, + // Keep the runtime alive after main() returns so Dawn-web's async + // adapter/device callbacks land (they set Module._app_ptr). + noExitRuntime: true, + print: opts.print || function (t) { console.log(t); }, + printErr: opts.printErr || function (t) { console.warn(t); }, + onRuntimeInitialized: function () { startLoop(this); }, + }); + + // Byte-source registry the wasm reads lazily: a picked File (Blob.slice) or + // a remote URL (HTTP Range). load_sidecar_from_source_c(sid) streams one. + Module.__ifcvSources = Module.__ifcvSources || []; + + // The wasm calls this on every pick; (0, '', -1) means the selection was + // cleared. modelIndex is the picked object's model in load order (matches + // the modelProgress index), or -1. + Module.__ifcvOnSelect = function (objectId, guid, modelIndex) { + const detail = { + objectId: objectId >>> 0, + guid: guid || null, + modelIndex: (typeof modelIndex === 'number' && modelIndex >= 0) ? modelIndex : null, + }; + selectListeners.forEach(function (cb) { + try { cb(detail); } catch (e) { console.error(e); } + }); + try { + document.dispatchEvent(new CustomEvent('ifcviewer:select', { detail: detail })); + } catch (_) { /* older browsers */ } + }; + + // Some test harnesses / the fullscreen page want the raw module on window. + if (opts.exposeAsModuleGlobal) global.Module = Module; + + function registerFile(file) { + const sid = Module.__ifcvSources.length; + Module.__ifcvSources.push({ file: file, url: null, size: file.size }); + return sid; + } + async function registerUrl(url) { + const size = await sizeUrl(url); + if (!size) throw new Error('could not size ' + url + ' (need HEAD or Range support)'); + const sid = Module.__ifcvSources.length; + Module.__ifcvSources.push({ file: null, url: url, size: size }); + return sid; + } + + api = { + module: Module, + ready: ready, + isLive: function () { return live; }, + + // Register a selection listener; returns an unsubscribe function. + onSelect: function (cb) { + selectListeners.push(cb); + return function () { + const i = selectListeners.indexOf(cb); + if (i >= 0) selectListeners.splice(i, 1); + }; + }, + + // Scene / camera passthroughs. + clearScene: function () { if (Module._clear_scene_c) Module._clear_scene_c(); }, + viewAll: function () { if (Module._view_all_c) Module._view_all_c(); }, + frameSelection: function () { if (Module._frame_selection_c) Module._frame_selection_c(); }, + + // Model bookkeeping (ordered by load). Progress is per-model chunk counts. + modelCount: function () { return Module._ifcv_model_count_c ? Module._ifcv_model_count_c() : 0; }, + modelProgress: function (i) { + return { + resident: Module._ifcv_model_resident_c ? Module._ifcv_model_resident_c(i) : 0, + total: Module._ifcv_model_total_c ? Module._ifcv_model_total_c(i) : 0, + }; + }, + bytes: function () { + return { + total: Module._ifcv_bytes_total_c ? Module._ifcv_bytes_total_c() : 0, + needed: Module._ifcv_bytes_needed_c ? Module._ifcv_bytes_needed_c() : 0, + loaded: Module._ifcv_bytes_loaded_c ? Module._ifcv_bytes_loaded_c() : 0, + }; + }, + + registerFileSource: registerFile, + registerUrlSource: registerUrl, + + // Add a model to the scene. `replace: true` drops the current scene first; + // otherwise it appends (a lightweight federation of streamed models). + addFile: async function (file, o) { + if (o && o.replace) this.clearScene(); + Module._load_sidecar_from_source_c(registerFile(file)); + }, + addUrl: async function (url, o) { + if (o && o.replace) this.clearScene(); + Module._load_sidecar_from_source_c(await registerUrl(url)); + }, + }; + return api; + } + + global.IfcViewer = { create: create, sizeUrl: sizeUrl }; +})(window); diff --git a/src/ifcviewer-web/web/index.html b/src/ifcviewer-web/web/index.html new file mode 100644 index 0000000000..c28f8cde09 --- /dev/null +++ b/src/ifcviewer-web/web/index.html @@ -0,0 +1,42 @@ + + + + +IfcOpenShell web viewer — examples + + + + + + diff --git a/src/ifcviewer/CameraMath.h b/src/ifcviewer/CameraMath.h index 33e16ef5d8..b2d282dc47 100644 --- a/src/ifcviewer/CameraMath.h +++ b/src/ifcviewer/CameraMath.h @@ -31,10 +31,13 @@ #include -#include - #include +// pi as float. A plain constant rather than boost::math::constants so this +// header stays dependency-light and compiles under the Emscripten sysroot +// (which has no Boost) — CameraMath is shared by the desktop and web builds. +inline constexpr float kPiF = 3.14159265358979323846f; + inline Eigen::Matrix4f lookAtRH(const Eigen::Vector3f& eye, const Eigen::Vector3f& target, const Eigen::Vector3f& up) { @@ -50,7 +53,7 @@ inline Eigen::Matrix4f lookAtRH(const Eigen::Vector3f& eye, inline Eigen::Matrix4f perspectiveYFovGL(float fovy_deg, float aspect, float near_plane, float far_plane) { - const float fovy_rad = fovy_deg * boost::math::constants::pi() / 180.0f; + const float fovy_rad = fovy_deg * kPiF / 180.0f; const float t = std::tan(fovy_rad * 0.5f); Eigen::Matrix4f m = Eigen::Matrix4f::Zero(); m(0, 0) = 1.0f / (aspect * t); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 99c3000a0d..778a724cc0 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -31,7 +31,6 @@ #include "InstanceCompose.h" #include "Log.h" -#include #include #include @@ -47,7 +46,7 @@ namespace { // updateCamera convention so framing aligns between backends. Eigen::Vector3f orbitEye(const float target[3], float dist, float yaw_deg, float pitch_deg) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; const float yaw = yaw_deg * kDeg2Rad; const float pit = pitch_deg * kDeg2Rad; const float cp = std::cos(pit), sp = std::sin(pit); @@ -178,7 +177,7 @@ void ViewportCore::buildViewProj(Eigen::Matrix4f& view_out, : 1.0f; Eigen::Matrix4f p; if (projection_ortho_) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; const float half_h = camera_distance_ * std::tan(camera_fov_y_deg_ * 0.5f * kDeg2Rad); const float half_w = half_h * aspect; @@ -387,7 +386,7 @@ void ViewportCore::composeInstanceFromPlacement(InstanceInfo& inst, void ViewportCore::frameAabb(const float mn[3], const float mx[3], float padding) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; const float cx = 0.5f * (mn[0] + mx[0]); const float cy = 0.5f * (mn[1] + mx[1]); const float cz = 0.5f * (mn[2] + mx[2]); @@ -513,7 +512,7 @@ void ViewportCore::orbitBy(float dx_px, float dy_px) { } void ViewportCore::panBy(float dx_px, float dy_px, int viewport_height_px) { - constexpr float kDeg2Rad = boost::math::constants::pi() / 180.0f; + constexpr float kDeg2Rad = kPiF / 180.0f; // Pan in the camera's screen-space plane. Within 1° of straight // up/down the world-Z up-reference degenerates (cross with forward @@ -3732,6 +3731,20 @@ void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) { ? m.string_table.substr(e.guid_offset, e.guid_length) : std::string("(none)"); Log::info() << "pick: object " << object_id << " GUID " << guid; + // Load-order index of the object's model (sorted by session id — the + // same order as streamingModelProgress and the JS model list); -1 if + // not found. Lets host pages show which model the pick belongs to. + std::vector model_ids; + model_ids.reserve(models_gpu_.size()); + for (const auto& [id, mm] : models_gpu_) model_ids.push_back(id); + std::sort(model_ids.begin(), model_ids.end()); + const auto pos = std::find(model_ids.begin(), model_ids.end(), session_model_id); + const int model_index = (pos != model_ids.end()) ? int(pos - model_ids.begin()) : -1; + // Surface the selection to JS so host pages can react (e.g. show the + // GUID + model). Fires Module.__ifcvOnSelect(object_id, guid, modelIndex). + EM_ASM({ + if (Module.__ifcvOnSelect) Module.__ifcvOnSelect($0, UTF8ToString($1), $2); + }, object_id, guid.c_str(), model_index); return; } Log::info() << "pick: object " << object_id << " not in element table"; @@ -6121,7 +6134,7 @@ namespace { // degrees → radians. Inline-only, used inside render() for the // focal-length derivation. constexpr float degreesToRadians(float deg) { - return deg * boost::math::constants::pi() / 180.0f; + return deg * kPiF / 180.0f; } // Format a float with N decimals into the running Log line. Used to From 9ccbcc221645c1ce4d8820a05e701326f722df45 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 17:23:48 +1000 Subject: [PATCH 14/20] viewport: wire up the backface-culling setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Backface Culling" checkbox persisted a value and reflected it, but nothing consumed AppSettings::backfaceCulling — the opaque pipeline hardcoded cullMode = Back, so toggling had no effect. Build a second opaque pipeline (cullMode None) alongside the culled one and pick between them per-frame from a backface_culling_ flag; setBackfaceCulling flips the flag and requests a redraw (no rebuild). ViewportWindow forwards it, and MainWindow applies the persisted value at startup and re-applies on change — same wiring as the nav preset. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/MainWindow.cpp | 9 +++++++++ src/ifcviewer/ViewportCore.cpp | 25 +++++++++++++++++++++++-- src/ifcviewer/ViewportCore.h | 8 ++++++++ src/ifcviewer/ViewportWindow.cpp | 4 ++++ src/ifcviewer/ViewportWindow.h | 1 + 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/bonsaiviewer/MainWindow.cpp b/src/bonsaiviewer/MainWindow.cpp index 452274b827..e7d00480b9 100644 --- a/src/bonsaiviewer/MainWindow.cpp +++ b/src/bonsaiviewer/MainWindow.cpp @@ -510,6 +510,15 @@ void MainWindow::setupStatus() { vp->applyNavPreset(AppSettings::navPresetName(preset)); }); + // Backface culling: apply the persisted choice and re-apply live on change. + if (auto* vp = viewport_widget_->viewport()) + vp->setBackfaceCulling(AppSettings::instance().backfaceCulling()); + connect(&AppSettings::instance(), &AppSettings::backfaceCullingChanged, this, + [this](bool enabled) { + if (auto* vp = viewport_widget_->viewport()) + vp->setBackfaceCulling(enabled); + }); + connect(session_state_, &bonsaiviewer::SessionState::statusMessageChanged, this, [this](const QString& mode, const QString& detail) { status_mode_label_->setText(mode); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 778a724cc0..1010753feb 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -479,6 +479,12 @@ void ViewportCore::setNavPreset(const char* name) { nav_bindings_ = { B::Middle, M::Plain, B::Middle, M::Shift, B::Left, M::Plain }; } +void ViewportCore::setBackfaceCulling(bool enabled) { + if (backface_culling_ == enabled) return; + backface_culling_ = enabled; + host_->requestFrame(); +} + bool ViewportCore::frameSelection() { if (selection_.count() == 0) return false; float lo[3] = { std::numeric_limits::infinity(), @@ -1150,6 +1156,19 @@ bool ViewportCore::buildPipelines() { return false; } + // ---- Backface-culling-off variant of the opaque pipeline ----------- + // The "Backface Culling" setting picks between this and main_pipeline_ at + // draw time (opaque pass). Identical but cullMode None, so single-sided + // IFC meshes show their back faces. + WGPURenderPipelineDescriptor rp_desc_nc = rp_desc; + rp_desc_nc.label = svFromCStr("ifcviewer-wgpu.main_pipeline_no_cull"); + rp_desc_nc.primitive.cullMode = WGPUCullMode_None; + main_pipeline_no_cull_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc_nc); + if (!main_pipeline_no_cull_) { + Log::warn() << "wgpu main no-cull render pipeline creation failed"; + return false; + } + // ---- Transparent variant of the main pipeline ---------------------- // Same shader, same layout, same vertex pulling, same depth test — // differs only in: @@ -1796,6 +1815,7 @@ void ViewportCore::shutdown() { if (selection_flags_buffer_) { wgpuBufferRelease(selection_flags_buffer_); selection_flags_buffer_ = nullptr; } selection_flags_capacity_ = 0; if (main_pipeline_) { wgpuRenderPipelineRelease(main_pipeline_); main_pipeline_ = nullptr; } + if (main_pipeline_no_cull_) { wgpuRenderPipelineRelease(main_pipeline_no_cull_); main_pipeline_no_cull_ = nullptr; } if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; } section_gizmo_.destroy(); if (main_shader_module_) { wgpuShaderModuleRelease(main_shader_module_); main_shader_module_ = nullptr; } @@ -6400,9 +6420,10 @@ void ViewportCore::render() { WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); // Two-pass main render: opaque first, then transparent. - if (main_pipeline_ && main_pipeline_transparent_ + if (main_pipeline_ && main_pipeline_no_cull_ && main_pipeline_transparent_ && frame_bind_group_ && !models_gpu_.empty()) { - wgpuRenderPassEncoderSetPipeline(pass, main_pipeline_); + wgpuRenderPassEncoderSetPipeline(pass, + backface_culling_ ? main_pipeline_ : main_pipeline_no_cull_); wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); for (const auto& [session_model_id, m] : models_gpu_) { diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index e03d60d3fe..797793214c 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -223,6 +223,12 @@ public: void setNavPreset(const char* name); const NavBindings& navBindings() const { return nav_bindings_; } + // Toggle backface culling of opaque geometry. Off draws back faces too + // (useful for single-sided IFC meshes). Switches the opaque pipeline at + // draw time — no rebuild. + void setBackfaceCulling(bool enabled); + bool backfaceCulling() const { return backface_culling_; } + // Frame the current selection: union the selected objects' world AABBs and // fit the camera to them (same 1.30 padding as the desktop "F" hotkey). // No-op with an empty selection or no resolvable AABBs; returns whether it @@ -909,7 +915,9 @@ private: WGPUBindGroupLayout model_bgl_ = nullptr; // group 1 WGPUPipelineLayout pipeline_layout_ = nullptr; WGPURenderPipeline main_pipeline_ = nullptr; + WGPURenderPipeline main_pipeline_no_cull_ = nullptr; // backface culling off WGPURenderPipeline main_pipeline_transparent_ = nullptr; + bool backface_culling_ = true; // Section-plane gizmo, shared by desktop + web (both render via render()). // Lifted out of the Qt-coupled OverlayRenderer so one identical gizmo draws // everywhere; the desktop's OverlayRenderer no longer draws it. diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 7c3fc394c9..c1acab8bbc 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1499,6 +1499,10 @@ void ViewportWindow::applyNavPreset(const char* name) { select_button_ = toQtBtn(b.select); select_mods_ = toQtMod(b.select_mod); } +void ViewportWindow::setBackfaceCulling(bool enabled) { + core_.setBackfaceCulling(enabled); +} + // ----------------------------------------------------------------------------- // One-shot framebuffer capture → PNG // ----------------------------------------------------------------------------- diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 82a77cc892..746811a2cf 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -247,6 +247,7 @@ public: // Sources the shared binding table from ViewportCore; called from init // (env / persisted setting) and live from the Settings dialog. void applyNavPreset(const char* name); + void setBackfaceCulling(bool enabled); // Queue a one-shot framebuffer capture: the next rendered frame is From db884047e17558f3388d4424db005756eccfcc7f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 18:32:37 +1000 Subject: [PATCH 15/20] ifcviewer: don't block the UI while baking the .ifcview at 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a fresh .ifc streams geometry to the GPU, then bakes the .ifcview cache. That bake — reorder + per-chunk zstd (level 19) — ran synchronously in SceneLoader::onStreamerFinished, which is a QueuedConnection slot on the main thread, so it froze the UI right as the progress bar hit 100% (≈15s of zstd for a 130 MB-geometry model). - Move the compress + writeSidecar onto a background thread. The geometry is already resident and the sidecar is only a cache for the next open, so the viewport is interactive the instant streaming finishes; the write is joined before the next write and in the destructor. - Parallelise the per-chunk zstd across hardware_concurrency threads (compress all chunks, then write serially to keep contiguous offsets) so the background write also finishes quickly. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer/SceneLoader.cpp | 17 +++++++--- src/ifcviewer/SceneLoader.h | 4 +++ src/ifcviewer/SidecarCache.cpp | 61 +++++++++++++++++++++++++++------- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/ifcviewer/SceneLoader.cpp b/src/ifcviewer/SceneLoader.cpp index a78fee1adb..45c2277e10 100644 --- a/src/ifcviewer/SceneLoader.cpp +++ b/src/ifcviewer/SceneLoader.cpp @@ -49,6 +49,8 @@ SceneLoader::SceneLoader(ViewportWindow* viewport, QObject* parent) SceneLoader::~SceneLoader() { joinSidecarThread(); joinDataSourceThreads(); + if (sidecar_write_thread_.joinable()) + sidecar_write_thread_.join(); } void SceneLoader::joinSidecarThread() { @@ -389,15 +391,20 @@ void SceneLoader::onStreamerFinished() { if (auto* file = model.streamer->ifcFile()) { georef = computeModelGeoref(file); } - QElapsedTimer write_timer; write_timer.start(); SidecarData data = model.sidecar_builder->finalize(georef, model.streamed_elements); // Lay geometry out in streaming-chunk order + bake the chunk TOC // (v14) so it streams as one contiguous range per chunk. reorderSidecarByMorton(data); - const bool ok = writeSidecar(model.file_path.toStdString(), data); - std::fprintf(stderr, - "[info] Sidecar finalize + write: %lld ms (%s)\n", - (long long)write_timer.elapsed(), ok ? "ok" : "FAILED"); + // Compress + write the .ifcview on a background thread so the + // seconds of zstd on a large model don't freeze the UI right at + // 100%. The geometry is already on the GPU and the sidecar is + // only a cache for the next open, so it finishes asynchronously + // (joined before the next write / in the destructor). + if (sidecar_write_thread_.joinable()) sidecar_write_thread_.join(); + sidecar_write_thread_ = std::thread( + [ifc_path = model.file_path.toStdString(), sd = std::move(data)]() { + writeSidecar(ifc_path, sd); + }); model.sidecar_builder.reset(); } diff --git a/src/ifcviewer/SceneLoader.h b/src/ifcviewer/SceneLoader.h index 4a03247de4..bcdcbf0f91 100644 --- a/src/ifcviewer/SceneLoader.h +++ b/src/ifcviewer/SceneLoader.h @@ -173,6 +173,10 @@ private: uint32_t next_session_model_id_ = 1; uint32_t loading_session_model_id_ = 0; std::thread sidecar_read_thread_; + // Background .ifcview compress + write, so the seconds of zstd on a big + // model don't freeze the UI at 100%. Joined before the next write and in + // the destructor so a pending write always completes. + std::thread sidecar_write_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. diff --git a/src/ifcviewer/SidecarCache.cpp b/src/ifcviewer/SidecarCache.cpp index 4c894f44e0..73ca7c7ab9 100644 --- a/src/ifcviewer/SidecarCache.cpp +++ b/src/ifcviewer/SidecarCache.cpp @@ -45,8 +45,11 @@ #include "SidecarCache.h" #include "SidecarCompress.h" +#include +#include #include #include +#include // The baker (writeSidecar) compresses — desktop only; the web build never bakes // and links a decompress-only zstd. Everything from here to writeSidecar's end @@ -184,20 +187,54 @@ bool writeSidecar(const std::string& ifc_path, const SidecarData& data) { const long geom_start = ftell(f); std::vector chunks = data.chunks; // fill blob offsets below - std::vector vraw, iraw; - for (auto& sidecar_chunk : chunks) { - extractChunkGeometry(data, sidecar_chunk, vraw, iraw); - auto vz = SidecarCompress::compress(vraw.data(), vraw.size(), kSidecarZstdLevel); - auto iz = SidecarCompress::compress(iraw.data(), iraw.size(), kSidecarZstdLevel); - if ((vraw.size() && vz.empty()) || (iraw.size() && iz.empty())) { fclose(f); return false; } + + // Compress every chunk's geometry in parallel — zstd is the bulk of the bake + // cost — then write the frames serially so their offsets stay contiguous. + struct ChunkBlob { + std::vector vz, iz; + std::size_t v_raw = 0, i_raw = 0; + }; + std::vector blobs(chunks.size()); + std::atomic compress_ok{true}; + { + const unsigned hw = std::max(1u, std::thread::hardware_concurrency()); + const std::size_t worker_count = + std::min(hw, std::max(std::size_t(1), chunks.size())); + std::atomic next{0}; + auto worker = [&]() { + std::vector vraw, iraw; + for (std::size_t idx = next.fetch_add(1); idx < chunks.size(); + idx = next.fetch_add(1)) { + extractChunkGeometry(data, chunks[idx], vraw, iraw); + blobs[idx].v_raw = vraw.size(); + blobs[idx].i_raw = iraw.size(); + blobs[idx].vz = SidecarCompress::compress(vraw.data(), vraw.size(), kSidecarZstdLevel); + blobs[idx].iz = SidecarCompress::compress(iraw.data(), iraw.size(), kSidecarZstdLevel); + if ((vraw.size() && blobs[idx].vz.empty()) || + (iraw.size() && blobs[idx].iz.empty())) { + compress_ok.store(false, std::memory_order_relaxed); + } + } + }; + std::vector pool; + pool.reserve(worker_count > 0 ? worker_count - 1 : 0); + for (std::size_t i = 1; i < worker_count; ++i) pool.emplace_back(worker); + worker(); // the calling thread participates too + for (auto& th : pool) th.join(); + } + if (!compress_ok.load()) { fclose(f); return false; } + + for (std::size_t idx = 0; idx < chunks.size(); ++idx) { + auto& sidecar_chunk = chunks[idx]; + const ChunkBlob& blob = blobs[idx]; sidecar_chunk.v_comp_off = std::uint64_t(ftell(f) - geom_start); - sidecar_chunk.v_comp_size = vz.size(); - sidecar_chunk.v_raw_size = vraw.size(); - if (!vz.empty() && !write_bytes(vz.data(), vz.size())) { fclose(f); return false; } + sidecar_chunk.v_comp_size = blob.vz.size(); + sidecar_chunk.v_raw_size = blob.v_raw; + if (!blob.vz.empty() && !write_bytes(blob.vz.data(), blob.vz.size())) { fclose(f); return false; } sidecar_chunk.i_comp_off = std::uint64_t(ftell(f) - geom_start); - sidecar_chunk.i_comp_size = iz.size(); - sidecar_chunk.i_raw_size = iraw.size(); - if (!iz.empty() && !write_bytes(iz.data(), iz.size())) { fclose(f); return false; } + sidecar_chunk.i_comp_size = blob.iz.size(); + sidecar_chunk.i_raw_size = blob.i_raw; + if (!blob.iz.empty() && !write_bytes(blob.iz.data(), blob.iz.size())) { fclose(f); return false; } } const long geom_end = ftell(f); if (geom_start < 0 || geom_end < 0) { fclose(f); return false; } From 79d8408684a1d0423973e1ead5519f484cca3e9b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 20:36:51 +1000 Subject: [PATCH 16/20] models: consume .rdbview bundles (extract at load time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .rdbview is a zip of model.rdb/ (the lossy IFC data DB — the rdb serializer skips IfcRepresentationItem) + model.ifcview (baked geometry). The viewer could produce them but not open them. - extractRdbview(): unzip a .rdbview (QZipReader) into a session temp dir keyed by a hash of path+mtime+size (reused on re-open), returning the extracted model.rdb. The producer's layout means sidecarPath(model.rdb) resolves the sibling model.ifcview automatically, so it then loads exactly like any pure .rdb: geometry from the sidecar, data from the .rdb via ifcopenshell::file(FT_AUTODETECT). No SceneLoader/engine changes. - detail::loadModels() resolves each source path through it before queueModels (both fresh-open and project reload go through here), so the Federation persists the .rdbview while the loader gets the extracted .rdb. - cleanupRdbviewCache() clears stale extractions at startup. - .rdbview is offered under "Add Geometry" (the file picker; "Add IFC Database" is a directory picker), not "Add IFC File" — it's a lossy viewer bundle, not a source IFC. Co-Authored-By: Claude Opus 4.8 --- src/bonsaiviewer/main.cpp | 4 + .../modules/models/AddModelDialog.cpp | 2 +- src/bonsaiviewer/modules/models/Commands.cpp | 81 +++++++++++++++++-- src/bonsaiviewer/modules/models/Commands.h | 4 + 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/bonsaiviewer/main.cpp b/src/bonsaiviewer/main.cpp index a77fe2896a..612167a6ee 100644 --- a/src/bonsaiviewer/main.cpp +++ b/src/bonsaiviewer/main.cpp @@ -21,6 +21,7 @@ #include "MainWindow.h" #include "ViewerSettings.h" #include "components/Style.h" +#include "modules/models/Commands.h" #include #include @@ -55,6 +56,9 @@ int main(int argc, char* argv[]) { app.setApplicationName("Bonsai Viewer"); app.setOrganizationName("IfcOpenShell"); + // Clear any .rdbview extractions left in temp by a previous session. + bonsaiviewer::modules::models::commands::cleanupRdbviewCache(); + QSurfaceFormat fmt; fmt.setVersion(4, 5); fmt.setProfile(QSurfaceFormat::CoreProfile); diff --git a/src/bonsaiviewer/modules/models/AddModelDialog.cpp b/src/bonsaiviewer/modules/models/AddModelDialog.cpp index 98df960344..20372ac7ac 100644 --- a/src/bonsaiviewer/modules/models/AddModelDialog.cpp +++ b/src/bonsaiviewer/modules/models/AddModelDialog.cpp @@ -100,7 +100,7 @@ void AddModelDialog::setupUi() { {SourceMode::IfcDatabase, "Add IFC\nDatabase", ":/icons/database.svg", "Add IFC RDB databases for optimised performance"}, {SourceMode::GeometryOnly, "Add Geometry", ":/icons/cube-bandage.svg", - "Add pure geometry for fast visualisation"}, + "Add a viewer cache (.ifcview) or geometry database (.rdbview) for fast visualisation"}, }; const QList cloud_choices = { {SourceMode::CloudModel, "Add From\nCloud", ":/icons/cloud-square.svg", diff --git a/src/bonsaiviewer/modules/models/Commands.cpp b/src/bonsaiviewer/modules/models/Commands.cpp index c098c77eb5..4ae98e4176 100644 --- a/src/bonsaiviewer/modules/models/Commands.cpp +++ b/src/bonsaiviewer/modules/models/Commands.cpp @@ -56,6 +56,8 @@ #include #include +#include +#include #include @@ -89,8 +91,56 @@ QString formatElapsed(qint64 ms) { : QString::number(ms) + " ms"; } +// Session-scoped scratch root where .rdbview bundles are unzipped for loading. +QString rdbviewCacheRoot() { + return QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)) + .filePath("ifcviewer-rdbview"); +} + +// A .rdbview is a zip of `model.rdb/` (the data DB) + `model.ifcview` (geometry +// sidecar). Unzip it into a per-source subdir (hashed from path + mtime + size, +// so a re-open reuses an existing extraction) and return the extracted +// `model.rdb` path — from there it loads exactly like any pure .rdb (geometry +// from the co-extracted sibling .ifcview). Returns empty on failure. +QString extractRdbview(const QString& rdbview_path) { + const QFileInfo info(rdbview_path); + const QString key = rdbview_path + '|' + + QString::number(info.lastModified().toMSecsSinceEpoch()) + '|' + + QString::number(info.size()); + const QString hash = QString::fromLatin1( + QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha1).toHex()); + const QString dir = QDir(rdbviewCacheRoot()).filePath(hash); + const QString rdb = QDir(dir).filePath("model.rdb"); + + if (QFileInfo::exists(rdb)) return rdb; // already extracted this session + + QZipReader reader(rdbview_path); + if (reader.status() != QZipReader::NoError) return {}; + QDir().mkpath(dir); + for (const QZipReader::FileInfo& entry : reader.fileInfoList()) { + if (!entry.isFile) continue; // dirs recreated below as needed + const QString out = QDir(dir).filePath(entry.filePath); + QDir().mkpath(QFileInfo(out).absolutePath()); + QFile f(out); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(reader.fileData(entry.filePath)); + } + return QFileInfo::exists(rdb) ? rdb : QString(); // empty if the bundle lacked model.rdb +} + +// Map a source path to the path the loader should open: a .rdbview is unzipped +// to its extracted .rdb; everything else passes through unchanged. +QString resolveLoadPath(const QString& path) { + if (path.endsWith(".rdbview", Qt::CaseInsensitive)) return extractRdbview(path); + return path; +} + } // namespace +void cleanupRdbviewCache() { + QDir(rdbviewCacheRoot()).removeRecursively(); +} + void toggleVisibility(SessionState& session, ItemKind kind, const QString& id) { Federation* federation = session.federation(); if (kind == ItemKind::Group) { @@ -201,9 +251,28 @@ namespace detail { void loadModels(SessionState& session, const QStringList& paths, const QStringList& model_ids) { if (paths.isEmpty()) return; - const auto session_model_ids = session.loader()->queueModels(paths); - for (int i = 0; i < paths.size() && i < static_cast(session_model_ids.size()) && i < model_ids.size(); ++i) { - session.setModelMapping(model_ids[i], session_model_ids[i]); + // The Federation stores the source paths (e.g. a .rdbview); the loader gets + // the resolved load path (a .rdbview is unzipped at load time to its .rdb). + // Keep model_ids aligned with the paths that actually resolve. + QStringList load_paths; + QStringList load_model_ids; + for (int i = 0; i < paths.size(); ++i) { + const QString resolved = resolveLoadPath(paths[i]); + if (resolved.isEmpty()) { + session.setStatusMessage("Error", + QString("Could not open %1").arg(QFileInfo(paths[i]).fileName())); + continue; + } + load_paths.push_back(resolved); + load_model_ids.push_back(i < model_ids.size() ? model_ids[i] : QString()); + } + if (load_paths.isEmpty()) return; + + const auto session_model_ids = session.loader()->queueModels(load_paths); + for (int i = 0; i < load_paths.size() + && i < static_cast(session_model_ids.size()) + && i < load_model_ids.size(); ++i) { + session.setModelMapping(load_model_ids[i], session_model_ids[i]); } } @@ -243,9 +312,11 @@ void addModel(SessionState& session, QWidget& host) { break; } case SourceMode::GeometryOnly: { - QFileDialog file_dialog(&host, "Add Geometry Only"); + QFileDialog file_dialog(&host, "Add Geometry"); file_dialog.setFileMode(QFileDialog::ExistingFiles); - file_dialog.setNameFilter("IFC Viewer Cache (*.ifcview);;All Files (*)"); + file_dialog.setNameFilter( + "Viewer Model (*.ifcview *.rdbview);;IFC Viewer Cache (*.ifcview);;" + "Geometry Database (*.rdbview);;All Files (*)"); file_dialog.setOption(QFileDialog::DontUseNativeDialog, true); if (file_dialog.exec() == QDialog::Accepted) { paths = file_dialog.selectedFiles(); diff --git a/src/bonsaiviewer/modules/models/Commands.h b/src/bonsaiviewer/modules/models/Commands.h index 12233598fc..78672e713e 100644 --- a/src/bonsaiviewer/modules/models/Commands.h +++ b/src/bonsaiviewer/modules/models/Commands.h @@ -74,6 +74,10 @@ void convertIfcToDatabase(SessionState& session, QWidget& host); void exportGeometryDatabase(SessionState& session, QWidget& host); void openSettings(SessionState& session, QWidget& host); +// Remove the scratch dir used to unzip .rdbview bundles for loading. Call once +// at startup to clear extractions left over from previous sessions. +void cleanupRdbviewCache(); + // Internal building blocks shared by commands here and by ProjectController. // These NEVER call notify*() — the caller is responsible for emitting once // at the end of its execution. From ecee648b44a5f135c67bdd714985d44f08b53a26 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 21:51:49 +1000 Subject: [PATCH 17/20] ci: install aqtinstall into the uv run env (fix Linux Qt6 install) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-all.py's install_qt6 runs `sys.executable -m aqt`, but the build now runs under `uv run`, whose isolated env never got aqtinstall — it was pip installed into the system Python. `uv run --with typing_extensions --with aqtinstall` puts them where the script actually executes. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/build_rocky.yml | 2 +- .github/workflows/build_rocky_arm.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml index 1af2066580..a5b2524b21 100644 --- a/.github/workflows/build_rocky.yml +++ b/.github/workflows/build_rocky.yml @@ -66,7 +66,7 @@ jobs: shell: bash run: | set -o pipefail - CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON uv run --with typing_extensions --with aqtinstall ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log - name: Upload Build Logs if: always() diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml index 9a813f807b..618af9c9e8 100644 --- a/.github/workflows/build_rocky_arm.yml +++ b/.github/workflows/build_rocky_arm.yml @@ -66,7 +66,7 @@ jobs: shell: bash run: | set -o pipefail - CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release BUILD_BONSAIVIEWER=ON uv run --with typing_extensions --with aqtinstall ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log - name: Upload Build Logs if: always() From 16845131096bb7996035259cc79f95d128bd2d7f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 22:00:41 +1000 Subject: [PATCH 18/20] ifcparse: parse doubles via C-locale strtod_l on macOS (fix Apple build) parse_num_ used std::from_chars for both integers and doubles, but the floating-point from_chars overload is =deleted in Apple clang's libc++, so the macOS build failed to compile (parse.cpp:136, instantiated for double). Split parse_num_ with `if constexpr`: integers keep std::from_chars everywhere; on macOS, doubles parse via strtod_l with a cached "C" locale (locale-independent, restoring the pre-charconv Apple path). libstdc++ and the MSVC STL have working float from_chars and are left unchanged. Co-Authored-By: Claude Opus 4.8 --- src/ifcparse/parse.cpp | 41 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/ifcparse/parse.cpp b/src/ifcparse/parse.cpp index 1b02dc3cc5..cf99dd98ff 100644 --- a/src/ifcparse/parse.cpp +++ b/src/ifcparse/parse.cpp @@ -40,6 +40,16 @@ #include #include #include +#include + +// Apple clang's libc++ has no floating-point std::from_chars overload (it's +// =deleted), so on macOS doubles are parsed via strtod_l with a cached "C" +// locale — locale-independent, unlike strtod. Other platforms (libstdc++, +// MSVC STL) have working float from_chars and are left unchanged. +#if defined(__APPLE__) +#include +#include +#endif #ifdef USE_MMAP #include @@ -121,6 +131,13 @@ std::string& spf_lexer::get_temp_string() const { namespace { +#if defined(__APPLE__) +double parse_double_c(const char* start, char** end) { + static const locale_t loc = newlocale(LC_NUMERIC_MASK, "C", (locale_t)0); + return strtod_l(start, end, loc); +} +#endif + template bool parse_num_(const char* pStart, size_t size, T& val) { if (size == 0) { @@ -133,11 +150,27 @@ bool parse_num_(const char* pStart, size_t size, T& val) { return false; } } - auto re = std::from_chars(pStart, pStart + size, val); - if (re.ec != std::errc() || re.ptr != pStart + size) { - return false; + if constexpr (std::is_floating_point_v) { +#if defined(__APPLE__) + // pStart is NUL-terminated at pStart + size (callers pass c_str()), so + // strtod_l stops exactly at the end of a well-formed number. from_chars + // is not instantiated for double here — its float overload is =deleted + // in Apple's libc++. + char* pEnd = nullptr; + const double result = parse_double_c(pStart, &pEnd); + if (pEnd != pStart + size) { + return false; + } + val = static_cast(result); + return true; +#else + auto re = std::from_chars(pStart, pStart + size, val); + return re.ec == std::errc() && re.ptr == pStart + size; +#endif + } else { + auto re = std::from_chars(pStart, pStart + size, val); + return re.ec == std::errc() && re.ptr == pStart + size; } - return true; } } // namespace From b3f83f67caf750748833957f4837e293fbee02fd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 23:28:41 +1000 Subject: [PATCH 19/20] ifcgeomserver: test iterator->next() via operator bool (fix ambiguous !=) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterator::next() now returns express::Base (data-model branch). Comparing it against 0 is ambiguous: 0 converts to Base via the pointer ctor while Base converts to int via operator bool, so both operator!=(int,int) and Base::operator!= are candidates. Use an explicit truthiness test — an empty Base signals end-of-iteration. Co-Authored-By: Claude Opus 4.8 --- src/ifcgeomserver/IfcGeomServer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcgeomserver/IfcGeomServer.cpp b/src/ifcgeomserver/IfcGeomServer.cpp index 25d7d5e0eb..65d55c944e 100644 --- a/src/ifcgeomserver/IfcGeomServer.cpp +++ b/src/ifcgeomserver/IfcGeomServer.cpp @@ -629,7 +629,7 @@ int main () { } case NEXT: { Next n; n.read(std::cin); - has_more = iterator->next() != 0; + has_more = static_cast(iterator->next()); if (!has_more) { delete file; delete iterator; From d3b12d0307752b8c146665bb4c3d22e8e5711188 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 9 Jul 2026 23:32:11 +1000 Subject: [PATCH 20/20] ifcwrap: ignore spf_header set_file_* setters in SWIG (fix Windows wrapper) The data-model branch's spf_header::set_file_description/name/schema take a const shared_pointer_type& (an internal instance_data* storage handle). SWIG wraps them and emits the alias unqualified into the global-scope wrapper, which MSVC rejects (C2065 'shared_pointer_type': undeclared identifier). The matching getters are already %ignore'd and re-exposed via %extend; the raw setters are not a usable Python API, so ignore them the same way. Co-Authored-By: Claude Opus 4.8 --- src/ifcwrap/IfcParseWrapper.i | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ifcwrap/IfcParseWrapper.i b/src/ifcwrap/IfcParseWrapper.i index 8f23cbab84..cde78f64ae 100644 --- a/src/ifcwrap/IfcParseWrapper.i +++ b/src/ifcwrap/IfcParseWrapper.i @@ -55,6 +55,13 @@ %ignore ifcopenshell::spf_header::file_description; %ignore ifcopenshell::spf_header::file_name; %ignore ifcopenshell::spf_header::file_schema; +// The setters take a raw shared_pointer_type (an internal instance_data* +// storage handle), not a Python-facing type. SWIG would emit the alias +// unqualified into the global-scope wrapper (C2065 on MSVC), and these +// aren't a usable Python API anyway — ignore them like the getters above. +%ignore ifcopenshell::spf_header::set_file_description; +%ignore ifcopenshell::spf_header::set_file_name; +%ignore ifcopenshell::spf_header::set_file_schema; %ignore ifcopenshell::HeaderEntity::is;