models: consume .rdbview bundles (extract at load time)

A .rdbview is a zip of model.rdb/ (the lossy IFC data DB — the rdb
serializer skips IfcRepresentationItem) + model.ifcview (baked geometry).
The viewer could produce them but not open them.

- extractRdbview(): unzip a .rdbview (QZipReader) into a session temp dir
  keyed by a hash of path+mtime+size (reused on re-open), returning the
  extracted model.rdb. The producer's layout means sidecarPath(model.rdb)
  resolves the sibling model.ifcview automatically, so it then loads exactly
  like any pure .rdb: geometry from the sidecar, data from the .rdb via
  ifcopenshell::file(FT_AUTODETECT). No SceneLoader/engine changes.
- detail::loadModels() resolves each source path through it before
  queueModels (both fresh-open and project reload go through here), so the
  Federation persists the .rdbview while the loader gets the extracted .rdb.
- cleanupRdbviewCache() clears stale extractions at startup.
- .rdbview is offered under "Add Geometry" (the file picker; "Add IFC
  Database" is a directory picker), not "Add IFC File" — it's a lossy viewer
  bundle, not a source IFC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-09 20:36:51 +10:00
parent db884047e1
commit 79d8408684
4 changed files with 85 additions and 6 deletions
+4
View File
@@ -21,6 +21,7 @@
#include "MainWindow.h"
#include "ViewerSettings.h"
#include "components/Style.h"
#include "modules/models/Commands.h"
#include <QApplication>
#include <QCommandLineParser>
@@ -55,6 +56,9 @@ int main(int argc, char* argv[]) {
app.setApplicationName("Bonsai Viewer");
app.setOrganizationName("IfcOpenShell");
// Clear any .rdbview extractions left in temp by a previous session.
bonsaiviewer::modules::models::commands::cleanupRdbviewCache();
QSurfaceFormat fmt;
fmt.setVersion(4, 5);
fmt.setProfile(QSurfaceFormat::CoreProfile);
@@ -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",
+76 -5
View File
@@ -56,6 +56,8 @@
#include <QUuid>
#include <QtCore/private/qzipwriter_p.h>
#include <QtCore/private/qzipreader_p.h>
#include <QCryptographicHash>
#include <Eigen/Dense>
@@ -89,8 +91,56 @@ QString formatElapsed(qint64 ms) {
: QString::number(ms) + " ms";
}
// Session-scoped scratch root where .rdbview bundles are unzipped for loading.
QString rdbviewCacheRoot() {
return QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation))
.filePath("ifcviewer-rdbview");
}
// A .rdbview is a zip of `model.rdb/` (the data DB) + `model.ifcview` (geometry
// sidecar). Unzip it into a per-source subdir (hashed from path + mtime + size,
// so a re-open reuses an existing extraction) and return the extracted
// `model.rdb` path — from there it loads exactly like any pure .rdb (geometry
// from the co-extracted sibling .ifcview). Returns empty on failure.
QString extractRdbview(const QString& rdbview_path) {
const QFileInfo info(rdbview_path);
const QString key = rdbview_path + '|'
+ QString::number(info.lastModified().toMSecsSinceEpoch()) + '|'
+ QString::number(info.size());
const QString hash = QString::fromLatin1(
QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha1).toHex());
const QString dir = QDir(rdbviewCacheRoot()).filePath(hash);
const QString rdb = QDir(dir).filePath("model.rdb");
if (QFileInfo::exists(rdb)) return rdb; // already extracted this session
QZipReader reader(rdbview_path);
if (reader.status() != QZipReader::NoError) return {};
QDir().mkpath(dir);
for (const QZipReader::FileInfo& entry : reader.fileInfoList()) {
if (!entry.isFile) continue; // dirs recreated below as needed
const QString out = QDir(dir).filePath(entry.filePath);
QDir().mkpath(QFileInfo(out).absolutePath());
QFile f(out);
if (!f.open(QIODevice::WriteOnly)) return {};
f.write(reader.fileData(entry.filePath));
}
return QFileInfo::exists(rdb) ? rdb : QString(); // empty if the bundle lacked model.rdb
}
// Map a source path to the path the loader should open: a .rdbview is unzipped
// to its extracted .rdb; everything else passes through unchanged.
QString resolveLoadPath(const QString& path) {
if (path.endsWith(".rdbview", Qt::CaseInsensitive)) return extractRdbview(path);
return path;
}
} // namespace
void cleanupRdbviewCache() {
QDir(rdbviewCacheRoot()).removeRecursively();
}
void toggleVisibility(SessionState& session, ItemKind kind, const QString& id) {
Federation* federation = session.federation();
if (kind == ItemKind::Group) {
@@ -201,9 +251,28 @@ namespace detail {
void loadModels(SessionState& session, const QStringList& paths, const QStringList& model_ids) {
if (paths.isEmpty()) return;
const auto session_model_ids = session.loader()->queueModels(paths);
for (int i = 0; i < paths.size() && i < static_cast<int>(session_model_ids.size()) && i < model_ids.size(); ++i) {
session.setModelMapping(model_ids[i], session_model_ids[i]);
// The Federation stores the source paths (e.g. a .rdbview); the loader gets
// the resolved load path (a .rdbview is unzipped at load time to its .rdb).
// Keep model_ids aligned with the paths that actually resolve.
QStringList load_paths;
QStringList load_model_ids;
for (int i = 0; i < paths.size(); ++i) {
const QString resolved = resolveLoadPath(paths[i]);
if (resolved.isEmpty()) {
session.setStatusMessage("Error",
QString("Could not open %1").arg(QFileInfo(paths[i]).fileName()));
continue;
}
load_paths.push_back(resolved);
load_model_ids.push_back(i < model_ids.size() ? model_ids[i] : QString());
}
if (load_paths.isEmpty()) return;
const auto session_model_ids = session.loader()->queueModels(load_paths);
for (int i = 0; i < load_paths.size()
&& i < static_cast<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();
@@ -74,6 +74,10 @@ void convertIfcToDatabase(SessionState& session, QWidget& host);
void exportGeometryDatabase(SessionState& session, QWidget& host);
void openSettings(SessionState& session, QWidget& host);
// Remove the scratch dir used to unzip .rdbview bundles for loading. Call once
// at startup to clear extractions left over from previous sessions.
void cleanupRdbviewCache();
// Internal building blocks shared by commands here and by ProjectController.
// These NEVER call notify*() — the caller is responsible for emitting once
// at the end of its execution.