Add geometry database (.rdbview) export to IfcViewerFull

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
`<dest>.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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-15 11:54:14 +10:00
parent 56273de443
commit fed739847e
11 changed files with 575 additions and 48 deletions
+214
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#include "HeadlessSidecarBuilder.h"
#include "Federation.h"
#include "GeometryStreamer.h"
#include "LodBuilder.h"
#include "SidecarCache.h"
#include "VertexQuantization.h"
#include <QEventLoop>
#include <Eigen/Dense>
#include <cstring>
#include <limits>
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<float>::infinity(),
std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity() };
float bmax[3] = { -std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::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<uint32_t>(vb_offset);
info.vertex_count = static_cast<uint32_t>(n_verts);
info.ebo_byte_offset = static_cast<uint32_t>(ib_offset * sizeof(uint32_t));
info.index_count = static_cast<uint32_t>(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<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()) {
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<uint32_t>(sidecar_data_.string_table.size());
packed.guid_length = static_cast<uint32_t>(info.guid.size());
sidecar_data_.string_table += info.guid;
packed.name_offset = static_cast<uint32_t>(sidecar_data_.string_table.size());
packed.name_length = static_cast<uint32_t>(info.name.size());
sidecar_data_.string_table += info.name;
packed.type_offset = static_cast<uint32_t>(sidecar_data_.string_table.size());
packed.type_length = static_cast<uint32_t>(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;
}
+60
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
#ifndef HEADLESSSIDECARBUILDER_H
#define HEADLESSSIDECARBUILDER_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.
//
// 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
// `<stem(anchor_path)>.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
+81
View File
@@ -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 <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// 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 <cmath>
#include <cstdint>
#include <cstring>
// 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<uint16_t*>(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<uint16_t>(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<int8_t*>(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<int8_t>(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
+1 -45
View File
@@ -20,6 +20,7 @@
#include "ViewportWindow.h"
#include "AppSettings.h"
#include "VertexQuantization.h"
#include <QMouseEvent>
#include <QKeyEvent>
@@ -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<uint16_t*>(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<uint16_t>(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<int8_t*>(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<int8_t>(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.