From fed739847ef340067f0a169e170d1fb6822ea8f3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 15 May 2026 11:54:14 +1000 Subject: [PATCH] Add geometry database (.rdbview) export to IfcViewerFull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire a new "Export Geometry Database" tool button in AddModelDialog, adjacent to "Convert IFC File to Database", to produce a zipped read-only artifact combining a lossy RDB (with IfcRepresentationItem stripped) and a .ifcview geometry sidecar. Intended for cloud coordination workflows where parametric geometry editing is not needed. Pipeline changes to support this: - document_serializer_context gains a `skip_supertypes` field; the rdb plugin forwards it to RocksDbSerializer so the same registry path produces full or lossy RDBs. - Vertex quantization helpers (octEncodeNormal + quantizeVertex) move out of ViewportWindow.cpp into a shared header so the sidecar's byte layout stays identical regardless of whether it came from a GPU readback or a CPU pipeline. - New HeadlessSidecarBuilder runs a GeometryStreamer on the calling thread, captures MeshChunk/InstanceChunk into a SidecarData on the CPU, then computes georef + packed elements + LODs and writes the .ifcview — no ViewportWindow or GL context required. The Controller's export flow runs RDB conversion + sidecar build + QZipWriter packaging on a background QThread, writing through `.tmp` then renaming for atomic appearance in cloud-sync folders. ifcviewer-full now links Qt6::CorePrivate for QZipWriter. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer-full/CMakeLists.txt | 3 +- .../modules/models/AddModelDialog.cpp | 12 +- .../modules/models/AddModelDialog.h | 1 + .../modules/models/Controller.cpp | 197 ++++++++++++++++ .../modules/models/Controller.h | 2 + src/ifcviewer/HeadlessSidecarBuilder.cpp | 214 ++++++++++++++++++ src/ifcviewer/HeadlessSidecarBuilder.h | 60 +++++ src/ifcviewer/VertexQuantization.h | 81 +++++++ src/ifcviewer/ViewportWindow.cpp | 46 +--- src/serializers/document_rdb_plugin.cpp | 2 +- src/serializers/document_serializer_plugin.h | 5 + 11 files changed, 575 insertions(+), 48 deletions(-) create mode 100644 src/ifcviewer/HeadlessSidecarBuilder.cpp create mode 100644 src/ifcviewer/HeadlessSidecarBuilder.h create mode 100644 src/ifcviewer/VertexQuantization.h diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt index a4d0f0a247..1ab5b003e6 100644 --- a/src/ifcviewer-full/CMakeLists.txt +++ b/src/ifcviewer-full/CMakeLists.txt @@ -21,7 +21,7 @@ message("Running CMakeLists.txt in /src/ifcviewer-full") set(QT_VERSION 6 CACHE STRING "Qt version") -find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets Svg REQUIRED PATHS ${QT_DIR}) +find_package(Qt${QT_VERSION} COMPONENTS Core CorePrivate Gui Widgets Svg REQUIRED PATHS ${QT_DIR}) set(IFCVIEWER_FULL_FILES ${CMAKE_CURRENT_SOURCE_DIR}/main.cpp @@ -99,6 +99,7 @@ set_target_properties(IfcViewerFull PROPERTIES target_link_libraries(IfcViewerFull PRIVATE IfcViewer Qt${QT_VERSION}::Core + Qt${QT_VERSION}::CorePrivate Qt${QT_VERSION}::Gui Qt${QT_VERSION}::Svg Qt${QT_VERSION}::Widgets diff --git a/src/ifcviewer-full/modules/models/AddModelDialog.cpp b/src/ifcviewer-full/modules/models/AddModelDialog.cpp index 2210c35b2f..364712f467 100644 --- a/src/ifcviewer-full/modules/models/AddModelDialog.cpp +++ b/src/ifcviewer-full/modules/models/AddModelDialog.cpp @@ -129,8 +129,18 @@ void AddModelDialog::setupUi() { "Convert IFC files to databases for smaller filesizes, reduced memory, and faster access. No data is lost.", default_description)); + auto* export_geometry_database = components::buttons::makeButton("Export Geometry\nDatabase", ":/icons/database-restore.svg", choices); + connect(export_geometry_database, &QToolButton::clicked, this, [this]() { + selected_mode_ = SourceMode::ExportGeometryDatabase; + accept(); + }); + export_geometry_database->installEventFilter(new HoverDescriptionFilter( + description, + "Convert IFC files to a read-only geometry database for smaller filesizes, reduced memory, and faster access. Ideal for cloud read-only coordination workflows. Only parametric geometry editing capabilities are lost.", + default_description)); + row->addWidget(components::buttons::makeButtonGroup("ADD", {add_ifc, add_database, add_geometry}, choices, true, 8)); - row->addWidget(components::buttons::makeButtonGroup("TOOLS", {convert_database}, choices, false, 8)); + row->addWidget(components::buttons::makeButtonGroup("TOOLS", {convert_database, export_geometry_database}, choices, false, 8)); choices_section->addBodyWidget(choices); addBodyWidget(description_section); diff --git a/src/ifcviewer-full/modules/models/AddModelDialog.h b/src/ifcviewer-full/modules/models/AddModelDialog.h index 4092da684e..b343735376 100644 --- a/src/ifcviewer-full/modules/models/AddModelDialog.h +++ b/src/ifcviewer-full/modules/models/AddModelDialog.h @@ -31,6 +31,7 @@ enum class SourceMode { IfcDatabase, GeometryOnly, ConvertToDatabase, + ExportGeometryDatabase, }; class AddModelDialog : public components::Dialog { diff --git a/src/ifcviewer-full/modules/models/Controller.cpp b/src/ifcviewer-full/modules/models/Controller.cpp index 39e7deb082..6ef9c9214d 100644 --- a/src/ifcviewer-full/modules/models/Controller.cpp +++ b/src/ifcviewer-full/modules/models/Controller.cpp @@ -27,6 +27,7 @@ #include "../../SessionState.h" #include "AddModelDialog.h" #include "../../../ifcviewer/Federation.h" +#include "../../../ifcviewer/HeadlessSidecarBuilder.h" #include "../../../ifcviewer/LodBuilder.h" #include "../../../ifcviewer/SceneLoader.h" #include "../../../ifcviewer/SidecarCache.h" @@ -35,14 +36,21 @@ #include "../../../serializers/document_serializer_plugin.h" #include +#include +#include #include +#include #include #include #include #include #include +#include #include #include +#include + +#include #include @@ -205,6 +213,9 @@ void ModelsPanelController::addFiles() { case modules::models::SourceMode::ConvertToDatabase: convertIfcToDatabase(); return; + case modules::models::SourceMode::ExportGeometryDatabase: + exportGeometryDatabase(); + return; case modules::models::SourceMode::None: return; } @@ -329,6 +340,192 @@ void ModelsPanelController::runIfcToDatabaseConversion(const QString& input_path thread->start(); } +void ModelsPanelController::exportGeometryDatabase() { + QFileDialog input_dialog(host_, "Select IFC File to Export"); + input_dialog.setFileMode(QFileDialog::ExistingFile); + input_dialog.setNameFilter("IFC Files (*.ifc);;All Files (*)"); + input_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (input_dialog.exec() != QDialog::Accepted) return; + const QStringList inputs = input_dialog.selectedFiles(); + if (inputs.isEmpty()) return; + const QString input_path = inputs.first(); + + const QFileInfo input_info(input_path); + const QString default_output = input_info.absoluteDir().filePath(input_info.completeBaseName() + ".rdbview"); + + QFileDialog output_dialog(host_, "Save Geometry Database As"); + output_dialog.setAcceptMode(QFileDialog::AcceptSave); + output_dialog.setFileMode(QFileDialog::AnyFile); + output_dialog.setNameFilter("Geometry Database (*.rdbview);;All Files (*)"); + output_dialog.setDefaultSuffix("rdbview"); + output_dialog.selectFile(default_output); + output_dialog.setOption(QFileDialog::DontUseNativeDialog, true); + if (output_dialog.exec() != QDialog::Accepted) return; + const QStringList outputs = output_dialog.selectedFiles(); + if (outputs.isEmpty()) return; + QString output_path = outputs.first(); + if (!output_path.endsWith(".rdbview", Qt::CaseInsensitive)) { + output_path += ".rdbview"; + } + + runGeometryDatabaseExport(input_path, output_path); +} + +void ModelsPanelController::runGeometryDatabaseExport(const QString& input_path, const QString& output_path) { + auto* progress = new QProgressDialog(host_); + progress->setWindowTitle("Export Geometry Database"); + progress->setLabelText(QString("Exporting %1 to %2…") + .arg(QFileInfo(input_path).fileName(), + QFileInfo(output_path).fileName())); + progress->setRange(0, 0); + progress->setCancelButton(nullptr); + progress->setMinimumDuration(0); + progress->setWindowModality(Qt::ApplicationModal); + progress->setAutoClose(false); + progress->setAutoReset(false); + progress->show(); + + session_state_->setStatusMessage("Exporting", + QString("%1 → %2").arg(QFileInfo(input_path).fileName(), QFileInfo(output_path).fileName())); + + auto timer = std::make_shared(); + timer->start(); + auto error_message = std::make_shared(); + + QThread* thread = QThread::create([input_path, output_path, error_message]() { + // Scratch dir holds the intermediate .ifcview and .rdb directory + // until they're zipped into the .rdbview. RAII-like cleanup at the + // bottom of this lambda; on early exception we leak it (cheap + // tradeoff to keep the failure log around for the user). + const QString tmp_root = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)) + .filePath(QString("ifcviewer-export-%1") + .arg(QUuid::createUuid().toString(QUuid::Id128))); + QDir().mkpath(tmp_root); + + const QString tmp_anchor = QDir(tmp_root).filePath("model.ifc"); + const QString tmp_sidecar = QDir(tmp_root).filePath("model.ifcview"); + const QString tmp_rdb_dir = QDir(tmp_root).filePath("model.rdb"); + + try { + // Step 1: lossy RDB with IfcRepresentationItem stripped. + ifcopenshell::serializers::document_serializer_context context; + context.file = nullptr; + context.input_filename = input_path.toStdString(); + context.output_filename = tmp_rdb_dir.toStdString(); + context.stream = true; + context.skip_supertypes = { "IfcRepresentationItem" }; + + auto& registry = ifcopenshell::serializers::document_serializer_registry_instance(); + const auto* info = registry.find("rdb"); + if (!info) { + throw ifcopenshell::exception( + "No 'rdb' document serializer is registered. The RocksDB serializer plugin may not be installed."); + } + if (!info->supports_input_filename) { + throw ifcopenshell::exception("RDB serializer does not support streaming from an input filename"); + } + + boost::shared_ptr serializer = registry.create("rdb", context); + serializer->finalize(); + serializer.reset(); + + // Step 2: .ifcview sidecar via the headless builder. + HeadlessSidecarBuilder builder; + if (!builder.build(input_path, tmp_anchor)) { + throw ifcopenshell::exception( + ("Sidecar build failed: " + builder.lastError()).toStdString()); + } + if (!QFileInfo::exists(tmp_sidecar)) { + throw ifcopenshell::exception( + ("Sidecar build reported success but " + tmp_sidecar + " is missing").toStdString()); + } + + // Step 3: zip the sidecar + RDB directory into the .rdbview. + // Write to a sibling `.tmp` then rename so a partial file never + // appears at the destination (matters for cloud-sync folders). + const QString tmp_zip = output_path + ".tmp"; + QFile::remove(tmp_zip); + { + QZipWriter writer(tmp_zip); + if (writer.status() != QZipWriter::NoError) { + throw ifcopenshell::exception( + ("Failed to open " + tmp_zip + " for writing").toStdString()); + } + writer.setCompressionPolicy(QZipWriter::AutoCompress); + + { + QFile sf(tmp_sidecar); + if (!sf.open(QIODevice::ReadOnly)) { + throw ifcopenshell::exception( + ("Failed to read sidecar " + tmp_sidecar).toStdString()); + } + writer.addFile("model.ifcview", sf.readAll()); + } + + QDirIterator it(tmp_rdb_dir, QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + const QDir rdb_root(tmp_rdb_dir); + while (it.hasNext()) { + const QString file_path = it.next(); + const QString rel = rdb_root.relativeFilePath(file_path); + QFile f(file_path); + if (!f.open(QIODevice::ReadOnly)) { + throw ifcopenshell::exception( + ("Failed to read " + file_path + " for zip").toStdString()); + } + writer.addFile(QString("model.rdb/%1").arg(rel), f.readAll()); + } + + writer.close(); + if (writer.status() != QZipWriter::NoError) { + throw ifcopenshell::exception( + ("Failed to finalize " + tmp_zip).toStdString()); + } + } + + QFile::remove(output_path); + if (!QFile::rename(tmp_zip, output_path)) { + QFile::remove(tmp_zip); + throw ifcopenshell::exception( + ("Failed to move " + tmp_zip + " to " + output_path).toStdString()); + } + } catch (const std::exception& e) { + *error_message = QString::fromUtf8(e.what()); + } catch (...) { + *error_message = "Unknown error during geometry database export"; + } + + QDir(tmp_root).removeRecursively(); + }); + + connect(thread, &QThread::finished, this, + [this, thread, progress, timer, error_message, input_path, output_path]() { + const qint64 elapsed = timer->elapsed(); + + progress->close(); + progress->deleteLater(); + thread->deleteLater(); + + if (!error_message->isEmpty()) { + session_state_->setStatusMessage("Error", *error_message); + QMessageBox::warning(host_, "Export Geometry Database", + QString("Export failed:\n%1").arg(*error_message)); + return; + } + + session_state_->setStatusMessage( + "Exported", + QString("%1 → %2 in %3") + .arg(QFileInfo(input_path).fileName(), + QFileInfo(output_path).fileName(), + formatElapsed(elapsed))); + QMessageBox::information(host_, "Export Geometry Database", + QString("Geometry database written to:\n%1").arg(output_path)); + }); + + thread->start(); +} + void ModelsPanelController::addFiles(const QStringList& paths) { QStringList accepted_paths; QStringList accepted_fed_ids; diff --git a/src/ifcviewer-full/modules/models/Controller.h b/src/ifcviewer-full/modules/models/Controller.h index bf23724d6b..6177342437 100644 --- a/src/ifcviewer-full/modules/models/Controller.h +++ b/src/ifcviewer-full/modules/models/Controller.h @@ -52,11 +52,13 @@ public: void removeLoadedModel(const QString& fed_id); void openSettings(); void convertIfcToDatabase(); + void exportGeometryDatabase(); private: QString formatElapsed(qint64 ms) const; void writeSidecarForModel(SceneLoader* loader, uint32_t mid) const; void runIfcToDatabaseConversion(const QString& input_path, const QString& output_path); + void runGeometryDatabaseExport(const QString& input_path, const QString& output_path); QWidget* host_ = nullptr; ModelsPanel* widget_ = nullptr; diff --git a/src/ifcviewer/HeadlessSidecarBuilder.cpp b/src/ifcviewer/HeadlessSidecarBuilder.cpp new file mode 100644 index 0000000000..c3bef7fdbe --- /dev/null +++ b/src/ifcviewer/HeadlessSidecarBuilder.cpp @@ -0,0 +1,214 @@ +/******************************************************************************** + * * + * 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 * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#include "HeadlessSidecarBuilder.h" + +#include "Federation.h" +#include "GeometryStreamer.h" +#include "LodBuilder.h" +#include "SidecarCache.h" +#include "VertexQuantization.h" + +#include + +#include + +#include +#include + +HeadlessSidecarBuilder::HeadlessSidecarBuilder(QObject* parent) + : QObject(parent) +{ +} + +void HeadlessSidecarBuilder::onMeshReady(const MeshChunk& chunk) { + if (chunk.vertices.empty() || chunk.indices.empty()) return; + + // Streamer format: 7 floats/vertex (pos3 + normal3 + color-as-float). + const size_t n_verts = chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS; + + // 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. + float bmin[3] = { std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity() }; + float bmax[3] = { -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity() }; + for (size_t i = 0; i < n_verts; ++i) { + const float* v = chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS; + for (int a = 0; a < 3; ++a) { + if (v[a] < bmin[a]) bmin[a] = v[a]; + if (v[a] > bmax[a]) bmax[a] = v[a]; + } + } + float extent_recip[3]; + for (int a = 0; a < 3; ++a) { + float ext = bmax[a] - bmin[a]; + extent_recip[a] = ext > 0.0f ? 1.0f / ext : 0.0f; + } + + const size_t vb_offset = sidecar_data_.vertices.size(); + sidecar_data_.vertices.resize(vb_offset + n_verts * INSTANCED_VERTEX_STRIDE_BYTES); + for (size_t i = 0; i < n_verts; ++i) { + quantizeVertex(chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS, + bmin, extent_recip, + sidecar_data_.vertices.data() + vb_offset + + i * INSTANCED_VERTEX_STRIDE_BYTES); + } + + const size_t ib_offset = sidecar_data_.indices.size(); + sidecar_data_.indices.insert(sidecar_data_.indices.end(), + chunk.indices.begin(), chunk.indices.end()); + + MeshInfo info; + info.vbo_byte_offset = static_cast(vb_offset); + info.vertex_count = static_cast(n_verts); + info.ebo_byte_offset = static_cast(ib_offset * sizeof(uint32_t)); + info.index_count = static_cast(chunk.indices.size()); + for (int a = 0; a < 3; ++a) { + info.local_aabb_min[a] = bmin[a]; + info.local_aabb_max[a] = bmax[a]; + } + info.first_instance = 0; + info.instance_count = 0; + info.lod1_ebo_byte_offset = 0; + info.lod1_index_count = 0; + + if (sidecar_data_.meshes.size() <= chunk.local_mesh_id) { + sidecar_data_.meshes.resize(chunk.local_mesh_id + 1); + } + sidecar_data_.meshes[chunk.local_mesh_id] = info; +} + +void HeadlessSidecarBuilder::onInstanceReady(const InstanceChunk& chunk) { + InstanceCpu inst; + inst.mesh_id = chunk.local_mesh_id; + inst.object_id = chunk.object_id; + inst.color_override_rgba8 = chunk.color_override_rgba8; + inst.model_id = chunk.model_id; + + // The streamer's chunk.transform is the placement_transformation. With + // identity stage matrices (no FederatedFalseOrigin / ModelTransformation + // / CoordinateOperation applied yet), transform == placement_transformation + // and chunk.world_aabb_* is already the world AABB. ViewportWindow's + // applyCachedModel will recompose against the consumer's stage matrices + // at load time, so the cached transform/world_aabb is just a sensible + // identity-stage baseline. + std::memcpy(inst.placement_transformation, chunk.transform, + sizeof(inst.placement_transformation)); + std::memcpy(inst.transform, chunk.transform, sizeof(inst.transform)); + std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min)); + std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max)); + + 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; + + // Per-mesh instance_count, matching ViewportWindow::finalizeModel. + for (auto& mesh : sidecar_data_.meshes) { + mesh.first_instance = 0; + mesh.instance_count = 0; + } + for (const auto& inst : sidecar_data_.instances) { + if (inst.mesh_id < sidecar_data_.meshes.size()) { + ++sidecar_data_.meshes[inst.mesh_id].instance_count; + } + } + + // 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>( + 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()) { + 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; + + packed.guid_offset = static_cast(sidecar_data_.string_table.size()); + packed.guid_length = static_cast(info.guid.size()); + sidecar_data_.string_table += info.guid; + + packed.name_offset = static_cast(sidecar_data_.string_table.size()); + packed.name_length = static_cast(info.name.size()); + sidecar_data_.string_table += info.name; + + packed.type_offset = static_cast(sidecar_data_.string_table.size()); + packed.type_length = static_cast(info.type.size()); + sidecar_data_.string_table += info.type; + + sidecar_data_.elements.push_back(packed); + } + + buildLods(sidecar_data_); + + if (!writeSidecar(anchor_path.toStdString(), sidecar_data_)) { + last_error_ = "writeSidecar failed"; + return false; + } + + return true; +} diff --git a/src/ifcviewer/HeadlessSidecarBuilder.h b/src/ifcviewer/HeadlessSidecarBuilder.h new file mode 100644 index 0000000000..c330f5aac6 --- /dev/null +++ b/src/ifcviewer/HeadlessSidecarBuilder.h @@ -0,0 +1,60 @@ +/******************************************************************************** + * * + * 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 * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +#ifndef HEADLESSSIDECARBUILDER_H +#define HEADLESSSIDECARBUILDER_H + +#include "InstancedGeometry.h" +#include "SidecarCache.h" + +#include +#include + +// 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. +// +// 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 { + Q_OBJECT +public: + explicit HeadlessSidecarBuilder(QObject* parent = nullptr); + + // `anchor_path` is fed to writeSidecar(), which normalises it to + // `.ifcview`. Pass either the IFC path itself + // (sidecar lands beside it) or a temp path whose stem you control. + bool build(const QString& ifc_path, + const QString& anchor_path, + int num_threads = 0); + + const QString& lastError() const { return last_error_; } + +private: + void onMeshReady(const MeshChunk& chunk); + void onInstanceReady(const InstanceChunk& chunk); + + SidecarData sidecar_data_; + QString last_error_; +}; + +#endif // HEADLESSSIDECARBUILDER_H diff --git a/src/ifcviewer/VertexQuantization.h b/src/ifcviewer/VertexQuantization.h new file mode 100644 index 0000000000..0b47f7528a --- /dev/null +++ b/src/ifcviewer/VertexQuantization.h @@ -0,0 +1,81 @@ +/******************************************************************************** + * * + * 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 * + * Lesser GNU General Public License for more details. * + * * + * You should have received a copy of the Lesser GNU General Public License * + * along with this program. If not, see . * + * * + ********************************************************************************/ + +// 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. + +#ifndef VERTEXQUANTIZATION_H +#define VERTEXQUANTIZATION_H + +#include "InstancedGeometry.h" + +#include +#include +#include + +// Meyer et al. octahedral normal encode. Input unit vector -> [-1,1]^2. +inline void octEncodeNormal(const float n[3], float out[2]) { + float ax = std::fabs(n[0]), ay = std::fabs(n[1]), az = std::fabs(n[2]); + float denom = ax + ay + az; + if (denom < 1e-12f) { out[0] = 0.0f; out[1] = 0.0f; return; } + float px = n[0] / denom; + float py = n[1] / denom; + if (n[2] < 0.0f) { + float sx = px >= 0.0f ? 1.0f : -1.0f; + float sy = py >= 0.0f ? 1.0f : -1.0f; + float nx = (1.0f - std::fabs(py)) * sx; + float ny = (1.0f - std::fabs(px)) * sy; + px = nx; py = ny; + } + out[0] = px; + out[1] = py; +} + +// Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into +// the 12 B VBO record, given the mesh's tight local AABB. `extent_recip` +// is 1/(max-min) per axis, or 0 for degenerate axes. +inline void quantizeVertex(const float src[INSTANCED_VERTEX_STRIDE_FLOATS], + const float aabb_min[3], + const float extent_recip[3], + uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]) { + // Position -> u16 normalized. + uint16_t* p = reinterpret_cast(dst + INSTANCED_VERTEX_POS_OFFSET); + for (int a = 0; a < 3; ++a) { + float t = (src[a] - aabb_min[a]) * extent_recip[a]; + if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; + p[a] = static_cast(t * 65535.0f + 0.5f); + } + // Normal -> oct i8x2. int8 gives ~1.4° worst-case error — fine for BIM. + float oct[2]; + octEncodeNormal(src + 3, oct); + int8_t* n = reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); + for (int a = 0; a < 2; ++a) { + float v = oct[a]; + if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f; + n[a] = static_cast(std::lrintf(v * 127.0f)); + } + // Color passes through — streamer packs 4 bytes into the 7th float slot. + std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4); +} + +#endif // VERTEXQUANTIZATION_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 265dd88bf4..1efcd31577 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -20,6 +20,7 @@ #include "ViewportWindow.h" #include "AppSettings.h" +#include "VertexQuantization.h" #include #include @@ -492,51 +493,6 @@ static GLuint linkProgram(QOpenGLFunctions_4_5_Core* gl, GLuint vert, GLuint fra // ----------------------------------------------------------------------------- -// Meyer et al. octahedral normal encode. Input unit vector -> [-1,1]^2. -static void octEncode(const float n[3], float out[2]) { - float ax = std::fabs(n[0]), ay = std::fabs(n[1]), az = std::fabs(n[2]); - float denom = ax + ay + az; - if (denom < 1e-12f) { out[0] = 0.0f; out[1] = 0.0f; return; } - float px = n[0] / denom; - float py = n[1] / denom; - if (n[2] < 0.0f) { - float sx = px >= 0.0f ? 1.0f : -1.0f; - float sy = py >= 0.0f ? 1.0f : -1.0f; - float nx = (1.0f - std::fabs(py)) * sx; - float ny = (1.0f - std::fabs(px)) * sy; - px = nx; py = ny; - } - out[0] = px; - out[1] = py; -} - -// Quantize a streamer-format vertex (pos3 + normal3 + color-as-float) into -// the 12 B VBO record, given the mesh's tight local AABB. `extent_recip` -// is 1/(max-min) per axis, or 0 for degenerate axes (quantum becomes 0). -static void quantizeVertex(const float src[7], - const float aabb_min[3], - const float extent_recip[3], - uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES]) { - // Position -> u16 normalized. - uint16_t* p = reinterpret_cast(dst + INSTANCED_VERTEX_POS_OFFSET); - for (int a = 0; a < 3; ++a) { - float t = (src[a] - aabb_min[a]) * extent_recip[a]; - if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f; - p[a] = static_cast(t * 65535.0f + 0.5f); - } - // Normal -> oct i8x2. int8 gives ~1.4° worst-case error — fine for BIM. - float oct[2]; - octEncode(src + 3, oct); - int8_t* n = reinterpret_cast(dst + INSTANCED_VERTEX_NORMAL_OFFSET); - for (int a = 0; a < 2; ++a) { - float v = oct[a]; - if (v < -1.0f) v = -1.0f; else if (v > 1.0f) v = 1.0f; - n[a] = static_cast(std::lrintf(v * 127.0f)); - } - // Color passes through — streamer packs 4 bytes into the 7th float slot. - std::memcpy(dst + INSTANCED_VERTEX_COLOR_OFFSET, src + 6, 4); -} - // Determinant of the upper-left 3x3 of a column-major mat4 stored as 16 floats. // Sign tells us whether the transform contains a reflection, which is what // decides which glFrontFace winding to draw the instance with. diff --git a/src/serializers/document_rdb_plugin.cpp b/src/serializers/document_rdb_plugin.cpp index ffcefcd1e8..244b3adf00 100644 --- a/src/serializers/document_rdb_plugin.cpp +++ b/src/serializers/document_rdb_plugin.cpp @@ -31,7 +31,7 @@ boost::shared_ptr create_serializer(const ifcopenshell::serializers: if (context.input_filename.empty()) { throw ifcopenshell::exception("RocksDB document serializer requires an input filename"); } - return boost::make_shared(context.input_filename, context.output_filename); + return boost::make_shared(context.input_filename, context.output_filename, context.skip_supertypes); } } diff --git a/src/serializers/document_serializer_plugin.h b/src/serializers/document_serializer_plugin.h index 9b45de731f..d32dc4b698 100644 --- a/src/serializers/document_serializer_plugin.h +++ b/src/serializers/document_serializer_plugin.h @@ -53,6 +53,11 @@ struct SERIALIZERS_API document_serializer_context { std::string schema_name; bool stream = false; int dialect = 0; + // Supertype names to skip when streaming entities into the serializer. + // Currently only honoured by the RocksDB serializer; consumers building + // lossy/read-only databases pass e.g. {"IfcRepresentationItem"} to drop + // geometry definitions. + std::vector skip_supertypes; }; class SERIALIZERS_API document_serializer_registry {