mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-14 19:34:34 +00:00
Merge branch 'ifcviewer-wgpu' of https://github.com/IfcOpenShell/IfcOpenShell into ifcviewer-wgpu
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -89,26 +89,32 @@ target_link_options(IfcViewerWeb PRIVATE
|
||||
# also instrument every function reachable from emscripten_sleep,
|
||||
# adding ~30% to wasm size for no win here.
|
||||
#
|
||||
# EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in shell.html)
|
||||
# EXIT_RUNTIME=0 + Module.noExitRuntime=true (set in the host page (web/ifcviewer.js))
|
||||
# keeps wasm alive after main() returns so Dawn-web's
|
||||
# RequestAdapter/RequestDevice promise callbacks land. The
|
||||
# alternative — calling emscripten_set_main_loop_arg early in
|
||||
# main() to set noExitRuntime as a side effect — registers a RAF
|
||||
# that starves the device promise (observed: ~10s delay in Firefox).
|
||||
"-sEXIT_RUNTIME=0"
|
||||
# Emit a reusable module factory (IfcViewerWeb.js) instead of a baked page,
|
||||
# so multiple static example pages can load the same wasm. Each page does
|
||||
# createIfcViewer({ canvas, ... }).then(Module => …)
|
||||
# (see web/ifcviewer.js, which wraps this into a small integration API).
|
||||
"-sMODULARIZE=1"
|
||||
"-sEXPORT_NAME=createIfcViewer"
|
||||
# Expose the C entry points to JS. _raf_tick_c drives the RAF loop
|
||||
# (shell.html); _load_sidecar_from_blob_c loads a user-picked File via
|
||||
# (the host page (web/ifcviewer.js)); _load_sidecar_from_blob_c loads a user-picked File via
|
||||
# byte-range Blob.slice reads; _load_sidecar_from_url_c streams a remote
|
||||
# sidecar via HTTP Range; _ifcv_on_range_done / _ifcv_source_ready are the
|
||||
# JS→C completion callbacks for a landed range / a resolved URL size.
|
||||
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
|
||||
# add them to Module. ccall lets shell.html pass a JS string (the ?model
|
||||
# add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model
|
||||
# URL) to load_sidecar_from_url_c without manual heap marshalling.
|
||||
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c']"
|
||||
# ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c.
|
||||
# ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c.
|
||||
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
|
||||
# sidecar streams by range instead of loading whole). Standard, zero-cost.
|
||||
"-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8']"
|
||||
"-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','UTF8ToString']"
|
||||
# Streaming + chunked geometry want a heap that can grow as buffers
|
||||
# arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer
|
||||
# cap; --shared64 / MEMORY64 would lift this later if we need it).
|
||||
@@ -124,7 +130,22 @@ target_link_options(IfcViewerWeb PRIVATE
|
||||
# mounts the file at the virtual path the wasm fopen()s. User-picked
|
||||
# files instead stream via Blob.slice byte ranges (load_sidecar_from_blob_c).
|
||||
"--embed-file=${CMAKE_CURRENT_SOURCE_DIR}/sample.ifcview@/sample.ifcview"
|
||||
# Shell template wraps the JS output in our canvas page.
|
||||
"--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/shell.html"
|
||||
)
|
||||
set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".html")
|
||||
# MODULARIZE emits IfcViewerWeb.js (the createIfcViewer factory) + .wasm.
|
||||
set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".js")
|
||||
|
||||
# Copy the static example pages + the JS integration helper next to the wasm so
|
||||
# a plain `python3 -m http.server --directory build-web` serves the whole demo:
|
||||
# /IfcViewerWeb.html fullscreen example
|
||||
# /embedded.html embedded viewer + DOM model list / selection (JS API)
|
||||
# /index.html links to both
|
||||
set(IFCVIEWERWEB_STATIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/web/ifcviewer.js"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/web/IfcViewerWeb.html"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/web/embedded.html"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/web/index.html"
|
||||
)
|
||||
add_custom_command(TARGET IfcViewerWeb POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
${IFCVIEWERWEB_STATIC} "$<TARGET_FILE_DIR:IfcViewerWeb>"
|
||||
COMMENT "Copying web example pages next to IfcViewerWeb.js")
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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. <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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user