ifcviewer: v16 zstd-compressed sidecars (~10x smaller over the wire)

The .ifcview data is hugely redundant (repeated double instance matrices,
patterned indices) — measured 12x zstd whole-file. Server Content-Encoding
can't be used (it breaks HTTP Range), so compress PER-CHUNK into the format.

Format (v16): geometry becomes per-chunk zstd(vertices)+zstd(indices) frames —
each independently Range-fetchable, so streaming is intact — and the critical +
deferred metadata blocks are single zstd frames. SidecarChunk carries the
compressed blob offsets/sizes; applyStreamedChunk (render/upload) is UNCHANGED —
decompression slots into the fetch. Full readSidecar (test/tooling) reconstructs
by decompress+scatter. zstd: desktop links libzstd (also compresses at bake);
the web build (Emscripten has no zstd port) FetchContent's the pinned zstd
source and compiles its decompress-only subset for wasm — no vendored blob,
same version as desktop. New SidecarCompress wraps it (compress guarded off
under Emscripten). Both stream paths — desktop StreamingThread worker + sync
fallback (readChunkGeometryCompressed) and web beginWebChunkLoad — decompress;
readSidecarMetadataOnly / the web bootstrap / loadDeferredMetadataWeb decompress
the metadata blocks. streamingByteProgress reports COMPRESSED bytes. MEASURED: a
752 MB v15 federation → 75 MB v16 (10x; per-file 6.7-15.3x); PP-PLP 118→15 MB,
loads 13/13 chunks on web, 0 errors.

Three fixes found while testing big federations on a real server:
- Web-streamed race: streaming_from_web was set in the deferred-header callback
  (a round-trip after the model+chunks exist), so driveStreamingLoads could take
  the sync fopen path meanwhile → "failed to read/decompress chunk 0". Now set
  immediately after applyCachedModel.
- OOM abort on 18 models: the pool grew unbounded until an alloc failed, but on
  web that's an uncatchable bad_alloc abort. Cap total pool capacity
  (setMaxTotalCapacity, 3 GB) so it stops before the heap ceiling, and raise
  MAXIMUM_MEMORY 2→4 GB (wasm32 max) for headroom.
- Web never evicted (grow-or-block only). At the hard budget, fall through to the
  LRU/priority evictor so a big federation stays navigable (highest-contribution
  chunks win) instead of freezing with holes.

113/113 desktop + 6/6 web smoke pass. No back-compat: regenerate sidecars
(desktop bakes v16; scratch conv tool migrates v15→v16).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-02 10:24:59 +10:00
parent 5299f6c13c
commit 0b8c787ac0
19 changed files with 862 additions and 442 deletions
+2 -2
View File
@@ -38,7 +38,7 @@
# Better to keep the web path's machinery local to this directory.
cmake_minimum_required(VERSION 3.21)
project(IfcViewerWeb LANGUAGES CXX)
project(IfcViewerWeb LANGUAGES C CXX) # C for the vendored zstd decoder
if(NOT EMSCRIPTEN)
message(FATAL_ERROR
@@ -114,7 +114,7 @@ target_link_options(IfcViewerWeb PRIVATE
# cap; --shared64 / MEMORY64 would lift this later if we need it).
"-sALLOW_MEMORY_GROWTH=1"
"-sINITIAL_MEMORY=268435456" # 256 MB
"-sMAXIMUM_MEMORY=2147483648" # 2 GB
"-sMAXIMUM_MEMORY=4294967296" # 4 GB (wasm32 max) — big federations need the headroom
# FETCH lets emscripten_fetch issue HTTP Range requests. The local
# file path (#88) reads byte ranges via Blob.slice and does NOT need
# this; it's retained for the remote-URL Range backend (follow-up).
Binary file not shown.
+13 -1
View File
@@ -109,7 +109,18 @@ public:
// false the first time addSubBuffer is refused even at the floor
// size — eviction callers need this to know whether a future alloc
// could rescue them, or whether eviction is the only path.
bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0; }
bool can_grow() const {
return !growth_disabled_ && per_sub_buffer_capacity_ > 0
&& (max_total_capacity_bytes_ == 0
|| total_capacity_bytes() < max_total_capacity_bytes_);
}
// Hard ceiling on total pool capacity (0 = unlimited). Once total capacity
// reaches this, can_grow() returns false so the streaming driver EVICTS
// instead of growing. Critical on web: a growth past the wasm heap ceiling
// is a bad_alloc that -fno-exceptions turns into an uncatchable abort, so
// the async grow-OOM detection can't save us — we must stop first.
void setMaxTotalCapacity(uint64_t max_bytes) { max_total_capacity_bytes_ = max_bytes; }
// Proactively add a sub-buffer (no allocation). On web this kicks off the
// async provisional-validation cycle so validated free space appears a
@@ -175,6 +186,7 @@ private:
WGPUDevice device_ = nullptr;
WGPUBufferUsage usage_ = 0;
uint64_t per_sub_buffer_capacity_ = 0;
uint64_t max_total_capacity_bytes_ = 0; // 0 = unlimited (see can_grow)
// The largest size addSubBuffer last *succeeded* at, in bytes.
// Starts at per_sub_buffer_capacity_ (the probe's discovered max)
// and decays as the driver refuses larger allocations. Future grow
+50
View File
@@ -143,10 +143,46 @@ set(IFCVIEWER_CORE_SOURCES
InstanceCompose.cpp
LodBuilder.cpp
SidecarCache.cpp
SidecarCompress.cpp
StreamingLoader.cpp
StreamingThread.cpp
ViewportCore.cpp
)
# Web needs a zstd DECODER (Emscripten has no zstd port; the desktop links the
# full libzstd below). Rather than vendor a generated blob, fetch the pinned
# zstd source and compile its decompress-only subset — the exact set zstd's
# own single-file decoder inlines — straight into the wasm.
if(EMSCRIPTEN)
include(FetchContent)
FetchContent_Declare(zstd_dec
GIT_REPOSITORY https://github.com/facebook/zstd.git
GIT_TAG v1.5.7
GIT_SHALLOW TRUE
# zstd's CMake lives in build/cmake, not the root — point SOURCE_SUBDIR
# at a non-existent dir so MakeAvailable only POPULATES the source and
# never tries to configure zstd's own (non-emscripten) build.
SOURCE_SUBDIR does-not-exist)
FetchContent_MakeAvailable(zstd_dec)
set(ZSTD_DEC_DIR "${zstd_dec_SOURCE_DIR}/lib")
set(ZSTD_DEC_SRC
${ZSTD_DEC_DIR}/decompress/zstd_decompress.c
${ZSTD_DEC_DIR}/decompress/zstd_decompress_block.c
${ZSTD_DEC_DIR}/decompress/zstd_ddict.c
${ZSTD_DEC_DIR}/decompress/huf_decompress.c
${ZSTD_DEC_DIR}/common/entropy_common.c
${ZSTD_DEC_DIR}/common/error_private.c
${ZSTD_DEC_DIR}/common/fse_decompress.c
${ZSTD_DEC_DIR}/common/pool.c
${ZSTD_DEC_DIR}/common/threading.c
${ZSTD_DEC_DIR}/common/xxhash.c
${ZSTD_DEC_DIR}/common/zstd_common.c
${ZSTD_DEC_DIR}/common/debug.c)
# wasm isn't x86-64 → no BMI2 asm path (belt-and-braces disable).
set_source_files_properties(${ZSTD_DEC_SRC} PROPERTIES
COMPILE_DEFINITIONS "ZSTD_DISABLE_ASM=1")
# NOT appended to IFCVIEWER_CORE_SOURCES — those get a source-dir PREPEND
# below which would mangle these absolute paths; added via target_sources.
endif()
set(IFCVIEWER_CORE_HEADERS
BufferPool.h
CameraMath.h
@@ -162,6 +198,7 @@ set(IFCVIEWER_CORE_HEADERS
SectionPlane.h
SelectionState.h
SidecarCache.h
SidecarCompress.h
StreamingLoader.h
StreamingThread.h
VertexQuantization.h
@@ -183,6 +220,19 @@ if(UNIX AND NOT APPLE)
find_package(Threads REQUIRED)
target_link_libraries(IfcViewerCore PUBLIC Threads::Threads)
endif()
# Sidecar (de)compression. Web compiles the vendored single-file decoder (added
# to the sources above) + uses the vendored zstd.h; desktop links the full
# libzstd (compress + decompress) and takes zstd.h from the system.
if(EMSCRIPTEN)
target_sources(IfcViewerCore PRIVATE ${ZSTD_DEC_SRC})
target_include_directories(IfcViewerCore PRIVATE ${ZSTD_DEC_DIR})
else()
find_library(ZSTD_LIBRARY NAMES zstd libzstd)
if(NOT ZSTD_LIBRARY)
message(FATAL_ERROR "libzstd not found (needed for .ifcview compression)")
endif()
target_link_libraries(IfcViewerCore PUBLIC ${ZSTD_LIBRARY})
endif()
install(TARGETS IfcViewerCore EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
# IfcViewer: the Qt + IfcGeom + OpenCASCADE shell — everything in this
+16 -4
View File
@@ -177,6 +177,14 @@ struct ModelGpuData {
// is recovered by walking mesh_ids and the model's MeshInfo[].
uint64_t vertex_byte_size = 0;
uint64_t index_count = 0;
// v16: where this chunk's two zstd frames live in the file's geometry
// section (offsets relative to model.geometry_section_offset) and their
// compressed sizes. The raw sizes are vertex_byte_size / index_count*4.
// A per-chunk load fetches [off, +comp) and decompresses.
uint64_t v_comp_off = 0;
uint64_t v_comp_size = 0;
uint64_t i_comp_off = 0;
uint64_t i_comp_size = 0;
// Of `index_count`, how many are LOD1 indices. LOD0 indices occupy
// chunk-local u32 offsets [0, index_count - lod1_index_count); LOD1
// indices occupy [index_count - lod1_index_count, index_count). 0
@@ -286,8 +294,9 @@ struct ModelGpuData {
// streaming path: chunks may be non-resident and need byte-range reads
// from this file. Empty path = legacy non-streaming load.
std::string streaming_file_path;
uint64_t streaming_vertex_section_offset = 0;
uint64_t streaming_index_section_offset = 0;
// v16: file offset of the compressed geometry section. A chunk's blobs are
// at geometry_section_offset + chunk.{v_comp_off,i_comp_off}.
uint64_t geometry_section_offset = 0;
// Web only: chunk byte ranges come from the JS-side source — a picked File
// (Blob.slice) or a remote URL (HTTP Range) — read asynchronously, not via
// a synchronous fopen on streaming_file_path. Set by loadSidecarMetadataWeb
@@ -308,8 +317,11 @@ struct ModelGpuData {
// it; deferred_meta_loaded latches so it fetches at most once.
std::vector<PackedElementInfo> elements;
std::string string_table;
uint64_t deferred_meta_offset = 0;
uint64_t deferred_meta_bytes = 0;
// v16: the deferred block is a single zstd frame at deferred_comp_offset of
// deferred_comp_size bytes, expanding to deferred_raw_size.
uint64_t deferred_comp_offset = 0;
uint64_t deferred_comp_size = 0;
uint64_t deferred_raw_size = 0;
bool deferred_meta_loaded = false;
// applyCachedModel rebases instance object_ids by this base to keep them
// globally unique across models; deferred elements carry the sidecar's
+3 -4
View File
@@ -189,6 +189,7 @@ void SceneLoader::startNextLoad() {
(long long)rt.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);
if (*result && !(*result)->meta.instances.empty()) {
applySidecarData(mid, std::move(**result));
if (!is_sidecar_source) {
@@ -197,7 +198,6 @@ void SceneLoader::startNextLoad() {
return;
}
auto it = models_.find(mid);
if (it == models_.end()) return;
if (is_sidecar_source) {
@@ -236,10 +236,9 @@ void SceneLoader::applySidecarData(uint32_t mid, StreamingSidecar metadata) {
SidecarData& d = metadata.meta;
std::fprintf(stderr,
"[info] Sidecar hit: %s (%zu metadata bytes, %zu indices, %zu meshes, %zu instances, %zu elements)\n",
"[info] Sidecar hit: %s (%zu chunks, %zu meshes, %zu instances, %zu elements)\n",
model.file_path.toStdString().c_str(),
size_t(metadata.vertex_total_bytes),
size_t(metadata.index_total_count),
d.chunks.size(),
d.meshes.size(),
d.instances.size(),
d.elements.size());
+225 -68
View File
@@ -43,10 +43,70 @@
// char[string_table_bytes]
#include "SidecarCache.h"
#include "SidecarCompress.h"
#include <cstdio>
#include <cstring>
// The baker (writeSidecar) compresses — desktop only; the web build never bakes
// and links a decompress-only zstd. Everything from here to writeSidecar's end
// is guarded off under Emscripten.
#if !defined(__EMSCRIPTEN__)
// zstd level for baking. 19 is near-max ratio; decode speed is level-
// independent and the bake is offline, so favour ratio.
static constexpr int kSidecarZstdLevel = 19;
// --- In-memory serialisation (a block is built in RAM, then compressed) ------
template<typename T>
static void appendVec(std::vector<std::uint8_t>& b, const std::vector<T>& v) {
std::uint32_t n = static_cast<std::uint32_t>(v.size());
const auto* np = reinterpret_cast<const std::uint8_t*>(&n);
b.insert(b.end(), np, np + 4);
if (n > 0) {
const auto* p = reinterpret_cast<const std::uint8_t*>(v.data());
b.insert(b.end(), p, p + std::size_t(sizeof(T)) * n);
}
}
static void appendBytes(std::vector<std::uint8_t>& b, const void* p, std::size_t n) {
const auto* c = static_cast<const std::uint8_t*>(p);
b.insert(b.end(), c, c + n);
}
// Pull one chunk's geometry out of the whole-model vertex/index arrays into the
// chunk-LOCAL layout applyStreamedChunk expects: vertices of its meshes in chunk
// order, then indices as LOD0 (per mesh) followed by LOD1 (per mesh).
static void extractChunkGeometry(const SidecarData& d, const SidecarChunk& c,
std::vector<std::uint8_t>& vbytes,
std::vector<std::uint8_t>& ibytes) {
vbytes.clear();
ibytes.clear();
const std::uint32_t end = c.first_mesh + c.mesh_count;
for (std::uint32_t mi = c.first_mesh; mi < end && mi < d.meshes.size(); ++mi) {
const MeshInfo& m = d.meshes[mi];
const std::size_t voff = m.vbo_byte_offset;
const std::size_t vn = std::size_t(m.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (voff + vn <= d.vertices.size())
vbytes.insert(vbytes.end(), d.vertices.begin() + voff,
d.vertices.begin() + voff + vn);
}
auto appendIdx = [&](std::size_t first_u32, std::size_t count) {
if (first_u32 + count > d.indices.size()) return;
const auto* p = reinterpret_cast<const std::uint8_t*>(d.indices.data() + first_u32);
ibytes.insert(ibytes.end(), p, p + count * sizeof(std::uint32_t));
};
for (std::uint32_t mi = c.first_mesh; mi < end && mi < d.meshes.size(); ++mi) {
const MeshInfo& m = d.meshes[mi];
if (m.index_count) appendIdx(m.ebo_byte_offset / sizeof(std::uint32_t), m.index_count);
}
for (std::uint32_t mi = c.first_mesh; mi < end && mi < d.meshes.size(); ++mi) {
const MeshInfo& m = d.meshes[mi];
if (m.lod1_index_count)
appendIdx(m.lod1_ebo_byte_offset / sizeof(std::uint32_t), m.lod1_index_count);
}
}
#endif // !__EMSCRIPTEN__ (bake-only serialisation helpers)
struct SidecarHeader {
uint32_t magic;
uint32_t version;
@@ -86,100 +146,197 @@ static bool readVec(FILE* f, std::vector<T>& v) {
return true;
}
#if !defined(__EMSCRIPTEN__) // bake path — compresses, desktop only
bool writeSidecar(const std::string& ifc_path, const SidecarData& data) {
std::string path = sidecarPath(ifc_path);
FILE* f = fopen(path.c_str(), "wb");
if (!f) return false;
auto wr = [&](const void* p, std::size_t n) {
return fwrite(p, 1, n, f) == n;
};
auto wrU64 = [&](std::uint64_t v) { return wr(&v, sizeof(v)); };
auto wrBlock = [&](const std::vector<std::uint8_t>& raw) -> bool {
auto z = SidecarCompress::compress(raw.data(), raw.size(), kSidecarZstdLevel);
if (raw.size() > 0 && z.empty()) return false; // compress failed
return wrU64(z.size()) && wrU64(raw.size()) && (z.empty() || wr(z.data(), z.size()));
};
SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN };
if (fwrite(&hdr, sizeof(hdr), 1, f) != 1) { fclose(f); return false; }
if (!wr(&hdr, sizeof(hdr))) { fclose(f); return false; }
if (!writeVec(f, data.vertices)) { fclose(f); return false; }
if (!writeVec(f, data.indices)) { fclose(f); return false; }
// --- Geometry section: per-chunk zstd(vertex) + zstd(index) frames -------
// Offsets in the chunk TOC are relative to the geometry section start, so
// the loader range-fetches exactly one chunk without reading anything else.
const long geom_len_pos = ftell(f);
if (!wrU64(0)) { fclose(f); return false; } // geom_bytes placeholder
const long geom_start = ftell(f);
// v15: a render-critical metadata block (meshes, instances, georef, chunk
// TOC) preceded by its byte length, then a deferred block (elements +
// string_table). The length lets the web loader read just the critical
// block before painting and fetch the property data lazily / not at all.
const long crit_len_pos = ftell(f);
uint64_t crit_bytes = 0;
if (fwrite(&crit_bytes, sizeof(crit_bytes), 1, f) != 1) { fclose(f); return false; }
const long crit_start = ftell(f);
if (!writeVec(f, data.meshes)) { fclose(f); return false; }
if (!writeVec(f, data.instances)) { fclose(f); return false; }
// v11 georef block (148 B).
if (fwrite(&data.has_coordinate_operation, 4, 1, f) != 1) { fclose(f); return false; }
if (fwrite(data.coordinate_operation_meters,
sizeof(double), 16, f) != 16) { fclose(f); return false; }
if (fwrite(&data.project_length_to_meters,
sizeof(double), 1, f) != 1) { fclose(f); return false; }
if (fwrite(&data.map_unit_to_meters,
sizeof(double), 1, f) != 1) { fclose(f); return false; }
if (!writeVec(f, data.chunks)) { fclose(f); return false; }
// Backpatch the critical-block length.
const long crit_end = ftell(f);
if (crit_start < 0 || crit_end < 0) { fclose(f); return false; }
crit_bytes = uint64_t(crit_end - crit_start);
if (fseek(f, crit_len_pos, SEEK_SET) != 0) { fclose(f); return false; }
if (fwrite(&crit_bytes, sizeof(crit_bytes), 1, f) != 1) { fclose(f); return false; }
if (fseek(f, crit_end, SEEK_SET) != 0) { fclose(f); return false; }
// Deferred block: element tree + string table (UI/picking, never rendered).
if (!writeVec(f, data.elements)) { fclose(f); return false; }
uint32_t stbl_len = static_cast<uint32_t>(data.string_table.size());
if (fwrite(&stbl_len, 4, 1, f) != 1) { fclose(f); return false; }
if (stbl_len > 0 && fwrite(data.string_table.data(), 1, stbl_len, f) != stbl_len) {
fclose(f); return false;
std::vector<SidecarChunk> chunks = data.chunks; // fill blob offsets below
std::vector<std::uint8_t> vraw, iraw;
for (auto& c : chunks) {
extractChunkGeometry(data, c, 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; }
c.v_comp_off = std::uint64_t(ftell(f) - geom_start);
c.v_comp_size = vz.size();
c.v_raw_size = vraw.size();
if (!vz.empty() && !wr(vz.data(), vz.size())) { fclose(f); return false; }
c.i_comp_off = std::uint64_t(ftell(f) - geom_start);
c.i_comp_size = iz.size();
c.i_raw_size = iraw.size();
if (!iz.empty() && !wr(iz.data(), iz.size())) { fclose(f); return false; }
}
const long geom_end = ftell(f);
if (geom_start < 0 || geom_end < 0) { fclose(f); return false; }
if (fseek(f, geom_len_pos, SEEK_SET) != 0) { fclose(f); return false; }
if (!wrU64(std::uint64_t(geom_end - geom_start))) { fclose(f); return false; }
if (fseek(f, geom_end, SEEK_SET) != 0) { fclose(f); return false; }
// --- Critical metadata block (zstd): meshes, instances, georef, chunk TOC
std::vector<std::uint8_t> crit;
appendVec(crit, data.meshes);
appendVec(crit, data.instances);
appendBytes(crit, &data.has_coordinate_operation, 4);
appendBytes(crit, data.coordinate_operation_meters, sizeof(double) * 16);
appendBytes(crit, &data.project_length_to_meters, sizeof(double));
appendBytes(crit, &data.map_unit_to_meters, sizeof(double));
appendVec(crit, chunks);
if (!wrBlock(crit)) { fclose(f); return false; }
// --- Deferred metadata block (zstd): element tree + string table ---------
std::vector<std::uint8_t> def;
appendVec(def, data.elements);
std::uint32_t stbl_len = static_cast<std::uint32_t>(data.string_table.size());
appendBytes(def, &stbl_len, 4);
appendBytes(def, data.string_table.data(), stbl_len);
if (!wrBlock(def)) { fclose(f); return false; }
fclose(f);
return true;
}
#endif // !__EMSCRIPTEN__
// Cursor over an in-memory (decompressed) metadata block.
namespace {
struct BufReader {
const std::uint8_t* p;
std::size_t n;
std::size_t pos = 0;
bool take(void* dst, std::size_t k) {
if (pos + k > n) return false;
std::memcpy(dst, p + pos, k);
pos += k;
return true;
}
template <typename T>
bool takeVec(std::vector<T>& v) {
std::uint32_t c = 0;
if (!take(&c, 4)) return false;
if (pos + std::size_t(c) * sizeof(T) > n) return false;
v.resize(c);
if (c) { std::memcpy(v.data(), p + pos, std::size_t(c) * sizeof(T)); pos += std::size_t(c) * sizeof(T); }
return true;
}
};
} // namespace
// Full read: reconstruct the whole SidecarData (test/tooling path — the runtime
// streams via readSidecarMetadataOnly + 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) {
std::string path = sidecarPath(ifc_path);
FILE* f = fopen(path.c_str(), "rb");
if (!f) return std::nullopt;
auto fail = [&]() -> std::optional<SidecarData> { fclose(f); return std::nullopt; };
SidecarHeader hdr;
if (fread(&hdr, sizeof(hdr), 1, f) != 1) return fail();
if (hdr.magic != SIDECAR_MAGIC ||
hdr.version != SIDECAR_VERSION ||
if (hdr.magic != SIDECAR_MAGIC || hdr.version != SIDECAR_VERSION ||
hdr.endian != SIDECAR_ENDIAN) return fail();
SidecarData data;
if (!readVec(f, data.vertices)) return fail();
if (!readVec(f, data.indices)) return fail();
auto rd = [&](void* p, std::size_t k) { return fread(p, 1, k, f) == k; };
auto rdU64 = [&](std::uint64_t& v) { return rd(&v, sizeof(v)); };
// v15 critical-block length (consumed; only the streaming/web readers need
// it for a one-shot range read — here we read sequentially).
uint64_t crit_bytes = 0;
if (fread(&crit_bytes, sizeof(crit_bytes), 1, f) != 1) return fail();
// Critical block: meshes, instances, georef, chunk TOC.
if (!readVec(f, data.meshes)) return fail();
if (!readVec(f, data.instances)) return fail();
if (fread(&data.has_coordinate_operation, 4, 1, f) != 1) return fail();
if (fread(data.coordinate_operation_meters,
sizeof(double), 16, f) != 16) return fail();
if (fread(&data.project_length_to_meters,
sizeof(double), 1, f) != 1) return fail();
if (fread(&data.map_unit_to_meters,
sizeof(double), 1, f) != 1) return fail();
if (!readVec(f, data.chunks)) return fail();
// Deferred block: element tree + string table.
if (!readVec(f, data.elements)) return fail();
uint32_t stbl_len;
if (fread(&stbl_len, 4, 1, f) != 1) return fail();
data.string_table.resize(stbl_len);
if (stbl_len > 0 && fread(data.string_table.data(), 1, stbl_len, f) != stbl_len)
return fail();
std::uint64_t geom_bytes = 0;
if (!rdU64(geom_bytes)) return fail();
std::vector<std::uint8_t> geom(static_cast<std::size_t>(geom_bytes));
if (geom_bytes && !rd(geom.data(), geom.size())) return fail();
auto readBlock = [&](std::vector<std::uint8_t>& out) -> bool {
std::uint64_t comp = 0, raw = 0;
if (!rdU64(comp) || !rdU64(raw)) return false;
std::vector<std::uint8_t> z(static_cast<std::size_t>(comp));
if (comp && !rd(z.data(), z.size())) return false;
out.assign(std::size_t(raw), 0);
return SidecarCompress::decompress(z.data(), z.size(), out.data(), out.size());
};
std::vector<std::uint8_t> crit, def;
if (!readBlock(crit) || !readBlock(def)) return fail();
fclose(f);
SidecarData data;
BufReader cr{ crit.data(), crit.size() };
if (!cr.takeVec(data.meshes)) return std::nullopt;
if (!cr.takeVec(data.instances)) return std::nullopt;
if (!cr.take(&data.has_coordinate_operation, 4)) return std::nullopt;
if (!cr.take(data.coordinate_operation_meters, sizeof(double) * 16)) return std::nullopt;
if (!cr.take(&data.project_length_to_meters, sizeof(double))) return std::nullopt;
if (!cr.take(&data.map_unit_to_meters, sizeof(double))) return std::nullopt;
if (!cr.takeVec(data.chunks)) return std::nullopt;
BufReader dr{ def.data(), def.size() };
if (!dr.takeVec(data.elements)) return std::nullopt;
std::uint32_t stbl_len = 0;
if (!dr.take(&stbl_len, 4)) return std::nullopt;
data.string_table.resize(stbl_len);
if (stbl_len && !dr.take(data.string_table.data(), stbl_len)) return std::nullopt;
// Reconstruct the whole-model vertex/index arrays from the per-chunk blobs.
std::size_t vsize = 0, isize = 0;
for (const auto& m : data.meshes) {
vsize = std::max<std::size_t>(vsize,
std::size_t(m.vbo_byte_offset) + std::size_t(m.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES);
isize = std::max<std::size_t>(isize, m.ebo_byte_offset / sizeof(std::uint32_t) + m.index_count);
if (m.lod1_index_count)
isize = std::max<std::size_t>(isize, m.lod1_ebo_byte_offset / sizeof(std::uint32_t) + m.lod1_index_count);
}
data.vertices.assign(vsize, 0);
data.indices.assign(isize, 0);
for (const auto& c : data.chunks) {
if (c.v_comp_off + c.v_comp_size > geom.size() ||
c.i_comp_off + c.i_comp_size > geom.size()) return std::nullopt;
std::vector<std::uint8_t> vraw(static_cast<std::size_t>(c.v_raw_size));
std::vector<std::uint8_t> iraw(static_cast<std::size_t>(c.i_raw_size));
if (!SidecarCompress::decompress(geom.data() + c.v_comp_off, c.v_comp_size, vraw.data(), vraw.size()) ||
!SidecarCompress::decompress(geom.data() + c.i_comp_off, c.i_comp_size, iraw.data(), iraw.size()))
return std::nullopt;
const auto* iu = reinterpret_cast<const std::uint32_t*>(iraw.data());
std::size_t vcur = 0, icur = 0;
const std::uint32_t end = c.first_mesh + c.mesh_count;
for (std::uint32_t mi = c.first_mesh; mi < end && mi < data.meshes.size(); ++mi) {
const MeshInfo& m = data.meshes[mi];
const std::size_t vn = std::size_t(m.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (vcur + vn <= vraw.size() && m.vbo_byte_offset + vn <= data.vertices.size())
std::memcpy(&data.vertices[m.vbo_byte_offset], vraw.data() + vcur, vn);
vcur += vn;
}
for (std::uint32_t mi = c.first_mesh; mi < end && mi < data.meshes.size(); ++mi) {
const MeshInfo& m = data.meshes[mi];
if (!m.index_count) continue;
if (icur + m.index_count <= iraw.size() / 4)
std::memcpy(&data.indices[m.ebo_byte_offset / sizeof(std::uint32_t)], iu + icur, m.index_count * 4);
icur += m.index_count;
}
for (std::uint32_t mi = c.first_mesh; mi < end && mi < data.meshes.size(); ++mi) {
const MeshInfo& m = data.meshes[mi];
if (!m.lod1_index_count) continue;
if (icur + m.lod1_index_count <= iraw.size() / 4)
std::memcpy(&data.indices[m.lod1_ebo_byte_offset / sizeof(std::uint32_t)], iu + icur, m.lod1_index_count * 4);
icur += m.lod1_index_count;
}
}
return data;
}
+21 -5
View File
@@ -81,16 +81,32 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
// painting, so first geometry no longer waits on the property data; the
// deferred block is fetched lazily (or skipped where unused). Desktop
// reads both. No back-compat: regenerate sidecars.
static constexpr uint32_t SIDECAR_VERSION = 15;
// v16 = Geometry + metadata are zstd-COMPRESSED. Each chunk's vertex bytes and
// index bytes are stored as two independent zstd frames (so per-chunk
// Range streaming still works — you fetch + decompress just one chunk),
// and the critical + deferred metadata blocks are single zstd frames.
// The chunk TOC records each chunk's compressed blob offsets/sizes plus
// the raw (decompressed) sizes. ~3-5x fewer bytes over the wire while
// keeping HTTP Range intact (unlike server Content-Encoding). No
// back-compat: regenerate sidecars.
static constexpr uint32_t SIDECAR_VERSION = 16;
static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304;
// Chunk table-of-contents entry (v14+). A chunk is a CONTIGUOUS range of
// meshes in the (reordered) meshes array — and therefore a contiguous span of
// vertex + index bytes, since the geometry is laid out in chunk order. The
// loader builds chunk `i` from meshes [first_mesh, first_mesh + mesh_count).
// Chunk table-of-contents entry (v16). A chunk is a CONTIGUOUS range of meshes
// [first_mesh, first_mesh + mesh_count). Its vertex + index bytes are stored as
// two zstd frames in the geometry section; the loader fetches [v_comp_off,
// +v_comp_size) / [i_comp_off, +i_comp_size) (offsets relative to the geometry
// section start) and decompresses them to v_raw_size / i_raw_size bytes — the
// chunk-local (vbytes, idx) applyStreamedChunk consumes.
struct SidecarChunk {
uint32_t first_mesh;
uint32_t mesh_count;
uint64_t v_comp_off;
uint64_t v_comp_size;
uint64_t v_raw_size;
uint64_t i_comp_off;
uint64_t i_comp_size;
uint64_t i_raw_size;
};
// Fixed-size element record. Strings are stored as (offset, length) pairs
+46
View File
@@ -0,0 +1,46 @@
/********************************************************************************
* *
* 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 "SidecarCompress.h"
#include <zstd.h>
namespace SidecarCompress {
bool decompress(const std::uint8_t* src, std::size_t src_size,
std::uint8_t* dst, std::size_t raw_size) {
if (raw_size == 0) return src_size == 0; // empty in ↔ empty out
if (!src || !dst || src_size == 0) return false;
const size_t got = ZSTD_decompress(dst, raw_size, src, src_size);
return !ZSTD_isError(got) && got == raw_size;
}
#if !defined(__EMSCRIPTEN__)
std::vector<std::uint8_t> compress(const std::uint8_t* src, std::size_t n,
int level) {
if (n == 0) return {};
std::vector<std::uint8_t> out(ZSTD_compressBound(n));
const size_t got = ZSTD_compress(out.data(), out.size(), src, n, level);
if (ZSTD_isError(got)) return {};
out.resize(got);
return out;
}
#endif
} // namespace SidecarCompress
+51
View File
@@ -0,0 +1,51 @@
/********************************************************************************
* *
* 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 SIDECARCOMPRESS_H
#define SIDECARCOMPRESS_H
// zstd wrappers for the .ifcview format (v16+). Geometry chunks and metadata
// blocks are stored zstd-compressed so the network pulls far fewer bytes while
// keeping HTTP Range streaming intact (unlike server Content-Encoding, which
// can't be byte-ranged). The baker compresses (desktop only); every loader —
// desktop and web — decompresses. The web build links the vendored single-file
// zstd DECODER (third_party/zstddeclib.c); desktop links the full libzstd.
#include <cstddef>
#include <cstdint>
#include <vector>
namespace SidecarCompress {
// Decompress a zstd frame in [src, src+src_size) into dst, which must have room
// for exactly raw_size bytes. Returns false on any zstd error or if the frame
// doesn't expand to exactly raw_size. Available on all platforms.
bool decompress(const std::uint8_t* src, std::size_t src_size,
std::uint8_t* dst, std::size_t raw_size);
#if !defined(__EMSCRIPTEN__)
// Compress [src, src+n) with zstd at `level`. Returns the compressed frame, or
// an empty vector on error. Bake/desktop only — the web build never compresses.
std::vector<std::uint8_t> compress(const std::uint8_t* src, std::size_t n,
int level);
#endif
} // namespace SidecarCompress
#endif // SIDECARCOMPRESS_H
+65 -43
View File
@@ -34,6 +34,7 @@
// can be range-read on demand. File handle is closed before return.
#include "StreamingLoader.h"
#include "SidecarCompress.h"
#include <algorithm>
#include <cstdio>
@@ -88,14 +89,14 @@ std::string sidecarPath(const std::string& ifc_path) {
} // namespace
bool parseSidecarHead(const uint8_t* data, size_t n, uint32_t& out_num_vertex_bytes) {
bool parseSidecarHead(const uint8_t* data, size_t n, uint64_t& out_geom_bytes) {
if (n < SIDECAR_HEAD_BYTES) return false;
SidecarHeaderRaw hdr;
std::memcpy(&hdr, data, sizeof(hdr));
if (hdr.magic != SIDECAR_MAGIC) return false;
if (hdr.version != SIDECAR_VERSION) return false;
if (hdr.endian != SIDECAR_ENDIAN) return false;
std::memcpy(&out_num_vertex_bytes, data + sizeof(hdr), 4);
std::memcpy(&out_geom_bytes, data + sizeof(hdr), 8);
return true;
}
@@ -134,58 +135,79 @@ std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_p
return std::nullopt;
};
// Head: 12-byte header + the vertex-byte count. The vertex section starts
// immediately after, at SIDECAR_HEAD_BYTES.
// Head (v16): 12-byte header + the compressed-geometry-section length. The
// metadata blocks follow the geometry at SIDECAR_HEAD_BYTES + geom_bytes.
uint8_t head[SIDECAR_HEAD_BYTES];
if (std::fread(head, 1, SIDECAR_HEAD_BYTES, f) != SIDECAR_HEAD_BYTES) return fail();
uint32_t num_vertex_bytes = 0;
if (!parseSidecarHead(head, SIDECAR_HEAD_BYTES, num_vertex_bytes)) return fail();
uint64_t geom_bytes = 0;
if (!parseSidecarHead(head, SIDECAR_HEAD_BYTES, geom_bytes)) return fail();
StreamingSidecar out;
out.file_path = path;
out.vertex_section_offset = SIDECAR_HEAD_BYTES;
out.vertex_total_bytes = num_vertex_bytes;
out.file_path = path;
out.geometry_section_offset = SIDECAR_HEAD_BYTES;
// Skip the vertex section; read the index count that follows it.
if (std::fseek(f, long(num_vertex_bytes), SEEK_CUR) != 0) return fail();
uint32_t num_indices = 0;
if (std::fread(&num_indices, 4, 1, f) != 1) return fail();
out.index_section_offset = uint64_t(std::ftell(f));
out.index_total_count = num_indices;
// Skip the index section; the metadata tail runs from there to EOF.
if (std::fseek(f, long(num_indices) * 4, SEEK_CUR) != 0) return fail();
const long tail_off = std::ftell(f);
if (tail_off < 0) return fail();
if (std::fseek(f, 0, SEEK_END) != 0) return fail();
const long file_end = std::ftell(f);
if (file_end < tail_off) return fail();
if (std::fseek(f, tail_off, SEEK_SET) != 0) return fail();
std::vector<uint8_t> tail(size_t(file_end - tail_off));
if (!tail.empty() && std::fread(tail.data(), 1, tail.size(), f) != tail.size())
// Skip the geometry section; the two compressed metadata blocks follow.
if (std::fseek(f, long(SIDECAR_HEAD_BYTES) + long(geom_bytes), SEEK_SET) != 0)
return fail();
// Each metadata block on disk is [comp u64][raw u64][zstd frame].
auto readBlock = [&](std::vector<uint8_t>& raw,
uint64_t* comp_off = nullptr, uint64_t* comp_sz = nullptr,
uint64_t* raw_sz = nullptr) -> bool {
uint64_t comp = 0, rawn = 0;
if (std::fread(&comp, 8, 1, f) != 1 || std::fread(&rawn, 8, 1, f) != 1) return false;
const long here = std::ftell(f);
std::vector<uint8_t> z(static_cast<size_t>(comp));
if (comp && std::fread(z.data(), 1, z.size(), f) != z.size()) return false;
raw.assign(size_t(rawn), 0);
if (comp_off) *comp_off = uint64_t(here);
if (comp_sz) *comp_sz = comp;
if (raw_sz) *raw_sz = rawn;
return SidecarCompress::decompress(z.data(), z.size(), raw.data(), raw.size());
};
std::vector<uint8_t> crit, def;
if (!readBlock(crit)) return fail();
if (!readBlock(def, &out.deferred_comp_offset, &out.deferred_comp_size,
&out.deferred_raw_size)) return fail();
std::fclose(f);
// Tail (v15) = [critical_meta_bytes (8)][critical block][deferred block].
// Desktop is local, so read both; the web path reads only the critical
// block before painting and the deferred block on demand.
if (tail.size() < sizeof(uint64_t)) return std::nullopt;
uint64_t crit_bytes = 0;
std::memcpy(&crit_bytes, tail.data(), sizeof(crit_bytes));
const size_t crit_off = sizeof(crit_bytes);
if (crit_off + crit_bytes > tail.size()) return std::nullopt;
out.critical_meta_offset = out.index_section_offset
+ uint64_t(num_indices) * 4u + crit_off;
out.critical_meta_bytes = crit_bytes;
if (!parseSidecarCritical(tail.data() + crit_off, size_t(crit_bytes), out.meta))
return std::nullopt;
if (!parseSidecarDeferred(tail.data() + crit_off + crit_bytes,
tail.size() - crit_off - size_t(crit_bytes), out.meta))
return std::nullopt;
// Desktop reads both blocks up front; the web path reads only critical
// before painting and fetches the deferred block on demand.
if (!parseSidecarCritical(crit.data(), crit.size(), out.meta)) return std::nullopt;
if (!parseSidecarDeferred(def.data(), def.size(), out.meta)) return std::nullopt;
return out;
}
bool readChunkGeometryCompressed(const std::string& ifc_path,
std::uint64_t geometry_section_offset,
std::uint64_t v_comp_off, std::uint64_t v_comp_size,
std::uint64_t v_raw_size,
std::uint64_t i_comp_off, std::uint64_t i_comp_size,
std::uint64_t i_raw_size,
std::vector<std::uint8_t>& out_vbytes,
std::vector<std::uint32_t>& out_idx) {
const std::string path = sidecarPath(ifc_path);
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return false;
auto readFrame = [&](std::uint64_t off, std::uint64_t comp, std::uint64_t raw,
std::uint8_t* dst) -> bool {
if (raw == 0) return comp == 0;
std::vector<std::uint8_t> z(static_cast<size_t>(comp));
if (std::fseek(f, long(geometry_section_offset + off), SEEK_SET) != 0) return false;
if (comp && std::fread(z.data(), 1, z.size(), f) != z.size()) return false;
return SidecarCompress::decompress(z.data(), z.size(), dst, size_t(raw));
};
out_vbytes.assign(size_t(v_raw_size), 0);
out_idx.assign(size_t(i_raw_size / sizeof(std::uint32_t)), 0);
const bool ok =
readFrame(v_comp_off, v_comp_size, v_raw_size, out_vbytes.data()) &&
readFrame(i_comp_off, i_comp_size, i_raw_size,
reinterpret_cast<std::uint8_t*>(out_idx.data()));
std::fclose(f);
return ok;
}
bool readSidecarVertexChunk(const std::string& ifc_path,
uint64_t vertex_section_offset,
uint64_t chunk_byte_offset,
+32 -23
View File
@@ -46,25 +46,20 @@
struct StreamingSidecar {
// Everything except vertices + indices — same shape as SidecarData but
// with empty vertices / indices vectors. The renderer uses meshes /
// instances / georef / elements immediately.
// instances / georef / chunks immediately (elements/strings deferred).
SidecarData meta;
// Byte offsets in the on-disk file where the vertex and index sections
// start (after their 4-byte count headers). Pair with vertex_total_bytes
// / index_total_bytes for the section length; per-chunk reads slice
// arbitrary ranges within these.
uint64_t vertex_section_offset = 0;
uint64_t vertex_total_bytes = 0;
uint64_t index_section_offset = 0;
uint64_t index_total_count = 0; // u32 indices, NOT bytes
// v16: the compressed geometry section starts here. Each chunk's two zstd
// blobs live at geometry_section_offset + SidecarChunk.{v_comp_off,i_comp_off};
// a per-chunk load fetches [that, +*_comp_size) and decompresses to *_raw_size.
uint64_t geometry_section_offset = 0;
// v15 deferred-metadata locator. The render-critical metadata block starts
// at critical_meta_offset and is critical_meta_bytes long; the deferred
// block (elements + string_table) runs from there to EOF. The web loader
// reads only the critical block before painting and fetches the deferred
// block on demand from [critical_meta_offset + critical_meta_bytes, EOF).
uint64_t critical_meta_offset = 0;
uint64_t critical_meta_bytes = 0;
// v16 deferred (property) block locator: a single zstd frame at
// deferred_comp_offset of deferred_comp_size bytes → deferred_raw_size. The
// web loader fetches it on demand (elements/strings); desktop reads it up front.
uint64_t deferred_comp_offset = 0;
uint64_t deferred_comp_size = 0;
uint64_t deferred_raw_size = 0;
// Resolved on-disk path so subsequent chunk reads can re-open / seek.
std::string file_path;
@@ -75,6 +70,20 @@ struct StreamingSidecar {
// before return — callers re-open for per-chunk reads.
std::optional<StreamingSidecar> readSidecarMetadataOnly(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
// bytes) and the index frame → out_idx (i_raw/4 u32s). Returns false on I/O or
// decompress failure. Used by the desktop StreamingThread worker + the sync
// first-frame fallback; the web path decompresses in beginWebChunkLoad instead.
bool readChunkGeometryCompressed(const std::string& ifc_path,
std::uint64_t geometry_section_offset,
std::uint64_t v_comp_off, std::uint64_t v_comp_size,
std::uint64_t v_raw_size,
std::uint64_t i_comp_off, std::uint64_t i_comp_size,
std::uint64_t i_raw_size,
std::vector<std::uint8_t>& out_vbytes,
std::vector<std::uint32_t>& out_idx);
// --- Pure, buffer-based building blocks ------------------------------------
//
// The metadata lives in two disjoint regions of the file: a small fixed
@@ -85,15 +94,15 @@ std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_p
// source and hand the bytes to these parsers, so the wire-format knowledge
// lives in exactly one place and is unit-testable without touching a file.
// Bytes the head spans: SidecarHeader (12) + uint32 num_vertex_bytes (4).
inline constexpr std::size_t SIDECAR_HEAD_BYTES = 16;
// Bytes the head spans: SidecarHeader (12) + uint64 geometry-section length (8).
inline constexpr std::size_t SIDECAR_HEAD_BYTES = 20;
// Parse the 16-byte head. Validates magic / version / endian and, on success,
// writes the vertex-section byte count (which locates the index-count field at
// SIDECAR_HEAD_BYTES + out_num_vertex_bytes). Returns false if `n` is short or
// the header is wrong. `data` must point at the start of the file.
// Parse the 20-byte head (v16). Validates magic / version / endian and, on
// success, writes the compressed-geometry-section byte length (the metadata
// blocks follow at SIDECAR_HEAD_BYTES + out_geom_bytes). Returns false if `n`
// is short or the header is wrong. `data` must point at the start of the file.
bool parseSidecarHead(const std::uint8_t* data, std::size_t n,
std::uint32_t& out_num_vertex_bytes);
std::uint64_t& out_geom_bytes);
// Parse the v15 render-CRITICAL metadata block (mesh dict, instance dict,
// georef, chunk TOC) — everything needed to set up + draw the scene. `data`
+5 -15
View File
@@ -97,21 +97,11 @@ void StreamingThread::workerLoop() {
Result res;
res.model_id = req.model_id;
res.chunk_idx = req.chunk_idx;
res.success = true;
if (!req.v_ranges.empty()) {
if (!readSidecarVertexRanges(req.file_path,
req.vertex_section_offset,
req.v_ranges, res.vbytes)) {
res.success = false;
}
}
if (res.success && !req.i_ranges.empty()) {
if (!readSidecarIndexRanges(req.file_path,
req.index_section_offset,
req.i_ranges, res.idx)) {
res.success = false;
}
}
res.success = readChunkGeometryCompressed(
req.file_path, req.geometry_section_offset,
req.v_comp_off, req.v_comp_size, req.v_raw_size,
req.i_comp_off, req.i_comp_size, req.i_raw_size,
res.vbytes, res.idx);
{
std::unique_lock lk(mu_);
+9 -9
View File
@@ -44,15 +44,15 @@
class StreamingThread {
public:
struct Request {
uint32_t model_id;
std::size_t chunk_idx;
std::string file_path;
uint64_t vertex_section_offset;
uint64_t index_section_offset;
// (section-relative byte_offset, byte_size)
std::vector<std::pair<uint64_t, uint64_t>> v_ranges;
// (first_u32, count_u32)
std::vector<std::pair<uint64_t, uint64_t>> i_ranges;
uint32_t model_id;
std::size_t chunk_idx;
std::string file_path;
// v16: the chunk's two zstd frames in the geometry section. The reader
// fetches [geometry_section_offset + *_comp_off, +*_comp_size) and
// decompresses to *_raw_size.
uint64_t geometry_section_offset = 0;
uint64_t v_comp_off = 0, v_comp_size = 0, v_raw_size = 0;
uint64_t i_comp_off = 0, i_comp_size = 0, i_raw_size = 0;
};
struct Result {
+175 -198
View File
@@ -1384,6 +1384,15 @@ bool ViewportCore::createPool() {
std::max<uint64_t>(MIN_POOL_CAPACITY, INITIAL_SUB_BUFFER));
pool_.configure(instance_, device_, pool_usage, per_sub,
"ifcviewer-wgpu.pool");
#if defined(__EMSCRIPTEN__)
// Cap total pool capacity below the wasm heap ceiling. On web a growth that
// would push the heap past MAXIMUM_MEMORY is a bad_alloc → uncatchable
// abort, so the pool must stop growing (and evict) before then. Leave
// headroom for metadata (instances/maps), transient decompression buffers,
// and wgpu overhead. Big federations then keep a bounded, highest-priority
// resident set instead of aborting.
pool_.setMaxTotalCapacity(3072ull * 1024 * 1024); // 3 GB (heap ceiling 4 GB)
#endif
Log::info() << "wgpu: pool per-sub-buffer capacity = "
<< (per_sub / (1024 * 1024)) << " MB (grows lazily on "
<< "demand; device maxBufferSize = "
@@ -1723,6 +1732,7 @@ void ViewportCore::shutdown() {
// ===========================================================================
#include "StreamingLoader.h"
#include "SidecarCompress.h"
namespace {
@@ -1941,37 +1951,17 @@ StreamingThread::Request ViewportCore::makeChunkRequest(
std::uint32_t model_id) {
const auto& c = m.chunks[chunk_idx];
StreamingThread::Request req;
req.model_id = model_id;
req.chunk_idx = chunk_idx;
req.file_path = m.streaming_file_path;
req.vertex_section_offset = m.streaming_vertex_section_offset;
req.index_section_offset = m.streaming_index_section_offset;
req.v_ranges.reserve(c.mesh_ids.size());
req.i_ranges.reserve(c.mesh_ids.size());
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
const std::uint64_t v_bytes =
std::uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (v_bytes > 0) {
req.v_ranges.emplace_back(std::uint64_t(mesh.vbo_byte_offset), v_bytes);
}
if (mesh.index_count > 0) {
req.i_ranges.emplace_back(
std::uint64_t(mesh.ebo_byte_offset / sizeof(std::uint32_t)),
std::uint64_t(mesh.index_count));
}
}
// LOD1 indices second pass — matches the chunk-local packing order
// (all LOD0 first, then LOD1) so the worker's concatenated index
// result lands at the offsets recorded in
// m.mesh_chunk_local_lod1_first_u32.
for (std::uint32_t mi : c.mesh_ids) {
const MeshInfo& mesh = m.meshes[mi];
if (mesh.lod1_index_count == 0) continue;
req.i_ranges.emplace_back(
std::uint64_t(mesh.lod1_ebo_byte_offset / sizeof(std::uint32_t)),
std::uint64_t(mesh.lod1_index_count));
}
req.model_id = 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.
req.geometry_section_offset = m.geometry_section_offset;
req.v_comp_off = c.v_comp_off;
req.v_comp_size = c.v_comp_size;
req.v_raw_size = c.vertex_byte_size;
req.i_comp_off = c.i_comp_off;
req.i_comp_size = c.i_comp_size;
req.i_raw_size = c.index_count * sizeof(std::uint32_t);
return req;
}
@@ -1982,34 +1972,18 @@ bool ViewportCore::loadChunkBytesAndUploadGpu(ModelGpuData& m,
if (c.is_resident) return true;
if (m.streaming_file_path.empty()) return false;
// Synchronous fallback: build the request, do the disk read inline,
// apply. Used only when the async path can't be — i.e. by the
// screenshot test on first frame.
StreamingThread::Request req = makeChunkRequest(m, chunk_idx, /*model_id*/ 0);
// Synchronous fallback: read + decompress the chunk inline, apply. Used
// only when the async path can't be — i.e. by the screenshot test on the
// first frame.
std::vector<std::uint8_t> vbytes;
std::vector<std::uint32_t> idx;
if (!req.v_ranges.empty()) {
if (!readSidecarVertexRanges(req.file_path,
req.vertex_section_offset,
req.v_ranges, vbytes)) {
Log::warn() << "[wgpu stream] failed to read vertex chunk "
<< chunk_idx
<< " (" << req.v_ranges.size() << " ranges, total "
<< c.vertex_byte_size << " B)";
return false;
}
}
if (!req.i_ranges.empty()) {
if (!readSidecarIndexRanges(req.file_path,
req.index_section_offset,
req.i_ranges, idx)) {
Log::warn() << "[wgpu stream] failed to read index chunk "
<< chunk_idx
<< " (" << req.i_ranges.size() << " ranges, total "
<< c.index_count << " indices)";
return false;
}
if (!readChunkGeometryCompressed(
m.streaming_file_path, m.geometry_section_offset,
c.v_comp_off, c.v_comp_size, c.vertex_byte_size,
c.i_comp_off, c.i_comp_size, c.index_count * sizeof(std::uint32_t),
vbytes, idx)) {
Log::warn() << "[wgpu stream] failed to read/decompress chunk " << chunk_idx;
return false;
}
return applyStreamedChunk(m, chunk_idx, vbytes, idx);
}
@@ -2316,24 +2290,34 @@ void ViewportCore::driveStreamingLoads() {
if (cand.m->streaming_from_web
&& pool_.total_free_bytes() < streaming_web_inflight_bytes_ + need) {
// Not enough VALIDATED pool space for this chunk plus what's already
// in flight. Don't fetch — the bytes would arrive, fail the alloc on
// a not-yet-grown pool, and re-fetch (network thrash). Instead grow
// the pool first (async on web: a provisional sub-buffer validates a
// frame or two later, then this chunk fits and is fetched exactly
// once). When the pool is saturated (model exceeds GPU memory) hold
// the chunk off for the full cooldown so we keep a stable resident
// subset instead of re-fetching what will never fit. Blocking before
// the evictor also avoids phase-2 visible↔visible swap thrash.
// in flight. If the pool can still grow, grow FIRST (async on web: a
// provisional sub-buffer validates a frame or two later) — cheaper
// than evict/refetch thrash while the model still fits by growing.
if (pool_.can_grow()) {
pool_.requestGrowth();
c.blocked_cooldown_until_frame_idx =
streaming_frame_idx_ + kGrowBackoffFrames;
} else {
more_pending = true;
continue;
}
// At the hard capacity budget (setMaxTotalCapacity): growth can't
// help — growing further would abort the wasm heap. Evict lower-
// priority / LRU resident chunks so this priority-sorted candidate
// fits. This is the only path that keeps a big federation navigable
// 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),
cand.priority)) continue;
break;
}
if (pool_.total_free_bytes() < streaming_web_inflight_bytes_ + need) {
c.blocked_cooldown_until_frame_idx =
streaming_frame_idx_ + kBlockedCooldownFrames;
more_pending = true;
continue; // couldn't free enough — hold off this frame
}
more_pending = true;
continue;
// Freed enough — fall through to the web load below.
}
#endif
while (!pool_can_fit(c.vertex_byte_size)
@@ -2853,13 +2837,12 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id,
}
ModelGpuData m;
m.vertex_bytes = metadata.vertex_total_bytes;
m.index_count = std::uint32_t(metadata.index_total_count);
m.vertex_bytes = 0; // accumulated from chunks below (v16 has no section)
m.index_count = 0;
m.mesh_count = std::uint32_t(metadata.meta.meshes.size());
m.instance_count = std::uint32_t(metadata.meta.instances.size());
m.streaming_file_path = metadata.file_path;
m.streaming_vertex_section_offset = metadata.vertex_section_offset;
m.streaming_index_section_offset = metadata.index_section_offset;
m.streaming_file_path = metadata.file_path;
m.geometry_section_offset = metadata.geometry_section_offset;
// ---- Spatial chunk plan ----------------------------------------------
// A sidecar carries a baked chunk TOC (v14): each chunk is a contiguous
@@ -2978,6 +2961,14 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id,
c.vertex_byte_size = std::uint64_t(chunk_local_v) * INSTANCED_VERTEX_STRIDE_BYTES;
c.index_count = chunk_local_i + chunk_local_lod1;
c.lod1_index_count = chunk_local_lod1;
// v16: compressed-blob locators from the baked TOC (streaming path).
if (ci < metadata.meta.chunks.size()) {
const SidecarChunk& sc = metadata.meta.chunks[ci];
c.v_comp_off = sc.v_comp_off; c.v_comp_size = sc.v_comp_size;
c.i_comp_off = sc.i_comp_off; c.i_comp_size = sc.i_comp_size;
}
m.vertex_bytes += c.vertex_byte_size;
m.index_count += std::uint32_t(c.index_count);
// Small per-chunk buffers, allocated upfront so cull can write into
// them. visible_draws_buffer cap = chunk's instance count.
@@ -3385,44 +3376,33 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
ModelGpuData& m = it->second;
if (chunk_idx >= m.chunks.size()) return;
const StreamingThread::Request req = makeChunkRequest(m, chunk_idx, model_id);
const ModelGpuData::Chunk& c = m.chunks[chunk_idx];
const int sid = m.web_source_id; // which registered byte-source to read from
const std::uint64_t vsec = req.vertex_section_offset;
const std::uint64_t isec = req.index_section_offset;
const std::vector<std::pair<std::uint64_t, std::uint64_t>> v_ranges = req.v_ranges;
// i_ranges are (first_u32, count_u32); convert to byte ranges.
std::vector<std::pair<std::uint64_t, std::uint64_t>> i_byte_ranges;
i_byte_ranges.reserve(req.i_ranges.size());
for (const auto& [first_u32, count] : req.i_ranges)
i_byte_ranges.emplace_back(first_u32 * 4u, count * 4u);
const std::uint64_t geom = m.geometry_section_offset;
const std::uint64_t v_comp_off = c.v_comp_off, v_comp_size = c.v_comp_size;
const std::uint64_t i_comp_off = c.i_comp_off, i_comp_size = c.i_comp_size;
const std::uint64_t v_raw = c.vertex_byte_size;
const std::uint64_t i_raw = std::uint64_t(c.index_count) * sizeof(std::uint32_t);
// Reserve this load's pool footprint while it's in flight (released when it
// resolves) so driveStreamingLoads doesn't over-commit the pool — see
// streaming_web_inflight_bytes_.
const std::uint64_t need = m.chunks[chunk_idx].vertex_byte_size
+ std::uint64_t(m.chunks[chunk_idx].index_count) * sizeof(std::uint32_t);
// Reserve the RAW (decompressed) footprint while in flight so
// driveStreamingLoads doesn't over-commit the pool.
const std::uint64_t need = v_raw + i_raw;
streaming_web_inflight_bytes_ += need;
// Fire the vertex and index range reads CONCURRENTLY and join when both
// land — over a network this halves per-chunk latency vs reading vertices
// then indices serially (two round trips → one). The join holds both
// payloads + completion flags; whichever read finishes second runs the
// apply. Re-look-up the model at apply time: a resetScene() could have
// landed mid-flight, in which case the model id is gone and we drop it.
// Fetch the chunk's two zstd frames CONCURRENTLY (vertex + index) and join
// when both land, then decompress and apply. Re-look-up the model at apply
// time: a resetScene() could have landed mid-flight.
struct ChunkJoin {
std::vector<std::uint8_t> vbytes;
std::vector<std::uint32_t> idx;
std::vector<std::uint8_t> vz, iz; // compressed frames
bool v_done = false, i_done = false, v_ok = false, i_ok = false;
};
auto join = std::make_shared<ChunkJoin>();
std::function<void()> finish = [this, model_id, chunk_idx, need, join]() {
if (!join->v_done || !join->i_done) return; // wait for the other read
// Release the in-flight reservation (clamped — a mid-flight resetScene
// could have zeroed it) + the concurrency slot, regardless of outcome.
streaming_web_inflight_bytes_ -=
std::min(streaming_web_inflight_bytes_, need);
std::function<void()> finish =
[this, 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(); // a slot freed — let driveStreamingLoads issue more
host_->requestFrame();
auto mit = models_gpu_.find(model_id);
if (mit == models_gpu_.end()) return;
@@ -3430,12 +3410,16 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
if (chunk_idx >= mm.chunks.size()) return;
auto& cc = mm.chunks[chunk_idx];
cc.is_loading = false;
// On read failure or a full pool, back off instead of re-candidating
// next frame → re-fetch thrash. If the pool can still grow (its async
// sub-buffer is mid-validation), retry soon; if it's saturated, hold
// off for the full cooldown.
if (!join->v_ok || !join->i_ok
|| !applyStreamedChunk(mm, chunk_idx, join->vbytes, join->idx)) {
std::vector<std::uint8_t> vbytes(static_cast<std::size_t>(v_raw));
std::vector<std::uint32_t> idx(static_cast<std::size_t>(i_raw / sizeof(std::uint32_t)));
const bool ok = join->v_ok && join->i_ok
&& SidecarCompress::decompress(join->vz.data(), join->vz.size(),
vbytes.data(), vbytes.size())
&& SidecarCompress::decompress(join->iz.data(), join->iz.size(),
reinterpret_cast<std::uint8_t*>(idx.data()),
std::size_t(i_raw));
if (!ok || !applyStreamedChunk(mm, chunk_idx, vbytes, idx)) {
cc.blocked_cooldown_until_frame_idx = streaming_frame_idx_
+ (pool_.can_grow() ? kGrowBackoffFrames : kBlockedCooldownFrames);
return;
@@ -3443,22 +3427,13 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
host_->requestFrame();
};
webReadRangesAsync(sid, vsec, v_ranges,
[join, finish](bool ok, std::vector<std::uint8_t>&& vbytes) {
join->v_ok = ok;
join->vbytes = std::move(vbytes);
join->v_done = true;
finish();
webReadRangesAsync(sid, geom, {{v_comp_off, v_comp_size}},
[join, finish](bool ok, std::vector<std::uint8_t>&& vz) {
join->v_ok = ok; join->vz = std::move(vz); join->v_done = true; finish();
});
webReadRangesAsync(sid, isec, i_byte_ranges,
[join, finish](bool ok, std::vector<std::uint8_t>&& ibytes) {
join->i_ok = ok;
join->idx.resize(ibytes.size() / sizeof(std::uint32_t));
if (!join->idx.empty())
std::memcpy(join->idx.data(), ibytes.data(),
join->idx.size() * sizeof(std::uint32_t));
join->i_done = true;
finish();
webReadRangesAsync(sid, geom, {{i_comp_off, i_comp_size}},
[join, finish](bool ok, std::vector<std::uint8_t>&& iz) {
join->i_ok = ok; join->iz = std::move(iz); join->i_done = true; finish();
});
}
@@ -3481,80 +3456,76 @@ void ViewportCore::loadSidecarMetadataWeb(int source_id, std::string source_labe
return;
}
// Head (v16): [header 12][geom_bytes 8]. The two compressed metadata blocks
// follow the compressed geometry at SIDECAR_HEAD_BYTES + geom_bytes.
webReadRangesAsync(source_id, 0, {{0, SIDECAR_HEAD_BYTES}},
[this, fsize, source_id, source_label](bool ok, std::vector<std::uint8_t>&& head) {
std::uint32_t nvb = 0;
if (!ok || !parseSidecarHead(head.data(), head.size(), nvb)) {
Log::warn() << "loadSidecarMetadataWeb: bad sidecar head";
std::uint64_t geom_bytes = 0;
if (!ok || !parseSidecarHead(head.data(), head.size(), geom_bytes)) {
Log::warn() << "loadSidecarMetadataWeb: bad sidecar head (wrong version?)";
return;
}
const std::uint64_t vsec = SIDECAR_HEAD_BYTES;
const std::uint64_t idx_count_off = std::uint64_t(SIDECAR_HEAD_BYTES) + nvb;
webReadRangesAsync(source_id, 0, {{idx_count_off, 4}},
[this, fsize, nvb, vsec, idx_count_off, source_id, source_label]
(bool ok2, std::vector<std::uint8_t>&& cnt) {
if (!ok2 || cnt.size() < 4) {
Log::warn() << "loadSidecarMetadataWeb: short index count";
return;
}
std::uint32_t num_indices = 0;
std::memcpy(&num_indices, cnt.data(), 4);
const std::uint64_t isec = idx_count_off + 4;
const std::uint64_t crit_size_off = isec + std::uint64_t(num_indices) * 4u;
if (double(crit_size_off + 8) > fsize) {
Log::warn() << "loadSidecarMetadataWeb: critical size past EOF";
return;
}
// v15: read the 8-byte critical-block length, then the
// render-critical metadata only. The deferred block
// (elements/strings) is fetched on demand later — first
// paint no longer waits on the property tree.
webReadRangesAsync(source_id, 0, {{crit_size_off, 8}},
[this, vsec, nvb, isec, num_indices, source_id, source_label,
crit_size_off, fsize]
(bool okc, std::vector<std::uint8_t>&& cb) {
if (!okc || cb.size() < 8) {
Log::warn() << "loadSidecarMetadataWeb: short critical size";
const std::uint64_t meta_off = std::uint64_t(SIDECAR_HEAD_BYTES) + geom_bytes;
if (double(meta_off + 16) > fsize) {
Log::warn() << "loadSidecarMetadataWeb: metadata past EOF";
return;
}
// Critical block on disk: [comp u64][raw u64][zstd frame].
webReadRangesAsync(source_id, 0, {{meta_off, 16}},
[this, fsize, meta_off, source_id, source_label]
(bool ok2, std::vector<std::uint8_t>&& h) {
if (!ok2 || h.size() < 16) { Log::warn() << "loadSidecarMetadataWeb: short crit header"; return; }
std::uint64_t crit_comp = 0, crit_raw = 0;
std::memcpy(&crit_comp, h.data(), 8);
std::memcpy(&crit_raw, h.data() + 8, 8);
const std::uint64_t crit_off = meta_off + 16;
if (double(crit_off + crit_comp + 16) > fsize) { Log::warn() << "loadSidecarMetadataWeb: crit past EOF"; return; }
webReadRangesAsync(source_id, 0, {{crit_off, crit_comp}},
[this, crit_off, crit_comp, crit_raw, source_id, source_label]
(bool ok3, std::vector<std::uint8_t>&& cz) {
if (!ok3) { Log::warn() << "loadSidecarMetadataWeb: critical read failed"; return; }
std::vector<std::uint8_t> crit(static_cast<std::size_t>(crit_raw));
if (!SidecarCompress::decompress(cz.data(), cz.size(), crit.data(), crit.size())) {
Log::warn() << "loadSidecarMetadataWeb: critical decompress failed";
return;
}
std::uint64_t crit_bytes = 0;
std::memcpy(&crit_bytes, cb.data(), 8);
const std::uint64_t crit_off = crit_size_off + 8;
if (double(crit_off + crit_bytes) > fsize) {
Log::warn() << "loadSidecarMetadataWeb: critical block past EOF";
StreamingSidecar sc;
sc.file_path = source_label;
sc.geometry_section_offset = SIDECAR_HEAD_BYTES;
if (!parseSidecarCritical(crit.data(), crit.size(), sc.meta)) {
Log::warn() << "loadSidecarMetadataWeb: bad critical metadata";
return;
}
webReadRangesAsync(source_id, 0, {{crit_off, crit_bytes}},
[this, vsec, nvb, isec, num_indices, source_id, source_label,
crit_off, crit_bytes, fsize]
(bool ok3, std::vector<std::uint8_t>&& crit) {
if (!ok3) {
Log::warn() << "loadSidecarMetadataWeb: critical read failed";
return;
}
StreamingSidecar sc;
sc.file_path = source_label;
sc.vertex_section_offset = vsec;
sc.vertex_total_bytes = nvb;
sc.index_section_offset = isec;
sc.index_total_count = num_indices;
if (!parseSidecarCritical(crit.data(), crit.size(), sc.meta)) {
Log::warn() << "loadSidecarMetadataWeb: bad critical metadata";
return;
}
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::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));
// Mark web-streamed + set the source IMMEDIATELY — the
// model now has non-resident chunks and the RAF loop's
// driveStreamingLoads will run before the deferred-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()) {
m0->second.streaming_from_web = true;
m0->second.web_source_id = source_id;
}
// Read the deferred block header to record its locator
// (the property block is fetched on demand later).
const std::uint64_t def_hdr_off = crit_off + crit_comp;
webReadRangesAsync(source_id, 0, {{def_hdr_off, 16}},
[this, mid, def_hdr_off, source_id, source_label, n_meshes, n_instances]
(bool ok4, std::vector<std::uint8_t>&& dh) {
auto mit = models_gpu_.find(mid);
if (mit != models_gpu_.end()) {
mit->second.streaming_from_web = true;
mit->second.web_source_id = source_id;
// Deferred block: [end of critical, EOF).
mit->second.deferred_meta_offset = crit_off + crit_bytes;
mit->second.deferred_meta_bytes =
std::uint64_t(fsize) - (crit_off + crit_bytes);
if (ok4 && dh.size() >= 16) {
std::uint64_t dc = 0, dr = 0;
std::memcpy(&dc, dh.data(), 8);
std::memcpy(&dr, dh.data() + 8, 8);
mit->second.deferred_comp_offset = def_hdr_off + 16;
mit->second.deferred_comp_size = dc;
mit->second.deferred_raw_size = dr;
}
}
viewAll();
host_->requestFrame();
@@ -3577,18 +3548,22 @@ void ViewportCore::loadDeferredMetadataWeb(std::uint32_t model_id,
auto it = models_gpu_.find(model_id);
if (it == models_gpu_.end()) { if (done) done(false); return; }
ModelGpuData& m = it->second;
if (m.deferred_meta_loaded || m.deferred_meta_bytes == 0) {
if (m.deferred_meta_loaded || m.deferred_comp_size == 0) {
m.deferred_meta_loaded = true;
if (done) done(true);
return;
}
webReadRangesAsync(m.web_source_id, 0, {{m.deferred_meta_offset, m.deferred_meta_bytes}},
[this, model_id, done](bool ok, std::vector<std::uint8_t>&& buf) {
const std::uint64_t raw_size = m.deferred_raw_size;
webReadRangesAsync(m.web_source_id, 0, {{m.deferred_comp_offset, m.deferred_comp_size}},
[this, model_id, raw_size, done](bool ok, std::vector<std::uint8_t>&& cz) {
auto mit = models_gpu_.find(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;
if (!ok || !parseSidecarDeferred(buf.data(), buf.size(), tmp)) {
Log::warn() << "loadDeferredMetadataWeb: read/parse failed";
if (!ok ||
!SidecarCompress::decompress(cz.data(), cz.size(), buf.data(), buf.size()) ||
!parseSidecarDeferred(buf.data(), buf.size(), tmp)) {
Log::warn() << "loadDeferredMetadataWeb: read/decompress/parse failed";
if (done) done(false);
return;
}
@@ -3678,8 +3653,11 @@ void ViewportCore::streamingByteProgress(std::uint64_t& total_bytes,
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const auto& c : m.chunks) {
const std::uint64_t bytes =
c.vertex_byte_size + c.index_count * sizeof(std::uint32_t);
// Report COMPRESSED bytes — what actually crosses the network. Fall
// back to raw for direct (in-memory) loads that have no blobs.
const std::uint64_t bytes = (c.v_comp_size + c.i_comp_size > 0)
? c.v_comp_size + c.i_comp_size
: c.vertex_byte_size + c.index_count * sizeof(std::uint32_t);
total_bytes += bytes;
if (c.contribution_visible_count > 0) {
needed_bytes += bytes;
@@ -3719,10 +3697,9 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) {
// applyStreamedChunk loop below).
StreamingSidecar metadata;
metadata.meta = std::move(s);
metadata.vertex_section_offset = 0;
metadata.vertex_total_bytes = metadata.meta.vertices.size();
metadata.index_section_offset = 0;
metadata.index_total_count = metadata.meta.indices.size();
// Direct load: geometry is already in memory (uploaded below), streamed
// from nothing — leave file_path empty so the streaming worker skips it.
metadata.geometry_section_offset = 0;
metadata.file_path.clear();
std::vector<std::uint8_t> raw_vertices = std::move(metadata.meta.vertices);
+15 -1
View File
@@ -46,8 +46,18 @@ if(WITH_MESH_OPTIMIZER)
target_compile_definitions(test_lod_builder PRIVATE -DWITH_MESH_OPTIMIZER)
endif()
# SidecarCompress: zstd compress/decompress wrappers. Links the system libzstd.
# SidecarCache now (de)compresses geometry + metadata, so every test that
# compiles SidecarCache.cpp needs SidecarCompress.cpp + libzstd too.
find_library(ZSTD_LIBRARY NAMES zstd libzstd)
add_ifcviewer_unit_test(test_sidecar_compress
SOURCES ${IFCVIEWER_SRC}/SidecarCompress.cpp
LIBS ${ZSTD_LIBRARY}
)
add_ifcviewer_unit_test(test_sidecar_cache
SOURCES ${IFCVIEWER_SRC}/SidecarCache.cpp
SOURCES ${IFCVIEWER_SRC}/SidecarCache.cpp ${IFCVIEWER_SRC}/SidecarCompress.cpp
LIBS ${ZSTD_LIBRARY}
)
# StreamingLoader: metadata-only read + range readers + the pure buffer-based
@@ -57,6 +67,8 @@ add_ifcviewer_unit_test(test_streaming_loader
SOURCES
${IFCVIEWER_SRC}/StreamingLoader.cpp
${IFCVIEWER_SRC}/SidecarCache.cpp
${IFCVIEWER_SRC}/SidecarCompress.cpp
LIBS ${ZSTD_LIBRARY}
)
add_ifcviewer_unit_test(test_instanced_geometry)
@@ -74,6 +86,8 @@ add_ifcviewer_unit_test(test_sidecar_layout
${IFCVIEWER_SRC}/SidecarLayout.cpp
${IFCVIEWER_SRC}/ChunkPlanner.cpp
${IFCVIEWER_SRC}/SidecarCache.cpp
${IFCVIEWER_SRC}/SidecarCompress.cpp
LIBS ${ZSTD_LIBRARY}
)
# InstanceCompose: matrix composition + cross-model object_id lookup.
+5 -2
View File
@@ -118,6 +118,9 @@ SidecarData buildFixture() {
e.name_offset = 1; e.name_length = 4; // "Wall"
e.type_offset = 6; e.type_length = 4; // "Slab"
}
// v16 stores geometry per-chunk (compressed), so a fixture with geometry
// needs a chunk TOC covering its meshes for write/read to round-trip.
sd.chunks = { {0, 2} };
return sd;
}
@@ -155,8 +158,8 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") {
REQUIRE(sizeof(MeshInfo) == 56);
REQUIRE(sizeof(InstanceGpu) == 80);
REQUIRE(SIDECAR_VERSION == 15);
REQUIRE(sizeof(SidecarChunk) == 8);
REQUIRE(SIDECAR_VERSION == 16);
REQUIRE(sizeof(SidecarChunk) == 56);
REQUIRE(SIDECAR_MAGIC == 0x49465657u);
}
@@ -0,0 +1,68 @@
/********************************************************************************
* *
* 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 "SidecarCompress.h"
#include <catch2/catch_test_macros.hpp>
#include <cstdint>
#include <vector>
TEST_CASE("zstd round-trips arbitrary bytes", "[compress]") {
// Structured data like the sidecar carries (repeated matrices, patterned
// indices) — should both round-trip AND actually shrink.
std::vector<std::uint8_t> raw;
for (int i = 0; i < 20000; ++i) {
raw.push_back(std::uint8_t(i & 0xFF));
raw.push_back(std::uint8_t((i >> 8) & 0x07)); // low-entropy high byte
raw.push_back(0);
raw.push_back(0xAA);
}
auto packed = SidecarCompress::compress(raw.data(), raw.size(), 19);
REQUIRE_FALSE(packed.empty());
REQUIRE(packed.size() < raw.size()); // it compressed
std::vector<std::uint8_t> out(raw.size());
REQUIRE(SidecarCompress::decompress(packed.data(), packed.size(),
out.data(), out.size()));
REQUIRE(out == raw);
}
TEST_CASE("decompress rejects a wrong raw size / garbage", "[compress]") {
std::vector<std::uint8_t> raw(1024, 0x42);
auto packed = SidecarCompress::compress(raw.data(), raw.size(), 3);
REQUIRE_FALSE(packed.empty());
// Wrong declared raw size must fail, not silently truncate.
std::vector<std::uint8_t> too_small(512);
REQUIRE_FALSE(SidecarCompress::decompress(packed.data(), packed.size(),
too_small.data(), too_small.size()));
// Garbage input fails cleanly.
std::vector<std::uint8_t> junk = { 1, 2, 3, 4, 5, 6, 7, 8 };
std::vector<std::uint8_t> dst(1024);
REQUIRE_FALSE(SidecarCompress::decompress(junk.data(), junk.size(),
dst.data(), dst.size()));
}
TEST_CASE("empty round-trips to empty", "[compress]") {
std::vector<std::uint8_t> dst;
REQUIRE(SidecarCompress::decompress(nullptr, 0, dst.data(), 0));
}
+61 -67
View File
@@ -18,6 +18,7 @@
********************************************************************************/
#include "SidecarCache.h"
#include "SidecarCompress.h"
#include "StreamingLoader.h"
#include <catch2/catch_test_macros.hpp>
@@ -85,12 +86,15 @@ SidecarData buildFixture() {
sd.elements[i].ifc_id = int32_t(1000 + i);
sd.elements[i].parent_id = (i == 0) ? -1 : int32_t(100);
}
// v16 stores geometry per-chunk (compressed); a fixture with geometry needs
// a chunk TOC covering its meshes (one chunk per mesh here).
sd.chunks = { {0, 1}, {1, 1} };
return sd;
}
} // namespace
TEST_CASE("readSidecarMetadataOnly returns metadata + section offsets, skips bulk",
TEST_CASE("readSidecarMetadataOnly returns metadata, skips bulk geometry",
"[streaming]") {
fs::path dir = makeScratchDir("metaonly");
fs::path ifc = dir / "model.ifc";
@@ -100,19 +104,19 @@ TEST_CASE("readSidecarMetadataOnly returns metadata + section offsets, skips bul
auto meta = readSidecarMetadataOnly(ifc.string());
REQUIRE(meta.has_value());
// Bulk sections are skipped, not loaded.
// Bulk geometry is skipped, not loaded.
REQUIRE(meta->meta.vertices.empty());
REQUIRE(meta->meta.indices.empty());
// Offsets locate the two skipped sections. The vertex section starts
// right after the 16-byte head.
REQUIRE(meta->vertex_section_offset == SIDECAR_HEAD_BYTES);
REQUIRE(meta->vertex_total_bytes == sd.vertices.size());
REQUIRE(meta->index_total_count == sd.indices.size());
REQUIRE(meta->index_section_offset ==
SIDECAR_HEAD_BYTES + sd.vertices.size() + 4);
// v16: the compressed geometry section starts right after the 20-byte head.
REQUIRE(meta->geometry_section_offset == SIDECAR_HEAD_BYTES);
// Chunk TOC carries compressed blob locators for each chunk.
REQUIRE(meta->meta.chunks.size() == sd.chunks.size());
REQUIRE(meta->meta.chunks[0].v_comp_size > 0);
// The deferred (property) block locator is recorded for on-demand fetch.
REQUIRE(meta->deferred_comp_size > 0);
// Tail metadata round-trips.
// Metadata round-trips.
REQUIRE(meta->meta.meshes.size() == sd.meshes.size());
REQUIRE(meta->meta.instances.size() == sd.instances.size());
REQUIRE(meta->meta.elements.size() == sd.elements.size());
@@ -140,100 +144,90 @@ TEST_CASE("readSidecarMetadataOnly rejects missing / corrupt files", "[streaming
REQUIRE_FALSE(readSidecarMetadataOnly(bad.string()).has_value());
}
TEST_CASE("readSidecarVertexRanges scatters byte ranges in input order", "[streaming]") {
fs::path dir = makeScratchDir("vranges");
TEST_CASE("readChunkGeometryCompressed decompresses a chunk's blobs", "[streaming]") {
fs::path dir = makeScratchDir("chunkgeom");
fs::path ifc = dir / "model.ifc";
SidecarData sd = buildFixture();
REQUIRE(writeSidecar(ifc.string(), sd));
auto meta = readSidecarMetadataOnly(ifc.string());
REQUIRE(meta.has_value());
REQUIRE(meta->meta.chunks.size() == 2);
// Two section-relative ranges given out of file order; the destination
// must preserve input order (second mesh's bytes first, then first).
// Chunk 0 = mesh 0: vertices [0, 2*stride), indices {0,1,2}.
const auto& c0 = meta->meta.chunks[0];
const uint64_t stride = INSTANCED_VERTEX_STRIDE_BYTES;
std::vector<std::pair<uint64_t, uint64_t>> ranges = {
{2 * stride, 2 * stride}, // last 2 vertices
{0, 2 * stride}, // first 2 vertices
};
std::vector<uint8_t> out;
REQUIRE(readSidecarVertexRanges(ifc.string(), meta->vertex_section_offset,
ranges, out));
REQUIRE(out.size() == 4 * stride);
REQUIRE(std::memcmp(out.data(), sd.vertices.data() + 2 * stride, 2 * stride) == 0);
REQUIRE(std::memcmp(out.data() + 2 * stride, sd.vertices.data(), 2 * stride) == 0);
std::vector<uint8_t> vbytes;
std::vector<uint32_t> idx;
REQUIRE(readChunkGeometryCompressed(
ifc.string(), meta->geometry_section_offset,
c0.v_comp_off, c0.v_comp_size, c0.v_raw_size,
c0.i_comp_off, c0.i_comp_size, c0.i_raw_size, vbytes, idx));
REQUIRE(vbytes.size() == 2 * stride);
REQUIRE(std::memcmp(vbytes.data(), sd.vertices.data(), 2 * stride) == 0);
REQUIRE(idx == std::vector<uint32_t>({0, 1, 2}));
// Chunk 1 = mesh 1: indices {1,2,3}.
const auto& c1 = meta->meta.chunks[1];
REQUIRE(readChunkGeometryCompressed(
ifc.string(), meta->geometry_section_offset,
c1.v_comp_off, c1.v_comp_size, c1.v_raw_size,
c1.i_comp_off, c1.i_comp_size, c1.i_raw_size, vbytes, idx));
REQUIRE(idx == std::vector<uint32_t>({1, 2, 3}));
REQUIRE(std::memcmp(vbytes.data(), sd.vertices.data() + 2 * stride, 2 * stride) == 0);
}
TEST_CASE("readSidecarIndexRanges reads u32 index ranges", "[streaming]") {
fs::path dir = makeScratchDir("iranges");
fs::path ifc = dir / "model.ifc";
SidecarData sd = buildFixture();
REQUIRE(writeSidecar(ifc.string(), sd));
auto meta = readSidecarMetadataOnly(ifc.string());
REQUIRE(meta.has_value());
std::vector<std::pair<uint64_t, uint64_t>> ranges = {{3, 3}}; // indices[3..6)
std::vector<uint32_t> out;
REQUIRE(readSidecarIndexRanges(ifc.string(), meta->index_section_offset,
ranges, out));
REQUIRE(out == std::vector<uint32_t>({1, 2, 3}));
}
TEST_CASE("parseSidecarHead validates magic / version / length", "[streaming]") {
TEST_CASE("parseSidecarHead validates magic / version, reads geom length", "[streaming]") {
uint8_t head[SIDECAR_HEAD_BYTES] = {};
uint32_t magic = SIDECAR_MAGIC, version = SIDECAR_VERSION, endian = SIDECAR_ENDIAN;
uint32_t nvb = 4096;
uint64_t geom = 123456;
std::memcpy(head + 0, &magic, 4);
std::memcpy(head + 4, &version, 4);
std::memcpy(head + 8, &endian, 4);
std::memcpy(head + 12, &nvb, 4);
std::memcpy(head + 12, &geom, 8);
uint32_t got = 0;
uint64_t got = 0;
REQUIRE(parseSidecarHead(head, sizeof(head), got));
REQUIRE(got == 4096);
REQUIRE(got == 123456);
// Short buffer.
REQUIRE_FALSE(parseSidecarHead(head, SIDECAR_HEAD_BYTES - 1, got));
// Wrong magic.
uint8_t bad[SIDECAR_HEAD_BYTES];
std::memcpy(bad, head, sizeof(bad));
bad[0] ^= 0xFF;
REQUIRE_FALSE(parseSidecarHead(bad, sizeof(bad), got));
}
TEST_CASE("v15 critical/deferred metadata split round-trips + rejects truncation",
"[streaming]") {
fs::path dir = makeScratchDir("v15split");
TEST_CASE("v16 deferred block: fetch via locator, decompress, parse", "[streaming]") {
fs::path dir = makeScratchDir("v16def");
fs::path ifc = dir / "model.ifc";
SidecarData sd = buildFixture();
sd.chunks = { {0, 1}, {1, 1} }; // a TOC, so the critical block carries chunks
REQUIRE(writeSidecar(ifc.string(), sd));
// readSidecarMetadataOnly (desktop) reads BOTH blocks + records the locator.
auto meta = readSidecarMetadataOnly(ifc.string());
REQUIRE(meta.has_value());
REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); // critical
REQUIRE(meta->meta.chunks.size() == sd.chunks.size()); // critical
REQUIRE(meta->meta.elements.size() == sd.elements.size()); // deferred
REQUIRE(meta->meta.string_table == sd.string_table); // deferred
REQUIRE(meta->critical_meta_bytes > 0);
REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); // critical
REQUIRE(meta->meta.chunks.size() == sd.chunks.size());
REQUIRE(meta->meta.elements.size() == sd.elements.size()); // desktop reads deferred too
REQUIRE(meta->deferred_comp_size > 0);
// Pull the raw critical block via the recorded locator and parse it alone —
// exactly what the web loader does before painting.
// The on-demand path (web) fetches the compressed deferred frame via the
// recorded locator and decompresses it — verify that round-trips.
FILE* f = std::fopen((dir / "model.ifcview").string().c_str(), "rb");
REQUIRE(f);
std::vector<uint8_t> crit(size_t(meta->critical_meta_bytes));
std::fseek(f, long(meta->critical_meta_offset), SEEK_SET);
REQUIRE(std::fread(crit.data(), 1, crit.size(), f) == crit.size());
std::vector<uint8_t> cz(size_t(meta->deferred_comp_size));
std::fseek(f, long(meta->deferred_comp_offset), SEEK_SET);
REQUIRE(std::fread(cz.data(), 1, cz.size(), f) == cz.size());
std::fclose(f);
SidecarData c;
REQUIRE(parseSidecarCritical(crit.data(), crit.size(), c));
REQUIRE(c.meshes.size() == sd.meshes.size());
REQUIRE(c.chunks.size() == sd.chunks.size());
REQUIRE(c.elements.empty()); // the critical block has no property data
std::vector<uint8_t> raw(size_t(meta->deferred_raw_size));
REQUIRE(SidecarCompress::decompress(cz.data(), cz.size(), raw.data(), raw.size()));
SidecarData d;
REQUIRE(parseSidecarDeferred(raw.data(), raw.size(), d));
REQUIRE(d.elements.size() == sd.elements.size());
REQUIRE(d.string_table == sd.string_table);
SidecarData chopped;
REQUIRE_FALSE(parseSidecarCritical(crit.data(), crit.size() - 1, chopped));
REQUIRE_FALSE(parseSidecarDeferred(raw.data(), raw.size() - 1, chopped));
}
TEST_CASE("planSidecarReadRanges coalesces adjacent ranges, keeps far ones split",