Merge branch 'ifcviewer-wgpu' of https://github.com/IfcOpenShell/IfcOpenShell into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-09 22:02:39 +02:00
82 changed files with 2904 additions and 1453 deletions
+9 -9
View File
@@ -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<BasicElementInfo> ElementRegistry::basicElementInfoForModel(uint32_t model_id) const {
std::vector<BasicElementInfo> ElementRegistry::basicElementInfoForModel(uint32_t session_model_id) const {
std::vector<BasicElementInfo> 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<express::Base> 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<express::Base> ElementRegistry::findEntity(uint32_t object_id) con
}
}
void ElementRegistry::onSidecarElementsReady(uint32_t /*model_id*/,
void ElementRegistry::onSidecarElementsReady(uint32_t /*session_model_id*/,
std::vector<ElementTableRecord> 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<ElementInfo> elements) {
void ElementRegistry::onStreamedElementsReady(uint32_t /*session_model_id*/, std::vector<ElementInfo> 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);
+5 -5
View File
@@ -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<BasicElementInfo> basicElementInfoForModel(uint32_t model_id) const;
void removeModel(uint32_t session_model_id);
std::vector<BasicElementInfo> basicElementInfoForModel(uint32_t session_model_id) const;
std::optional<BasicElementInfo> findBasicElementInfo(uint32_t object_id) const;
std::optional<express::Base> findEntity(uint32_t object_id) const;
private:
void onSidecarElementsReady(uint32_t model_id,
void onSidecarElementsReady(uint32_t session_model_id,
std::vector<ElementTableRecord> elements,
std::string string_table);
void onStreamedElementsReady(uint32_t model_id, std::vector<ElementInfo> elements);
void onStreamedElementsReady(uint32_t session_model_id, std::vector<ElementInfo> elements);
SceneLoader* loader_ = nullptr;
std::unordered_map<uint32_t, BasicElementInfo> elements_;
+9
View File
@@ -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);
+15 -15
View File
@@ -71,7 +71,7 @@ double volumeOfObjects(ViewportWindow& vp,
const std::vector<uint32_t>& object_ids) {
if (object_ids.empty()) return 0.0;
// Group selected instances by (model_id, mesh_id) so each unique mesh
// 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<uint64_t, std::vector<double>> 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<uint64_t, double> 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<float> world_xyz;
world_xyz.reserve(selected_.size() * 9);
for (const auto& [key, sel] : selected_) {
const uint64_t cache_key = (uint64_t(sel.model_id) << 32)
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) {
+3 -3
View File
@@ -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<float> positions; // 3 * N_verts
@@ -89,14 +89,14 @@ private:
// edge_key (min<<32 | max) → list of triangle indices touching it.
std::unordered_map<uint64_t, std::vector<uint32_t>> edges;
};
MeshCache* meshCache(ViewportWindow& vp, uint32_t model_id, uint32_t mesh_id);
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];
+53 -32
View File
@@ -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,
@@ -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) {
@@ -118,44 +127,56 @@ 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);
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() {
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();
setActiveModelId(QString());
}
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::setActiveModelId(const QString& model_id) {
if (model_id == active_model_id_) return;
active_model_id_ = model_id;
emit activeModelChanged(active_model_id_);
}
QVariantMap SessionState::cloudMetadata(const QString& fed_id) const {
return cloud_metadata_.value(fed_id);
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);
}
uint32_t SessionState::modelIdForFedId(const QString& fed_id) const {
return fed_id_to_model_id_.value(fed_id, 0);
QVariantMap SessionState::cloudMetadata(const QString& model_id) const {
return cloud_metadata_.value(model_id);
}
QString SessionState::fedIdForModelId(uint32_t model_id) const {
return model_id_to_fed_id_.value(model_id);
uint32_t SessionState::sessionModelIdForModelId(const QString& model_id) const {
return model_id_to_session_model_id_.value(model_id, 0);
}
QList<uint32_t> SessionState::modelIds() const {
return model_id_to_fed_id_.keys();
QString SessionState::modelIdForSessionModelId(uint32_t session_model_id) const {
return session_model_id_to_model_id_.value(session_model_id);
}
QList<uint32_t> SessionState::sessionModelIds() const {
return session_model_id_to_model_id_.keys();
}
void SessionState::notifySelectionChanged() {
@@ -174,8 +195,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) {
+25 -12
View File
@@ -64,25 +64,31 @@ 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<uint32_t> 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<uint32_t> 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();
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 +106,13 @@ 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 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.
@@ -119,8 +131,9 @@ private:
uint32_t selected_object_id_ = 0;
QString status_mode_;
QString status_detail_;
QHash<QString, uint32_t> fed_id_to_model_id_;
QHash<uint32_t, QString> model_id_to_fed_id_;
QHash<QString, uint32_t> model_id_to_session_model_id_;
QHash<uint32_t, QString> session_model_id_to_model_id_;
QString active_model_id_;
QHash<QString, QVariantMap> cloud_metadata_;
};
+30 -1
View File
@@ -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};
}
@@ -143,6 +166,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;
@@ -176,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 {
@@ -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
+8 -1
View File
@@ -21,6 +21,7 @@
#include "MainWindow.h"
#include "ViewerSettings.h"
#include "components/Style.h"
#include "modules/models/Commands.h"
#include <QApplication>
#include <QCommandLineParser>
@@ -41,7 +42,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));
}
}
@@ -52,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);
@@ -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<Choice> cloud_choices = {
{SourceMode::CloudModel, "Add From\nCloud", ":/icons/cloud-square.svg",
+123 -51
View File
@@ -56,6 +56,8 @@
#include <QUuid>
#include <QtCore/private/qzipwriter_p.h>
#include <QtCore/private/qzipreader_p.h>
#include <QCryptographicHash>
#include <Eigen/Dense>
@@ -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) {
@@ -166,31 +216,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 +248,31 @@ 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<int>(model_ids.size()) && i < fed_ids.size(); ++i) {
session.setModelMapping(fed_ids[i], 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<int>(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();
@@ -269,20 +340,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 +389,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 +409,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 +440,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 +488,15 @@ void saveModelToCloud(SessionState& session, QWidget& host, const QString& fed_i
QPointer<SessionState> 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 +510,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 +552,20 @@ void saveModelAsToCloud(SessionState& session, QWidget& host, const QString& fed
QPointer<SessionState> 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));
},
+8 -4
View File
@@ -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,21 +66,25 @@ 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);
// 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.
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
@@ -87,14 +87,28 @@ QStandardItem* FederationItemModel::makeGroupNameItem(const QString& group_id, c
return item;
}
QStandardItem* FederationItemModel::makeModelNameItem(const QString& fed_id, const QString& display_name) const {
auto* item = new QStandardItem(components::icons::makeSvgIcon(":/icons/cube.svg"), display_name);
item->setData(fed_id, IdRole);
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(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) {
@@ -129,14 +143,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 +242,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();
@@ -53,32 +53,39 @@ 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);
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);
QIcon modelIcon(const QString& model_id) const; // accent cube when active, else plain
Federation* federation_ = nullptr;
QHash<QString, QStandardItem*> id_to_name_item_; // both group_ids and fed_ids
QHash<QString, QStandardItem*> id_to_name_item_; // both group_ids and model_ids
QString active_model_id_;
};
} // namespace bonsaiviewer::modules::models
+9 -2
View File
@@ -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) {
@@ -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<int>(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();
@@ -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;
@@ -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;
@@ -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;
+3
View File
@@ -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
+27 -27
View File
@@ -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<QString, QStringList> connector_to_fed_ids;
QHash<QString, QStringList> 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<SessionState> sguard(&session);
QPointer<ViewportWindow> 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",
+98 -12
View File
@@ -33,6 +33,8 @@
#include <QToolButton>
#include <QVBoxLayout>
#include <optional>
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<bonsaiviewer::modules::properties::KeyValueRow>& rows, QWidget* parent = nullptr) {
QList<bonsaiviewer::components::KeyValueTableRow> table_rows;
for (const auto& row : rows) {
@@ -71,6 +91,12 @@ QWidget* makeRelationshipList(const QList<bonsaiviewer::modules::properties::Rel
return new bonsaiviewer::components::KeyValueTable(table_rows, parent);
}
QLabel* makeEmptyStateLabel(const QString& text, QWidget* parent = nullptr) {
auto* label = new QLabel(text, parent);
label->setObjectName("panelSectionEmptyLabel");
return label;
}
QWidget* makeFilterWrapper(QLineEdit** field_out, QWidget* parent = nullptr) {
auto* wrapper = new QWidget(parent);
wrapper->setObjectName("panelSectionFilterWrapper");
@@ -130,16 +156,12 @@ PropertiesPanel::PropertiesPanel(QWidget* parent)
void PropertiesPanel::render(const PropertiesPanelState& state) {
clearBodyWidgets();
QList<QWidget*> property_set_widgets;
for (const auto& property_set : state.property_sets) {
property_set_widgets.append(makePropertySetPanel(property_set, this));
}
QList<QWidget*> 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));
@@ -173,9 +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);
properties_container_ = makeSetContainer(properties_section);
properties_section->addBodyWidget(properties_container_);
rebuildPropertyWidgets();
properties_section->setExpanded(properties_expanded_);
properties_filter_toggle->setChecked(properties_filter_visible_);
@@ -200,9 +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);
quantities_container_ = makeSetContainer(quantities_section);
quantities_section->addBodyWidget(quantities_container_);
rebuildQuantityWidgets();
quantities_section->setExpanded(quantities_expanded_);
quantities_filter_toggle->setChecked(quantities_filter_visible_);
@@ -234,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<PropertySet> 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<PropertySet>& sets,
const QString& filter_text,
const QString& empty_text,
const QString& no_match_text) {
if (!container) return;
auto* layout = qobject_cast<QVBoxLayout*>(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
@@ -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<PropertySet> property_sets_data_;
QList<PropertySet> quantity_sets_data_;
QWidget* properties_container_ = nullptr;
QWidget* quantities_container_ = nullptr;
};
} // namespace bonsaiviewer::modules::properties
+96 -59
View File
@@ -25,14 +25,70 @@
#include "../../ElementRegistry.h"
#include "../../SessionState.h"
#include "element.h" // helpers: get_predefined_type, get_type, get_container
#include "pset.h" // helpers: get_psets
#include <cstdint>
#include <optional>
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<QString> formatPropertyValue(const property_value& value) {
if (const auto* text = value.get_if<std::string>()) {
return QString::fromStdString(*text);
}
if (const auto* flag = value.get_if<bool>()) {
return *flag ? QStringLiteral("True") : QStringLiteral("False");
}
if (const auto* integer = value.get_if<std::int64_t>()) {
return QString::number(static_cast<qlonglong>(*integer));
}
if (const auto* real = value.get_if<double>()) {
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<PropertySet> toPropertySets(const element_properties& sets) {
QList<PropertySet> 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)
: 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]() {
@@ -46,76 +102,57 @@ 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;
}
auto entity = registry->findEntity(object_id);
auto entity = registry ? registry->findEntity(object_id)
: std::optional<express::Base>{};
if (entity) {
state.entity.entity_class = QString::fromStdString(entity->declaration().name());
if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) {
state.property_sets[1].rows[0].value = state.entity.entity_class;
if (auto predefined_type = get_predefined_type(*entity)) {
state.entity.predefined_type = QString::fromStdString(*predefined_type);
}
} else {
// 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)});
}
// 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 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 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;
if (!state.property_sets.isEmpty() && !state.property_sets[1].rows.isEmpty()) {
state.property_sets[1].rows[0].value = info->type;
}
}
if (info && !info->name.isEmpty()) {
state.attributes[1].value = info->name;
if (state.property_sets.size() > 1 && state.property_sets[1].rows.size() > 1) {
state.property_sets[1].rows[1].value = info->name;
}
// Geometry only — no live IFC entity to read a predefined type from.
state.entity.predefined_type = "N/A";
}
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);
@@ -24,34 +24,83 @@
#include "../../components/SvgIcon.h"
#include <QHeaderView>
#include <QMenu>
#include <QShowEvent>
#include <QSizePolicy>
#include <QTreeWidget>
#include <QTreeWidgetItem>
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_->setColumnCount(2);
tree_->setHeaderLabels({"Spatial Item", ""});
tree_->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
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));
});
// 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::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<TreeNode>& nodes) {
@@ -63,11 +112,15 @@ void SpatialHierarchyPanel::setNodes(const QList<TreeNode>& 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);
}
@@ -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
@@ -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<TreeNode> children;
@@ -23,11 +23,34 @@
#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 "placement.h" // helpers: get_storey_elevation
#include <QCollator>
#include <algorithm>
namespace bonsaiviewer::modules::spatial_hierarchy {
namespace {
// Sort siblings by name with natural ordering (so "Level 2" precedes "Level 10").
void sortByName(QList<TreeNode>& 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<TreeNode>& nodes, const NodePath& path, int depth) {
for (auto& node : nodes) {
if (node.name != path.at(depth)) continue;
@@ -37,6 +60,40 @@ TreeNode* findNodeRecursive(QList<TreeNode>& 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;
// 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));
}
sortByName(node.children);
return node;
}
} // namespace
SpatialHierarchyPanelView::SpatialHierarchyPanelView(SpatialHierarchyPanel* widget,
@@ -44,14 +101,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 +109,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();
}
@@ -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);
+25 -25
View File
@@ -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() {
+4 -4
View File
@@ -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;
+1
View File
@@ -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)
+305
View File
@@ -0,0 +1,305 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <boost/logic/tribool.hpp>
#include <cstddef>
#include <sstream>
#include <type_traits>
namespace {
template <typename Schema, typename = void>
struct is_ifc4_or_higher : std::false_type {};
template <typename Schema>
struct is_ifc4_or_higher<Schema, std::void_t<typename Schema::IfcMaterialDefinition>> : 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);
}
// 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<std::string> format_scalar(const attribute_value& value) {
if (value.isNull()) {
return std::nullopt;
}
switch (value.type()) {
case ifcopenshell::Argument_STRING:
return static_cast<std::string>(value);
case ifcopenshell::Argument_ENUMERATION: {
const enumeration_reference enumeration = value;
return enumeration.value() ? std::string(enumeration.value()) : std::string();
}
case ifcopenshell::Argument_INT:
return std::to_string(static_cast<int>(value));
case ifcopenshell::Argument_DOUBLE: {
std::ostringstream stream;
stream << static_cast<double>(value);
return stream.str();
}
case ifcopenshell::Argument_BOOL:
return static_cast<bool>(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<bool>(logical) ? std::string("True") : std::string("False");
}
default:
return std::nullopt;
}
}
// ifcopenshell.util.element.get_type: the construction type of an occurrence
// (get_type(type_element) == type_element).
template <typename Schema>
express::Base get_type_s(const express::Base& element) {
if (element.template as<typename Schema::IfcTypeObject>()) {
return element;
}
const auto object = element.template as<typename Schema::IfcObject>();
if (!object) {
return {};
}
if constexpr (is_ifc4_or_higher<Schema>::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<typename Schema::IfcRelDefinesByType>()) {
return by_type.RelatingType();
}
}
}
return {};
}
template <typename Schema>
std::optional<std::string> get_predefined_type_s(const express::Base& element) {
// Prefer the associated type element's predefined type.
if (const express::Base type = get_type_s<Schema>(element)) {
std::optional<std::string> 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<std::string> 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<std::string> predefined_type = get_string_attribute(element, "PredefinedType");
if (!predefined_type || *predefined_type == "USERDEFINED") {
predefined_type = get_string_attribute(element, "ObjectType");
}
return predefined_type;
}
// ifcopenshell.util.element.get_aggregate: the aggregate parent, via the
// Decomposes inverse (IfcRelAggregates.RelatingObject).
template <typename Schema>
express::Base get_aggregate_s(const express::Base& element) {
const auto object = element.template as<typename Schema::IfcObjectDefinition>();
if (!object) {
return {};
}
const auto decomposes = object.Decomposes();
if (decomposes.empty()) {
return {};
}
const auto relationship = decomposes.front();
if constexpr (!is_ifc4_or_higher<Schema>::value) {
// IFC2X3 reuses Decomposes for both aggregates and nests.
if (!relationship.template as<typename Schema::IfcRelAggregates>()) {
return {};
}
}
return relationship.RelatingObject();
}
// The spatial-structure children aggregated under this element (IsDecomposedBy →
// RelatedObjects, filtered to spatial elements).
template <typename Schema>
std::vector<express::Base> get_spatial_children_s(const express::Base& element) {
std::vector<express::Base> children;
const auto object = element.template as<typename Schema::IfcObjectDefinition>();
if (!object) {
return children;
}
for (const auto& relationship : object.IsDecomposedBy()) {
for (const auto& related : relationship.RelatedObjects()) {
if (related.template as<typename Schema::IfcSpatialStructureElement>()) {
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.
template <typename Schema>
express::Base get_container_s(const express::Base& element) {
if (const auto product = element.template as<typename Schema::IfcElement>()) {
const auto relationships = product.ContainedInStructure();
if (!relationships.empty()) {
return relationships.front().RelatingStructure();
}
}
if (const express::Base aggregate = get_aggregate_s<Schema>(element)) {
return get_container_s<Schema>(aggregate);
}
return {};
}
} // namespace
std::optional<std::string> 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<Schema>(element); \
}
IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH)
#undef IFCOPENSHELL_DISPATCH
unsupported_schema(name);
}
std::vector<std::pair<std::string, std::string>> get_scalar_attributes(const express::Base& element) {
std::vector<std::pair<std::string, std::string>> 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<Schema>(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<Schema>(element); \
}
IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH)
#undef IFCOPENSHELL_DISPATCH
unsupported_schema(name);
}
std::vector<express::Base> 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<Schema>(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<std::string> 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<std::size_t>(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<std::string>(value);
default:
return std::nullopt;
}
}
+69
View File
@@ -0,0 +1,69 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 of the License, or *
* (at your option) any later version. *
* *
* IfcOpenShell is distributed in the hope 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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// This file was generated with the assistance of an AI coding tool.
#ifndef ELEMENT_H
#define ELEMENT_H
#include "../ifcparse/express.h"
#include <optional>
#include <string>
#include <utility>
#include <vector>
// 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<std::string> 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<std::string> 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<express::Base> 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,
// aggregates / lists, or unset (IFC null) are omitted — so the caller gets a
// flat, display-ready view with no nested objects.
std::vector<std::pair<std::string, std::string>> get_scalar_attributes(const express::Base& element);
#endif // ELEMENT_H
+41
View File
@@ -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 <cstddef>
#include <type_traits>
#include <vector>
@@ -125,6 +128,31 @@ Eigen::Matrix4d get_local_placement_s(const express::Base& placement) {
return get_axis2_placement_s<Schema>(placement);
}
// ifcopenshell.util.placement.get_storey_elevation: the Z of the storey's
// placement in project units, falling back to the Elevation attribute.
template <typename Schema>
double get_storey_elevation_s(const express::Base& storey) {
const auto typed = storey.template as<typename Schema::IfcBuildingStorey>();
if (!typed) {
return 0.0;
}
if (const auto placement = typed.ObjectPlacement()) {
return get_local_placement_s<Schema>(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<std::size_t>(index));
if (!value.isNull() && value.type() == ifcopenshell::Argument_DOUBLE) {
return static_cast<double>(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<Schema>(storey);
IFCOPENSHELL_HELPER_FOR_EACH_SCHEMA(IFCOPENSHELL_DISPATCH)
#undef IFCOPENSHELL_DISPATCH
unsupported_schema(name);
}
+5
View File
@@ -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
+37 -4
View File
@@ -40,6 +40,16 @@
#include <string>
#include <iomanip>
#include <charconv>
#include <type_traits>
// 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 <xlocale.h>
#include <locale.h>
#endif
#ifdef USE_MMAP
#include <boost/filesystem/path.hpp>
@@ -123,6 +133,13 @@ std::string& spf_lexer<Reader>::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 <typename T>
bool parse_num_(const char* pStart, size_t size, T& val) {
if (size == 0) {
@@ -135,11 +152,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<T>) {
#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<T>(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
+29 -8
View File
@@ -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
# JSC 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} "$<TARGET_FILE_DIR:IfcViewerWeb>"
COMMENT "Copying web example pages next to IfcViewerWeb.js")
+1 -1
View File
@@ -35,7 +35,7 @@
class WebViewportHost final : public ViewportHost {
public:
// `canvas_selector` is the CSS selector for the host <canvas> (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);
+55 -22
View File
@@ -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 <canvas>; must match shell.html + the
// CSS selector for the host <canvas>; 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 <div> (shell.html)
// Marquee rectangle overlay. The rubber-band is a plain DOM <div> (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<AppState*>(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<long>(app->down_x, e->targetX);
const long y0 = std::min<long>(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<long>(app->down_x, mx);
const long y0 = std::min<long>(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<long>(app->down_x, e->targetX);
const long y0 = std::min<long>(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<long>(app->down_x, mx);
const long y0 = std::min<long>(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<std::uint32_t> 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);
});
-384
View File
@@ -1,384 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcViewer (web)</title>
<style>
html, body { margin: 0; height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
#viewer-canvas { display: block; width: 100vw; height: 100vh; outline: none;
background: #1a1d24; }
/* Marquee (box-select) rubber-band. Positioned in CSS px by main_web; never
eats pointer events so the drag keeps reaching the canvas. */
#marquee { position: fixed; display: none; z-index: 50; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
/* Log overlay sits bottom-left and never eats pointer events (so it
can't block orbit drags over the canvas). It auto-scrolls to the
newest line. Capped small; collapses further once the app is live. */
#status { position: fixed; bottom: 8px; left: 12px;
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
font-size: 11px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
background: rgba(20,22,28,.78); padding: 6px 10px; border-radius: 4px;
white-space: pre-wrap; pointer-events: none; }
#status.ready { max-height: 4.5em; opacity: .5; }
#status.error { background: rgba(120,30,30,.85); color: #fff; }
/* Errors re-expand and re-opaque even after the ready-collapse. */
#status.ready.error { max-height: 28vh; opacity: 1; }
#open-btn, #add-btn { position: fixed; top: 8px; z-index: 10;
background: #2b6cb0; color: #fff; border: none; padding: 6px 12px;
border-radius: 4px; font-size: 12px; cursor: pointer; }
#open-btn { right: 12px; }
#add-btn { right: 120px; background: #2d3748; }
#open-btn:hover { background: #3182ce; }
#add-btn:hover { background: #3b465c; }
#file-input { display: none; }
/* Navigation toolbar (bottom-centre): buttons mirror the desktop hotkeys. */
#nav-toolbar { position: fixed; bottom: 10px; left: 50%;
transform: translateX(-50%); z-index: 10; display: flex; gap: 4px;
background: rgba(20,22,28,.82); padding: 5px 6px; border-radius: 6px; }
#nav-toolbar button { background: #2d3748; color: #c8ccd6; border: none;
padding: 5px 9px; border-radius: 4px; font-size: 12px; cursor: pointer; }
#nav-toolbar button:hover { background: #3b465c; }
#nav-toolbar button.active { background: #2b6cb0; color: #fff; }
#nav-toolbar .sep { width: 1px; background: #3b465c; margin: 2px 3px; }
/* Streaming loading UI: a thin top progress strip (aggregate) + a centred
panel with a per-model segmented bar. Shown only while models stream. */
#progress { position: fixed; top: 0; left: 0; right: 0; height: 3px;
background: rgba(43,108,176,.2); z-index: 20; display: none; }
#progress-fill { height: 100%; width: 0%; background: #3182ce;
transition: width .15s ease; }
#progress-panel { position: fixed; top: 10px; left: 50%;
transform: translateX(-50%); z-index: 20; font-size: 12px;
background: rgba(20,22,28,.9); padding: 8px 12px; border-radius: 6px;
pointer-events: none; display: none; min-width: 280px; max-width: 60vw; }
#progress-summary { margin-bottom: 6px; white-space: nowrap; }
/* Combined bar over the FULL model content: dark track = not needed for this
view, dim = needed-but-not-loaded, bright = loaded. So the bright fill vs
the dim span shows "loaded / needed", and the dim span vs the whole track
shows "needed / total". */
#progress-track { position: relative; height: 8px; border-radius: 3px;
background: #232833; overflow: hidden; }
#progress-needed, #progress-loaded { position: absolute; left: 0; top: 0;
height: 100%; width: 0%; transition: width .2s ease; }
#progress-needed { background: #2b4a6b; }
#progress-loaded { background: #3182ce; }
</style>
</head>
<body>
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
<div id="marquee"></div>
<div id="progress"><div id="progress-fill"></div></div>
<div id="progress-panel">
<div id="progress-summary"></div>
<div id="progress-track">
<div id="progress-needed"></div>
<div id="progress-loaded"></div>
</div>
</div>
<button id="add-btn" title="Add file(s) to the current scene (federation)">Add</button>
<button id="open-btn">Open .ifcview…</button>
<input id="file-input" type="file" accept=".ifcview" multiple>
<div id="nav-toolbar">
<button data-act="fit" title="Fit all (Home)">Fit</button>
<button data-act="focus" title="Zoom to selected (F)">Focus</button>
<button data-act="ortho" id="ortho-btn" title="Toggle orthographic / perspective (P)">Persp</button>
<button data-act="fly" id="fly-btn" title="Fly / first-person — WASD+mouse (⇧F)">Fly</button>
<span class="sep"></span>
<button data-view="0" title="Front (X)">Front</button>
<button data-view="1" title="Back (Shift+X)">Back</button>
<button data-view="2" title="Left (Shift+Y)">Left</button>
<button data-view="3" title="Right (Y)">Right</button>
<button data-view="4" title="Top (Z)">Top</button>
<button data-view="5" title="Bottom (Shift+Z)">Bottom</button>
<span class="sep"></span>
<button data-act="hide" title="Hide selected (H)">Hide</button>
<button data-act="isolate" title="Isolate selected (Shift+H)">Isolate</button>
<button data-act="showall" title="Show all (Alt+H)">Show all</button>
<button data-act="xray" id="xray-btn" title="X-ray — translucent everything (Alt+X)">X-ray</button>
<span class="sep"></span>
<button data-act="section" id="section-btn" title="Section tool — click a surface to cut (K)">Section</button>
<button data-act="clearcut" title="Clear all section cuts (Shift+K)">Clear cuts</button>
</div>
<div id="status">Starting…</div>
<script>
// Emscripten Module hook: route stderr to the status overlay so any
// wgpu init failure (no WebGPU, canvas missing, etc.) is visible
// without opening devtools.
var statusEl = document.getElementById('status');
var Module = {
canvas: document.getElementById('viewer-canvas'),
// Keep the wasm runtime alive after main() returns so the
// Dawn-web RequestAdapter/RequestDevice promise callbacks
// (queued from main) actually land. Without this flag the
// runtime tears down at end-of-main and the callbacks never
// fire — symptom: adapter cb fires (synchronous-ish on the
// first JS tick) but device cb does not. EXIT_RUNTIME=0 in
// CMakeLists is the build-time half; this is the runtime half.
noExitRuntime: true,
print: function(t) { console.log(t); },
printErr: function(t) {
console.warn(t);
// Accumulate every stderr line so the init sequence is visible
// even when the wasm hangs partway. The first time something is
// printed we drop the "Starting…" placeholder.
if (statusEl.textContent === 'Starting…' ||
statusEl.textContent === 'wasm loaded — waiting for WebGPU') {
statusEl.textContent = '';
}
statusEl.textContent += t + '\n';
statusEl.scrollTop = statusEl.scrollHeight;
if (/fail|error|null/i.test(t)) statusEl.classList.add('error');
},
onRuntimeInitialized: function() {
statusEl.textContent = 'wasm loaded — waiting for WebGPU';
// RAF loop. Polls for Module._app_ptr (set by C once the wgpu
// device callback completes init) and only then drives the C
// tick. Living in shell.html means the loop is set up from a
// clean JS top-level, NOT nested inside Dawn-web's Promise.then
// chain — which is the configuration that stalls device-callback
// delivery (verified during web bring-up).
var collapsedOnce = false;
var urlLoadTried = false;
// Models to auto-load from the query string, as a federation. Accepts
// either repeated params (?model=a&model=b&…) or a comma list
// (?models=a,b,c) — or a mix. Each becomes its own streamed source.
var qs = new URLSearchParams(location.search);
var modelUrls = qs.getAll('model');
var modelsCsv = qs.get('models');
if (modelsCsv) modelUrls = modelUrls.concat(
modelsCsv.split(',').map(function(s){ return s.trim(); }).filter(Boolean));
function shellTick() {
if (Module._app_ptr && Module._raf_tick_c) {
// First time the app goes live, collapse the log overlay so it
// stops covering the viewport.
if (!collapsedOnce) { statusEl.classList.add('ready'); collapsedOnce = true; }
// Auto-load every ?model= sidecar via HTTP Range once the app is live
// (one-shot). Same-origin needs no CORS; cross-origin URLs require the
// host to send CORS + Accept-Ranges headers. They stream concurrently
// into one scene (chunk concurrency is globally capped downstream).
if (!urlLoadTried && modelUrls.length && Module._load_sidecar_from_source_c) {
urlLoadTried = true;
window.beginLoadProgress(modelUrls.length);
Module._clear_scene_c(); // replace the embedded sample once
modelUrls.forEach(function(url) {
registerUrlSource(url).then(function(sid) {
Module._load_sidecar_from_source_c(sid);
}).catch(function(e) {
statusEl.textContent += 'url load failed (' + url + '): ' + e + '\n';
statusEl.classList.add('error');
});
});
}
window.updateLoadProgress();
if (Module._fly_is_active_c) {
var fb = document.getElementById('fly-btn');
if (fb) fb.classList.toggle('active', !!Module._fly_is_active_c());
}
if (Module._xray_is_active_c) {
var xb = document.getElementById('xray-btn');
if (xb) xb.classList.toggle('active', !!Module._xray_is_active_c());
}
if (Module._section_is_active_c) {
var sb = document.getElementById('section-btn');
if (sb) sb.classList.toggle('active', !!Module._section_is_active_c());
}
Module._raf_tick_c(Module._app_ptr);
}
requestAnimationFrame(shellTick);
}
requestAnimationFrame(shellTick);
}
};
if (!navigator.gpu) {
statusEl.textContent = 'navigator.gpu is missing — open in a browser with WebGPU enabled';
statusEl.classList.add('error');
}
// --- Byte-source registry (multi-file federation) -------------------------
// Each model streams from its own source: a picked File (Blob.slice) or a
// remote URL (HTTP Range), registered here and read lazily by the wasm side
// via Module.__ifcvSources[id]. URLs are sized up front (HEAD, else a 0-0
// Range's Content-Range) so the loader can bound its reads.
Module.__ifcvSources = Module.__ifcvSources || [];
function registerFileSource(file) {
var sid = Module.__ifcvSources.length;
Module.__ifcvSources.push({ file: file, url: null, size: file.size });
return sid;
}
function registerUrlSource(url) {
return fetch(url, { method: 'HEAD' }).then(function(resp) {
var len = resp.ok ? parseInt(resp.headers.get('Content-Length') || '0', 10) : 0;
if (len > 0) return len;
return fetch(url, { headers: { Range: 'bytes=0-0' } }).then(function(r2) {
var cr = r2.headers.get('Content-Range'); // "bytes 0-0/12345"
return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0;
});
}).then(function(size) {
if (!size) throw new Error('could not size ' + url + ' (need HEAD or Range)');
var sid = Module.__ifcvSources.length;
Module.__ifcvSources.push({ file: null, url: url, size: size });
return sid;
});
}
// --- Streaming loading bar ------------------------------------------------
// Driven by the C-side progress exports (resident/total chunks) + the
// bytes-downloaded counter the EM_JS range reader maintains. Shown only for
// streamed loads (URL / picked file), not the tiny embedded sample.
var progEl = document.getElementById('progress');
var fillEl = document.getElementById('progress-fill');
var panelEl = document.getElementById('progress-panel');
var summaryEl = document.getElementById('progress-summary');
var neededEl = document.getElementById('progress-needed');
var loadedEl = document.getElementById('progress-loaded');
var loadActive = false;
var expectedModels = 1;
var caughtUpAt = 0;
// Call with the number of models this batch will load (federation).
window.beginLoadProgress = function(nModels) {
loadActive = true;
expectedModels = Math.max(1, nModels || 1);
Module.__ifcvBytesLoaded = 0;
caughtUpAt = 0;
progEl.style.display = 'block';
panelEl.style.display = 'block';
summaryEl.textContent = 'Loading ' + expectedModels +
' model' + (expectedModels === 1 ? '' : 's') + '…';
};
function endLoadProgress() {
progEl.style.display = 'none'; panelEl.style.display = 'none'; loadActive = false;
}
function fmtMB(b) { return (b / 1e6).toFixed(b < 1e8 ? 1 : 0); }
// Combined progress over the whole federation, honouring contribution culling:
// loaded / needed = how done THIS view is
// needed / total = how much of the whole model this view even requires
// Auto-shows whenever there's work (initial load OR navigation revealing new
// chunks) and fades shortly after the current view is fully loaded.
window.updateLoadProgress = function() {
if (!Module._ifcv_bytes_total_c) return;
var total = Module._ifcv_bytes_total_c();
var needed = Module._ifcv_bytes_needed_c();
var loaded = Module._ifcv_bytes_loaded_c();
var mc = Module._ifcv_model_count_c ? Module._ifcv_model_count_c() : 0;
var dlMB = (Module.__ifcvBytesLoaded || 0) / 1e6;
// No geometry chunks yet = still fetching the per-model "overhead" (mesh +
// instance metadata) needed before we can even tell which chunks are visible.
var overhead = total === 0;
var streaming = needed > loaded + 1; // geometry still to fetch for this view
if (overhead || streaming) { loadActive = true; caughtUpAt = 0; }
if (!loadActive) return;
// Re-assert visibility every active frame so the bar REAPPEARS when
// navigation reveals new chunks after a previous hide (fix: was only set
// in beginLoadProgress, so it stayed hidden).
progEl.style.display = 'block';
panelEl.style.display = 'block';
if (overhead) {
var frac = expectedModels > 0 ? mc / expectedModels : 0;
neededEl.style.width = '100%';
loadedEl.style.width = (100 * frac) + '%';
fillEl.style.width = Math.max(4, 100 * frac) + '%';
summaryEl.textContent = 'Loading model data — ' + dlMB.toFixed(1) + ' MB · ' +
mc + ' / ' + expectedModels + ' models ready';
return;
}
// Geometry phase: bar spans the whole model; dim = needed for this view,
// bright = loaded. "loaded / needed" = this view's progress; "needed / total"
// = how much of the model this view requires.
neededEl.style.width = (100 * needed / total) + '%';
loadedEl.style.width = (100 * loaded / total) + '%';
fillEl.style.width = (needed > 0 ? Math.round(100 * loaded / needed) : 100) + '%';
var pctNeeded = Math.round(100 * needed / total);
var more = (mc < expectedModels) ? ' · ' + mc + '/' + expectedModels + ' models' : '';
if (streaming) {
summaryEl.textContent = 'Loading ' + fmtMB(loaded) + ' / ' + fmtMB(needed) +
' MB for this view · ' + pctNeeded + '% of ' + fmtMB(total) + ' MB total' + more;
} else {
summaryEl.textContent = (pctNeeded >= 99 ? 'Loaded ' : 'View loaded — ') +
fmtMB(loaded) + ' MB · ' + pctNeeded + '% of ' + fmtMB(total) + ' MB total' + more;
if (!caughtUpAt) caughtUpAt = performance.now();
if (performance.now() - caughtUpAt > 1500) endLoadProgress();
}
};
// File-browse loading (#88, byte-range). The picked File object is stashed
// on Module.__ifcvFile and load_sidecar_from_blob_c reads it lazily via
// Blob.slice — the file is NOT copied into the wasm heap, so a 500 MB
// sidecar stays in the browser File object and only chunk-sized slices
// ever cross into wasm. No drag-drop: that needs an X11 drag source (a
// file manager), which a minimal WM may not provide; the native file
// chooser this button opens is WM-independent.
// "Open" replaces the scene with the picked file(s); "Add" appends them to
// the current scene (federation). Multiple files can be picked at once. Each
// File is registered as its own byte-source (kept alive in __ifcvSources for
// lazy Blob.slice reads) and streamed independently.
// RMB is the select/marquee button in the Web nav preset, so suppress the
// browser context menu over the canvas. (Firefox forces its native menu on
// Shift+RightClick regardless — a browser escape hatch pages can't override.)
var viewerCanvas = document.getElementById('viewer-canvas');
if (viewerCanvas) {
viewerCanvas.addEventListener('contextmenu', function(ev) { ev.preventDefault(); });
}
var openBtn = document.getElementById('open-btn');
var addBtn = document.getElementById('add-btn');
var fileInput = document.getElementById('file-input');
var pendingMode = 'replace';
openBtn.addEventListener('click', function() { pendingMode = 'replace'; fileInput.click(); });
addBtn.addEventListener('click', function() { pendingMode = 'add'; fileInput.click(); });
fileInput.addEventListener('change', function(ev) {
var files = ev.target.files;
if (!files || !files.length) return;
if (!Module._load_sidecar_from_source_c) {
statusEl.textContent += 'viewer not ready yet — wait for WebGPU init\n';
return;
}
try {
// Replace: N = picked files. Add: existing models + picked files.
var existing = (pendingMode === 'add' && Module._ifcv_model_count_c)
? Module._ifcv_model_count_c() : 0;
if (pendingMode === 'replace') Module._clear_scene_c();
window.beginLoadProgress(existing + files.length);
for (var i = 0; i < files.length; i++) {
var sid = registerFileSource(files[i]);
Module._load_sidecar_from_source_c(sid);
}
} catch (e) {
statusEl.textContent += 'load failed: ' + e + '\n';
statusEl.classList.add('error');
}
fileInput.value = ''; // let the same file be re-picked
});
// Navigation toolbar → C camera calls (same core methods as the hotkeys).
function refreshOrthoLabel() {
var b = document.getElementById('ortho-btn');
if (b && Module._projection_is_ortho_c)
b.textContent = Module._projection_is_ortho_c() ? 'Ortho' : 'Persp';
}
document.querySelectorAll('#nav-toolbar button').forEach(function(b) {
b.addEventListener('click', function() {
if (!Module._view_all_c) return; // viewer not ready yet
var act = b.getAttribute('data-act');
var view = b.getAttribute('data-view');
if (act === 'fit') Module._view_all_c();
else if (act === 'focus') Module._frame_selection_c();
else if (act === 'ortho') { Module._toggle_projection_c(); refreshOrthoLabel(); }
else if (act === 'fly') Module._toggle_fly_c();
else if (act === 'hide') Module._hide_selected_c();
else if (act === 'isolate') Module._isolate_selected_c();
else if (act === 'showall') Module._show_all_c();
else if (act === 'xray') Module._toggle_xray_c();
else if (act === 'section') Module._toggle_section_c();
else if (act === 'clearcut') Module._clear_section_c();
else if (view !== null) Module._standard_view_c(parseInt(view, 10));
});
});
</script>
<!-- IfcViewerWeb.js is emitted alongside this shell by the emcc build;
`--shell-file` injects this HTML around it. -->
{{{ SCRIPT }}}
</body>
</html>
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcViewer (web) — fullscreen</title>
<style>
html, body { margin: 0; height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
#viewer-canvas { display: block; width: 100vw; height: 100vh; outline: none;
background: #1a1d24; }
/* Marquee (box-select) rubber-band. Positioned in CSS px by main_web; never
eats pointer events so the drag keeps reaching the canvas. */
#marquee { position: fixed; display: none; z-index: 50; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
/* Log overlay sits bottom-left and never eats pointer events. */
#status { position: fixed; bottom: 8px; left: 12px;
max-width: min(60vw, 680px); max-height: 28vh; overflow-y: auto;
font-size: 11px;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
background: rgba(20,22,28,.78); padding: 6px 10px; border-radius: 4px;
white-space: pre-wrap; pointer-events: none; }
#status.ready { max-height: 4.5em; opacity: .5; }
#status.error { background: rgba(120,30,30,.85); color: #fff; }
#status.ready.error { max-height: 28vh; opacity: 1; }
#open-btn, #add-btn { position: fixed; top: 8px; z-index: 10;
background: #2b6cb0; color: #fff; border: none; padding: 6px 12px;
border-radius: 4px; font-size: 12px; cursor: pointer; }
#open-btn { right: 12px; }
#add-btn { right: 120px; background: #2d3748; }
#open-btn:hover { background: #3182ce; }
#add-btn:hover { background: #3b465c; }
#file-input { display: none; }
#nav-toolbar { position: fixed; bottom: 10px; left: 50%;
transform: translateX(-50%); z-index: 10; display: flex; gap: 4px;
background: rgba(20,22,28,.82); padding: 5px 6px; border-radius: 6px; }
#nav-toolbar button { background: #2d3748; color: #c8ccd6; border: none;
padding: 5px 9px; border-radius: 4px; font-size: 12px; cursor: pointer; }
#nav-toolbar button:hover { background: #3b465c; }
#nav-toolbar button.active { background: #2b6cb0; color: #fff; }
#nav-toolbar .sep { width: 1px; background: #3b465c; margin: 2px 3px; }
#progress { position: fixed; top: 0; left: 0; right: 0; height: 3px;
background: rgba(43,108,176,.2); z-index: 20; display: none; }
#progress-fill { height: 100%; width: 0%; background: #3182ce;
transition: width .15s ease; }
#progress-panel { position: fixed; top: 10px; left: 50%;
transform: translateX(-50%); z-index: 20; font-size: 12px;
background: rgba(20,22,28,.9); padding: 8px 12px; border-radius: 6px;
pointer-events: none; display: none; min-width: 280px; max-width: 60vw; }
#progress-summary { margin-bottom: 6px; white-space: nowrap; }
#progress-track { position: relative; height: 8px; border-radius: 3px;
background: #232833; overflow: hidden; }
#progress-needed, #progress-loaded { position: absolute; left: 0; top: 0;
height: 100%; width: 0%; transition: width .2s ease; }
#progress-needed { background: #2b4a6b; }
#progress-loaded { background: #3182ce; }
#example-link { position: fixed; top: 8px; left: 12px; z-index: 10;
font-size: 12px; color: #7f9bd6; text-decoration: none; }
</style>
</head>
<body>
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
<div id="marquee"></div>
<a id="example-link" href="embedded.html">↗ embedded / JS-integration example</a>
<div id="progress"><div id="progress-fill"></div></div>
<div id="progress-panel">
<div id="progress-summary"></div>
<div id="progress-track">
<div id="progress-needed"></div>
<div id="progress-loaded"></div>
</div>
</div>
<button id="add-btn" title="Add file(s) to the current scene (federation)">Add</button>
<button id="open-btn">Open .ifcview…</button>
<input id="file-input" type="file" accept=".ifcview" multiple>
<div id="nav-toolbar">
<button data-act="fit" title="Fit all (Home)">Fit</button>
<button data-act="focus" title="Zoom to selected (F)">Focus</button>
<button data-act="ortho" id="ortho-btn" title="Toggle orthographic / perspective (P)">Persp</button>
<button data-act="fly" id="fly-btn" title="Fly / first-person — WASD+mouse (⇧F)">Fly</button>
<span class="sep"></span>
<button data-view="0" title="Front (X)">Front</button>
<button data-view="1" title="Back (Shift+X)">Back</button>
<button data-view="2" title="Left (Shift+Y)">Left</button>
<button data-view="3" title="Right (Y)">Right</button>
<button data-view="4" title="Top (Z)">Top</button>
<button data-view="5" title="Bottom (Shift+Z)">Bottom</button>
<span class="sep"></span>
<button data-act="hide" title="Hide selected (H)">Hide</button>
<button data-act="isolate" title="Isolate selected (Shift+H)">Isolate</button>
<button data-act="showall" title="Show all (Alt+H)">Show all</button>
<button data-act="xray" id="xray-btn" title="X-ray — translucent everything (Alt+X)">X-ray</button>
<span class="sep"></span>
<button data-act="section" id="section-btn" title="Section tool — click a surface to cut (K)">Section</button>
<button data-act="clearcut" title="Clear all section cuts (Shift+K)">Clear cuts</button>
</div>
<div id="status">Starting…</div>
<script src="IfcViewerWeb.js"></script>
<script src="ifcviewer.js"></script>
<script>
var statusEl = document.getElementById('status');
function routeStatus(t, isErr) {
if (statusEl.textContent === 'Starting…') statusEl.textContent = '';
statusEl.textContent += t + '\n';
statusEl.scrollTop = statusEl.scrollHeight;
if (isErr || /fail|error|null/i.test(t)) statusEl.classList.add('error');
}
if (!navigator.gpu) {
statusEl.textContent = 'navigator.gpu is missing — open in a browser with WebGPU enabled';
statusEl.classList.add('error');
}
// RMB is the select/marquee button in the Web nav preset — suppress the
// browser context menu over the canvas.
var viewerCanvas = document.getElementById('viewer-canvas');
viewerCanvas.addEventListener('contextmenu', function (ev) { ev.preventDefault(); });
// --- Streaming loading bar (driven off the C progress exports) ------------
var progEl = document.getElementById('progress');
var fillEl = document.getElementById('progress-fill');
var panelEl = document.getElementById('progress-panel');
var summaryEl = document.getElementById('progress-summary');
var neededEl = document.getElementById('progress-needed');
var loadedEl = document.getElementById('progress-loaded');
var loadActive = false, expectedModels = 1, caughtUpAt = 0;
function beginLoadProgress(nModels) {
loadActive = true; expectedModels = Math.max(1, nModels || 1); caughtUpAt = 0;
progEl.style.display = 'block'; panelEl.style.display = 'block';
summaryEl.textContent = 'Loading ' + expectedModels +
' model' + (expectedModels === 1 ? '' : 's') + '…';
}
function endLoadProgress() {
progEl.style.display = 'none'; panelEl.style.display = 'none'; loadActive = false;
}
function fmtMB(b) { return (b / 1e6).toFixed(b < 1e8 ? 1 : 0); }
function updateLoadProgress(viewer) {
var b = viewer.bytes();
var mc = viewer.modelCount();
var dlMB = (viewer.module.__ifcvBytesLoaded || 0) / 1e6;
var overhead = b.total === 0;
var streaming = b.needed > b.loaded + 1;
if (overhead || streaming) { loadActive = true; caughtUpAt = 0; }
if (!loadActive) return;
progEl.style.display = 'block'; panelEl.style.display = 'block';
if (overhead) {
var frac = expectedModels > 0 ? mc / expectedModels : 0;
neededEl.style.width = '100%';
loadedEl.style.width = (100 * frac) + '%';
fillEl.style.width = Math.max(4, 100 * frac) + '%';
summaryEl.textContent = 'Loading model data — ' + dlMB.toFixed(1) + ' MB · ' +
mc + ' / ' + expectedModels + ' models ready';
return;
}
neededEl.style.width = (100 * b.needed / b.total) + '%';
loadedEl.style.width = (100 * b.loaded / b.total) + '%';
fillEl.style.width = (b.needed > 0 ? Math.round(100 * b.loaded / b.needed) : 100) + '%';
var pctNeeded = Math.round(100 * b.needed / b.total);
var more = (mc < expectedModels) ? ' · ' + mc + '/' + expectedModels + ' models' : '';
if (streaming) {
summaryEl.textContent = 'Loading ' + fmtMB(b.loaded) + ' / ' + fmtMB(b.needed) +
' MB for this view · ' + pctNeeded + '% of ' + fmtMB(b.total) + ' MB total' + more;
} else {
summaryEl.textContent = (pctNeeded >= 99 ? 'Loaded ' : 'View loaded — ') +
fmtMB(b.loaded) + ' MB · ' + pctNeeded + '% of ' + fmtMB(b.total) + ' MB total' + more;
if (!caughtUpAt) caughtUpAt = performance.now();
if (performance.now() - caughtUpAt > 1500) endLoadProgress();
}
}
function syncButton(id, active) {
var el = document.getElementById(id);
if (el) el.classList.toggle('active', !!active);
}
IfcViewer.create({
canvas: viewerCanvas,
exposeAsModuleGlobal: true, // window.Module — used by the smoke tests
print: function (t) { console.log(t); },
// The wasm logs (incl. [info]) come through stderr; only redden the status
// box on actual error/failure lines, not on every info message.
printErr: function (t) { console.warn(t); routeStatus(t); },
onReady: function (viewer) {
statusEl.classList.add('ready');
// Auto-load ?model= / ?models= sidecars as a federation (one-shot).
var qs = new URLSearchParams(location.search);
var urls = qs.getAll('model');
var csv = qs.get('models');
if (csv) urls = urls.concat(csv.split(',').map(function (s) { return s.trim(); }).filter(Boolean));
if (urls.length) {
beginLoadProgress(urls.length);
viewer.clearScene(); // replace the embedded sample once
urls.forEach(function (url) {
viewer.addUrl(url).catch(function (e) { routeStatus('url load failed (' + url + '): ' + e, true); });
});
}
},
onFrame: function (viewer) {
updateLoadProgress(viewer);
var M = viewer.module;
syncButton('fly-btn', M._fly_is_active_c && M._fly_is_active_c());
syncButton('xray-btn', M._xray_is_active_c && M._xray_is_active_c());
syncButton('section-btn', M._section_is_active_c && M._section_is_active_c());
},
}).then(function (viewer) {
// File open (replace) / add (append). Multiple files → a federation.
var openBtn = document.getElementById('open-btn');
var addBtn = document.getElementById('add-btn');
var fileInput = document.getElementById('file-input');
var pendingMode = 'replace';
openBtn.addEventListener('click', function () { pendingMode = 'replace'; fileInput.click(); });
addBtn.addEventListener('click', function () { pendingMode = 'add'; fileInput.click(); });
fileInput.addEventListener('change', function (ev) {
var files = ev.target.files;
if (!files || !files.length) return;
var existing = pendingMode === 'add' ? viewer.modelCount() : 0;
if (pendingMode === 'replace') viewer.clearScene();
beginLoadProgress(existing + files.length);
for (var i = 0; i < files.length; i++) viewer.addFile(files[i]);
fileInput.value = '';
});
var orthoBtn = document.getElementById('ortho-btn');
document.querySelectorAll('#nav-toolbar button').forEach(function (b) {
b.addEventListener('click', function () {
var M = viewer.module;
var act = b.getAttribute('data-act');
var view = b.getAttribute('data-view');
if (act === 'fit') viewer.viewAll();
else if (act === 'focus') viewer.frameSelection();
else if (act === 'ortho') { M._toggle_projection_c(); orthoBtn.textContent = M._projection_is_ortho_c() ? 'Ortho' : 'Persp'; }
else if (act === 'fly') M._toggle_fly_c();
else if (act === 'hide') M._hide_selected_c();
else if (act === 'isolate') M._isolate_selected_c();
else if (act === 'showall') M._show_all_c();
else if (act === 'xray') M._toggle_xray_c();
else if (act === 'section') M._toggle_section_c();
else if (act === 'clearcut') M._clear_section_c();
else if (view !== null) M._standard_view_c(parseInt(view, 10));
});
});
});
</script>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcViewer (web) — embedded / JS integration</title>
<style>
:root { color-scheme: dark; }
html, body { margin: 0; height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
header { padding: 12px 16px; border-bottom: 1px solid #232833; }
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
header p { margin: 4px 0 0; font-size: 12px; color: #8a93a6; }
header a { color: #7f9bd6; }
.layout { display: flex; gap: 16px; padding: 16px; align-items: flex-start;
flex-wrap: wrap; }
/* The viewer is a normal, sized DOM box — NOT fullscreen. */
#viewer-box { position: relative; width: 640px; height: 460px; max-width: 100%;
border: 1px solid #232833; border-radius: 6px; overflow: hidden;
background: #1a1d24; }
#viewer-canvas { display: block; width: 100%; height: 100%; outline: none; }
#marquee { position: absolute; display: none; z-index: 5; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
.sidebar { flex: 1 1 300px; min-width: 280px; display: flex; flex-direction: column; gap: 14px; }
.card { border: 1px solid #232833; border-radius: 6px; background: #151821; }
.card h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase; color: #8a93a6;
border-bottom: 1px solid #232833; }
.card .body { padding: 12px; }
.row { display: flex; gap: 8px; align-items: center; }
.row + .row { margin-top: 8px; }
input[type=text] { flex: 1; min-width: 0; background: #0f1117; color: #c8ccd6;
border: 1px solid #2b3244; border-radius: 4px; padding: 6px 8px; font-size: 12px; }
button { background: #2b6cb0; color: #fff; border: none; padding: 6px 12px;
border-radius: 4px; font-size: 12px; cursor: pointer; }
button.secondary { background: #2d3748; color: #c8ccd6; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .5; cursor: default; filter: none; }
ul#model-list { list-style: none; margin: 0; padding: 0; font-size: 12px; }
ul#model-list li { padding: 7px 12px; border-bottom: 1px solid #1c202b; }
ul#model-list li:last-child { border-bottom: none; }
ul#model-list .name { display: flex; justify-content: space-between; gap: 8px; }
ul#model-list .name b { font-weight: 600; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; }
ul#model-list .pct { color: #8a93a6; flex: 0 0 auto; }
.bar { height: 4px; margin-top: 5px; border-radius: 2px; background: #232833; overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0%; background: #3182ce; }
.empty { color: #6f7988; font-size: 12px; padding: 4px 0; }
.sel-field { display: flex; gap: 8px; font-size: 13px; }
.sel-field + .sel-field { margin-top: 6px; }
.sel-field label { flex: 0 0 70px; color: #8a93a6; }
#sel-guid { font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
#sel-guid.none { color: #6f7988; }
#sel-model { word-break: break-all; }
.hint { font-size: 11px; color: #6f7988; margin-top: 6px; }
</style>
</head>
<body>
<header>
<h1>IfcOpenShell web viewer — JavaScript integration</h1>
<p>The viewer is an ordinary page element; the model list and selected GUID are
plain DOM updated from JS. &nbsp;<a href="IfcViewerWeb.html">↗ fullscreen example</a></p>
</header>
<div class="layout">
<!-- The viewer. The canvas MUST be id="viewer-canvas" (the wasm hard-codes
that selector). #marquee is the box-select rubber-band the wasm draws. -->
<div>
<div id="viewer-box">
<canvas id="viewer-canvas" width="1280" height="920"></canvas>
<div id="marquee"></div>
</div>
<div class="hint">Drag to orbit · scroll to zoom · <b>right-click to select</b> (drag right-click to box-select)</div>
</div>
<div class="sidebar">
<div class="card">
<h2>Add model (.ifcview)</h2>
<div class="body">
<div class="row">
<button id="browse-btn" class="secondary" disabled>Browse file(s)…</button>
<input id="file-input" type="file" accept=".ifcview" multiple style="display:none">
<button id="clear-btn" class="secondary" disabled>Clear</button>
</div>
<div class="row">
<input id="url-input" type="text" placeholder="https://…/model.ifcview" disabled>
<button id="url-btn" disabled>Add URL</button>
</div>
<div class="hint" id="status-hint">Starting WebGPU…</div>
</div>
</div>
<div class="card">
<h2>Models in scene</h2>
<ul id="model-list"><li class="empty">No models loaded.</li></ul>
</div>
<div class="card">
<h2>Selected object</h2>
<div class="body">
<div class="sel-field"><label>Model</label><span id="sel-model"></span></div>
<div class="sel-field"><label>GlobalId</label><span id="sel-guid" class="none">Right-click an object in the viewer…</span></div>
</div>
</div>
</div>
</div>
<script src="IfcViewerWeb.js"></script>
<script src="ifcviewer.js"></script>
<script>
// JS-side model list. The wasm orders models by load, so a model's array
// index here matches its index in the C progress exports.
var models = [];
var userAddedAny = false; // once true, stop dropping the embedded sample
var listEl = document.getElementById('model-list');
var selGuidEl = document.getElementById('sel-guid');
var selModelEl = document.getElementById('sel-model');
var hintEl = document.getElementById('status-hint');
function renderList() {
if (!models.length) {
listEl.innerHTML = '<li class="empty">No models loaded.</li>';
return;
}
listEl.innerHTML = '';
models.forEach(function (m, i) {
var li = document.createElement('li');
var pct = m.total > 0 ? Math.round(100 * m.resident / m.total) : 0;
var label = m.total > 0 ? pct + '%' : '…';
li.innerHTML =
'<div class="name"><b title="' + m.name + '">' + m.name + '</b>' +
'<span class="pct">' + label + '</span></div>' +
'<div class="bar"><i style="width:' + pct + '%"></i></div>';
listEl.appendChild(li);
});
}
function setSelection(guid, modelName) {
selModelEl.textContent = modelName || '—';
if (guid) { selGuidEl.textContent = guid; selGuidEl.classList.remove('none'); }
else { selGuidEl.textContent = 'Right-click an object in the viewer…'; selGuidEl.classList.add('none'); }
}
if (!navigator.gpu) hintEl.textContent = 'navigator.gpu missing — needs a WebGPU browser';
var canvas = document.getElementById('viewer-canvas');
canvas.addEventListener('contextmenu', function (ev) { ev.preventDefault(); });
IfcViewer.create({
canvas: canvas,
// Per-frame: refresh each model's streaming progress in the list.
onFrame: function (viewer) {
// Start empty: drop the wasm's embedded sample cube (kept for the
// fullscreen page/tests) so it doesn't linger or skew the first fit-all.
// Keep clearing until it's gone; stop once the user adds their own model.
if (!userAddedAny && viewer.modelCount() > 0) viewer.clearScene();
if (!models.length) return;
var changed = false;
for (var i = 0; i < models.length; i++) {
var p = viewer.modelProgress(i);
if (p.resident !== models[i].resident || p.total !== models[i].total) {
models[i].resident = p.resident; models[i].total = p.total; changed = true;
}
}
if (changed) renderList();
},
}).then(function (viewer) {
// Report the picked object's model + IFC GlobalId in our own DOM (empty on
// deselect). sel.modelIndex indexes our JS model list (load order).
viewer.onSelect(function (sel) {
var name = (sel.modelIndex !== null && models[sel.modelIndex]) ? models[sel.modelIndex].name : null;
setSelection(sel.guid, name);
});
var browseBtn = document.getElementById('browse-btn');
var clearBtn = document.getElementById('clear-btn');
var fileInput = document.getElementById('file-input');
var urlInput = document.getElementById('url-input');
var urlBtn = document.getElementById('url-btn');
function addModelEntry(name) { models.push({ name: name, resident: 0, total: 0 }); renderList(); }
viewer.ready.then(function () {
hintEl.textContent = 'Ready — add a .ifcview model.';
[browseBtn, clearBtn, urlInput, urlBtn].forEach(function (el) { el.disabled = false; });
});
browseBtn.addEventListener('click', function () { fileInput.click(); });
fileInput.addEventListener('change', function (ev) {
if (ev.target.files.length) userAddedAny = true;
Array.prototype.forEach.call(ev.target.files, function (file) {
viewer.addFile(file).then(function () { addModelEntry(file.name); });
});
fileInput.value = '';
});
urlBtn.addEventListener('click', function () {
var url = urlInput.value.trim();
if (!url) return;
userAddedAny = true;
urlBtn.disabled = true;
viewer.addUrl(url).then(function () {
addModelEntry(url.split('/').pop() || url);
urlInput.value = '';
}).catch(function (e) {
hintEl.textContent = 'URL load failed: ' + e.message;
}).finally(function () { urlBtn.disabled = false; });
});
clearBtn.addEventListener('click', function () {
viewer.clearScene();
models = []; renderList(); setSelection(null);
});
});
</script>
</body>
</html>
+169
View File
@@ -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.
//
// <script src="IfcViewerWeb.js"></script>
// <script src="ifcviewer.js"></script>
// <script>
// const viewer = await IfcViewer.create({ canvas: myCanvas });
// await viewer.ready; // GPU app is live
// viewer.onSelect(({ objectId, guid }) => …);
// await viewer.addFile(file, { replace: true });
// await viewer.addUrl('/model.ifcview'); // appends (federation)
// </script>
//
// 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);
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcOpenShell web viewer — examples</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; min-height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 40px; }
.wrap { max-width: 640px; }
h1 { font-size: 20px; margin: 0 0 4px; }
p.lead { color: #8a93a6; margin: 0 0 24px; font-size: 13px; }
a.card { display: block; text-decoration: none; color: inherit;
border: 1px solid #232833; border-radius: 8px; padding: 16px 18px;
background: #151821; margin-bottom: 14px; }
a.card:hover { border-color: #2b6cb0; }
a.card h2 { margin: 0 0 4px; font-size: 15px; color: #dfe4ee; }
a.card p { margin: 0; font-size: 12px; color: #8a93a6; }
</style>
</head>
<body>
<div class="wrap">
<h1>IfcOpenShell web viewer</h1>
<p class="lead">Two examples of the same WebGPU viewer wasm (IfcViewerWeb.js),
loaded through the small <code>ifcviewer.js</code> integration helper.</p>
<a class="card" href="IfcViewerWeb.html">
<h2>Fullscreen viewer →</h2>
<p>The viewer fills the window with an overlay toolbar. Open/add .ifcview
files, or auto-load remote models with <code>?model=URL</code>.</p>
</a>
<a class="card" href="embedded.html">
<h2>Embedded viewer + JavaScript integration →</h2>
<p>The viewer is a sized page element. Plain DOM outside it adds models
(file or URL), lists the loaded models with streaming progress, and shows
the GlobalId of whatever you click in the scene.</p>
</a>
</div>
</body>
</html>
+29 -29
View File
@@ -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<uint64_t, ViewportWindow::MeshTriangles> 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<uint32_t> 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;
+3 -3
View File
@@ -64,16 +64,16 @@ private:
// edge_key (min<<32 | max) → list of triangle indices touching it.
std::unordered_map<uint64_t, std::vector<uint32_t>> 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];
+6 -3
View File
@@ -31,10 +31,13 @@
#include <Eigen/Dense>
#include <boost/math/constants/constants.hpp>
#include <cmath>
// 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<float>() / 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);
+137 -139
View File
@@ -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<Group>();
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>();
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<QString> moved_model_ids;
for (auto& m : models_) {
if (m.group_id == group_id) {
m.group_id = new_parent_id;
moved_model_ids.push_back(m.id);
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<Group*> 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<const Federation::Group*> Federation::allGroups() const {
std::vector<const Group*> 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<const Group*>& 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<const Group*>& out) {
if (!group) return;
out.push_back(group);
for (const auto& c : group->children) appendDfs(c.get(), out);
}
std::unique_ptr<Federation::Group> 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<Group>();
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>();
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<QJsonArray(const std::vector<std::unique_ptr<Group>>&)> dump;
dump = [&](const std::vector<std::unique_ptr<Group>>& src) {
QJsonArray out;
for (const auto& g : src) {
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);
}
+19 -17
View File
@@ -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<Model>& 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<std::unique_ptr<Group>>& 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);
+8 -8
View File
@@ -86,7 +86,7 @@ void GeometryStreamer::setIfcFile(std::unique_ptr<ifcopenshell::file> file) {
ifc_file_ = std::move(file);
}
void GeometryStreamer::loadFile(const std::string& path, uint32_t start_object_id, uint32_t model_id, int num_threads) {
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<std::mutex> lock(elements_mutex_);
@@ -153,12 +153,12 @@ std::vector<ElementInfo> 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;
+6 -5
View File
@@ -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<ElementInfo> pending_elements_;
uint32_t next_object_id_ = 1;
uint32_t model_id_ = 0;
uint32_t session_model_id_ = 0;
};
#endif // GEOMETRYSTREAMER_H
+2 -2
View File
@@ -79,13 +79,13 @@ bool findInstanceInModels(
const std::unordered_map<uint32_t, ModelGpuData>& 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,
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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<float> vertices; // 7 floats * N_verts (pos3+norm3+color1_packed)
std::vector<uint32_t> 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;
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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
+156 -142
View File
@@ -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() {
@@ -63,38 +65,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<uint32_t> SceneLoader::addFiles(const QStringList& paths) {
std::vector<uint32_t> SceneLoader::queueModels(const QStringList& paths) {
std::vector<uint32_t> 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 +107,7 @@ std::vector<uint32_t> 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 +128,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 +148,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 +178,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::optional<StreamingSidecar>>(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 +203,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<SidecarBuilder>();
m.streamed_elements.clear();
model.sidecar_builder = std::make_unique<SidecarBuilder>();
}
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<const Eigen::Matrix<double, 4, 4, Eigen::ColMajor>> 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<const Eigen::Matrix<double, 4, 4, Eigen::ColMajor>> 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<ElementTableRecord> 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<ElementTableRecord> 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<ifcopenshell::file> file;
try {
file = std::make_unique<ifcopenshell::file>(
@@ -310,11 +309,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::unique_ptr<ifcopenshell::file>>(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 +321,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 +332,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 +341,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 +351,91 @@ 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);
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);
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();
// 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();
}
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 +443,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);
}
+29 -26
View File
@@ -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<uint32_t> addFiles(const QStringList& paths);
std::vector<uint32_t> 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<ElementTableRecord> 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<ElementInfo> elements);
void streamedElementsReady(uint32_t session_model_id, std::vector<ElementInfo> elements);
// Fired once after the streamer finishes and the viewport has been
// finalized. Consumer may synchronously perform work that needs all
// elements to be known (e.g. sidecar write) — SceneLoader will only
// start the next queued load after all slots return.
void loadedFromStream(uint32_t mid, qint64 elapsed_ms);
void loadCancelled(uint32_t mid);
void 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,22 +158,25 @@ 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<uint32_t, Model> models_;
std::deque<uint32_t> load_queue_;
uint32_t next_model_id_ = 1;
uint32_t next_object_id_ = 1;
uint32_t loading_model_id_ = 0;
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.
+27 -18
View File
@@ -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<float, 3> s, e, c; };
static constexpr std::array<float, 3> kRed = { 1.000f, 0.200f, 0.322f };
static constexpr std::array<float, 3> 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<float> 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<SectionPlane>& 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);
@@ -313,17 +316,23 @@ void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matri
const float vh = float(viewport_h_px);
const int n = std::min<int>(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;
// 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, p.origin, half, tangent, line_w,
bitangent, nn, 1.0f, 1.0f, 1.0f, 1.0f, vw, vh);
packSectionUniform(slot, view_proj, plane.origin, half, tangent, line_w,
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);
@@ -340,12 +349,12 @@ int SectionGizmoRenderer::hitTest(int x, int y, const std::vector<SectionPlane>&
float best_d = tolerance_px;
const int n = std::min<int>(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;
+3 -1
View File
@@ -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<SectionPlane>& 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
+3 -4
View File
@@ -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<uint32_t>(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();
+50 -13
View File
@@ -45,8 +45,11 @@
#include "SidecarCache.h"
#include "SidecarCompress.h"
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <thread>
// 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<SidecarChunk> chunks = data.chunks; // fill blob offsets below
std::vector<std::uint8_t> 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<std::uint8_t> vz, iz;
std::size_t v_raw = 0, i_raw = 0;
};
std::vector<ChunkBlob> blobs(chunks.size());
std::atomic<bool> compress_ok{true};
{
const unsigned hw = std::max(1u, std::thread::hardware_concurrency());
const std::size_t worker_count =
std::min<std::size_t>(hw, std::max<std::size_t>(std::size_t(1), chunks.size()));
std::atomic<std::size_t> next{0};
auto worker = [&]() {
std::vector<std::uint8_t> 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<std::thread> 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; }
@@ -254,7 +291,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<SidecarData> readSidecar(const std::string& ifc_path) {
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -125,7 +125,7 @@ bool parseSidecarElementMetadata(const uint8_t* data, size_t n, SidecarData& out
return true;
}
std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_path) {
std::optional<StreamingSidecar> 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;
+1 -1
View File
@@ -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<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_path);
std::optional<StreamingSidecar> 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
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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<uint8_t> vbytes;
+200 -134
View File
@@ -31,7 +31,6 @@
#include "InstanceCompose.h"
#include "Log.h"
#include <boost/math/constants/constants.hpp>
#include <algorithm>
#include <cmath>
@@ -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<float>() / 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);
@@ -102,8 +101,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 +110,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 +120,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 +140,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 ----------------------------------------------------------
@@ -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<float>() / 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;
@@ -199,7 +198,7 @@ bool ViewportCore::computeSceneAabb(float mn[3], float mx[3]) const {
mn[i] = std::numeric_limits<float>::infinity();
mx[i] = -std::numeric_limits<float>::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 +264,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 +315,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;
@@ -382,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<float>() / 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]);
@@ -475,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<float>::infinity(),
@@ -508,7 +518,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<float>() / 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
@@ -636,7 +646,7 @@ bool ViewportCore::computeObjectAabb(uint32_t object_id,
mn[i] = std::numeric_limits<float>::infinity();
mx[i] = -std::numeric_limits<float>::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 +688,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 +709,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 +852,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;
@@ -1146,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:
@@ -1784,7 +1807,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; }
@@ -1792,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; }
@@ -2035,10 +2059,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 +2154,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 +2223,7 @@ void ViewportCore::driveStreamingLoads() {
ModelGpuData* victim_m = nullptr;
std::size_t victim_ci = 0;
std::uint64_t victim_lru = std::numeric_limits<std::uint64_t>::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 +2257,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 +2280,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 +2295,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 +2311,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 +2319,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 +2354,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<Candidate> 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 +2372,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 +2418,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 +2436,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 +2498,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 +2514,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,10 +2531,20 @@ 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)) {
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;
}
@@ -2578,7 +2612,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<Stat> all;
all.reserve(64);
for (const auto& [mid2, m2] : models_gpu_) {
@@ -2594,7 +2628,7 @@ void ViewportCore::driveStreamingLoads() {
const std::size_t n = std::min<std::size_t>(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 +2641,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 +2931,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<std::uint32_t, std::unique_ptr<SidecarData>>& 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<SidecarData>());
session_model_id, std::make_unique<SidecarData>());
return *it_new->second;
}
return *it->second;
@@ -2909,7 +2943,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 +2951,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 +3237,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 +3258,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 +3284,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 +3311,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 +3333,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 +3341,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 +3441,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 +3490,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 +3499,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 +3527,13 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
};
auto join = std::make_shared<ChunkJoin>();
std::function<void()> 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 +3642,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 +3659,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<std::uint8_t>&& 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 +3680,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 +3688,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<void(bool)> 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 +3706,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<std::uint8_t>&& cz) {
auto mit = models_gpu_.find(model_id);
[this, session_model_id, raw_size, done](bool ok, std::vector<std::uint8_t>&& cz) {
auto mit = models_gpu_.find(session_model_id);
if (mit == models_gpu_.end()) { if (done) done(false); return; }
std::vector<std::uint8_t> buf(static_cast<std::size_t>(raw_size));
SidecarData tmp;
@@ -3700,13 +3734,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) {
@@ -3717,6 +3751,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<std::uint32_t> 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";
@@ -3727,7 +3775,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 +3792,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<std::uint32_t> 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 +3815,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 +3832,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 +3849,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 +3870,12 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) {
std::vector<std::uint8_t> raw_vertices = std::move(metadata.meta.vertices);
std::vector<std::uint32_t> 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 +3911,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 +3920,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 +4971,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 +5170,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 +5321,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 +5476,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 +5652,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 +5762,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 +5792,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];
@@ -6106,7 +6154,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<float>() / 180.0f;
return deg * kPiF / 180.0f;
}
// Format a float with N decimals into the running Log line. Used to
@@ -6275,10 +6323,10 @@ void ViewportCore::render() {
#endif
std::vector<std::pair<std::uint32_t, std::future<std::uint32_t>>> 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 +6336,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 +6352,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) {
@@ -6372,12 +6420,13 @@ 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& [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 +6437,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;
@@ -6424,7 +6473,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.
@@ -6477,7 +6526,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 +6541,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 +6583,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 +6659,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;
@@ -6713,6 +6762,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() << ")"
@@ -6721,9 +6772,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();
}
@@ -6731,6 +6796,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();
}
+41 -21
View File
@@ -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 --------------------------------------------------------
//
@@ -217,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
@@ -384,7 +396,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 +409,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 +443,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<void(bool)> done = {});
// Demo consumer of the element metadata fetch: on pick, ensure the owning model's
@@ -444,7 +456,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 +466,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 +486,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) -------------------------
//
@@ -528,6 +540,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
@@ -774,7 +791,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};
@@ -898,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.
@@ -1051,6 +1070,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;
@@ -1082,9 +1102,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<uint32_t, ModelGpuData> 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 +1183,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<std::uint32_t, std::unique_ptr<SidecarData>>
+85 -68
View File
@@ -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<float>::infinity(),
std::numeric_limits<float>::infinity(),
@@ -623,7 +627,7 @@ void ViewportWindow::frameOnFederatedOrigin(uint32_t model_id,
float mx[3] = { -std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::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 << ")";
@@ -770,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;
@@ -1023,13 +1030,13 @@ void ViewportWindow::setHighlightTriangles(const std::vector<float>& 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 +1056,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 +1079,7 @@ bool ViewportWindow::meshLocalToGlobal(uint32_t object_id,
static_cast<double>(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 +1155,9 @@ void ViewportWindow::invertElementVisibility() {
// don't mutate the set we're iterating over.
std::vector<uint32_t> 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 +1258,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 +1342,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).
@@ -1492,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
// -----------------------------------------------------------------------------
@@ -1550,6 +1561,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;
@@ -1738,15 +1750,15 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
std::set<std::pair<uint32_t, size_t>> 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 +1766,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;
}
}
}
@@ -1914,9 +1926,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();
@@ -1932,7 +1945,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;
}
}
+18 -16
View File
@@ -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(); }
@@ -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
@@ -378,11 +379,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 +402,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 +423,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 +580,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 +902,7 @@ private:
// Per-model state aliases (storage in core_).
std::unordered_map<uint32_t, ModelGpuData>& models_gpu_;
uint32_t& next_model_id_;
uint32_t& next_session_model_id_;
uint32_t& next_object_id_;
// Sidecar paths queued before init completes.
+39 -33
View File
@@ -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);
@@ -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);
@@ -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);
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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);
@@ -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());
+4
View File
@@ -55,6 +55,10 @@
%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;