mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-14 19:34:34 +00:00
ifcviewer: extract pure buffer-based sidecar parse + read-plan helpers
Splits the v13 metadata wire-format knowledge out of the FILE*-bound streaming reader into pure, buffer-based functions so the web byte-range path (#88) can reuse it without loading the whole sidecar into the wasm heap: - parseSidecarHead — validates the 16-byte head, yields num_vertex_bytes - parseSidecarTail — parses meshes/instances/georef/elements/strings from an in-memory tail buffer, bounds-checked - planSidecarReadRanges + SidecarReadPlan — the range-coalescing / scatter planner, promoted out of the anonymous namespace readSidecarMetadataOnly and the range readers now call these; desktop behaviour is unchanged (head + tail are small, the bulk is still skipped via seek). The metadata tail is split from the head around the bulk sections, so a blob-backed loader just slices those two regions and hands the bytes to the same parsers. Closes a coverage gap: StreamingLoader had no unit tests. Adds test_streaming_loader.cpp (7 cases: metadata round-trip, corrupt/truncated rejection, vertex+index range scatter, head validation, tail truncation, read-plan coalescing). 107/107 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -47,14 +47,32 @@ struct SidecarHeaderRaw {
|
||||
uint32_t endian;
|
||||
};
|
||||
|
||||
template<typename T>
|
||||
bool readVec(FILE* f, std::vector<T>& v) {
|
||||
uint32_t n;
|
||||
if (std::fread(&n, 4, 1, f) != 1) return false;
|
||||
v.resize(n);
|
||||
if (n > 0 && std::fread(v.data(), sizeof(T), n, f) != n) return false;
|
||||
return true;
|
||||
}
|
||||
// Bounds-checked forward cursor over an in-memory buffer. parseSidecarTail
|
||||
// walks the metadata tail through one of these so a truncated buffer fails
|
||||
// cleanly (return false) instead of reading out of bounds.
|
||||
struct BufCursor {
|
||||
const uint8_t* p;
|
||||
size_t remaining;
|
||||
|
||||
bool take(void* dst, size_t bytes) {
|
||||
if (bytes > remaining) return false;
|
||||
std::memcpy(dst, p, bytes);
|
||||
p += bytes;
|
||||
remaining -= bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read a uint32 length prefix followed by length*sizeof(T) elements.
|
||||
template<typename T>
|
||||
bool takeVec(std::vector<T>& v) {
|
||||
uint32_t n;
|
||||
if (!take(&n, 4)) return false;
|
||||
if (uint64_t(n) * sizeof(T) > remaining) return false;
|
||||
v.resize(n);
|
||||
if (n > 0 && !take(v.data(), size_t(n) * sizeof(T))) return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
std::string sidecarPath(const std::string& ifc_path) {
|
||||
std::string p = ifc_path;
|
||||
@@ -70,6 +88,37 @@ std::string sidecarPath(const std::string& ifc_path) {
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parseSidecarHead(const uint8_t* data, size_t n, uint32_t& out_num_vertex_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);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseSidecarTail(const uint8_t* data, size_t n, SidecarData& out) {
|
||||
BufCursor c{data, n};
|
||||
if (!c.takeVec(out.meshes)) return false;
|
||||
if (!c.takeVec(out.instances)) return false;
|
||||
|
||||
// v11 georef block (148 bytes total).
|
||||
if (!c.take(&out.has_coordinate_operation, 4)) return false;
|
||||
if (!c.take(out.coordinate_operation_meters, sizeof(double) * 16)) return false;
|
||||
if (!c.take(&out.project_length_to_meters, sizeof(double))) return false;
|
||||
if (!c.take(&out.map_unit_to_meters, sizeof(double))) return false;
|
||||
|
||||
if (!c.takeVec(out.elements)) return false;
|
||||
uint32_t stbl_len = 0;
|
||||
if (!c.take(&stbl_len, 4)) return false;
|
||||
if (stbl_len > c.remaining) return false;
|
||||
out.string_table.resize(stbl_len);
|
||||
if (stbl_len > 0 && !c.take(out.string_table.data(), stbl_len)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_path) {
|
||||
const std::string path = sidecarPath(ifc_path);
|
||||
FILE* f = std::fopen(path.c_str(), "rb");
|
||||
@@ -80,52 +129,40 @@ std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_p
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
SidecarHeaderRaw hdr;
|
||||
if (std::fread(&hdr, sizeof(hdr), 1, f) != 1) return fail();
|
||||
if (hdr.magic != SIDECAR_MAGIC) return fail();
|
||||
if (hdr.version != SIDECAR_VERSION) return fail();
|
||||
if (hdr.endian != SIDECAR_ENDIAN) return fail();
|
||||
// Head: 12-byte header + the vertex-byte count. The vertex section starts
|
||||
// immediately after, at SIDECAR_HEAD_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();
|
||||
|
||||
StreamingSidecar out;
|
||||
out.file_path = path;
|
||||
|
||||
// Vertex section: read count, record offset of data, seek past.
|
||||
uint32_t num_vertex_bytes = 0;
|
||||
if (std::fread(&num_vertex_bytes, 4, 1, f) != 1) return fail();
|
||||
out.vertex_section_offset = uint64_t(std::ftell(f));
|
||||
out.file_path = path;
|
||||
out.vertex_section_offset = SIDECAR_HEAD_BYTES;
|
||||
out.vertex_total_bytes = num_vertex_bytes;
|
||||
if (std::fseek(f, long(num_vertex_bytes), SEEK_CUR) != 0) return fail();
|
||||
|
||||
// Index section: same dance, in u32 units.
|
||||
// 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();
|
||||
|
||||
// Mesh dict + instance dict — small, load into meta.
|
||||
if (!readVec(f, out.meta.meshes)) return fail();
|
||||
if (!readVec(f, out.meta.instances)) return fail();
|
||||
|
||||
// v11 georef block (148 bytes total).
|
||||
if (std::fread(&out.meta.has_coordinate_operation, 4, 1, f) != 1) return fail();
|
||||
if (std::fread(out.meta.coordinate_operation_meters,
|
||||
sizeof(double), 16, f) != 16) return fail();
|
||||
if (std::fread(&out.meta.project_length_to_meters,
|
||||
sizeof(double), 1, f) != 1) return fail();
|
||||
if (std::fread(&out.meta.map_unit_to_meters,
|
||||
sizeof(double), 1, f) != 1) return fail();
|
||||
|
||||
// Element table + string table.
|
||||
if (!readVec(f, out.meta.elements)) return fail();
|
||||
uint32_t stbl_len = 0;
|
||||
if (std::fread(&stbl_len, 4, 1, f) != 1) return fail();
|
||||
out.meta.string_table.resize(stbl_len);
|
||||
if (stbl_len > 0 &&
|
||||
std::fread(out.meta.string_table.data(), 1, stbl_len, f) != stbl_len)
|
||||
std::vector<uint8_t> tail(size_t(file_end - tail_off));
|
||||
if (!tail.empty() && std::fread(tail.data(), 1, tail.size(), f) != tail.size())
|
||||
return fail();
|
||||
|
||||
std::fclose(f);
|
||||
|
||||
if (!parseSidecarTail(tail.data(), tail.size(), out.meta)) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -171,33 +208,14 @@ bool readSidecarIndexChunk(const std::string& ifc_path,
|
||||
return got == size_t(chunk_index_count);
|
||||
}
|
||||
|
||||
// Coalesce ranges that are close in file order into single reads. The
|
||||
// input order is preserved in the destination buffer; we just merge
|
||||
// reads on the file side. A `max_gap_bytes` tolerance lets us swallow
|
||||
// small file gaps when reading would be cheaper than seeking.
|
||||
// Coalesce ranges that are close in file order into single reads. The input
|
||||
// order is preserved in the destination buffer; we just merge reads on the
|
||||
// source side. A `max_gap_bytes` tolerance lets us swallow small gaps when one
|
||||
// read is cheaper than a seek + fresh read.
|
||||
//
|
||||
// SIDE EFFECT: callers must give the dst buffer in INPUT order; the
|
||||
// reader scatters bytes via per-input-range dst offsets after a single
|
||||
// coalesced fread. Returns false on any I/O failure.
|
||||
namespace {
|
||||
|
||||
struct ReadPlan {
|
||||
uint64_t file_offset; // absolute file offset
|
||||
uint64_t read_size; // total bytes to read
|
||||
// Per input range: where its bytes land in this read, and where to
|
||||
// copy them into the destination buffer.
|
||||
struct Slice {
|
||||
uint64_t src_offset; // offset within the read buffer
|
||||
uint64_t dst_offset; // offset within the destination buffer
|
||||
uint64_t bytes;
|
||||
};
|
||||
std::vector<Slice> slices;
|
||||
};
|
||||
|
||||
// Build a plan that merges adjacent file ranges into single reads.
|
||||
// `ranges` are (section-relative offset, size). `max_gap_bytes` is the
|
||||
// largest "wasted bytes" we'll read to bridge two ranges into one read.
|
||||
std::vector<ReadPlan> buildReadPlan(
|
||||
// Callers must lay out the destination in INPUT order; the reader scatters
|
||||
// bytes via per-input-range dst offsets after a single coalesced read.
|
||||
std::vector<SidecarReadPlan> planSidecarReadRanges(
|
||||
uint64_t section_offset,
|
||||
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
|
||||
uint64_t max_gap_bytes) {
|
||||
@@ -214,11 +232,11 @@ std::vector<ReadPlan> buildReadPlan(
|
||||
std::sort(sorted.begin(), sorted.end(),
|
||||
[](const Indexed& a, const Indexed& b) { return a.off < b.off; });
|
||||
|
||||
std::vector<ReadPlan> plans;
|
||||
std::vector<SidecarReadPlan> plans;
|
||||
for (const auto& r : sorted) {
|
||||
if (r.size == 0) continue;
|
||||
if (!plans.empty()) {
|
||||
ReadPlan& back = plans.back();
|
||||
SidecarReadPlan& back = plans.back();
|
||||
const uint64_t end_of_back = back.file_offset + back.read_size;
|
||||
const uint64_t r_file = section_offset + r.off;
|
||||
if (r_file >= end_of_back && r_file - end_of_back <= max_gap_bytes) {
|
||||
@@ -233,7 +251,7 @@ std::vector<ReadPlan> buildReadPlan(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ReadPlan np;
|
||||
SidecarReadPlan np;
|
||||
np.file_offset = section_offset + r.off;
|
||||
np.read_size = r.size;
|
||||
np.slices.push_back({0, r.dst, r.size});
|
||||
@@ -242,8 +260,6 @@ std::vector<ReadPlan> buildReadPlan(
|
||||
return plans;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool readSidecarVertexRanges(const std::string& ifc_path,
|
||||
uint64_t vertex_section_offset,
|
||||
const std::vector<std::pair<uint64_t, uint64_t>>& ranges,
|
||||
@@ -255,7 +271,7 @@ bool readSidecarVertexRanges(const std::string& ifc_path,
|
||||
|
||||
// 64 KB max gap: on SSDs a small contiguous read is much cheaper
|
||||
// than a seek + fresh read, even if some bytes are discarded.
|
||||
auto plans = buildReadPlan(vertex_section_offset, ranges, 64 * 1024);
|
||||
auto plans = planSidecarReadRanges(vertex_section_offset, ranges, 64 * 1024);
|
||||
|
||||
const std::string path = sidecarPath(ifc_path);
|
||||
FILE* f = std::fopen(path.c_str(), "rb");
|
||||
@@ -296,7 +312,7 @@ bool readSidecarIndexRanges(const std::string& ifc_path,
|
||||
byte_ranges.emplace_back(first_u32 * 4u, count * 4u);
|
||||
out_byte_cursor += count * 4u;
|
||||
}
|
||||
auto plans = buildReadPlan(index_section_offset, byte_ranges, 64 * 1024);
|
||||
auto plans = planSidecarReadRanges(index_section_offset, byte_ranges, 64 * 1024);
|
||||
|
||||
const std::string path = sidecarPath(ifc_path);
|
||||
FILE* f = std::fopen(path.c_str(), "rb");
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
|
||||
#include "SidecarCache.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
// Metadata-only sidecar load — the foundation for streaming. Reads the v13
|
||||
@@ -65,6 +67,56 @@ struct StreamingSidecar {
|
||||
// before return — callers re-open for per-chunk reads.
|
||||
std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_path);
|
||||
|
||||
// --- Pure, buffer-based building blocks ------------------------------------
|
||||
//
|
||||
// The metadata lives in two disjoint regions of the file: a small fixed
|
||||
// "head" (12-byte header + the 4-byte vertex-byte count) that precedes the
|
||||
// bulk vertex/index sections, and a "tail" (mesh dict, instance dict, georef,
|
||||
// element table, string table) that follows them. Both desktop (FILE*) and
|
||||
// web (Blob.slice / fetch Range) readers slice those two regions out of the
|
||||
// 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;
|
||||
|
||||
// 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.
|
||||
bool parseSidecarHead(const std::uint8_t* data, std::size_t n,
|
||||
std::uint32_t& out_num_vertex_bytes);
|
||||
|
||||
// Parse the metadata tail (everything after the index section): mesh dict,
|
||||
// instance dict, georef block, element table, string table. `data` points at
|
||||
// the first tail byte; `n` is the tail length (read to EOF). Returns false on
|
||||
// any bounds overrun (truncated buffer), leaving out_meta partially filled.
|
||||
bool parseSidecarTail(const std::uint8_t* data, std::size_t n,
|
||||
SidecarData& out_meta);
|
||||
|
||||
// A coalesced read plan: a single contiguous source read whose bytes are
|
||||
// scattered into the destination at the recorded offsets. Merging adjacent
|
||||
// (or near-adjacent, within max_gap_bytes) ranges into one read amortises seek
|
||||
// cost on disk and request count over the network / Blob boundary.
|
||||
struct SidecarReadPlan {
|
||||
std::uint64_t file_offset; // absolute source offset of this read
|
||||
std::uint64_t read_size; // bytes to read
|
||||
struct Slice {
|
||||
std::uint64_t src_offset; // offset within the read buffer
|
||||
std::uint64_t dst_offset; // offset within the destination buffer
|
||||
std::uint64_t bytes;
|
||||
};
|
||||
std::vector<Slice> slices;
|
||||
};
|
||||
|
||||
// Build read plans for `ranges` (section-relative (offset, size) pairs) that
|
||||
// land in a destination laid out in input order. `section_offset` is added to
|
||||
// turn section-relative offsets into absolute source offsets. Pure — no I/O.
|
||||
std::vector<SidecarReadPlan> planSidecarReadRanges(
|
||||
std::uint64_t section_offset,
|
||||
const std::vector<std::pair<std::uint64_t, std::uint64_t>>& ranges,
|
||||
std::uint64_t max_gap_bytes);
|
||||
|
||||
// Read a byte range from a sidecar's vertex section. `chunk_byte_offset` is
|
||||
// RELATIVE to vertex_section_offset (i.e. 0 = first vertex byte). Returns
|
||||
// false on I/O error or out-of-range request.
|
||||
|
||||
@@ -50,6 +50,15 @@ add_ifcviewer_unit_test(test_sidecar_cache
|
||||
SOURCES ${IFCVIEWER_SRC}/SidecarCache.cpp
|
||||
)
|
||||
|
||||
# StreamingLoader: metadata-only read + range readers + the pure buffer-based
|
||||
# parse / read-plan helpers shared with the web (Blob.slice) path. Needs
|
||||
# SidecarCache.cpp for writeSidecar (to lay down on-disk fixtures).
|
||||
add_ifcviewer_unit_test(test_streaming_loader
|
||||
SOURCES
|
||||
${IFCVIEWER_SRC}/StreamingLoader.cpp
|
||||
${IFCVIEWER_SRC}/SidecarCache.cpp
|
||||
)
|
||||
|
||||
add_ifcviewer_unit_test(test_instanced_geometry)
|
||||
|
||||
# ChunkPlanner: Morton sort + greedy-pack — pure CPU, no Qt / no wgpu.
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* 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 "SidecarCache.h"
|
||||
#include "StreamingLoader.h"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
fs::path makeScratchDir(const char* tag) {
|
||||
fs::path base = fs::temp_directory_path() / "ifcviewer_test_streaming";
|
||||
fs::create_directories(base);
|
||||
static std::atomic<uint64_t> counter{0};
|
||||
fs::path dir = base / (std::to_string(counter.fetch_add(1)) + "_" + tag);
|
||||
fs::create_directories(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// Minimal but representative fixture: two meshes sharing one VBO, a non-default
|
||||
// georef block, and a string table with embedded NULs (so the byte-exact tail
|
||||
// parse is actually exercised).
|
||||
SidecarData buildFixture() {
|
||||
SidecarData sd;
|
||||
|
||||
sd.vertices.resize(4 * INSTANCED_VERTEX_STRIDE_BYTES);
|
||||
for (size_t i = 0; i < sd.vertices.size(); ++i) sd.vertices[i] = uint8_t(i * 7 + 1);
|
||||
|
||||
sd.indices = {0, 1, 2, 1, 2, 3};
|
||||
|
||||
MeshInfo m1{};
|
||||
m1.vbo_byte_offset = 0;
|
||||
m1.vertex_count = 2;
|
||||
m1.ebo_byte_offset = 0;
|
||||
m1.index_count = 3;
|
||||
MeshInfo m2{};
|
||||
m2.vbo_byte_offset = 2 * INSTANCED_VERTEX_STRIDE_BYTES;
|
||||
m2.vertex_count = 2;
|
||||
m2.ebo_byte_offset = 3 * sizeof(uint32_t);
|
||||
m2.index_count = 3;
|
||||
sd.meshes = {m1, m2};
|
||||
|
||||
sd.instances.resize(3);
|
||||
for (size_t i = 0; i < sd.instances.size(); ++i) {
|
||||
sd.instances[i].mesh_id = (i < 2) ? 0u : 1u;
|
||||
sd.instances[i].object_id = uint32_t(100 + i);
|
||||
sd.instances[i].model_id = 1;
|
||||
}
|
||||
|
||||
sd.has_coordinate_operation = 1;
|
||||
for (int k = 0; k < 16; ++k) sd.coordinate_operation_meters[k] = 0.5 + 0.1 * k;
|
||||
sd.project_length_to_meters = 0.001;
|
||||
sd.map_unit_to_meters = 1.0;
|
||||
|
||||
sd.string_table = std::string("\0Wall\0Slab\0", 11);
|
||||
sd.elements.resize(2);
|
||||
for (size_t i = 0; i < sd.elements.size(); ++i) {
|
||||
sd.elements[i].object_id = uint32_t(100 + i);
|
||||
sd.elements[i].model_id = 1;
|
||||
sd.elements[i].ifc_id = int32_t(1000 + i);
|
||||
sd.elements[i].parent_id = (i == 0) ? -1 : int32_t(100);
|
||||
}
|
||||
return sd;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("readSidecarMetadataOnly returns metadata + section offsets, skips bulk",
|
||||
"[streaming]") {
|
||||
fs::path dir = makeScratchDir("metaonly");
|
||||
fs::path ifc = dir / "model.ifc";
|
||||
SidecarData sd = buildFixture();
|
||||
REQUIRE(writeSidecar(ifc.string(), sd));
|
||||
|
||||
auto meta = readSidecarMetadataOnly(ifc.string());
|
||||
REQUIRE(meta.has_value());
|
||||
|
||||
// Bulk sections are 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);
|
||||
|
||||
// Tail 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());
|
||||
REQUIRE(meta->meta.string_table == sd.string_table);
|
||||
REQUIRE(meta->meta.has_coordinate_operation == 1);
|
||||
REQUIRE(meta->meta.project_length_to_meters == 0.001);
|
||||
for (int k = 0; k < 16; ++k)
|
||||
REQUIRE(meta->meta.coordinate_operation_meters[k] == 0.5 + 0.1 * k);
|
||||
REQUIRE(std::memcmp(&meta->meta.meshes[1], &sd.meshes[1], sizeof(MeshInfo)) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("readSidecarMetadataOnly rejects missing / corrupt files", "[streaming]") {
|
||||
fs::path dir = makeScratchDir("reject");
|
||||
REQUIRE_FALSE(readSidecarMetadataOnly((dir / "absent.ifc").string()).has_value());
|
||||
|
||||
// Truncated head (under 16 bytes).
|
||||
fs::path bad = dir / "bad.ifc";
|
||||
{
|
||||
FILE* f = std::fopen((dir / "bad.ifcview").string().c_str(), "wb");
|
||||
REQUIRE(f);
|
||||
const char junk[] = "XYZ";
|
||||
std::fwrite(junk, 1, sizeof(junk), f);
|
||||
std::fclose(f);
|
||||
}
|
||||
REQUIRE_FALSE(readSidecarMetadataOnly(bad.string()).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("readSidecarVertexRanges scatters byte ranges in input order", "[streaming]") {
|
||||
fs::path dir = makeScratchDir("vranges");
|
||||
fs::path ifc = dir / "model.ifc";
|
||||
SidecarData sd = buildFixture();
|
||||
REQUIRE(writeSidecar(ifc.string(), sd));
|
||||
auto meta = readSidecarMetadataOnly(ifc.string());
|
||||
REQUIRE(meta.has_value());
|
||||
|
||||
// Two section-relative ranges given out of file order; the destination
|
||||
// must preserve input order (second mesh's bytes first, then first).
|
||||
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);
|
||||
}
|
||||
|
||||
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]") {
|
||||
uint8_t head[SIDECAR_HEAD_BYTES] = {};
|
||||
uint32_t magic = SIDECAR_MAGIC, version = SIDECAR_VERSION, endian = SIDECAR_ENDIAN;
|
||||
uint32_t nvb = 4096;
|
||||
std::memcpy(head + 0, &magic, 4);
|
||||
std::memcpy(head + 4, &version, 4);
|
||||
std::memcpy(head + 8, &endian, 4);
|
||||
std::memcpy(head + 12, &nvb, 4);
|
||||
|
||||
uint32_t got = 0;
|
||||
REQUIRE(parseSidecarHead(head, sizeof(head), got));
|
||||
REQUIRE(got == 4096);
|
||||
|
||||
// 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("parseSidecarTail rejects a truncated tail", "[streaming]") {
|
||||
// A valid full tail, then everything but its last byte must fail.
|
||||
fs::path dir = makeScratchDir("tailtrunc");
|
||||
fs::path ifc = dir / "model.ifc";
|
||||
SidecarData sd = buildFixture();
|
||||
REQUIRE(writeSidecar(ifc.string(), sd));
|
||||
auto meta = readSidecarMetadataOnly(ifc.string());
|
||||
REQUIRE(meta.has_value());
|
||||
|
||||
// Re-read the raw tail bytes from disk (offset = index section end).
|
||||
const uint64_t tail_off =
|
||||
meta->index_section_offset + meta->index_total_count * 4u;
|
||||
FILE* f = std::fopen((dir / "model.ifcview").string().c_str(), "rb");
|
||||
REQUIRE(f);
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
const long end = std::ftell(f);
|
||||
const size_t tail_len = size_t(end - long(tail_off));
|
||||
std::vector<uint8_t> tail(tail_len);
|
||||
std::fseek(f, long(tail_off), SEEK_SET);
|
||||
REQUIRE(std::fread(tail.data(), 1, tail_len, f) == tail_len);
|
||||
std::fclose(f);
|
||||
|
||||
SidecarData full;
|
||||
REQUIRE(parseSidecarTail(tail.data(), tail.size(), full));
|
||||
REQUIRE(full.meshes.size() == sd.meshes.size());
|
||||
REQUIRE(full.string_table == sd.string_table);
|
||||
|
||||
SidecarData chopped;
|
||||
REQUIRE_FALSE(parseSidecarTail(tail.data(), tail.size() - 1, chopped));
|
||||
}
|
||||
|
||||
TEST_CASE("planSidecarReadRanges coalesces adjacent ranges, keeps far ones split",
|
||||
"[streaming]") {
|
||||
const uint64_t base = 1000;
|
||||
|
||||
SECTION("adjacent ranges merge into one read") {
|
||||
// Two ranges that touch (0..16, 16..48) plus a gap small enough to
|
||||
// bridge (gap of 8 within a 64-byte tolerance).
|
||||
std::vector<std::pair<uint64_t, uint64_t>> ranges = {{0, 16}, {24, 24}};
|
||||
auto plans = planSidecarReadRanges(base, ranges, 64);
|
||||
REQUIRE(plans.size() == 1);
|
||||
REQUIRE(plans[0].file_offset == base + 0);
|
||||
REQUIRE(plans[0].read_size == 48); // 0 .. 24+24
|
||||
REQUIRE(plans[0].slices.size() == 2);
|
||||
}
|
||||
|
||||
SECTION("far-apart ranges stay separate") {
|
||||
std::vector<std::pair<uint64_t, uint64_t>> ranges = {{0, 16}, {1024, 16}};
|
||||
auto plans = planSidecarReadRanges(base, ranges, 64);
|
||||
REQUIRE(plans.size() == 2);
|
||||
}
|
||||
|
||||
SECTION("input order preserved in destination offsets") {
|
||||
// Ranges given high-offset-first; dst offsets must follow input order
|
||||
// (range 0 -> dst 0, range 1 -> dst 16) regardless of file order.
|
||||
std::vector<std::pair<uint64_t, uint64_t>> ranges = {{2048, 16}, {0, 16}};
|
||||
auto plans = planSidecarReadRanges(base, ranges, 64);
|
||||
REQUIRE(plans.size() == 2);
|
||||
uint64_t total_bytes = 0;
|
||||
for (const auto& p : plans)
|
||||
for (const auto& s : p.slices) total_bytes += s.bytes;
|
||||
REQUIRE(total_bytes == 32);
|
||||
// The range at file offset 0 (input index 1) lands at dst 16.
|
||||
bool found_dst16 = false;
|
||||
for (const auto& p : plans)
|
||||
for (const auto& s : p.slices)
|
||||
if (p.file_offset == base + 0 && s.dst_offset == 16) found_dst16 = true;
|
||||
REQUIRE(found_dst16);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user