Unify sidecar production via SidecarBuilder

Renamed HeadlessSidecarBuilder to SidecarBuilder and reused it for live
loads. SceneLoader now constructs one per stream load, forwards meshReady
/instanceReady chunks alongside the viewport upload, and finalizes +
writes the sidecar at onStreamerFinished — no more GPU readback path
via ViewportWindow::snapshotModel (removed). Same code path now produces
sidecars for both live loads and the .rdbview offline export.

Sidecar use is opt-in per direction via SceneLoader::setShouldReadSidecar
and setShouldWriteSidecar; both default off so embedders that don't want
caching get a pure-streaming loader. ifcviewer-full and ifcviewer-minimal
opt in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-16 21:11:27 +10:00
parent 5f467cd8fa
commit 8b8fafa698
13 changed files with 212 additions and 210 deletions
-2
View File
@@ -470,8 +470,6 @@ void MainWindow::setupLoader() {
session_state_->createLoader(viewport_widget_->viewport());
viewport_view_ = new modules::viewport::ViewportView(
session_state_, viewport_widget_->viewport(), this);
modules::models::commands::addHandlers(
*session_state_, *viewport_widget_->viewport(), *this);
// Load errors surface through SessionState as a session-level signal; the
// status text + progress are already cleared there, we only show the modal.
+2 -1
View File
@@ -36,6 +36,8 @@ SessionState::SessionState(QObject* parent)
void SessionState::createLoader(ViewportWindow* viewport) {
Q_ASSERT(!loader_);
loader_ = new SceneLoader(viewport, this);
loader_->setShouldReadSidecar(true);
loader_->setShouldWriteSidecar(true);
element_registry_->bindLoader(loader_);
auto format_elapsed = [](qint64 ms) {
@@ -69,7 +71,6 @@ void SessionState::createLoader(ViewportWindow* viewport) {
.arg(format_elapsed(elapsed_ms)));
endProgress();
emit modelGeometryReady(mid);
emit modelGeometryStreamed(mid);
});
connect(loader_, &SceneLoader::loadCancelled, this, [this](uint32_t mid) {
setStatusMessage("Cancelled", loader_->displayName(mid));
-4
View File
@@ -90,10 +90,6 @@ signals:
// for both sidecar-cache and stream loads; subscribers that just need to
// re-derive view state (e.g. ViewportView::refresh) listen to this.
void modelGeometryReady(uint32_t model_id);
// Fires only after a stream load — i.e. when no sidecar cache existed
// yet. The sidecar-write subscriber listens to this so it doesn't
// re-persist a cache that was just read from disk.
void modelGeometryStreamed(uint32_t 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.
+2 -69
View File
@@ -26,10 +26,8 @@
#include "../../ElementRegistry.h"
#include "../../SessionState.h"
#include "../../../ifcviewer/Federation.h"
#include "../../../ifcviewer/HeadlessSidecarBuilder.h"
#include "../../../ifcviewer/LodBuilder.h"
#include "../../../ifcviewer/SceneLoader.h"
#include "../../../ifcviewer/SidecarCache.h"
#include "../../../ifcviewer/SidecarBuilder.h"
#include "../../../ifcviewer/ViewportWindow.h"
#include "../../../ifcgeom/Serializer.h"
#include "../../../serializers/document_serializer_plugin.h"
@@ -173,11 +171,6 @@ void removeModel(SessionState& s, ViewportWindow& vp, QWidget& host, const QStri
s.setStatusMessage("Models", "Model removed");
}
void addHandlers(SessionState& s, ViewportWindow& vp, QObject& context) {
QObject::connect(&s, &SessionState::modelGeometryStreamed, &context,
[&s, &vp](uint32_t mid) { writeSidecarForLoadedModel(s, vp, mid); });
}
namespace detail {
void loadModels(SessionState& s, const QStringList& paths, const QStringList& fed_ids) {
@@ -432,7 +425,7 @@ void exportGeometryDatabase(SessionState& s, QWidget& host) {
serializer->finalize();
serializer.reset();
HeadlessSidecarBuilder builder;
SidecarBuilder builder;
if (!builder.build(input_path, tmp_anchor)) {
throw ifcopenshell::exception(
("Sidecar build failed: " + builder.lastError()).toStdString());
@@ -531,64 +524,4 @@ void openSettings(SessionState& s, QWidget& host) {
dialog.exec();
}
void writeSidecarForLoadedModel(SessionState& s, ViewportWindow& vp, uint32_t mid) {
SceneLoader* loader = s.loader();
if (!loader) return;
SidecarData sidecar_data;
if (!vp.snapshotModel(mid, sidecar_data)) return;
if (const ModelGeoref* georef = loader->modelGeoref(mid)) {
sidecar_data.has_coordinate_operation = georef->has_coordinate_operation ? 1 : 0;
Eigen::Map<Eigen::Matrix<double, 4, 4, Eigen::ColMajor>>(
sidecar_data.coordinate_operation_meters) = georef->coordinate_operation_meters;
sidecar_data.project_length_to_meters = georef->units.project_length_to_meters;
sidecar_data.map_unit_to_meters = georef->units.map_unit_to_meters;
}
if (auto* element_registry = s.elementRegistry()) {
for (const auto& info : element_registry->basicElementInfoForModel(mid)) {
PackedElementInfo packed;
packed.object_id = info.object_id;
packed.model_id = info.model_id;
packed.ifc_id = info.ifc_id;
packed.parent_id = info.parent_id;
const std::string guid = info.guid.toStdString();
packed.guid_offset = static_cast<uint32_t>(sidecar_data.string_table.size());
packed.guid_length = static_cast<uint32_t>(guid.size());
sidecar_data.string_table += guid;
const std::string name = info.name.toStdString();
packed.name_offset = static_cast<uint32_t>(sidecar_data.string_table.size());
packed.name_length = static_cast<uint32_t>(name.size());
sidecar_data.string_table += name;
const std::string type = info.type.toStdString();
packed.type_offset = static_cast<uint32_t>(sidecar_data.string_table.size());
packed.type_length = static_cast<uint32_t>(type.size());
sidecar_data.string_table += type;
sidecar_data.elements.push_back(packed);
}
}
QElapsedTimer lod_timer;
lod_timer.start();
buildLods(sidecar_data);
const LodStats lod_stats = summariseLods(sidecar_data);
qDebug(" LOD build: %lld ms — %u/%u meshes got LOD1 "
"(%u tris -> %u tris for those meshes)",
lod_timer.elapsed(),
lod_stats.meshes_with_lod1, lod_stats.meshes_total,
lod_stats.tris_lod0_for_lod1, lod_stats.tris_lod1);
vp.applyLodExtension(mid, sidecar_data);
QElapsedTimer sidecar_timer;
sidecar_timer.start();
const bool ok = writeSidecar(loader->filePath(mid).toStdString(), sidecar_data);
qDebug(" Sidecar write: %lld ms (%s)", sidecar_timer.elapsed(), ok ? "ok" : "FAILED");
}
} // namespace ifcviewerfull::modules::models::commands
@@ -47,17 +47,6 @@ void convertIfcToDatabase(SessionState& s, QWidget& host);
void exportGeometryDatabase(SessionState& s, QWidget& host);
void openSettings(SessionState& s, QWidget& host);
// Snapshots the in-memory geometry + element registry for a freshly streamed
// model and persists it as a sidecar next to the source IFC. Called after
// SceneLoader::loadedFromStream so subsequent loads can skip the stream phase.
void writeSidecarForLoadedModel(SessionState& s, ViewportWindow& vp, uint32_t mid);
// Persistent post-load handler. Call once at app startup. Whenever the
// session reports a model was streamed (not loaded from cache), persists a
// sidecar so the next load skips the stream phase. Connection lifetime is
// tied to `context`.
void addHandlers(SessionState& s, ViewportWindow& vp, QObject& context);
// 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.
+2
View File
@@ -39,6 +39,8 @@ MinimalWindow::MinimalWindow(QWidget* parent)
statusBar()->addPermanentWidget(stats_label_);
loader_ = new SceneLoader(viewport_, this);
loader_->setShouldReadSidecar(true);
loader_->setShouldWriteSidecar(true);
connect(loader_, &SceneLoader::loadStarted,
this, &MinimalWindow::onLoadStarted);
connect(loader_, &SceneLoader::loadedFromSidecar,
+79 -14
View File
@@ -29,6 +29,14 @@
#include <optional>
#include <utility>
void SceneLoader::setShouldReadSidecar(bool enabled) {
should_read_sidecar_ = enabled;
}
void SceneLoader::setShouldWriteSidecar(bool enabled) {
should_write_sidecar_ = enabled;
}
SceneLoader::SceneLoader(ViewportWindow* viewport, QObject* parent)
: QObject(parent), viewport_(viewport)
{
@@ -169,6 +177,14 @@ void SceneLoader::startNextLoad() {
const bool is_sidecar_source =
QFileInfo(model.file_path).suffix().compare("ifcview", Qt::CaseInsensitive) == 0;
// No sidecar read probe when caching is off (and the user didn't pick a
// .ifcview file directly). Skip the background thread and go straight
// to a stream load.
if (!is_sidecar_source && !should_read_sidecar_) {
startStreamLoadFor(mid);
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]() {
@@ -195,15 +211,28 @@ void SceneLoader::startNextLoad() {
return;
}
auto& m = it->second;
connectStreamer(m.streamer);
element_poll_timer_.start();
m.streamer->loadFile(
m.file_path.toStdString(), next_object_id_, loading_model_id_);
startStreamLoadFor(mid);
}, Qt::QueuedConnection);
});
}
void SceneLoader::startStreamLoadFor(uint32_t mid) {
auto it = models_.find(mid);
if (it == models_.end()) return;
auto& m = 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();
}
connectStreamer(m.streamer);
element_poll_timer_.start();
m.streamer->loadFile(
m.file_path.toStdString(), next_object_id_, loading_model_id_);
}
void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) {
auto it = models_.find(mid);
if (it == models_.end()) return;
@@ -314,16 +343,27 @@ void SceneLoader::onStreamerProgressChanged(int percent) {
void SceneLoader::onStreamerMeshReady(MeshChunk chunk) {
viewport_->uploadMeshChunk(chunk);
if (loading_model_id_ != 0) {
auto it = models_.find(loading_model_id_);
if (it != models_.end() && it->second.sidecar_builder) {
it->second.sidecar_builder->onMeshReady(chunk);
}
}
}
void SceneLoader::onStreamerInstanceReady(InstanceChunk chunk) {
if (loading_model_id_ != 0) {
auto it = models_.find(loading_model_id_);
if (it != models_.end() && !it->second.has_first_placement) {
using Mat4fCol = Eigen::Matrix<float, 4, 4, Eigen::ColMajor>;
it->second.first_placement =
Eigen::Map<const Mat4fCol>(chunk.transform).cast<double>();
it->second.has_first_placement = true;
if (it != models_.end()) {
if (!it->second.has_first_placement) {
using Mat4fCol = Eigen::Matrix<float, 4, 4, Eigen::ColMajor>;
it->second.first_placement =
Eigen::Map<const Mat4fCol>(chunk.transform).cast<double>();
it->second.has_first_placement = true;
}
if (it->second.sidecar_builder) {
it->second.sidecar_builder->onInstanceReady(chunk);
}
}
}
viewport_->uploadInstanceChunk(chunk);
@@ -335,9 +375,15 @@ void SceneLoader::onElementPollTick() {
if (it == models_.end()) return;
auto batch = it->second.streamer->drainElements();
if (!batch.empty()) {
emit streamedElementsReady(loading_model_id_, std::move(batch));
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));
}
void SceneLoader::onStreamerFinished() {
@@ -348,10 +394,29 @@ void SceneLoader::onStreamerFinished() {
if (mid != 0) {
auto it = models_.find(mid);
if (it != models_.end()) {
next_object_id_ = it->second.streamer->lastObjectId();
auto& m = it->second;
next_object_id_ = m.streamer->lastObjectId();
viewport_->finalizeModel(mid);
qint64 ms = it->second.load_timer.elapsed();
// Sidecar finalize + LOD application + disk write. Runs on the
// GUI thread so applyLodExtension's GL touches are safe.
if (m.sidecar_builder) {
ModelGeoref georef;
if (auto* file = m.streamer->ifcFile()) {
georef = computeModelGeoref(file);
}
QElapsedTimer wt; wt.start();
SidecarData data = m.sidecar_builder->finalize(georef, m.streamed_elements);
viewport_->applyLodExtension(mid, data);
const bool ok = writeSidecar(m.file_path.toStdString(), data);
qDebug(" Sidecar finalize + write: %lld ms (%s)",
wt.elapsed(), ok ? "ok" : "FAILED");
m.sidecar_builder.reset();
m.streamed_elements.clear();
m.streamed_elements.shrink_to_fit();
}
qint64 ms = m.load_timer.elapsed();
emit loadedFromStream(mid, ms);
}
}
+28 -3
View File
@@ -29,6 +29,7 @@
#include <cstdint>
#include <deque>
#include <map>
#include <memory>
#include <string>
#include <thread>
#include <vector>
@@ -36,6 +37,7 @@
#include "Federation.h"
#include "ViewportWindow.h"
#include "GeometryStreamer.h"
#include "SidecarBuilder.h"
#include "SidecarCache.h"
// Drives IFC file loading into a ViewportWindow. Owns the per-model
@@ -45,15 +47,27 @@
//
// Consumers (MainWindow, MinimalWindow) observe progress through signals
// and never touch the streamer, sidecar thread, or queue directly.
// Sidecar *writes* are intentionally left to the consumer: they need the
// consumer's element metadata (guid/name/type strings) which SceneLoader
// does not retain.
// Sidecar *writes* happen automatically as a side effect of stream loads —
// the SidecarBuilder accumulates from the streamer chunks alongside the
// viewport upload, and SceneLoader finalizes + writes the result when the
// stream finishes. No GPU readback involved.
class SceneLoader : public QObject {
Q_OBJECT
public:
explicit SceneLoader(ViewportWindow* viewport, QObject* parent = nullptr);
~SceneLoader();
// Sidecar cache use is opt-in per direction. Embedders that don't care
// about .ifcview can leave both off (default) and SceneLoader will never
// probe for or produce one. Toggles only affect *subsequent* loads;
// a load already in flight finishes with whatever was set when it
// started. Opening a `.ifcview` file directly always reads it,
// regardless of these flags.
void setShouldReadSidecar(bool enabled);
void setShouldWriteSidecar(bool enabled);
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.
// Callers can use these to set up per-model UI state (tree roots, etc.)
// before any load signal fires.
@@ -146,9 +160,18 @@ private:
// streamer's first InstanceChunk.
Eigen::Matrix4d first_placement = Eigen::Matrix4d::Identity();
bool has_first_placement = false;
// Live-load sidecar accumulator. Constructed at the start of a
// stream load when shouldWriteSidecar is on; null otherwise.
std::unique_ptr<SidecarBuilder> sidecar_builder;
// Element batches accumulated as the streamer yields, mirrored from
// what's emitted via streamedElementsReady so finalize() has the
// full set without re-draining.
std::vector<ElementInfo> streamed_elements;
};
void startNextLoad();
void startStreamLoadFor(uint32_t mid);
void connectStreamer(GeometryStreamer* streamer);
void joinSidecarThread();
void joinDataSourceThreads();
@@ -156,6 +179,8 @@ private:
void startDataSourceLoad(uint32_t mid);
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;
@@ -17,10 +17,9 @@
* *
********************************************************************************/
#include "HeadlessSidecarBuilder.h"
#include "SidecarBuilder.h"
#include "Federation.h"
#include "GeometryStreamer.h"
#include "LodBuilder.h"
#include "SidecarCache.h"
#include "VertexQuantization.h"
@@ -31,13 +30,14 @@
#include <cstring>
#include <limits>
#include <utility>
HeadlessSidecarBuilder::HeadlessSidecarBuilder(QObject* parent)
SidecarBuilder::SidecarBuilder(QObject* parent)
: QObject(parent)
{
}
void HeadlessSidecarBuilder::onMeshReady(const MeshChunk& chunk) {
void SidecarBuilder::onMeshReady(const MeshChunk& chunk) {
if (chunk.vertices.empty() || chunk.indices.empty()) return;
// Streamer format: 7 floats/vertex (pos3 + normal3 + color-as-float).
@@ -45,7 +45,7 @@ void HeadlessSidecarBuilder::onMeshReady(const MeshChunk& chunk) {
// Recompute a tight local AABB from the actual vertex positions, same
// way ViewportWindow::uploadMeshChunk does so the .ifcview byte layout
// matches the GPU-readback path.
// matches the live-render path.
float bmin[3] = { std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity() };
@@ -98,7 +98,7 @@ void HeadlessSidecarBuilder::onMeshReady(const MeshChunk& chunk) {
sidecar_data_.meshes[chunk.local_mesh_id] = info;
}
void HeadlessSidecarBuilder::onInstanceReady(const InstanceChunk& chunk) {
void SidecarBuilder::onInstanceReady(const InstanceChunk& chunk) {
InstanceCpu inst;
inst.mesh_id = chunk.local_mesh_id;
inst.object_id = chunk.object_id;
@@ -121,44 +121,8 @@ void HeadlessSidecarBuilder::onInstanceReady(const InstanceChunk& chunk) {
sidecar_data_.instances.push_back(inst);
}
bool HeadlessSidecarBuilder::build(const QString& ifc_path,
const QString& anchor_path,
int num_threads) {
sidecar_data_ = SidecarData{};
last_error_.clear();
// Streamer lives on the calling thread; its worker_thread_ is its own
// internal QThread. AutoConnection routes meshReady/instanceReady
// through our local event loop.
GeometryStreamer streamer;
QEventLoop loop;
bool failed = false;
connect(&streamer, &GeometryStreamer::meshReady,
this, &HeadlessSidecarBuilder::onMeshReady);
connect(&streamer, &GeometryStreamer::instanceReady,
this, &HeadlessSidecarBuilder::onInstanceReady);
connect(&streamer, &GeometryStreamer::finished,
&loop, &QEventLoop::quit);
connect(&streamer, &GeometryStreamer::cancelled,
&loop, &QEventLoop::quit);
connect(&streamer, &GeometryStreamer::errorOccurred, this,
[&](const QString& msg) {
last_error_ = msg;
failed = true;
loop.quit();
});
streamer.loadFile(ifc_path.toStdString(),
/*start_object_id*/ 1,
/*model_id*/ 1,
num_threads);
loop.exec();
if (failed) return false;
SidecarData SidecarBuilder::finalize(const ModelGeoref& georef,
const std::vector<ElementInfo>& elements) {
// Per-mesh instance_count, matching ViewportWindow::finalizeModel.
for (auto& mesh : sidecar_data_.meshes) {
mesh.first_instance = 0;
@@ -170,18 +134,13 @@ bool HeadlessSidecarBuilder::build(const QString& ifc_path,
}
}
// CoordinateOperation cache from the IFC the streamer just parsed.
if (auto* file = streamer.ifcFile()) {
ModelGeoref georef = computeModelGeoref(file);
sidecar_data_.has_coordinate_operation = georef.has_coordinate_operation ? 1 : 0;
Eigen::Map<Eigen::Matrix<double, 4, 4, Eigen::ColMajor>>(
sidecar_data_.coordinate_operation_meters) = georef.coordinate_operation_meters;
sidecar_data_.project_length_to_meters = georef.units.project_length_to_meters;
sidecar_data_.map_unit_to_meters = georef.units.map_unit_to_meters;
}
sidecar_data_.has_coordinate_operation = georef.has_coordinate_operation ? 1 : 0;
Eigen::Map<Eigen::Matrix<double, 4, 4, Eigen::ColMajor>>(
sidecar_data_.coordinate_operation_meters) = georef.coordinate_operation_meters;
sidecar_data_.project_length_to_meters = georef.units.project_length_to_meters;
sidecar_data_.map_unit_to_meters = georef.units.map_unit_to_meters;
// Element metadata accumulated by the streamer's worker thread.
for (const auto& info : streamer.drainElements()) {
for (const auto& info : elements) {
PackedElementInfo packed;
packed.object_id = info.object_id;
packed.model_id = info.model_id;
@@ -205,7 +164,51 @@ bool HeadlessSidecarBuilder::build(const QString& ifc_path,
buildLods(sidecar_data_);
if (!writeSidecar(anchor_path.toStdString(), sidecar_data_)) {
return std::exchange(sidecar_data_, SidecarData{});
}
bool SidecarBuilder::build(const QString& ifc_path,
const QString& anchor_path,
int num_threads) {
sidecar_data_ = SidecarData{};
last_error_.clear();
GeometryStreamer streamer;
QEventLoop loop;
bool failed = false;
connect(&streamer, &GeometryStreamer::meshReady,
this, &SidecarBuilder::onMeshReady);
connect(&streamer, &GeometryStreamer::instanceReady,
this, &SidecarBuilder::onInstanceReady);
connect(&streamer, &GeometryStreamer::finished,
&loop, &QEventLoop::quit);
connect(&streamer, &GeometryStreamer::cancelled,
&loop, &QEventLoop::quit);
connect(&streamer, &GeometryStreamer::errorOccurred, this,
[&](const QString& msg) {
last_error_ = msg;
failed = true;
loop.quit();
});
streamer.loadFile(ifc_path.toStdString(),
/*start_object_id*/ 1,
/*model_id*/ 1,
num_threads);
loop.exec();
if (failed) return false;
ModelGeoref georef;
if (auto* file = streamer.ifcFile()) {
georef = computeModelGeoref(file);
}
SidecarData data = finalize(georef, streamer.drainElements());
if (!writeSidecar(anchor_path.toStdString(), data)) {
last_error_ = "writeSidecar failed";
return false;
}
@@ -17,44 +17,63 @@
* *
********************************************************************************/
#ifndef HEADLESSSIDECARBUILDER_H
#define HEADLESSSIDECARBUILDER_H
#ifndef SIDECARBUILDER_H
#define SIDECARBUILDER_H
#include "Federation.h"
#include "GeometryStreamer.h"
#include "InstancedGeometry.h"
#include "SidecarCache.h"
#include <QObject>
#include <QString>
// Produces a .ifcview sidecar from an IFC file without touching the
// ViewportWindow or any GL state. Mirrors the data path that
// SceneLoader + ViewportWindow + ModelsPanelController.writeSidecarForModel
// take for live loads, but does the vertex quantization and SidecarData
// assembly entirely on the CPU.
#include <vector>
// Assembles a .ifcview SidecarData from streamer output, then finalizes it
// (LOD build, georef, packed elements) ready for writeSidecar() and/or
// ViewportWindow::applyLodExtension(). Two use modes:
//
// Threading: call ::build() from a non-GUI worker thread that has a Qt
// event dispatcher (e.g. the thread spawned by QThread::create). build()
// spins a local QEventLoop until the streamer's worker thread completes.
class HeadlessSidecarBuilder : public QObject {
// 1. Live load — host (SceneLoader) drives its own GeometryStreamer and
// forwards meshReady/instanceReady chunks via onMeshReady/onInstanceReady
// while the viewport also consumes them. When the stream finishes the
// host calls finalize(georef, elements) and writeSidecar() with the
// returned data. No GPU readback involved.
//
// 2. Offline — build() owns a streamer, runs it on a non-GUI worker thread,
// and writes the sidecar to disk. Used by the .rdbview export path.
class SidecarBuilder : public QObject {
Q_OBJECT
public:
explicit HeadlessSidecarBuilder(QObject* parent = nullptr);
explicit SidecarBuilder(QObject* parent = nullptr);
// `anchor_path` is fed to writeSidecar(), which normalises it to
// `<stem(anchor_path)>.ifcview`. Pass either the IFC path itself
// (sidecar lands beside it) or a temp path whose stem you control.
// Convenience for the offline path: construct an internal streamer,
// accumulate, finalize, and write to disk. anchor_path is normalised to
// <stem>.ifcview by writeSidecar. Call from a non-GUI worker thread with
// a Qt event dispatcher; build() spins a local QEventLoop until the
// streamer's worker thread completes.
bool build(const QString& ifc_path,
const QString& anchor_path,
int num_threads = 0);
const QString& lastError() const { return last_error_; }
private:
// Accumulator interface. Safe to call repeatedly from the same thread the
// streamer signals are delivered to.
void onMeshReady(const MeshChunk& chunk);
void onInstanceReady(const InstanceChunk& chunk);
// Finishes assembly using the georef + element batch the host collected
// during streaming. Returns the assembled SidecarData by move; the
// builder's internal state is left empty so the same instance can be
// reused for another load.
SidecarData finalize(const ModelGeoref& georef,
const std::vector<ElementInfo>& elements);
const QString& lastError() const { return last_error_; }
private:
SidecarData sidecar_data_;
QString last_error_;
};
#endif // HEADLESSSIDECARBUILDER_H
#endif // SIDECARBUILDER_H
+2 -3
View File
@@ -20,9 +20,8 @@
// Inline helpers that turn streamer-format vertices (7 floats per vertex:
// pos3 + normal3 + color-as-float) into the 12 B quantized VBO layout used
// by both the viewport's GPU buffers and the .ifcview sidecar. Shared
// between ViewportWindow::uploadMeshChunk and HeadlessSidecarBuilder so
// the on-disk format stays identical to what the viewer would have
// produced via the GPU readback path.
// between ViewportWindow::uploadMeshChunk and SidecarBuilder so the
// on-disk format stays identical to what the viewport renders.
#ifndef VERTEXQUANTIZATION_H
#define VERTEXQUANTIZATION_H
-22
View File
@@ -1187,28 +1187,6 @@ void ViewportWindow::finalizeModel(uint32_t model_id) {
m.ssbo_capacity / (1024.0*1024.0));
}
bool ViewportWindow::snapshotModel(uint32_t model_id, SidecarData& out) const {
auto it = models_gpu_.find(model_id);
if (!gl_ || it == models_gpu_.end()) return false;
const auto& m = it->second;
if (!m.finalized) return false;
// GPU readback of the packed VBO/EBO ranges actually in use. VBO is
// raw bytes at the quantized layout.
if (m.vbo_used > 0) {
out.vertices.resize(m.vbo_used);
gl_->glGetNamedBufferSubData(m.vbo, 0, m.vbo_used, out.vertices.data());
}
if (m.ebo_used > 0) {
out.indices.resize(m.ebo_used / sizeof(uint32_t));
gl_->glGetNamedBufferSubData(m.ebo, 0, m.ebo_used, out.indices.data());
}
out.meshes = m.meshes;
out.instances = m.instances;
return true;
}
void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
if (!gl_initialized_) {
PendingOperation op;
-6
View File
@@ -161,12 +161,6 @@ public:
void resetScene();
// Snapshot the finalised model into a SidecarData struct for caching.
// Vertices + indices are read back from the GPU; meshes/instances come
// from the CPU-side vectors. Leaves `elements` and `string_table` empty
// for the caller to fill in.
bool snapshotModel(uint32_t model_id, SidecarData& out) const;
// Restore a finalised model from a cached SidecarData struct. Replaces
// any existing state for model_id and marks it drawable.
void applyCachedModel(uint32_t model_id, SidecarData data);