Merge branch 'ifcviewer-wgpu' of https://github.com/IfcOpenShell/IfcOpenShell into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-03 13:35:06 +02:00
137 changed files with 4451 additions and 2902 deletions
+11 -1
View File
@@ -186,6 +186,16 @@ AppSettings::NavPreset AppSettings::navPreset() const {
return nav_preset_;
}
const char* AppSettings::navPresetName(NavPreset preset) {
switch (preset) {
case NavPreset::Rhino: return "rhino";
case NavPreset::Revit: return "revit";
case NavPreset::Web: return "web";
case NavPreset::Blender: break;
}
return "blender";
}
void AppSettings::setNavPreset(NavPreset value) {
if (nav_preset_ == value) return;
nav_preset_ = value;
@@ -220,7 +230,7 @@ void AppSettings::load() {
static_cast<int>(NavPreset::Blender)).toInt();
// Clamp to known values so a stale config doesn't drop us into
// an undefined preset slot.
if (raw < 0 || raw > static_cast<int>(NavPreset::Revit)) {
if (raw < 0 || raw > static_cast<int>(NavPreset::Web)) {
nav_preset_ = NavPreset::Blender;
} else {
nav_preset_ = static_cast<NavPreset>(raw);
+5
View File
@@ -37,13 +37,18 @@ public:
// Blender — Orbit MMB, Pan Shift+MMB (current default)
// Rhino — Orbit RMB, Pan Shift+RMB
// Revit — Orbit Shift+MMB, Pan MMB
// Web — Orbit LMB, Pan MMB, Select RMB
enum class NavPreset {
Blender = 0,
Rhino = 1,
Revit = 2,
Web = 3,
};
Q_ENUM(NavPreset)
// Preset → the lowercase name ViewportCore::setNavPreset / applyNavPreset take.
static const char* navPresetName(NavPreset preset);
static AppSettings& instance();
QString geometryLibrary() const;
+56 -49
View File
@@ -41,8 +41,8 @@ void BufferPool::configure(WGPUInstance instance, WGPUDevice device,
}
void BufferPool::destroy() {
for (auto& sp : sub_pools_) {
if (sp.buffer) wgpuBufferRelease(sp.buffer);
for (auto& sub_pool : sub_pools_) {
if (sub_pool.buffer) wgpuBufferRelease(sub_pool.buffer);
}
sub_pools_.clear();
device_ = nullptr;
@@ -140,7 +140,7 @@ bool BufferPool::addSubBuffer() {
WGPUBuffer buf = wgpuDeviceCreateBuffer(device_, &desc);
struct PopResult { bool done = false; bool error = false; };
auto pop = [&](PopResult& pr) {
auto pop = [&](PopResult& pop_result) {
WGPUPopErrorScopeCallbackInfo pcb = {};
pcb.mode = WGPUCallbackMode_AllowProcessEvents;
pcb.callback = [](WGPUPopErrorScopeStatus, WGPUErrorType type,
@@ -149,9 +149,9 @@ bool BufferPool::addSubBuffer() {
p->done = true;
p->error = (type != WGPUErrorType_NoError);
};
pcb.userdata1 = &pr;
pcb.userdata1 = &pop_result;
wgpuDevicePopErrorScope(device_, pcb);
while (!pr.done) wgpuInstanceProcessEvents(instance_);
while (!pop_result.done) wgpuInstanceProcessEvents(instance_);
};
PopResult oom_pop, validation_pop;
pop(oom_pop);
@@ -205,11 +205,12 @@ void BufferPool::resolveProvisionalGrowth(bool failed) {
(unsigned long long)(total_capacity_bytes() / (1024 * 1024)),
sub_pools_.size());
} else {
sub_pools_[i].provisional = false;
SubPool& sub_pool = sub_pools_[i];
sub_pool.provisional = false;
std::fprintf(stderr,
"[wgpu pool] added sub-buffer %zu (%llu MB); pool total now %llu MB\n",
i,
(unsigned long long)(sub_pools_[i].capacity / (1024 * 1024)),
(unsigned long long)(sub_pool.capacity / (1024 * 1024)),
(unsigned long long)(total_capacity_bytes() / (1024 * 1024)));
}
return;
@@ -225,34 +226,34 @@ BufferPool::Slice BufferPool::alloc(uint64_t size, uint64_t align) {
// adding another sub-buffer and retry once.
for (int attempt = 0; attempt < 2; ++attempt) {
for (size_t sp_idx = 0; sp_idx < sub_pools_.size(); ++sp_idx) {
SubPool& sp = sub_pools_[sp_idx];
SubPool& sub_pool = sub_pools_[sp_idx];
// Web: never allocate out of a sub-buffer still awaiting OOM
// validation — its handle may be a Dawn error buffer.
if (sp.provisional) continue;
for (size_t i = 0; i < sp.free_ranges.size(); ++i) {
const FreeRange& r = sp.free_ranges[i];
const uint64_t aligned = (r.offset + (align - 1)) & ~(align - 1);
const uint64_t pad = aligned - r.offset;
if (pad >= r.size) continue;
if (size > r.size - pad) continue;
if (sub_pool.provisional) continue;
for (size_t i = 0; i < sub_pool.free_ranges.size(); ++i) {
const FreeRange& free_range = sub_pool.free_ranges[i];
const uint64_t aligned = (free_range.offset + (align - 1)) & ~(align - 1);
const uint64_t alignment_padding = aligned - free_range.offset;
if (alignment_padding >= free_range.size) continue;
if (size > free_range.size - alignment_padding) continue;
const uint64_t post_off = aligned + size;
const uint64_t post_size = (r.offset + r.size) - post_off;
const uint64_t post_size = (free_range.offset + free_range.size) - post_off;
if (pad == 0 && post_size == 0) {
sp.free_ranges.erase(sp.free_ranges.begin() + i);
} else if (pad == 0) {
sp.free_ranges[i] = {post_off, post_size};
if (alignment_padding == 0 && post_size == 0) {
sub_pool.free_ranges.erase(sub_pool.free_ranges.begin() + i);
} else if (alignment_padding == 0) {
sub_pool.free_ranges[i] = {post_off, post_size};
} else if (post_size == 0) {
sp.free_ranges[i] = {r.offset, pad};
sub_pool.free_ranges[i] = {free_range.offset, alignment_padding};
} else {
sp.free_ranges[i] = {r.offset, pad};
sp.free_ranges.insert(sp.free_ranges.begin() + i + 1,
sub_pool.free_ranges[i] = {free_range.offset, alignment_padding};
sub_pool.free_ranges.insert(sub_pool.free_ranges.begin() + i + 1,
{post_off, post_size});
}
sp.used += size;
out.buffer = sp.buffer;
sub_pool.used += size;
out.buffer = sub_pool.buffer;
out.offset = aligned;
out.size = size;
out.sub_idx = int(sp_idx);
@@ -270,50 +271,56 @@ BufferPool::Slice BufferPool::alloc(uint64_t size, uint64_t align) {
void BufferPool::free(const Slice& s) {
if (!s.valid()) return;
if (s.sub_idx < 0 || size_t(s.sub_idx) >= sub_pools_.size()) return;
SubPool& sp = sub_pools_[size_t(s.sub_idx)];
assert(s.offset + s.size <= sp.capacity);
SubPool& sub_pool = sub_pools_[size_t(s.sub_idx)];
assert(s.offset + s.size <= sub_pool.capacity);
size_t i = 0;
while (i < sp.free_ranges.size() && sp.free_ranges[i].offset < s.offset) ++i;
sp.free_ranges.insert(sp.free_ranges.begin() + i, {s.offset, s.size});
sp.used -= s.size;
while (i < sub_pool.free_ranges.size() && sub_pool.free_ranges[i].offset < s.offset) ++i;
sub_pool.free_ranges.insert(sub_pool.free_ranges.begin() + i, {s.offset, s.size});
sub_pool.used -= s.size;
if (i + 1 < sp.free_ranges.size()
&& sp.free_ranges[i].offset + sp.free_ranges[i].size == sp.free_ranges[i + 1].offset) {
sp.free_ranges[i].size += sp.free_ranges[i + 1].size;
sp.free_ranges.erase(sp.free_ranges.begin() + i + 1);
if (i + 1 < sub_pool.free_ranges.size()
&& sub_pool.free_ranges[i].offset + sub_pool.free_ranges[i].size
== sub_pool.free_ranges[i + 1].offset) {
sub_pool.free_ranges[i].size += sub_pool.free_ranges[i + 1].size;
sub_pool.free_ranges.erase(sub_pool.free_ranges.begin() + i + 1);
}
if (i > 0
&& sp.free_ranges[i - 1].offset + sp.free_ranges[i - 1].size == sp.free_ranges[i].offset) {
sp.free_ranges[i - 1].size += sp.free_ranges[i].size;
sp.free_ranges.erase(sp.free_ranges.begin() + i);
&& sub_pool.free_ranges[i - 1].offset + sub_pool.free_ranges[i - 1].size
== sub_pool.free_ranges[i].offset) {
sub_pool.free_ranges[i - 1].size += sub_pool.free_ranges[i].size;
sub_pool.free_ranges.erase(sub_pool.free_ranges.begin() + i);
}
}
uint64_t BufferPool::total_capacity_bytes() const {
uint64_t s = 0;
uint64_t total_capacity = 0;
// Skip provisional sub-pools (web, awaiting OOM validation) — their
// capacity isn't usable yet, so counting it would mislead the
// evictor's "is there room?" heuristics.
for (const auto& sp : sub_pools_) if (!sp.provisional) s += sp.capacity;
return s;
for (const auto& sub_pool : sub_pools_) {
if (!sub_pool.provisional) total_capacity += sub_pool.capacity;
}
return total_capacity;
}
uint64_t BufferPool::total_used_bytes() const {
uint64_t s = 0;
for (const auto& sp : sub_pools_) if (!sp.provisional) s += sp.used;
return s;
uint64_t total_used = 0;
for (const auto& sub_pool : sub_pools_) {
if (!sub_pool.provisional) total_used += sub_pool.used;
}
return total_used;
}
uint64_t BufferPool::largest_free_run_bytes() const {
uint64_t m = 0;
for (const auto& sp : sub_pools_) {
if (sp.provisional) continue;
for (const auto& r : sp.free_ranges) {
if (r.size > m) m = r.size;
uint64_t largest_free_run = 0;
for (const auto& sub_pool : sub_pools_) {
if (sub_pool.provisional) continue;
for (const auto& free_range : sub_pool.free_ranges) {
if (free_range.size > largest_free_run) largest_free_run = free_range.size;
}
}
return m;
return largest_free_run;
}
void BufferPool::addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity) {
+2
View File
@@ -146,6 +146,7 @@ set(IFCVIEWER_CORE_SOURCES
SidecarCompress.cpp
StreamingLoader.cpp
StreamingThread.cpp
SectionGizmoRenderer.cpp
ViewportCore.cpp
)
# Web needs a zstd DECODER (Emscripten has no zstd port; the desktop links the
@@ -195,6 +196,7 @@ set(IFCVIEWER_CORE_HEADERS
ModelGpuData.h
FrameStats.h
OverlayFrame.h
SectionGizmoRenderer.h
SectionPlane.h
SelectionState.h
SidecarCache.h
+1 -1
View File
@@ -131,7 +131,7 @@ ModelGeoref computeModelGeoref(ifcopenshell::file* ifc_file);
// Build a FederatedFalseOrigin guess so that a model lands near the
// federation origin instead of out at its surveyor coordinates. Designed
// to work without an open IFC file so it's usable from sidecar-only loads
// (the inputs are all derivable from the resident MeshInfo + InstanceCpu
// (the inputs are all derivable from the resident MeshInfo + InstanceInfo
// data + ModelGeoref).
//
// Position: `first_geometry_point_m` is a point that actually lies on the
+74 -73
View File
@@ -42,29 +42,29 @@ struct MaterialInfo {
};
static MaterialInfo materialFromStyle(const ifcopenshell::geometry::taxonomy::style::ptr& style) {
MaterialInfo m;
if (!style) return m;
MaterialInfo material;
if (!style) return material;
const auto& color = style->get_color();
if (color) {
m.r = static_cast<float>(color.r());
m.g = static_cast<float>(color.g());
m.b = static_cast<float>(color.b());
material.r = static_cast<float>(color.r());
material.g = static_cast<float>(color.g());
material.b = static_cast<float>(color.b());
}
if (!std::isnan(style->transparency)) {
m.a = 1.0f - static_cast<float>(style->transparency);
material.a = 1.0f - static_cast<float>(style->transparency);
}
return m;
return material;
}
static inline uint32_t packRGBA8(const MaterialInfo& m) {
auto to_byte = [](float v) -> uint32_t {
float c = std::clamp(v, 0.0f, 1.0f);
return static_cast<uint32_t>(c * 255.0f + 0.5f);
static inline uint32_t packRGBA8(const MaterialInfo& material) {
auto to_byte = [](float channel_value) -> uint32_t {
float clamped_value = std::clamp(channel_value, 0.0f, 1.0f);
return static_cast<uint32_t>(clamped_value * 255.0f + 0.5f);
};
uint32_t r = to_byte(m.r);
uint32_t g = to_byte(m.g);
uint32_t b = to_byte(m.b);
uint32_t a = to_byte(m.a);
uint32_t r = to_byte(material.r);
uint32_t g = to_byte(material.g);
uint32_t b = to_byte(material.b);
uint32_t a = to_byte(material.a);
// Little-endian byte layout [r,g,b,a] for GL_UNSIGNED_BYTE * 4 normalized.
return r | (g << 8) | (b << 16) | (a << 24);
}
@@ -144,7 +144,7 @@ std::vector<ElementInfo> GeometryStreamer::drainElements() {
return result;
}
// Build a mesh chunk (local coords, 28-byte interleaved vertices) from a
// Build a streamed mesh record (local coords, 28-byte interleaved vertices) from a
// TriangulationElement. Per-vertex color is baked from material_ids so that
// triangulations with per-face materials still render correctly.
// Vertex rebasing: when `offset` is non-zero, every vertex position is
@@ -153,13 +153,13 @@ std::vector<ElementInfo> GeometryStreamer::drainElements() {
// compensates by post-multiplying each instance's PlacementTransformation
// by T(+offset), which is mathematically the identity overall but moves
// the magnitude off the float-precision-sensitive vertex column.
static MeshChunk buildMeshChunk(uint32_t model_id,
static StreamedMesh buildStreamedMesh(uint32_t model_id,
uint32_t local_mesh_id,
const IfcGeom::TriangulationElement* elem,
const Eigen::Vector3d& offset) {
MeshChunk chunk;
chunk.model_id = model_id;
chunk.local_mesh_id = local_mesh_id;
StreamedMesh mesh;
mesh.model_id = model_id;
mesh.local_mesh_id = local_mesh_id;
const auto& geom = elem->geometry();
const auto& verts = geom.verts();
@@ -168,7 +168,7 @@ static MeshChunk buildMeshChunk(uint32_t model_id,
const auto& materials = geom.materials();
const auto& material_ids = geom.material_ids();
if (verts.empty() || faces.empty()) return chunk;
if (verts.empty() || faces.empty()) return mesh;
const size_t num_verts_src = verts.size() / 3;
const size_t num_tris = faces.size() / 3;
@@ -184,16 +184,16 @@ static MeshChunk buildMeshChunk(uint32_t model_id,
std::unordered_map<uint64_t, uint32_t> remap;
remap.reserve(num_verts_src);
chunk.vertices.reserve(num_verts_src * INSTANCED_VERTEX_STRIDE_FLOATS);
chunk.indices.reserve(faces.size());
mesh.vertices.reserve(num_verts_src * INSTANCED_VERTEX_STRIDE_FLOATS);
mesh.indices.reserve(faces.size());
// Track local AABB as we emit vertices.
float amin[3] = { std::numeric_limits<float>::max(),
std::numeric_limits<float>::max(),
std::numeric_limits<float>::max() };
float amax[3] = { -std::numeric_limits<float>::max(),
-std::numeric_limits<float>::max(),
-std::numeric_limits<float>::max() };
float local_aabb_min[3] = { std::numeric_limits<float>::max(),
std::numeric_limits<float>::max(),
std::numeric_limits<float>::max() };
float local_aabb_max[3] = { -std::numeric_limits<float>::max(),
-std::numeric_limits<float>::max(),
-std::numeric_limits<float>::max() };
auto emit_vertex = [&](uint32_t orig_idx, int mat_id) -> uint32_t {
const uint64_t key = make_key(orig_idx, mat_id);
@@ -201,28 +201,31 @@ static MeshChunk buildMeshChunk(uint32_t model_id,
if (it != remap.end()) return it->second;
const uint32_t new_idx = static_cast<uint32_t>(
chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS);
mesh.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS);
// Subtract in double, narrow to float — preserves precision when
// verts are far from origin and offset cancels the magnitude.
float px = static_cast<float>(verts[orig_idx * 3 + 0] - offset.x());
float py = static_cast<float>(verts[orig_idx * 3 + 1] - offset.y());
float pz = static_cast<float>(verts[orig_idx * 3 + 2] - offset.z());
chunk.vertices.push_back(px);
chunk.vertices.push_back(py);
chunk.vertices.push_back(pz);
if (px < amin[0]) amin[0] = px; if (px > amax[0]) amax[0] = px;
if (py < amin[1]) amin[1] = py; if (py > amax[1]) amax[1] = py;
if (pz < amin[2]) amin[2] = pz; if (pz > amax[2]) amax[2] = pz;
mesh.vertices.push_back(px);
mesh.vertices.push_back(py);
mesh.vertices.push_back(pz);
if (px < local_aabb_min[0]) local_aabb_min[0] = px;
if (px > local_aabb_max[0]) local_aabb_max[0] = px;
if (py < local_aabb_min[1]) local_aabb_min[1] = py;
if (py > local_aabb_max[1]) local_aabb_max[1] = py;
if (pz < local_aabb_min[2]) local_aabb_min[2] = pz;
if (pz > local_aabb_max[2]) local_aabb_max[2] = pz;
if (orig_idx * 3 + 2 < normals.size()) {
chunk.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 0]));
chunk.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 1]));
chunk.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 2]));
mesh.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 0]));
mesh.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 1]));
mesh.vertices.push_back(static_cast<float>(normals[orig_idx * 3 + 2]));
} else {
chunk.vertices.push_back(0.0f);
chunk.vertices.push_back(1.0f);
chunk.vertices.push_back(0.0f);
mesh.vertices.push_back(0.0f);
mesh.vertices.push_back(1.0f);
mesh.vertices.push_back(0.0f);
}
MaterialInfo m;
@@ -232,7 +235,7 @@ static MeshChunk buildMeshChunk(uint32_t model_id,
uint32_t packed = packRGBA8(m);
float packed_as_float;
std::memcpy(&packed_as_float, &packed, sizeof(float));
chunk.vertices.push_back(packed_as_float);
mesh.vertices.push_back(packed_as_float);
remap.emplace(key, new_idx);
return new_idx;
@@ -240,19 +243,19 @@ static MeshChunk buildMeshChunk(uint32_t model_id,
for (size_t t = 0; t < num_tris; ++t) {
const int mat_id = have_per_tri_material ? material_ids[t] : -1;
chunk.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 0]), mat_id));
chunk.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 1]), mat_id));
chunk.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 2]), mat_id));
mesh.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 0]), mat_id));
mesh.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 1]), mat_id));
mesh.indices.push_back(emit_vertex(static_cast<uint32_t>(faces[t * 3 + 2]), mat_id));
}
if (chunk.vertices.empty()) {
for (int a = 0; a < 3; ++a) amin[a] = amax[a] = 0.0f;
if (mesh.vertices.empty()) {
for (int a = 0; a < 3; ++a) local_aabb_min[a] = local_aabb_max[a] = 0.0f;
}
for (int a = 0; a < 3; ++a) {
chunk.local_aabb_min[a] = amin[a];
chunk.local_aabb_max[a] = amax[a];
mesh.local_aabb_min[a] = local_aabb_min[a];
mesh.local_aabb_max[a] = local_aabb_max[a];
}
return chunk;
return mesh;
}
// Port of ifcopenshell.util.representation.get_prioritised_contexts: rank every
@@ -527,7 +530,6 @@ void GeometryStreamer::run(const std::string& path, int num_threads) {
emit errorOccurred(QString("Failed to create geometry iterator: %1").arg(e.what()));
return false;
}
if (!iterator->initialize()) {
// No geometry survived this context for the remaining ids.
// Subsequent contexts will pick them up; nothing to emit.
@@ -560,7 +562,6 @@ void GeometryStreamer::run(const std::string& path, int num_threads) {
info.guid = tri_elem->guid();
info.name = tri_elem->name();
info.type = tri_elem->type();
info.parent_id = tri_elem->parent_id();
{
std::lock_guard<std::mutex> lock(elements_mutex_);
pending_elements_.push_back(std::move(info));
@@ -602,19 +603,19 @@ void GeometryStreamer::run(const std::string& path, int num_threads) {
}
}
MeshChunk mesh_chunk =
buildMeshChunk(model_id_, local_mesh_id, tri_elem, offset);
MeshAabb ma;
StreamedMesh streamed_mesh =
buildStreamedMesh(model_id_, local_mesh_id, tri_elem, offset);
MeshAabb mesh_aabb;
for (int a = 0; a < 3; ++a) {
ma.lmin[a] = mesh_chunk.local_aabb_min[a];
ma.lmax[a] = mesh_chunk.local_aabb_max[a];
ma.offset[a] = offset[a];
mesh_aabb.lmin[a] = streamed_mesh.local_aabb_min[a];
mesh_aabb.lmax[a] = streamed_mesh.local_aabb_max[a];
mesh_aabb.offset[a] = offset[a];
}
ma.has_offset = (offset.squaredNorm() > 0.0);
mesh_aabb.has_offset = (offset.squaredNorm() > 0.0);
if (mesh_aabbs.size() <= local_mesh_id) mesh_aabbs.resize(local_mesh_id + 1);
mesh_aabbs[local_mesh_id] = ma;
if (!mesh_chunk.indices.empty()) {
emit meshReady(std::move(mesh_chunk));
mesh_aabbs[local_mesh_id] = mesh_aabb;
if (!streamed_mesh.indices.empty()) {
emit meshReady(std::move(streamed_mesh));
}
}
@@ -626,14 +627,14 @@ void GeometryStreamer::run(const std::string& path, int num_threads) {
Eigen::Matrix4d mat_d =
tri_elem->transformation().data()->ccomponents();
if (mesh_aabbs[local_mesh_id].has_offset) {
const Eigen::Vector3d off(
const Eigen::Vector3d mesh_rebase_offset(
mesh_aabbs[local_mesh_id].offset[0],
mesh_aabbs[local_mesh_id].offset[1],
mesh_aabbs[local_mesh_id].offset[2]);
mat_d.block<3, 1>(0, 3) += mat_d.block<3, 3>(0, 0) * off;
mat_d.block<3, 1>(0, 3) += mat_d.block<3, 3>(0, 0) * mesh_rebase_offset;
}
InstanceChunk inst;
StreamedInstance inst;
inst.model_id = model_id_;
inst.local_mesh_id = local_mesh_id;
inst.object_id = object_id;
@@ -642,25 +643,25 @@ void GeometryStreamer::run(const std::string& path, int num_threads) {
inst.transform[i] = mat_d.data()[i];
}
const MeshAabb& ma = mesh_aabbs[local_mesh_id];
const MeshAabb& mesh_aabb = mesh_aabbs[local_mesh_id];
float mat_f[16];
for (int i = 0; i < 16; ++i) {
mat_f[i] = static_cast<float>(inst.transform[i]);
}
worldAabbFromLocal(ma.lmin, ma.lmax, mat_f,
worldAabbFromLocal(mesh_aabb.lmin, mesh_aabb.lmax, mat_f,
inst.world_aabb_min, inst.world_aabb_max);
emit instanceReady(std::move(inst));
total_shapes++;
yielded_count++;
const int p = total_count > 0
const int progress_percent = total_count > 0
? static_cast<int>((100 * yielded_count) / total_count)
: 100;
if (p != last_emitted_progress) {
last_emitted_progress = p;
progress_ = p;
emit progressChanged(p);
if (progress_percent != last_emitted_progress) {
last_emitted_progress = progress_percent;
progress_ = progress_percent;
emit progressChanged(progress_percent);
}
} while (iterator->next());
+2 -3
View File
@@ -41,7 +41,6 @@ struct ElementInfo {
std::string guid;
std::string name;
std::string type;
int parent_id;
};
class GeometryStreamer : public QObject {
@@ -70,8 +69,8 @@ public:
signals:
void progressChanged(int percent);
void meshReady(MeshChunk chunk);
void instanceReady(InstanceChunk chunk);
void meshReady(StreamedMesh mesh);
void instanceReady(StreamedInstance instance_record);
void finished();
void cancelled();
void errorOccurred(const QString& message);
+9 -9
View File
@@ -79,16 +79,16 @@ bool findInstanceInModels(
const std::unordered_map<uint32_t, ModelGpuData>& models,
InstanceLookup& out) {
if (object_id == 0) return false;
for (const auto& [mid, m] : models) {
auto it = m.object_id_to_instance.find(object_id);
if (it == m.object_id_to_instance.end()) continue;
const uint32_t inst_idx = it->second;
if (inst_idx >= m.instances.size()) continue;
const InstanceCpu& inst = m.instances[inst_idx];
out.model_id = mid;
out.mesh_id = inst.mesh_id;
for (const auto& [model_id, model_data] : models) {
auto it = model_data.object_id_to_instance.find(object_id);
if (it == model_data.object_id_to_instance.end()) continue;
const uint32_t instance_index = it->second;
if (instance_index >= model_data.instances.size()) continue;
const InstanceInfo& instance = model_data.instances[instance_index];
out.model_id = model_id;
out.mesh_id = instance.mesh_id;
std::memcpy(out.placement_transformation,
inst.placement_transformation,
instance.placement_transformation,
sizeof(out.placement_transformation));
return true;
}
+1 -1
View File
@@ -66,7 +66,7 @@ void composeInstance(
// Result of a successful findInstance lookup. The placement_transformation
// is double[16] column-major (pre-CoordinateOperation / FederatedFalseOrigin
// / ModelTransformation) — the same convention as InstanceCpu so the
// / ModelTransformation) — the same convention as InstanceInfo so the
// measurement / picking tools can re-compose at need.
struct InstanceLookup {
uint32_t model_id = 0;
+7 -7
View File
@@ -39,8 +39,8 @@
static constexpr int INSTANCED_VERTEX_STRIDE_BYTES = 12;
// Streamer-side intermediate format: 7 floats per vertex (pos3 + normal3 +
// color-as-float). GeometryStreamer writes this into MeshChunk.vertices;
// ViewportWindow::uploadMeshChunk quantizes it down to STRIDE_BYTES on the
// color-as-float). GeometryStreamer writes this into StreamedMesh.vertices;
// ViewportWindow::uploadStreamedMesh quantizes it down to STRIDE_BYTES on the
// way to the VBO. Not the GPU layout — purely a transfer convention.
static constexpr int INSTANCED_VERTEX_STRIDE_FLOATS = 7;
@@ -109,7 +109,7 @@ static_assert(sizeof(InstanceGpu) == 80, "InstanceGpu must be 80 bytes");
// uploaded to the SSBO, and used to compute world_aabb_*. When ViewportWindow's
// stage matrices are all identity (default), transform is the float rendering
// copy of placement_transformation.
struct InstanceCpu {
struct InstanceInfo {
uint32_t mesh_id = 0; // index into meshes array
uint32_t object_id = 0;
uint32_t color_override_rgba8 = 0;
@@ -120,12 +120,12 @@ struct InstanceCpu {
float world_aabb_max[3]{};
};
// Chunks emitted by the streamer to the viewport (main thread).
// Transfer records emitted by the streamer to the viewport (main thread).
// Emitted the first time a representation id is seen. Carries the mesh
// geometry in local coords. `local_mesh_id` is the streamer-assigned id
// within this model.
struct MeshChunk {
struct StreamedMesh {
uint32_t model_id = 0;
uint32_t local_mesh_id = 0;
std::vector<float> vertices; // 7 floats * N_verts (pos3+norm3+color1_packed)
@@ -135,9 +135,9 @@ struct MeshChunk {
};
// Emitted for every placement (every triangulation element from the
// iterator). For the first instance of a mesh, the MeshChunk is emitted
// iterator). For the first instance of a mesh, the StreamedMesh is emitted
// just before this.
struct InstanceChunk {
struct StreamedInstance {
uint32_t model_id = 0;
uint32_t local_mesh_id = 0;
uint32_t object_id = 0;
+17 -16
View File
@@ -34,7 +34,7 @@
#include "InstancedGeometry.h"
#include "BufferPool.h"
#include "ChunkPlanner.h" // WGPU_CHUNK_VERTEX_BYTES_LIMIT (shared with bake)
#include "SidecarCache.h" // PackedElementInfo (deferred property metadata)
#include "SidecarCache.h" // ElementTableRecord (element metadata)
// Per-model wgpu state. Mirrors the GL backend's ModelGpuData but with
// wgpu handles. Stage 2 only allocates and uploads the four core buffers;
@@ -305,26 +305,27 @@ struct ModelGpuData {
bool streaming_from_web = false;
// Web analog of streaming_file_path: which registered JS byte-source
// (Module.__ifcvSources[id] = a picked File or a remote URL) this model's
// chunk + deferred reads pull from. Lets several federated models stream
// chunk + element metadata reads pull from. Lets several federated models stream
// from different files at once, mirroring the desktop per-model path.
int web_source_id = 0;
// v15 deferred property metadata (web, on-demand). The IFC element tree
// (elements + string_table — names/GUIDs/hierarchy, for UI/picking, never
// v15 element metadata (web, on-demand). The IFC element metadata
// (elements + string_table — names/GUIDs, for UI/picking, never
// rendering) lives in a separate file block fetched only when a consumer
// asks, so first paint doesn't wait on it. Empty until
// loadDeferredMetadataWeb fetches [deferred_meta_offset, +bytes) and parses
// it; deferred_meta_loaded latches so it fetches at most once.
std::vector<PackedElementInfo> elements;
// loadElementMetadataWeb fetches [element_metadata_comp_offset, +bytes) and parses
// it; element_metadata_loaded latches so it fetches at most once.
std::vector<ElementTableRecord> elements;
std::string string_table;
// 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;
// v16: the element metadata block is a single zstd frame at
// element_metadata_comp_offset of element_metadata_comp_size bytes,
// expanding to element_metadata_raw_size.
uint64_t element_metadata_comp_offset = 0;
uint64_t element_metadata_comp_size = 0;
uint64_t element_metadata_raw_size = 0;
bool element_metadata_loaded = false;
// applyCachedModel rebases instance object_ids by this base to keep them
// globally unique across models; deferred elements carry the sidecar's
// globally unique across models; element metadata records carry the sidecar's
// original (local) ids, so they're rebased by the same amount on load.
uint32_t object_id_base = 0;
@@ -378,10 +379,10 @@ struct ModelGpuData {
// CPU side, kept for cull / picking / federation recompose.
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> instances;
std::vector<InstanceInfo> instances;
// Per-mesh "any vertex has alpha < 255?" flag, indexed by mesh_id.
// Populated at uploadMeshChunk / applyStreamedChunk as vertex bytes
// Populated at uploadStreamedMesh / applyStreamedChunk as vertex bytes
// become CPU-resident. Used at cull time to classify each instance
// into the opaque or transparent draw partition: an instance with
// color_override_rgba8==0 (the "use baked vertex color" sentinel)
+1 -270
View File
@@ -87,36 +87,6 @@ void packAxisUniform(uint8_t* dst,
std::memcpy(dst + 92, &viewport_h, sizeof(float));
}
// Pack the section uniform's 256-byte slot. Layout matches WGSL
// SectionUniforms: mat4 + 4×(vec3 + scalar pad) + vec4 + vec2 + 8 B pad
// = 160 B used, padded to 256.
void packSectionUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp,
const Eigen::Vector3f& origin, float half_size,
const Eigen::Vector3f& tangent, float line_width_px,
const Eigen::Vector3f& bitangent,
const Eigen::Vector3f& normal,
float r, float g, float b, float a,
float viewport_w, float viewport_h) {
std::memset(dst, 0, 256);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
auto put_vec3_pad = [&](size_t off, const Eigen::Vector3f& v, float pad_val) {
float vx = v.x(), vy = v.y(), vz = v.z();
std::memcpy(dst + off + 0, &vx, sizeof(float));
std::memcpy(dst + off + 4, &vy, sizeof(float));
std::memcpy(dst + off + 8, &vz, sizeof(float));
std::memcpy(dst + off + 12, &pad_val, sizeof(float));
};
put_vec3_pad(64, origin, half_size);
put_vec3_pad(80, tangent, line_width_px);
put_vec3_pad(96, bitangent, 0.0f);
put_vec3_pad(112, normal, 0.0f);
float tint[4] = { r, g, b, a };
std::memcpy(dst + 128, tint, sizeof(tint));
std::memcpy(dst + 144, &viewport_w, sizeof(float));
std::memcpy(dst + 148, &viewport_h, sizeof(float));
}
} // namespace
// -----------------------------------------------------------------------------
@@ -185,46 +155,6 @@ fn vs_main(@location(0) start: vec3<f32>,
}
)WGSL";
static const std::string SECTION_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct SectionUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
half_size: f32,
tangent: vec3<f32>,
line_width_px: f32,
bitangent: vec3<f32>,
_pad1: f32,
normal: vec3<f32>,
_pad2: f32,
tint: vec4<f32>,
viewport_size: vec2<f32>,
_pad3: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: SectionUniforms;
fn plane_to_world(p: vec3<f32>) -> vec3<f32> {
return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
* u.half_size;
}
@vertex
fn vs_main(@location(0) start_local: vec3<f32>,
@location(1) end_local: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(plane_to_world(start_local), 1.0);
let p_end = u.mvp * vec4<f32>(plane_to_world(end_local), 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col * u.tint.xyz, u.tint.w);
out.side_t = side;
return out;
}
)WGSL";
static const std::string MARQUEE_WGSL = std::string(THICK_LINE_HELPERS_WGSL) + R"WGSL(
struct MarqueeUniforms {
rect_min: vec2<f32>,
@@ -454,7 +384,7 @@ bool OverlayRenderer::init(WGPUInstance instance, WGPUDevice device,
surface_format_ = surface_format;
sample_count_ = sample_count;
if (!buildAxisIndicator()) return false;
if (!buildSectionVisualizer()) return false;
// Section-plane gizmos moved to the shared SectionGizmoRenderer (ViewportCore).
if (!buildMarquee()) return false;
if (!buildOverlayLines()) return false;
if (!buildOverlayPoints()) return false;
@@ -476,13 +406,6 @@ void OverlayRenderer::destroy() {
if (axis_vertex_buffer_) { wgpuBufferRelease(axis_vertex_buffer_); axis_vertex_buffer_ = nullptr; }
// Section visualizer
if (section_bind_group_) { wgpuBindGroupRelease(section_bind_group_); section_bind_group_ = nullptr; }
if (section_pipeline_) { wgpuRenderPipelineRelease(section_pipeline_); section_pipeline_ = nullptr; }
if (section_shader_module_) { wgpuShaderModuleRelease(section_shader_module_); section_shader_module_ = nullptr; }
if (section_pipeline_layout_) { wgpuPipelineLayoutRelease(section_pipeline_layout_); section_pipeline_layout_ = nullptr; }
if (section_bgl_) { wgpuBindGroupLayoutRelease(section_bgl_); section_bgl_ = nullptr; }
if (section_uniform_buffer_) { wgpuBufferRelease(section_uniform_buffer_); section_uniform_buffer_ = nullptr; }
if (section_vertex_buffer_) { wgpuBufferRelease(section_vertex_buffer_); section_vertex_buffer_ = nullptr; }
// Marquee
if (marquee_bind_group_) { wgpuBindGroupRelease(marquee_bind_group_); marquee_bind_group_ = nullptr; }
@@ -825,198 +748,6 @@ void OverlayRenderer::encodeCornerAxis(WGPUCommandEncoder enc,
wgpuRenderPassEncoderRelease(pass);
}
// -----------------------------------------------------------------------------
// Section plane visualizer
// -----------------------------------------------------------------------------
bool OverlayRenderer::buildSectionVisualizer() {
struct Seg {
std::array<float, 3> s, e;
std::array<float, 3> c;
};
static constexpr std::array<float, 3> kSectionRed = {1.000f, 0.200f, 0.322f};
static const Seg segs[] = {
// ---- quad outline ----
{ {-1, -1, 0}, { 1, -1, 0}, kSectionRed },
{ { 1, -1, 0}, { 1, 1, 0}, kSectionRed },
{ { 1, 1, 0}, {-1, 1, 0}, kSectionRed },
{ {-1, 1, 0}, {-1, -1, 0}, kSectionRed },
// ---- arrow shaft along +n ----
{ { 0, 0, 0}, { 0, 0, 1}, kSectionRed },
// ---- arrow head: 4 diagonals from tip to ring at z = 0.78 ----
{ { 0, 0, 1}, {-0.18f, 0, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0.18f, 0, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0, -0.18f, 0.78f}, kSectionRed },
{ { 0, 0, 1}, { 0, 0.18f, 0.78f}, kSectionRed },
};
std::vector<float> verts;
verts.reserve(std::size(segs) * 6 * 11);
auto push_v = [&](const Seg& s, float t, float side) {
verts.insert(verts.end(), { s.s[0], s.s[1], s.s[2],
s.e[0], s.e[1], s.e[2],
s.c[0], s.c[1], s.c[2],
t, side });
};
for (const auto& s : segs) {
push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
bdesc.size = verts.size() * sizeof(float);
bdesc.label = svFromCStr("ifcviewer-wgpu.section_gizmo_vbo");
section_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
wgpuQueueWriteBuffer(queue_, section_vertex_buffer_, 0,
verts.data(), verts.size() * sizeof(float));
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
bdesc.size = uint64_t(kMaxSectionPlanes) * kSectionUniformSlotSize;
bdesc.label = svFromCStr("ifcviewer-wgpu.section_uniforms");
section_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
}
{
WGPUBindGroupLayoutEntry entry = {};
entry.binding = 0;
entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
entry.buffer.type = WGPUBufferBindingType_Uniform;
entry.buffer.hasDynamicOffset = 1;
entry.buffer.minBindingSize = 160;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &entry;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.section_bgl");
section_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
}
{
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &section_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline_layout");
section_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
}
{
WGPUBindGroupEntry entry = {};
entry.binding = 0;
entry.buffer = section_uniform_buffer_;
entry.offset = 0;
entry.size = kSectionUniformSlotSize;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = section_bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &entry;
bg_desc.label = svFromCStr("ifcviewer-wgpu.section_bind_group");
section_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
}
{
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(SECTION_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.section_wgsl");
section_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
}
WGPUVertexAttribute attribs[5] = {};
WGPUVertexBufferLayout vbl = thickLineVertexLayout(attribs);
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = section_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = WGPUCompareFunction_LessEqual;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = section_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.section_pipeline");
rp_desc.vertex.module = section_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 1;
rp_desc.vertex.buffers = &vbl;
rp_desc.fragment = &frag;
rp_desc.depthStencil = &depth;
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = uint32_t(sample_count_);
rp_desc.multisample.mask = 0xFFFFFFFFu;
section_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
return section_pipeline_ != nullptr;
}
void OverlayRenderer::encodeSectionGizmos(WGPURenderPassEncoder pass,
const OverlayFrame& f,
const std::vector<SectionPlane>& planes) {
if (!section_pipeline_ || planes.empty()) return;
wgpuRenderPassEncoderSetPipeline(pass, section_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, section_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
const int n = std::min<int>(int(planes.size()), kMaxSectionPlanes);
for (int i = 0; i < n; ++i) {
const SectionPlane& p = planes[i];
// Stable in-plane basis: pick the world axis least parallel to n
// so the cross-product stays well-conditioned at any orientation.
Eigen::Vector3f nn = p.n.normalized();
const float ax = std::abs(nn.x()), ay = std::abs(nn.y()), az = std::abs(nn.z());
Eigen::Vector3f seed = (ax < ay && ax < az) ? Eigen::Vector3f(1, 0, 0)
: (ay < az) ? Eigen::Vector3f(0, 1, 0)
: Eigen::Vector3f(0, 0, 1);
Eigen::Vector3f tangent = nn.cross(seed);
if (tangent.squaredNorm() < 1e-12f) tangent = Eigen::Vector3f(1, 0, 0);
tangent.normalize();
Eigen::Vector3f bitangent = nn.cross(tangent).normalized();
// Fixed 1 m half-size matches GL's renderSectionPlanes constant.
const float half_size = 1.0f;
const float dpr = float(std::max(1, f.device_pixel_ratio));
const float line_w = 5.0f * dpr;
const float vw = float(f.viewport_w_px);
const float vh = float(f.viewport_h_px);
uint8_t slot[256];
// Neutral tint — actual colours come from the per-vertex VBO
// (red quad outline + red arrow). Tint stays available for a
// future "selected" multiplier.
packSectionUniform(slot, f.view_proj, p.origin, half_size,
tangent, line_w, bitangent, nn,
1.0f, 1.0f, 1.0f, 1.0f,
vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlotSize;
wgpuQueueWriteBuffer(queue_, section_uniform_buffer_,
slot_offset, slot, sizeof(slot));
wgpuRenderPassEncoderSetBindGroup(pass, 0, section_bind_group_,
1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, 54, 1, 0, 0);
}
}
// -----------------------------------------------------------------------------
// Marquee
// -----------------------------------------------------------------------------
+2 -17
View File
@@ -64,12 +64,8 @@ public:
const OverlayFrame& f,
bool visible);
// Per-plane wireframe gizmo (2 × 2 m quad outline + arrow shaft +
// arrow head). Drawn at each plane's origin in its local basis;
// colour comes from Bonsai's decorator_color_error.
void encodeSectionGizmos(WGPURenderPassEncoder pass,
const OverlayFrame& f,
const std::vector<SectionPlane>& planes);
// Section-plane gizmos moved to the shared SectionGizmoRenderer (drawn by
// ViewportCore for both desktop + web).
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 vertices per triangle, in world space (post-composed-
@@ -175,7 +171,6 @@ public:
private:
bool buildAxisIndicator();
bool buildSectionVisualizer();
bool buildMarquee();
bool buildOverlayLines();
bool buildOverlayPoints();
@@ -218,16 +213,6 @@ private:
WGPUBindGroup axis_bind_group_ = nullptr;
static constexpr uint32_t kAxisUniformSlotSize = 256;
// ---- Section plane gizmos (1 pipeline, dynamic offset per plane) ----
WGPUShaderModule section_shader_module_ = nullptr;
WGPUBindGroupLayout section_bgl_ = nullptr;
WGPUPipelineLayout section_pipeline_layout_ = nullptr;
WGPURenderPipeline section_pipeline_ = nullptr;
WGPUBuffer section_vertex_buffer_ = nullptr;
WGPUBuffer section_uniform_buffer_ = nullptr;
WGPUBindGroup section_bind_group_ = nullptr;
static constexpr uint32_t kSectionUniformSlotSize = 256;
// ---- Marquee (fill + outline pipelines, one uniform buffer) ----
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
+7 -7
View File
@@ -279,7 +279,7 @@ void SceneLoader::applySidecarData(uint32_t mid, StreamingSidecar metadata) {
model.has_georef = true;
}
std::vector<PackedElementInfo> elements = std::move(d.elements);
std::vector<ElementTableRecord> elements = std::move(d.elements);
std::string stbl = std::move(d.string_table);
viewport_->applyCachedModel(mid, std::move(metadata));
@@ -331,24 +331,24 @@ void SceneLoader::onStreamerProgressChanged(int percent) {
emit progressChanged(percent);
}
void SceneLoader::onStreamerMeshReady(MeshChunk chunk) {
viewport_->uploadMeshChunk(chunk);
void SceneLoader::onStreamerMeshReady(StreamedMesh mesh) {
viewport_->uploadStreamedMesh(mesh);
if (loading_model_id_ != 0) {
auto it = models_.find(loading_model_id_);
if (it != models_.end() && it->second.sidecar_builder) {
it->second.sidecar_builder->onMeshReady(chunk);
it->second.sidecar_builder->onMeshReady(mesh);
}
}
}
void SceneLoader::onStreamerInstanceReady(InstanceChunk chunk) {
void SceneLoader::onStreamerInstanceReady(StreamedInstance instance_record) {
if (loading_model_id_ != 0) {
auto it = models_.find(loading_model_id_);
if (it != models_.end() && it->second.sidecar_builder) {
it->second.sidecar_builder->onInstanceReady(chunk);
it->second.sidecar_builder->onInstanceReady(instance_record);
}
}
viewport_->uploadInstanceChunk(chunk);
viewport_->uploadStreamedInstance(instance_record);
}
void SceneLoader::onElementPollTick() {
+3 -3
View File
@@ -102,7 +102,7 @@ signals:
// packed element set. Consumer is responsible for decoding + tree/
// property-map population. Moved arguments — avoid unnecessary copies.
void sidecarElementsReady(uint32_t mid,
std::vector<PackedElementInfo> elements,
std::vector<ElementTableRecord> elements,
std::string string_table);
void loadedFromSidecar(uint32_t mid, qint64 elapsed_ms);
@@ -128,8 +128,8 @@ signals:
private slots:
void onStreamerProgressChanged(int percent);
void onStreamerMeshReady(MeshChunk chunk);
void onStreamerInstanceReady(InstanceChunk chunk);
void onStreamerMeshReady(StreamedMesh mesh);
void onStreamerInstanceReady(StreamedInstance instance_record);
void onStreamerFinished();
void onStreamerCancelled();
void onStreamerError(const QString& msg);
+369
View File
@@ -0,0 +1,369 @@
/********************************************************************************
* *
* 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 "SectionGizmoRenderer.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <string>
namespace {
constexpr int kMaxPlanes = 6; // matches kMaxSectionPlanes
constexpr uint32_t kSectionUniformSlot = 256; // dynamic-offset slot stride
WGPUStringView svFromCStr(const char* s) {
WGPUStringView v;
v.data = s;
v.length = s ? std::strlen(s) : 0;
return v;
}
// Thick-line rendering helper (shared shape with OverlayRenderer's other
// overlays) + the section-gizmo vertex/fragment shaders. Each line segment is
// expanded to a screen-space-thick, anti-aliased quad.
static const std::string SECTION_GIZMO_WGSL = std::string(R"WGSL(
struct VsOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) side_t: f32,
};
fn thick_line_clip(p_start: vec4<f32>, p_end: vec4<f32>,
t: f32, side: f32,
viewport_size: vec2<f32>,
line_width_px: f32) -> vec4<f32> {
let p_here = mix(p_start, p_end, t);
let s_start = (p_start.xy / p_start.w) * viewport_size * 0.5;
let s_end = (p_end.xy / p_end.w ) * viewport_size * 0.5;
let dir = normalize(s_end - s_start);
let perp = vec2<f32>(-dir.y, dir.x);
let off_pixels = perp * (line_width_px * 0.5) * side;
let off_ndc = off_pixels * 2.0 / viewport_size;
return vec4<f32>(p_here.xy + off_ndc * p_here.w, p_here.zw);
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let d = abs(in.side_t);
let aa = fwidth(in.side_t);
let coverage = 1.0 - smoothstep(1.0 - aa, 1.0, d);
return vec4<f32>(in.color.xyz, in.color.w * coverage);
}
struct SectionUniforms {
mvp: mat4x4<f32>,
origin: vec3<f32>,
half_size: f32,
tangent: vec3<f32>,
line_width_px: f32,
bitangent: vec3<f32>,
_pad1: f32,
normal: vec3<f32>,
_pad2: f32,
tint: vec4<f32>,
viewport_size: vec2<f32>,
_pad3: vec2<f32>,
};
@group(0) @binding(0) var<uniform> u: SectionUniforms;
fn plane_to_world(p: vec3<f32>) -> vec3<f32> {
return u.origin + (u.tangent * p.x + u.bitangent * p.y + u.normal * p.z)
* u.half_size;
}
@vertex
fn vs_main(@location(0) start_local: vec3<f32>,
@location(1) end_local: vec3<f32>,
@location(2) col: vec3<f32>,
@location(3) t: f32,
@location(4) side: f32) -> VsOut {
let p_start = u.mvp * vec4<f32>(plane_to_world(start_local), 1.0);
let p_end = u.mvp * vec4<f32>(plane_to_world(end_local), 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = vec4<f32>(col * u.tint.xyz, u.tint.w);
out.side_t = side;
return out;
}
)WGSL");
// Pack the 256-byte dynamic-offset slot. Layout matches SectionUniforms above:
// mat4 + 4×(vec3 + scalar) + vec4 + vec2 + pad = 160 B used, padded to 256.
void packSectionUniform(uint8_t* dst,
const Eigen::Matrix4f& mvp,
const Eigen::Vector3f& origin, float half_size,
const Eigen::Vector3f& tangent, float line_width_px,
const Eigen::Vector3f& bitangent,
const Eigen::Vector3f& normal,
float r, float g, float b, float a,
float viewport_w, float viewport_h) {
std::memset(dst, 0, 256);
std::memcpy(dst, mvp.data(), 16 * sizeof(float));
auto put_vec3_pad = [&](size_t off, const Eigen::Vector3f& v, float pad_val) {
float vx = v.x(), vy = v.y(), vz = v.z();
std::memcpy(dst + off + 0, &vx, sizeof(float));
std::memcpy(dst + off + 4, &vy, sizeof(float));
std::memcpy(dst + off + 8, &vz, sizeof(float));
std::memcpy(dst + off + 12, &pad_val, sizeof(float));
};
put_vec3_pad(64, origin, half_size);
put_vec3_pad(80, tangent, line_width_px);
put_vec3_pad(96, bitangent, 0.0f);
put_vec3_pad(112, normal, 0.0f);
float tint[4] = { r, g, b, a };
std::memcpy(dst + 128, tint, sizeof(tint));
std::memcpy(dst + 144, &viewport_w, sizeof(float));
std::memcpy(dst + 148, &viewport_h, sizeof(float));
}
// Stable in-plane basis: pick the world axis least parallel to n so the
// cross-product stays well-conditioned at any orientation.
void planeBasis(const Eigen::Vector3f& n_in,
Eigen::Vector3f& nn, Eigen::Vector3f& tangent, Eigen::Vector3f& bitangent) {
nn = n_in.normalized();
const float ax = std::abs(nn.x()), ay = std::abs(nn.y()), az = std::abs(nn.z());
Eigen::Vector3f seed = (ax < ay && ax < az) ? Eigen::Vector3f(1, 0, 0)
: (ay < az) ? Eigen::Vector3f(0, 1, 0)
: Eigen::Vector3f(0, 0, 1);
tangent = nn.cross(seed);
if (tangent.squaredNorm() < 1e-12f) tangent = Eigen::Vector3f(1, 0, 0);
tangent.normalize();
bitangent = nn.cross(tangent).normalized();
}
bool projectWorldToLogicalScreen(const Eigen::Matrix4f& vp, const Eigen::Vector3f& world,
int win_w, int win_h, Eigen::Vector2f& out) {
const Eigen::Vector4f clip = vp * Eigen::Vector4f(world.x(), world.y(), world.z(), 1.0f);
if (clip.w() <= 0.0f) return false;
const float invw = 1.0f / clip.w();
out = Eigen::Vector2f((clip.x() * invw * 0.5f + 0.5f) * float(win_w),
(1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(win_h));
return true;
}
} // namespace
SectionGizmoRenderer::~SectionGizmoRenderer() { destroy(); }
bool SectionGizmoRenderer::init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count) {
device_ = device;
queue_ = queue;
if (!device_ || !queue_) return false;
// ---- Gizmo geometry: 9 line segments (quad outline + normal arrow) ----
struct Seg { std::array<float, 3> s, e, c; };
static constexpr std::array<float, 3> kRed = { 1.000f, 0.200f, 0.322f };
static const Seg segs[] = {
{ {-1, -1, 0}, { 1, -1, 0}, kRed }, // quad outline
{ { 1, -1, 0}, { 1, 1, 0}, kRed },
{ { 1, 1, 0}, {-1, 1, 0}, kRed },
{ {-1, 1, 0}, {-1, -1, 0}, kRed },
{ { 0, 0, 0}, { 0, 0, 1}, kRed }, // arrow shaft along +n
{ { 0, 0, 1}, {-0.18f, 0, 0.78f}, kRed }, // arrow head
{ { 0, 0, 1}, { 0.18f, 0, 0.78f}, kRed },
{ { 0, 0, 1}, { 0, -0.18f, 0.78f}, kRed },
{ { 0, 0, 1}, { 0, 0.18f, 0.78f}, kRed },
};
std::vector<float> verts;
verts.reserve(std::size(segs) * 6 * 11);
auto push_v = [&](const Seg& s, float t, float side) {
verts.insert(verts.end(), { s.s[0], s.s[1], s.s[2], s.e[0], s.e[1], s.e[2],
s.c[0], s.c[1], s.c[2], t, side });
};
for (const auto& s : segs) {
push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
}
vertex_count_ = int(std::size(segs)) * 6;
WGPUBufferDescriptor vb = {};
vb.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
vb.size = verts.size() * sizeof(float);
vb.label = svFromCStr("ifcviewer-wgpu.section_gizmo_vbo");
vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &vb);
wgpuQueueWriteBuffer(queue_, vertex_buffer_, 0, verts.data(), verts.size() * sizeof(float));
WGPUBufferDescriptor ub = {};
ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
ub.size = uint64_t(kMaxPlanes) * kSectionUniformSlot;
ub.label = svFromCStr("ifcviewer-wgpu.section_gizmo_uniforms");
uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub);
WGPUBindGroupLayoutEntry ble = {};
ble.binding = 0;
ble.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
ble.buffer.type = WGPUBufferBindingType_Uniform;
ble.buffer.hasDynamicOffset = 1;
ble.buffer.minBindingSize = 160;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &ble;
bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &bgl_;
layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
WGPUBindGroupEntry bge = {};
bge.binding = 0;
bge.buffer = uniform_buffer_;
bge.offset = 0;
bge.size = kSectionUniformSlot;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &bge;
bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
WGPUShaderSourceWGSL wgsl = {};
wgsl.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl.code = svFromCStr(SECTION_GIZMO_WGSL.c_str());
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl.chain;
shader_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
// Vertex layout: start_local vec3, end_local vec3, col vec3, t f32, side f32.
WGPUVertexAttribute attribs[5] = {};
attribs[0].format = WGPUVertexFormat_Float32x3; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x3; attribs[1].offset = 12; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32x3; attribs[2].offset = 24; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 36; attribs[3].shaderLocation = 3;
attribs[4].format = WGPUVertexFormat_Float32; attribs[4].offset = 40; attribs[4].shaderLocation = 4;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 44;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 5;
vbl.attributes = attribs;
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState ct = {};
ct.format = color_format;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = shader_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
// Depth-test against geometry (LessEqual) but don't write depth.
WGPUDepthStencilState depth = {};
depth.format = WGPUTextureFormat_Depth32Float;
depth.depthWriteEnabled = WGPUOptionalBool_False;
depth.depthCompare = WGPUCompareFunction_LessEqual;
depth.stencilFront.compare = WGPUCompareFunction_Always;
depth.stencilBack.compare = WGPUCompareFunction_Always;
WGPURenderPipelineDescriptor rp = {};
rp.layout = layout_;
rp.label = svFromCStr("ifcviewer-wgpu.section_gizmo_pipeline");
rp.vertex.module = shader_;
rp.vertex.entryPoint = svFromCStr("vs_main");
rp.vertex.bufferCount = 1;
rp.vertex.buffers = &vbl;
rp.fragment = &frag;
rp.depthStencil = &depth;
rp.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp.primitive.cullMode = WGPUCullMode_None;
rp.multisample.count = uint32_t(sample_count);
rp.multisample.mask = 0xFFFFFFFFu;
pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp);
return pipeline_ != nullptr;
}
void SectionGizmoRenderer::encode(WGPURenderPassEncoder pass, const Eigen::Matrix4f& view_proj,
const std::vector<SectionPlane>& planes,
int viewport_w_px, int viewport_h_px, int device_pixel_ratio) {
if (!pipeline_ || planes.empty()) return;
wgpuRenderPassEncoderSetPipeline(pass, pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, vertex_buffer_, 0, WGPU_WHOLE_SIZE);
const float dpr = float(std::max(1, device_pixel_ratio));
const float line_w = 5.0f * dpr;
const float vw = float(viewport_w_px);
const float vh = float(viewport_h_px);
const int n = std::min<int>(int(planes.size()), kMaxPlanes);
for (int i = 0; i < n; ++i) {
const SectionPlane& p = planes[i];
Eigen::Vector3f nn, tangent, bitangent;
planeBasis(p.n, nn, tangent, bitangent);
// Fixed 1 m gizmo (matches the desktop OverlayRenderer / GL constant).
// NOT visual_radius: the normal is flipped toward the camera, so a large
// arrow would shoot past the eye (clip.w<0) and vanish.
const float half = 1.0f;
uint8_t slot[256];
packSectionUniform(slot, view_proj, p.origin, half, tangent, line_w,
bitangent, nn, 1.0f, 1.0f, 1.0f, 1.0f, vw, vh);
const uint32_t slot_offset = uint32_t(i) * kSectionUniformSlot;
wgpuQueueWriteBuffer(queue_, uniform_buffer_, slot_offset, slot, sizeof(slot));
wgpuRenderPassEncoderSetBindGroup(pass, 0, bind_group_, 1, &slot_offset);
wgpuRenderPassEncoderDraw(pass, uint32_t(vertex_count_), 1, 0, 0);
}
}
int SectionGizmoRenderer::hitTest(int x, int y, const std::vector<SectionPlane>& planes,
const Eigen::Matrix4f& view, const Eigen::Matrix4f& proj,
int viewport_w_px, int viewport_h_px, float tolerance_px) {
const Eigen::Matrix4f vp = proj * view;
const Eigen::Vector2f q{ float(x), float(y) };
int best_i = -1;
float best_d = tolerance_px;
const int n = std::min<int>(int(planes.size()), kMaxPlanes);
for (int i = 0; i < n; ++i) {
const SectionPlane& p = planes[i];
// The arrow runs origin → origin + n * 1 m (visual radius scales the
// gizmo, but hit-test the unit arrow to mirror the desktop).
Eigen::Vector2f s_origin, s_tip;
if (!projectWorldToLogicalScreen(vp, p.origin, viewport_w_px, viewport_h_px, s_origin)) continue;
if (!projectWorldToLogicalScreen(vp, p.origin + p.n * 1.0f, viewport_w_px, viewport_h_px, s_tip)) continue;
const Eigen::Vector2f ab = s_tip - s_origin;
const float ab_len2 = ab.squaredNorm();
if (ab_len2 < 1e-3f) continue;
float t = (q - s_origin).dot(ab) / ab_len2;
t = std::clamp(t, 0.0f, 1.0f);
const Eigen::Vector2f proj_pt = s_origin + ab * t;
const float d = (q - proj_pt).norm();
if (d < best_d) { best_d = d; best_i = i; }
}
return best_i;
}
void SectionGizmoRenderer::destroy() {
if (pipeline_) { wgpuRenderPipelineRelease(pipeline_); pipeline_ = nullptr; }
if (layout_) { wgpuPipelineLayoutRelease(layout_); layout_ = nullptr; }
if (bgl_) { wgpuBindGroupLayoutRelease(bgl_); bgl_ = nullptr; }
if (bind_group_) { wgpuBindGroupRelease(bind_group_); bind_group_ = nullptr; }
if (vertex_buffer_) { wgpuBufferRelease(vertex_buffer_); vertex_buffer_ = nullptr; }
if (uniform_buffer_) { wgpuBufferRelease(uniform_buffer_); uniform_buffer_ = nullptr; }
if (shader_) { wgpuShaderModuleRelease(shader_); shader_ = nullptr; }
}
+79
View File
@@ -0,0 +1,79 @@
/********************************************************************************
* *
* 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 SECTIONGIZMORENDERER_H
#define SECTIONGIZMORENDERER_H
#include <webgpu/webgpu.h>
#include <Eigen/Dense>
#include <vector>
#include "SectionPlane.h"
// Qt-free renderer for the section-plane gizmo — a red quad outline plus a
// normal arrow, drawn with anti-aliased thick lines. Lifted out of the
// Qt-coupled OverlayRenderer so BOTH the desktop and web builds draw one
// identical gizmo from a single place (ViewportCore::render calls it on both).
//
// The gizmo is plane-local geometry scaled by each plane's visual radius and
// oriented by a stable tangent/bitangent basis derived from the plane normal.
class SectionGizmoRenderer {
public:
SectionGizmoRenderer() = default;
~SectionGizmoRenderer();
SectionGizmoRenderer(const SectionGizmoRenderer&) = delete;
SectionGizmoRenderer& operator=(const SectionGizmoRenderer&) = delete;
// Create the pipeline, gizmo VBO, and per-plane uniform buffer. `color_format`
// is the render target's format; `sample_count` the MSAA count. Returns false
// (and leaves the renderer inert) if pipeline creation fails.
bool init(WGPUDevice device, WGPUQueue queue,
WGPUTextureFormat color_format, int sample_count);
void destroy();
bool ready() const { return pipeline_ != nullptr; }
// Draw one gizmo per plane into an already-open render pass (the main pass).
void encode(WGPURenderPassEncoder pass, const Eigen::Matrix4f& view_proj,
const std::vector<SectionPlane>& planes,
int viewport_w_px, int viewport_h_px, int device_pixel_ratio);
// Screen-space hit test: index of the plane whose gizmo (arrow segment,
// origin→origin+normal) the (x, y) logical-pixel point lies within
// `tolerance_px` of, or -1. Pure math — no GPU. Nearest wins.
static int hitTest(int x, int y, const std::vector<SectionPlane>& planes,
const Eigen::Matrix4f& view, const Eigen::Matrix4f& proj,
int viewport_w_px, int viewport_h_px,
float tolerance_px = 12.0f);
private:
WGPUDevice device_ = nullptr;
WGPUQueue queue_ = nullptr;
WGPURenderPipeline pipeline_ = nullptr;
WGPUPipelineLayout layout_ = nullptr;
WGPUBindGroupLayout bgl_ = nullptr;
WGPUBindGroup bind_group_ = nullptr;
WGPUBuffer vertex_buffer_ = nullptr;
WGPUBuffer uniform_buffer_ = nullptr;
WGPUShaderModule shader_ = nullptr;
int vertex_count_ = 0;
};
#endif // SECTIONGIZMORENDERER_H
+27 -28
View File
@@ -37,14 +37,14 @@ SidecarBuilder::SidecarBuilder(QObject* parent)
{
}
void SidecarBuilder::onMeshReady(const MeshChunk& chunk) {
if (chunk.vertices.empty() || chunk.indices.empty()) return;
void SidecarBuilder::onMeshReady(const StreamedMesh& mesh) {
if (mesh.vertices.empty() || mesh.indices.empty()) return;
// Streamer format: 7 floats/vertex (pos3 + normal3 + color-as-float).
const size_t n_verts = chunk.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS;
const size_t n_verts = mesh.vertices.size() / INSTANCED_VERTEX_STRIDE_FLOATS;
// Recompute a tight local AABB from the actual vertex positions, same
// way ViewportWindow::uploadMeshChunk does so the .ifcview byte layout
// way ViewportWindow::uploadStreamedMesh does so the .ifcview byte layout
// matches the live-render path.
float bmin[3] = { std::numeric_limits<float>::infinity(),
std::numeric_limits<float>::infinity(),
@@ -53,10 +53,10 @@ void SidecarBuilder::onMeshReady(const MeshChunk& chunk) {
-std::numeric_limits<float>::infinity(),
-std::numeric_limits<float>::infinity() };
for (size_t i = 0; i < n_verts; ++i) {
const float* v = chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS;
const float* vertex = mesh.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS;
for (int a = 0; a < 3; ++a) {
if (v[a] < bmin[a]) bmin[a] = v[a];
if (v[a] > bmax[a]) bmax[a] = v[a];
if (vertex[a] < bmin[a]) bmin[a] = vertex[a];
if (vertex[a] > bmax[a]) bmax[a] = vertex[a];
}
}
float extent_recip[3];
@@ -68,7 +68,7 @@ void SidecarBuilder::onMeshReady(const MeshChunk& chunk) {
const size_t vb_offset = sidecar_data_.vertices.size();
sidecar_data_.vertices.resize(vb_offset + n_verts * INSTANCED_VERTEX_STRIDE_BYTES);
for (size_t i = 0; i < n_verts; ++i) {
quantizeVertex(chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS,
quantizeVertex(mesh.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS,
bmin, extent_recip,
sidecar_data_.vertices.data() + vb_offset
+ i * INSTANCED_VERTEX_STRIDE_BYTES);
@@ -76,13 +76,13 @@ void SidecarBuilder::onMeshReady(const MeshChunk& chunk) {
const size_t ib_offset = sidecar_data_.indices.size();
sidecar_data_.indices.insert(sidecar_data_.indices.end(),
chunk.indices.begin(), chunk.indices.end());
mesh.indices.begin(), mesh.indices.end());
MeshInfo info;
info.vbo_byte_offset = static_cast<uint32_t>(vb_offset);
info.vertex_count = static_cast<uint32_t>(n_verts);
info.ebo_byte_offset = static_cast<uint32_t>(ib_offset * sizeof(uint32_t));
info.index_count = static_cast<uint32_t>(chunk.indices.size());
info.index_count = static_cast<uint32_t>(mesh.indices.size());
for (int a = 0; a < 3; ++a) {
info.local_aabb_min[a] = bmin[a];
info.local_aabb_max[a] = bmax[a];
@@ -92,32 +92,32 @@ void SidecarBuilder::onMeshReady(const MeshChunk& chunk) {
info.lod1_ebo_byte_offset = 0;
info.lod1_index_count = 0;
if (sidecar_data_.meshes.size() <= chunk.local_mesh_id) {
sidecar_data_.meshes.resize(chunk.local_mesh_id + 1);
if (sidecar_data_.meshes.size() <= mesh.local_mesh_id) {
sidecar_data_.meshes.resize(mesh.local_mesh_id + 1);
}
sidecar_data_.meshes[chunk.local_mesh_id] = info;
sidecar_data_.meshes[mesh.local_mesh_id] = info;
}
void SidecarBuilder::onInstanceReady(const InstanceChunk& chunk) {
InstanceCpu inst;
inst.mesh_id = chunk.local_mesh_id;
inst.object_id = chunk.object_id;
inst.color_override_rgba8 = chunk.color_override_rgba8;
inst.model_id = chunk.model_id;
void SidecarBuilder::onInstanceReady(const StreamedInstance& instance_record) {
InstanceInfo instance;
instance.mesh_id = instance_record.local_mesh_id;
instance.object_id = instance_record.object_id;
instance.color_override_rgba8 = instance_record.color_override_rgba8;
instance.model_id = instance_record.model_id;
// The streamer's chunk.transform is the double-precision
// The streamer's instance transform is the double-precision
// placement_transformation. The cached float transform/world_aabb is only
// an identity-stage baseline; applyCachedModel recomposes from placement
// against the consumer's stage matrices at load time.
std::memcpy(inst.placement_transformation, chunk.transform,
sizeof(inst.placement_transformation));
std::memcpy(instance.placement_transformation, instance_record.transform,
sizeof(instance.placement_transformation));
for (int i = 0; i < 16; ++i) {
inst.transform[i] = static_cast<float>(chunk.transform[i]);
instance.transform[i] = static_cast<float>(instance_record.transform[i]);
}
std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min));
std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max));
std::memcpy(instance.world_aabb_min, instance_record.world_aabb_min, sizeof(instance.world_aabb_min));
std::memcpy(instance.world_aabb_max, instance_record.world_aabb_max, sizeof(instance.world_aabb_max));
sidecar_data_.instances.push_back(inst);
sidecar_data_.instances.push_back(instance);
}
SidecarData SidecarBuilder::finalize(const ModelGeoref& georef,
@@ -140,11 +140,10 @@ SidecarData SidecarBuilder::finalize(const ModelGeoref& georef,
sidecar_data_.map_unit_to_meters = georef.units.map_unit_to_meters;
for (const auto& info : elements) {
PackedElementInfo packed;
ElementTableRecord packed;
packed.object_id = info.object_id;
packed.model_id = info.model_id;
packed.ifc_id = info.ifc_id;
packed.parent_id = info.parent_id;
packed.guid_offset = static_cast<uint32_t>(sidecar_data_.string_table.size());
packed.guid_length = static_cast<uint32_t>(info.guid.size());
+2 -3
View File
@@ -58,8 +58,8 @@ public:
// Accumulator interface. Safe to call repeatedly from the same thread the
// streamer signals are delivered to.
void onMeshReady(const MeshChunk& chunk);
void onInstanceReady(const InstanceChunk& chunk);
void onMeshReady(const StreamedMesh& mesh);
void onInstanceReady(const StreamedInstance& instance_record);
// Finishes assembly using the georef + element batch the host collected
// during streaming. Returns the assembled SidecarData by move; the
@@ -76,4 +76,3 @@ private:
};
#endif // SIDECARBUILDER_H
+142 -107
View File
@@ -30,7 +30,7 @@
// MeshInfo[num_meshes]
//
// uint32_t num_instances
// InstanceCpu[num_instances] (already sorted by mesh_id; v13 layout)
// InstanceInfo[num_instances] (already sorted by mesh_id; v13 layout)
//
// uint32_t has_coordinate_operation (v11+)
// double[16] coordinate_operation_meters (v11+; column-major)
@@ -38,7 +38,7 @@
// double map_unit_to_meters (v11+)
//
// uint32_t num_elements
// PackedElementInfo[num_elements]
// ElementTableRecord[num_elements]
// uint32_t string_table_bytes
// char[string_table_bytes]
@@ -59,50 +59,61 @@ 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 appendVec(std::vector<std::uint8_t>& buffer, const std::vector<T>& values) {
std::uint32_t count = static_cast<std::uint32_t>(values.size());
const auto* count_bytes = reinterpret_cast<const std::uint8_t*>(&count);
buffer.insert(buffer.end(), count_bytes, count_bytes + 4);
if (count > 0) {
const auto* value_bytes = reinterpret_cast<const std::uint8_t*>(values.data());
buffer.insert(buffer.end(), value_bytes, value_bytes + std::size_t(sizeof(T)) * count);
}
}
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);
static void appendBytes(std::vector<std::uint8_t>& buffer, const void* data, std::size_t byte_count) {
const auto* bytes = static_cast<const std::uint8_t*>(data);
buffer.insert(buffer.end(), bytes, bytes + byte_count);
}
// 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,
static void extractChunkGeometry(const SidecarData& sidecar_data, const SidecarChunk& sidecar_chunk,
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);
const std::uint32_t end = sidecar_chunk.first_mesh + sidecar_chunk.mesh_count;
for (std::uint32_t mesh_index = sidecar_chunk.first_mesh;
mesh_index < end && mesh_index < sidecar_data.meshes.size();
++mesh_index) {
const MeshInfo& mesh_info = sidecar_data.meshes[mesh_index];
const std::size_t vertex_offset = mesh_info.vbo_byte_offset;
const std::size_t vertex_byte_count =
std::size_t(mesh_info.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (vertex_offset + vertex_byte_count <= sidecar_data.vertices.size())
vbytes.insert(vbytes.end(), sidecar_data.vertices.begin() + vertex_offset,
sidecar_data.vertices.begin() + vertex_offset + vertex_byte_count);
}
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));
if (first_u32 + count > sidecar_data.indices.size()) return;
const auto* index_bytes =
reinterpret_cast<const std::uint8_t*>(sidecar_data.indices.data() + first_u32);
ibytes.insert(ibytes.end(), index_bytes, index_bytes + 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 mesh_index = sidecar_chunk.first_mesh;
mesh_index < end && mesh_index < sidecar_data.meshes.size();
++mesh_index) {
const MeshInfo& mesh_info = sidecar_data.meshes[mesh_index];
if (mesh_info.index_count) {
appendIdx(mesh_info.ebo_byte_offset / sizeof(std::uint32_t), mesh_info.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);
for (std::uint32_t mesh_index = sidecar_chunk.first_mesh;
mesh_index < end && mesh_index < sidecar_data.meshes.size();
++mesh_index) {
const MeshInfo& mesh_info = sidecar_data.meshes[mesh_index];
if (mesh_info.lod1_index_count) {
appendIdx(mesh_info.lod1_ebo_byte_offset / sizeof(std::uint32_t), mesh_info.lod1_index_count);
}
}
}
#endif // !__EMSCRIPTEN__ (bake-only serialisation helpers)
@@ -118,14 +129,14 @@ struct SidecarHeader {
// foo.ifcdb -> foo.ifcview
// foo (no ext) -> foo.ifcview
static std::string sidecarPath(const std::string& ifc_path) {
std::string p = ifc_path;
while (!p.empty() && (p.back() == '/' || p.back() == '\\')) p.pop_back();
auto slash = p.find_last_of("/\\");
auto dot = p.find_last_of('.');
std::string path = ifc_path;
while (!path.empty() && (path.back() == '/' || path.back() == '\\')) path.pop_back();
auto slash = path.find_last_of("/\\");
auto dot = path.find_last_of('.');
std::string stem = (dot != std::string::npos &&
(slash == std::string::npos || dot > slash))
? p.substr(0, dot)
: p;
? path.substr(0, dot)
: path;
return stem + ".ifcview";
}
@@ -152,18 +163,18 @@ bool writeSidecar(const std::string& ifc_path, const SidecarData& data) {
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 write_bytes = [&](const void* data, std::size_t byte_count) {
return fwrite(data, 1, byte_count, f) == byte_count;
};
auto wrU64 = [&](std::uint64_t v) { return wr(&v, sizeof(v)); };
auto wrU64 = [&](std::uint64_t v) { return write_bytes(&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()));
return wrU64(z.size()) && wrU64(raw.size()) && (z.empty() || write_bytes(z.data(), z.size()));
};
SidecarHeader hdr = { SIDECAR_MAGIC, SIDECAR_VERSION, SIDECAR_ENDIAN };
if (!wr(&hdr, sizeof(hdr))) { fclose(f); return false; }
if (!write_bytes(&hdr, sizeof(hdr))) { 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
@@ -174,19 +185,19 @@ bool writeSidecar(const std::string& ifc_path, const SidecarData& data) {
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);
for (auto& sidecar_chunk : chunks) {
extractChunkGeometry(data, sidecar_chunk, 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; }
sidecar_chunk.v_comp_off = std::uint64_t(ftell(f) - geom_start);
sidecar_chunk.v_comp_size = vz.size();
sidecar_chunk.v_raw_size = vraw.size();
if (!vz.empty() && !write_bytes(vz.data(), vz.size())) { fclose(f); return false; }
sidecar_chunk.i_comp_off = std::uint64_t(ftell(f) - geom_start);
sidecar_chunk.i_comp_size = iz.size();
sidecar_chunk.i_raw_size = iraw.size();
if (!iz.empty() && !write_bytes(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; }
@@ -194,24 +205,24 @@ bool writeSidecar(const std::string& ifc_path, const SidecarData& data) {
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; }
// --- Geometry metadata block (zstd): meshes, instances, georef, chunk TOC
std::vector<std::uint8_t> geometry_metadata;
appendVec(geometry_metadata, data.meshes);
appendVec(geometry_metadata, data.instances);
appendBytes(geometry_metadata, &data.has_coordinate_operation, 4);
appendBytes(geometry_metadata, data.coordinate_operation_meters, sizeof(double) * 16);
appendBytes(geometry_metadata, &data.project_length_to_meters, sizeof(double));
appendBytes(geometry_metadata, &data.map_unit_to_meters, sizeof(double));
appendVec(geometry_metadata, chunks);
if (!wrBlock(geometry_metadata)) { fclose(f); return false; }
// --- Deferred metadata block (zstd): element tree + string table ---------
std::vector<std::uint8_t> def;
appendVec(def, data.elements);
// --- Element metadata block (zstd): elements + string table --------------
std::vector<std::uint8_t> element_metadata;
appendVec(element_metadata, 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; }
appendBytes(element_metadata, &stbl_len, 4);
appendBytes(element_metadata, data.string_table.data(), stbl_len);
if (!wrBlock(element_metadata)) { fclose(f); return false; }
fclose(f);
return true;
@@ -257,28 +268,30 @@ std::optional<SidecarData> readSidecar(const std::string& ifc_path) {
if (hdr.magic != SIDECAR_MAGIC || hdr.version != SIDECAR_VERSION ||
hdr.endian != SIDECAR_ENDIAN) 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)); };
auto read_bytes = [&](void* data, std::size_t byte_count) {
return fread(data, 1, byte_count, f) == byte_count;
};
auto rdU64 = [&](std::uint64_t& v) { return read_bytes(&v, sizeof(v)); };
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();
if (geom_bytes && !read_bytes(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;
if (comp && !read_bytes(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();
std::vector<std::uint8_t> geometry_metadata, element_metadata;
if (!readBlock(geometry_metadata) || !readBlock(element_metadata)) return fail();
fclose(f);
SidecarData data;
BufReader cr{ crit.data(), crit.size() };
BufReader cr{ geometry_metadata.data(), geometry_metadata.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;
@@ -287,7 +300,7 @@ std::optional<SidecarData> readSidecar(const std::string& ifc_path) {
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() };
BufReader dr{ element_metadata.data(), element_metadata.size() };
if (!dr.takeVec(data.elements)) return std::nullopt;
std::uint32_t stbl_len = 0;
if (!dr.take(&stbl_len, 4)) return std::nullopt;
@@ -296,46 +309,68 @@ std::optional<SidecarData> readSidecar(const std::string& ifc_path) {
// 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) {
for (const auto& mesh_info : 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);
std::size_t(mesh_info.vbo_byte_offset) +
std::size_t(mesh_info.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES);
isize = std::max<std::size_t>(
isize, mesh_info.ebo_byte_offset / sizeof(std::uint32_t) + mesh_info.index_count);
if (mesh_info.lod1_index_count) {
isize = std::max<std::size_t>(
isize,
mesh_info.lod1_ebo_byte_offset / sizeof(std::uint32_t) + mesh_info.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()))
for (const auto& sidecar_chunk : data.chunks) {
if (sidecar_chunk.v_comp_off + sidecar_chunk.v_comp_size > geom.size() ||
sidecar_chunk.i_comp_off + sidecar_chunk.i_comp_size > geom.size()) return std::nullopt;
std::vector<std::uint8_t> vraw(static_cast<std::size_t>(sidecar_chunk.v_raw_size));
std::vector<std::uint8_t> iraw(static_cast<std::size_t>(sidecar_chunk.i_raw_size));
if (!SidecarCompress::decompress(
geom.data() + sidecar_chunk.v_comp_off, sidecar_chunk.v_comp_size, vraw.data(), vraw.size()) ||
!SidecarCompress::decompress(
geom.data() + sidecar_chunk.i_comp_off, sidecar_chunk.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;
const std::uint32_t end = sidecar_chunk.first_mesh + sidecar_chunk.mesh_count;
for (std::uint32_t mesh_index = sidecar_chunk.first_mesh;
mesh_index < end && mesh_index < data.meshes.size();
++mesh_index) {
const MeshInfo& mesh_info = data.meshes[mesh_index];
const std::size_t vertex_byte_count =
std::size_t(mesh_info.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
if (vcur + vertex_byte_count <= vraw.size() &&
mesh_info.vbo_byte_offset + vertex_byte_count <= data.vertices.size()) {
std::memcpy(&data.vertices[mesh_info.vbo_byte_offset], vraw.data() + vcur, vertex_byte_count);
}
vcur += vertex_byte_count;
}
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 mesh_index = sidecar_chunk.first_mesh;
mesh_index < end && mesh_index < data.meshes.size();
++mesh_index) {
const MeshInfo& mesh_info = data.meshes[mesh_index];
if (!mesh_info.index_count) continue;
if (icur + mesh_info.index_count <= iraw.size() / 4) {
std::memcpy(&data.indices[mesh_info.ebo_byte_offset / sizeof(std::uint32_t)],
iu + icur,
mesh_info.index_count * 4);
}
icur += mesh_info.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;
for (std::uint32_t mesh_index = sidecar_chunk.first_mesh;
mesh_index < end && mesh_index < data.meshes.size();
++mesh_index) {
const MeshInfo& mesh_info = data.meshes[mesh_index];
if (!mesh_info.lod1_index_count) continue;
if (icur + mesh_info.lod1_index_count <= iraw.size() / 4) {
std::memcpy(&data.indices[mesh_info.lod1_ebo_byte_offset / sizeof(std::uint32_t)],
iu + icur,
mesh_info.lod1_index_count * 4);
}
icur += mesh_info.lod1_index_count;
}
}
return data;
+17 -16
View File
@@ -46,7 +46,7 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
// same cache serves either source format. Staleness is user-managed
// (delete the sidecar to force a rebuild).
// v9 = unused `reserved` field dropped from header (16 B -> 12 B).
// v10 = InstanceCpu gains placement_transformation[16] alongside transform[16]
// v10 = InstanceInfo gains placement_transformation[16] alongside transform[16]
// — record grew from 104 B to 168 B. placement_transformation is the
// raw streamer output; transform is the composed FederatedFalseOrigin ·
// ModelTransformation · CoordinateOperation · placement_transformation
@@ -59,8 +59,8 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
// georef without re-parsing the IFC source. Edits to the IFC's
// IfcMapConversion do NOT invalidate the sidecar — delete the
// .ifcview manually if you change the source's georef parameters.
// v12 = InstanceCpu::placement_transformation is double[16], and
// InstanceChunk carries the streamer placement as double[16]. This keeps
// v12 = InstanceInfo::placement_transformation is double[16], and
// StreamedInstance carries the streamer placement as double[16]. This keeps
// large IFC placements exact until CoordinateOperation / FederatedFalseOrigin
// composition has reduced them to viewport-local float-sized values.
// v13 = Map unit scale in cached ModelGeoref is derived from
@@ -73,23 +73,25 @@ static constexpr uint32_t SIDECAR_MAGIC = 0x49465657; // "IFVW"
// Morton quantisation isn't bit-identical across toolchains (x86 baker vs
// wasm loader), so it must be baked in. No back-compat: v13 sidecars are
// rejected (regenerate them).
// v15 = The post-index metadata is split into a render-CRITICAL block (meshes,
// instances, georef, chunk TOC) followed by a DEFERRED block (elements +
// string_table — the IFC element tree, used for UI/picking, never for
// rendering), with the critical block's byte length written just after
// the index section. The web loader reads only the critical block before
// v15 = The post-index metadata is split into a geometry metadata block (meshes,
// instances, georef, chunk TOC) followed by an element metadata block (elements +
// string_table — IFC element metadata, used for UI/picking, never for
// rendering), with the geometry block's byte length written just after
// the index section. The web loader reads only the geometry block before
// painting, so first geometry no longer waits on the property data; the
// deferred block is fetched lazily (or skipped where unused). Desktop
// element block is fetched lazily (or skipped where unused). Desktop
// reads both. No back-compat: regenerate sidecars.
// 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.
// and the geometry + element 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;
// v17 = Removes unused element hierarchy metadata. No back-compat: regenerate
// sidecars.
static constexpr uint32_t SIDECAR_VERSION = 17;
static constexpr uint32_t SIDECAR_ENDIAN = 0x01020304;
// Chunk table-of-contents entry (v16). A chunk is a CONTIGUOUS range of meshes
@@ -111,11 +113,10 @@ struct SidecarChunk {
// Fixed-size element record. Strings are stored as (offset, length) pairs
// into a separate string table.
struct PackedElementInfo {
struct ElementTableRecord {
uint32_t object_id;
uint32_t model_id;
int32_t ifc_id;
int32_t parent_id;
uint32_t guid_offset;
uint32_t guid_length;
uint32_t name_offset;
@@ -134,7 +135,7 @@ struct SidecarData {
// Mesh dictionary and per-instance data.
std::vector<MeshInfo> meshes; // indexed by local_mesh_id
std::vector<InstanceCpu> instances; // sorted by mesh_id
std::vector<InstanceInfo> instances; // sorted by mesh_id
// CoordinateOperation cache (v11+). Mirrors ModelGeoref so a sidecar
// load can apply georef without re-parsing the IFC source.
@@ -149,8 +150,8 @@ struct SidecarData {
double map_unit_to_meters = 1.0;
uint32_t has_coordinate_operation = 0;
// Element tree metadata.
std::vector<PackedElementInfo> elements;
// Element metadata.
std::vector<ElementTableRecord> elements;
std::string string_table;
// Streaming chunk TOC. Always written on disk (v14); geometry is laid out
+55 -47
View File
@@ -26,37 +26,42 @@
#include <vector>
void reorderSidecarByMorton(SidecarData& sd) {
const std::size_t n = sd.meshes.size();
if (n < 2) return;
const std::size_t mesh_count = sd.meshes.size();
if (mesh_count < 2) return;
// Per-mesh centroid + instance count, exactly as the loader computes them
// before chunk planning (average of instance world-AABB centres).
std::vector<float> cx(n, 0.0f), cy(n, 0.0f), cz(n, 0.0f);
std::vector<std::uint32_t> cnt(n, 0);
std::vector<float> mesh_centroid_x(mesh_count, 0.0f),
mesh_centroid_y(mesh_count, 0.0f),
mesh_centroid_z(mesh_count, 0.0f);
std::vector<std::uint32_t> mesh_instance_count(mesh_count, 0);
for (const auto& inst : sd.instances) {
if (inst.mesh_id >= n) continue;
cx[inst.mesh_id] += 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]);
cy[inst.mesh_id] += 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]);
cz[inst.mesh_id] += 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]);
++cnt[inst.mesh_id];
if (inst.mesh_id >= mesh_count) continue;
mesh_centroid_x[inst.mesh_id] += 0.5f * (inst.world_aabb_min[0] + inst.world_aabb_max[0]);
mesh_centroid_y[inst.mesh_id] += 0.5f * (inst.world_aabb_min[1] + inst.world_aabb_max[1]);
mesh_centroid_z[inst.mesh_id] += 0.5f * (inst.world_aabb_min[2] + inst.world_aabb_max[2]);
++mesh_instance_count[inst.mesh_id];
}
for (std::size_t i = 0; i < n; ++i) {
if (cnt[i] > 0) {
const float inv = 1.0f / float(cnt[i]);
cx[i] *= inv; cy[i] *= inv; cz[i] *= inv;
for (std::size_t i = 0; i < mesh_count; ++i) {
if (mesh_instance_count[i] > 0) {
const float inv = 1.0f / float(mesh_instance_count[i]);
mesh_centroid_x[i] *= inv;
mesh_centroid_y[i] *= inv;
mesh_centroid_z[i] *= inv;
}
}
// order[new_id] = old mesh id, in the loader's Morton order.
const std::vector<std::uint32_t> order =
ChunkPlanner::sortMeshIdsByMorton(n, cx, cy, cz, cnt);
ChunkPlanner::sortMeshIdsByMorton(
mesh_count, mesh_centroid_x, mesh_centroid_y, mesh_centroid_z, mesh_instance_count);
// Greedy-pack the sorted order into chunks (the same plan the loader used
// to derive). Each chunk is a CONSECUTIVE run of `order`, so once we lay
// meshes out in `order` the chunk is a contiguous mesh range — recorded in
// the TOC as {first_mesh, mesh_count}.
std::vector<std::uint32_t> mesh_vertex_count(n, 0);
for (std::size_t i = 0; i < n; ++i) mesh_vertex_count[i] = sd.meshes[i].vertex_count;
std::vector<std::uint32_t> mesh_vertex_count(mesh_count, 0);
for (std::size_t i = 0; i < mesh_count; ++i) mesh_vertex_count[i] = sd.meshes[i].vertex_count;
const std::vector<std::vector<std::uint32_t>> packed = ChunkPlanner::greedyPackChunks(
order, mesh_vertex_count, INSTANCED_VERTEX_STRIDE_BYTES,
WGPU_CHUNK_VERTEX_BYTES_LIMIT);
@@ -74,58 +79,61 @@ void reorderSidecarByMorton(SidecarData& sd) {
// MeshInfo.first_instance: the baker leaves it 0 for every mesh and stores
// instances ungrouped, so first_instance describes nothing. Grouping here
// by mesh_id both reorders instances correctly AND fixes first_instance.
std::vector<std::vector<std::uint32_t>> insts_by_mesh(n);
for (std::uint32_t ii = 0; ii < sd.instances.size(); ++ii) {
const std::uint32_t mid = sd.instances[ii].mesh_id;
if (mid < n) insts_by_mesh[mid].push_back(ii);
std::vector<std::vector<std::uint32_t>> insts_by_mesh(mesh_count);
for (std::uint32_t instance_index = 0; instance_index < sd.instances.size(); ++instance_index) {
const std::uint32_t mesh_id = sd.instances[instance_index].mesh_id;
if (mesh_id < mesh_count) insts_by_mesh[mesh_id].push_back(instance_index);
}
std::vector<std::uint8_t> new_vertices; new_vertices.reserve(sd.vertices.size());
std::vector<std::uint32_t> new_indices; new_indices.reserve(sd.indices.size());
std::vector<MeshInfo> new_meshes(n);
std::vector<InstanceCpu> new_instances; new_instances.reserve(sd.instances.size());
std::vector<MeshInfo> new_meshes(mesh_count);
std::vector<InstanceInfo> new_instances; new_instances.reserve(sd.instances.size());
// Pass A: vertices + LOD0 indices + instances, mesh-by-mesh in the new
// order, recording the new offsets on each MeshInfo.
for (std::uint32_t ni = 0; ni < n; ++ni) {
const std::uint32_t old = order[ni];
const MeshInfo& om = sd.meshes[old];
MeshInfo nm = om; // carries AABB; offsets/instance fields overwritten below
for (std::uint32_t new_mesh_index = 0; new_mesh_index < mesh_count; ++new_mesh_index) {
const std::uint32_t old = order[new_mesh_index];
const MeshInfo& old_mesh_info = sd.meshes[old];
MeshInfo new_mesh_info = old_mesh_info; // carries AABB; offsets/instance fields overwritten below
nm.vbo_byte_offset = std::uint32_t(new_vertices.size());
const std::size_t vbytes = std::size_t(om.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
new_mesh_info.vbo_byte_offset = std::uint32_t(new_vertices.size());
const std::size_t vbytes = std::size_t(old_mesh_info.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES;
new_vertices.insert(new_vertices.end(),
sd.vertices.begin() + om.vbo_byte_offset,
sd.vertices.begin() + om.vbo_byte_offset + vbytes);
sd.vertices.begin() + old_mesh_info.vbo_byte_offset,
sd.vertices.begin() + old_mesh_info.vbo_byte_offset + vbytes);
nm.ebo_byte_offset = std::uint32_t(new_indices.size() * sizeof(std::uint32_t));
const std::size_t i0 = om.ebo_byte_offset / sizeof(std::uint32_t);
new_mesh_info.ebo_byte_offset = std::uint32_t(new_indices.size() * sizeof(std::uint32_t));
const std::size_t i0 = old_mesh_info.ebo_byte_offset / sizeof(std::uint32_t);
new_indices.insert(new_indices.end(),
sd.indices.begin() + i0,
sd.indices.begin() + i0 + om.index_count);
sd.indices.begin() + i0 + old_mesh_info.index_count);
nm.first_instance = std::uint32_t(new_instances.size());
nm.instance_count = std::uint32_t(insts_by_mesh[old].size());
for (std::uint32_t ii : insts_by_mesh[old]) {
InstanceCpu ic = sd.instances[ii];
ic.mesh_id = ni;
new_instances.push_back(ic);
new_mesh_info.first_instance = std::uint32_t(new_instances.size());
new_mesh_info.instance_count = std::uint32_t(insts_by_mesh[old].size());
for (std::uint32_t instance_index : insts_by_mesh[old]) {
InstanceInfo instance = sd.instances[instance_index];
instance.mesh_id = new_mesh_index;
new_instances.push_back(instance);
}
new_meshes[ni] = nm;
new_meshes[new_mesh_index] = new_mesh_info;
}
// Pass B: LOD1 indices appended after all LOD0 (same global layout as the
// baker), in the new order, so a chunk's LOD1 slice is contiguous too.
for (std::uint32_t ni = 0; ni < n; ++ni) {
const MeshInfo& om = sd.meshes[order[ni]];
MeshInfo& nm = new_meshes[ni];
if (om.lod1_index_count == 0) { nm.lod1_ebo_byte_offset = 0; continue; }
nm.lod1_ebo_byte_offset = std::uint32_t(new_indices.size() * sizeof(std::uint32_t));
const std::size_t l0 = om.lod1_ebo_byte_offset / sizeof(std::uint32_t);
for (std::uint32_t new_mesh_index = 0; new_mesh_index < mesh_count; ++new_mesh_index) {
const MeshInfo& old_mesh_info = sd.meshes[order[new_mesh_index]];
MeshInfo& new_mesh_info = new_meshes[new_mesh_index];
if (old_mesh_info.lod1_index_count == 0) {
new_mesh_info.lod1_ebo_byte_offset = 0;
continue;
}
new_mesh_info.lod1_ebo_byte_offset = std::uint32_t(new_indices.size() * sizeof(std::uint32_t));
const std::size_t l0 = old_mesh_info.lod1_ebo_byte_offset / sizeof(std::uint32_t);
new_indices.insert(new_indices.end(),
sd.indices.begin() + l0,
sd.indices.begin() + l0 + om.lod1_index_count);
sd.indices.begin() + l0 + old_mesh_info.lod1_index_count);
}
sd.vertices = std::move(new_vertices);
+1 -1
View File
@@ -42,7 +42,7 @@
//
// Pure transform (no Qt / no wgpu): meshes, vertices, indices (LOD0 + LOD1),
// and instances are all rebuilt in the new order with vbo/ebo/lod1 offsets,
// MeshInfo.first_instance, and InstanceCpu.mesh_id remapped consistently.
// MeshInfo.first_instance, and InstanceInfo.mesh_id remapped consistently.
// Index values are mesh-local, so they move unchanged. Element/georef/string
// data is mesh-independent and untouched. No-op for < 2 meshes.
//
+27 -25
View File
@@ -25,9 +25,9 @@
// uint32 num_indices
// uint32[num_indices] index data <-- streaming skips
// uint32 num_meshes + MeshInfo[] <-- streaming reads
// uint32 num_instances + InstanceCpu[] <-- streaming reads
// uint32 num_instances + InstanceInfo[] <-- streaming reads
// uint32 has_coord_op + double[16] + 2× double <-- streaming reads
// uint32 num_elements + PackedElementInfo[] <-- streaming reads
// uint32 num_elements + ElementTableRecord[] <-- streaming reads
// uint32 string_table_bytes + char[] <-- streaming reads
//
// Streaming reader returns offsets to the two skipped sections so chunks
@@ -52,25 +52,25 @@ struct SidecarHeaderRaw {
// 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;
const uint8_t* cursor;
size_t remaining_bytes;
bool take(void* dst, size_t bytes) {
if (bytes > remaining) return false;
std::memcpy(dst, p, bytes);
p += bytes;
remaining -= bytes;
if (bytes > remaining_bytes) return false;
std::memcpy(dst, cursor, bytes);
cursor += bytes;
remaining_bytes -= bytes;
return true;
}
// Read a uint32 length prefix followed by length*sizeof(T) elements.
template<typename T>
bool takeVec(std::vector<T>& v) {
bool takeVec(std::vector<T>& values) {
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;
if (uint64_t(n) * sizeof(T) > remaining_bytes) return false;
values.resize(n);
if (n > 0 && !take(values.data(), size_t(n) * sizeof(T))) return false;
return true;
}
};
@@ -100,8 +100,8 @@ bool parseSidecarHead(const uint8_t* data, size_t n, uint64_t& out_geom_bytes) {
return true;
}
bool parseSidecarCritical(const uint8_t* data, size_t n, SidecarData& out) {
// v15 render-critical block: meshes, instances, georef, chunk TOC.
bool parseSidecarGeometryMetadata(const uint8_t* data, size_t n, SidecarData& out) {
// v15 geometry metadata block: meshes, instances, georef, chunk TOC.
BufCursor c{data, n};
if (!c.takeVec(out.meshes)) return false;
if (!c.takeVec(out.instances)) return false;
@@ -113,13 +113,13 @@ bool parseSidecarCritical(const uint8_t* data, size_t n, SidecarData& out) {
return true;
}
bool parseSidecarDeferred(const uint8_t* data, size_t n, SidecarData& out) {
// v15 deferred block: element tree + string table (UI/picking, not rendered).
bool parseSidecarElementMetadata(const uint8_t* data, size_t n, SidecarData& out) {
// v15+ element metadata block: elements + string table (UI/picking, not rendered).
BufCursor c{data, n};
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;
if (stbl_len > c.remaining_bytes) return false;
out.string_table.resize(stbl_len);
if (stbl_len > 0 && !c.take(out.string_table.data(), stbl_len)) return false;
return true;
@@ -166,16 +166,18 @@ std::optional<StreamingSidecar> readSidecarMetadataOnly(const std::string& ifc_p
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::vector<uint8_t> geometry_metadata, element_metadata;
if (!readBlock(geometry_metadata)) return fail();
if (!readBlock(element_metadata, &out.element_metadata_comp_offset, &out.element_metadata_comp_size,
&out.element_metadata_raw_size)) return fail();
std::fclose(f);
// 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;
// Desktop reads both blocks up front; the web path reads only geometry
// metadata before painting and fetches the element metadata block on demand.
if (!parseSidecarGeometryMetadata(geometry_metadata.data(), geometry_metadata.size(), out.meta))
return std::nullopt;
if (!parseSidecarElementMetadata(element_metadata.data(), element_metadata.size(), out.meta))
return std::nullopt;
return out;
}
+16 -15
View File
@@ -46,7 +46,7 @@
struct StreamingSidecar {
// Everything except vertices + indices — same shape as SidecarData but
// with empty vertices / indices vectors. The renderer uses meshes /
// instances / georef / chunks immediately (elements/strings deferred).
// instances / georef / chunks immediately (elements/strings are element metadata).
SidecarData meta;
// v16: the compressed geometry section starts here. Each chunk's two zstd
@@ -54,12 +54,13 @@ struct StreamingSidecar {
// a per-chunk load fetches [that, +*_comp_size) and decompresses to *_raw_size.
uint64_t geometry_section_offset = 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;
// v16 element metadata block locator: a single zstd frame at
// element_metadata_comp_offset of element_metadata_comp_size bytes →
// element_metadata_raw_size. The web loader fetches it on demand
// (elements/strings); desktop reads it up front.
uint64_t element_metadata_comp_offset = 0;
uint64_t element_metadata_comp_size = 0;
uint64_t element_metadata_raw_size = 0;
// Resolved on-disk path so subsequent chunk reads can re-open / seek.
std::string file_path;
@@ -104,18 +105,18 @@ inline constexpr std::size_t SIDECAR_HEAD_BYTES = 20;
bool parseSidecarHead(const std::uint8_t* data, std::size_t n,
std::uint64_t& out_geom_bytes);
// Parse the v15 render-CRITICAL metadata block (mesh dict, instance dict,
// Parse the v15 geometry metadata block (mesh dict, instance dict,
// georef, chunk TOC) — everything needed to set up + draw the scene. `data`
// points at the first critical byte; `n` is critical_meta_bytes. Returns false
// points at the first geometry metadata byte; `n` is geometry_metadata_bytes. Returns false
// on any bounds overrun, leaving out_meta partially filled.
bool parseSidecarCritical(const std::uint8_t* data, std::size_t n,
SidecarData& out_meta);
bool parseSidecarGeometryMetadata(const std::uint8_t* data, std::size_t n,
SidecarData& out_meta);
// Parse the v15 DEFERRED metadata block (element table + string table — the
// Parse the v15 element metadata block (element table + string table — the
// IFC property tree, used for UI/picking, never for rendering). Fetched on
// demand. `data` points at the first deferred byte; `n` is its length.
bool parseSidecarDeferred(const std::uint8_t* data, std::size_t n,
SidecarData& out_meta);
// demand. `data` points at the first element metadata byte; `n` is its length.
bool parseSidecarElementMetadata(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
+20 -20
View File
@@ -26,23 +26,23 @@ StreamingThread::~StreamingThread() {
}
void StreamingThread::start() {
std::unique_lock lk(mu_);
std::unique_lock lock(mu_);
if (running_) return;
shutdown_ = false;
running_ = true;
lk.unlock();
lock.unlock();
worker_ = std::thread(&StreamingThread::workerLoop, this);
}
void StreamingThread::stop() {
{
std::unique_lock lk(mu_);
std::unique_lock lock(mu_);
if (!running_) return;
shutdown_ = true;
}
cv_.notify_all();
if (worker_.joinable()) worker_.join();
std::unique_lock lk(mu_);
std::unique_lock lock(mu_);
running_ = false;
requests_.clear();
results_.clear();
@@ -50,7 +50,7 @@ void StreamingThread::stop() {
bool StreamingThread::enqueue(Request req) {
{
std::unique_lock lk(mu_);
std::unique_lock lock(mu_);
if (!running_ || shutdown_) return false;
requests_.push_back(std::move(req));
}
@@ -59,20 +59,20 @@ bool StreamingThread::enqueue(Request req) {
}
std::vector<StreamingThread::Result> StreamingThread::drainResults() {
std::vector<Result> out;
std::vector<Result> results;
{
std::unique_lock lk(mu_);
out.reserve(results_.size());
std::unique_lock lock(mu_);
results.reserve(results_.size());
while (!results_.empty()) {
out.push_back(std::move(results_.front()));
results.push_back(std::move(results_.front()));
results_.pop_front();
}
}
return out;
return results;
}
std::size_t StreamingThread::inFlightApprox() const {
std::unique_lock lk(mu_);
std::unique_lock lock(mu_);
return requests_.size() + (in_progress_ ? 1u : 0u);
}
@@ -80,8 +80,8 @@ void StreamingThread::workerLoop() {
for (;;) {
Request req;
{
std::unique_lock lk(mu_);
cv_.wait(lk, [this]() { return shutdown_ || !requests_.empty(); });
std::unique_lock lock(mu_);
cv_.wait(lock, [this]() { return shutdown_ || !requests_.empty(); });
if (shutdown_ && requests_.empty()) return;
req = std::move(requests_.front());
requests_.pop_front();
@@ -94,18 +94,18 @@ void StreamingThread::workerLoop() {
// us. The vbytes / idx buffers are allocated here on the worker
// thread — they cross back to the main thread when the result
// is drained and applied (pool.alloc + queueWriteBuffer).
Result res;
res.model_id = req.model_id;
res.chunk_idx = req.chunk_idx;
res.success = readChunkGeometryCompressed(
Result result;
result.model_id = req.model_id;
result.chunk_idx = req.chunk_idx;
result.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);
result.vbytes, result.idx);
{
std::unique_lock lk(mu_);
results_.push_back(std::move(res));
std::unique_lock lock(mu_);
results_.push_back(std::move(result));
in_progress_ = false;
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
// Inline helpers that turn streamer-format vertices (7 floats per vertex:
// pos3 + normal3 + color-as-float) into the 12 B quantized VBO layout used
// by both the viewport's GPU buffers and the .ifcview sidecar. Shared
// between ViewportWindow::uploadMeshChunk and SidecarBuilder so the
// between ViewportWindow::uploadStreamedMesh and SidecarBuilder so the
// on-disk format stays identical to what the viewport renders.
#ifndef VERTEXQUANTIZATION_H
File diff suppressed because it is too large Load Diff
+152 -12
View File
@@ -50,6 +50,7 @@
#include "InstanceCompose.h"
#include "InstancedGeometry.h"
#include "ModelGpuData.h"
#include "SectionGizmoRenderer.h"
#include "SectionPlane.h"
#include "SelectionState.h"
#include "SidecarCache.h"
@@ -110,7 +111,7 @@ public:
// from the mesh-local one. Used by the per-model recompose path
// after any of the four federation matrices change. Pure scene
// math — no GPU touch.
void composeInstanceFromPlacement(InstanceCpu& inst,
void composeInstanceFromPlacement(InstanceInfo& inst,
const ModelGpuData& m) const;
// Cross-model object_id lookup. Delegates to
@@ -192,6 +193,30 @@ public:
enum class StandardView { Front, Back, Left, Right, Top, Bottom };
void setStandardView(StandardView view);
// ---- Navigation mouse bindings (shared, preset-driven) ------------------
//
// Which mouse button (+ modifier) orbits / pans / selects. Owned by the core
// as pure data so BOTH hosts and ALL presets share one source of truth — the
// desktop maps these to Qt::MouseButton, the web to DOM button codes. Select
// is preset-driven too (not hardcoded to LMB) so a "web" preset can move it
// to RMB. Marquee box-select uses the same button as select (drag vs click).
enum class MouseBtn { Left, Middle, Right };
// Plain (not "None": X11 #defines None to 0L, which would corrupt the token).
enum class NavMod { Plain, Shift, Ctrl, Alt };
struct NavBindings {
MouseBtn orbit; NavMod orbit_mod;
MouseBtn pan; NavMod pan_mod;
MouseBtn select; NavMod select_mod;
};
// name: "blender" (default) | "rhino" | "revit" | "web". Unknown → blender.
// blender orbit MMB, pan Shift+MMB, select LMB
// rhino orbit RMB, pan Shift+RMB, select LMB
// revit orbit Shift+MMB, pan MMB, select LMB
// web orbit LMB, pan MMB, select RMB (LMB stays free to
// orbit-drag; RMB click-selects / drag-marquees, no ambiguity)
void setNavPreset(const char* name);
const NavBindings& navBindings() const { return nav_bindings_; }
// Frame the current selection: union the selected objects' world AABBs and
// fit the camera to them (same 1.30 padding as the desktop "F" hotkey).
// No-op with an empty selection or no resolvable AABBs; returns whether it
@@ -401,16 +426,16 @@ public:
// `source_label` is a log/identity tag.
void loadSidecarMetadataWeb(int source_id, std::string source_label);
// On-demand fetch of the v15 deferred property block (element tree + string
// On-demand fetch of the v15 element metadata block (elements + string
// table) for a web-streamed model — what a UI (object tree / selected-name
// / search) needs, fetched only when asked so first paint never waits on
// it. Populates ModelGpuData.elements/string_table; fires done(ok). At most
// one fetch per model.
void loadDeferredMetadataWeb(std::uint32_t model_id,
std::function<void(bool)> done = {});
void loadElementMetadataWeb(std::uint32_t model_id,
std::function<void(bool)> done = {});
// Demo consumer of the deferred fetch: on pick, ensure the owning model's
// property block is loaded (loadDeferredMetadataWeb — fetched once, on
// Demo consumer of the element metadata fetch: on pick, ensure the owning model's
// property block is loaded (loadElementMetadataWeb — fetched once, on
// demand), then log the picked object's IFC GUID. The first pick triggers
// the network fetch; later picks reuse the cached element table.
void logSelectedObjectGuidWeb(std::uint32_t object_id);
@@ -447,8 +472,8 @@ public:
// the viewer one mesh + one instance at a time, then calls
// finalizeModel once everything's staged. The staging map lives on
// ViewportCore so both halves can share it.
void uploadMeshChunk(const MeshChunk& chunk);
void uploadInstanceChunk(const InstanceChunk& chunk);
void uploadStreamedMesh(const StreamedMesh& mesh);
void uploadStreamedInstance(const StreamedInstance& instance_record);
void finalizeModel(std::uint32_t model_id);
// ---- Cross-chunk + screenshot capture (#84-v) -------------------------
@@ -500,6 +525,22 @@ public:
// Drop every section plane. No-op when none are active.
void clearSectionPlanes();
// Number of active section planes (0..kMaxSectionPlanes).
int sectionPlaneCount() const { return int(section_planes_.size()); }
// ---- Section gizmo interaction (shared desktop + web) -------------------
//
// All coords are LOGICAL (CSS) pixels; the core derives the logical viewport
// from the host. hitTestSectionGizmo returns the plane index whose gizmo
// arrow is under (x,y), or -1. The drag trio slides a plane along its normal:
// begin captures the plane origin + press point, update reprojects and moves
// it, end finishes.
int hitTestSectionGizmo(int x, int y);
bool beginSectionDrag(int gizmo_index, int mouse_x, int mouse_y);
void updateSectionDrag(int mouse_x, int mouse_y);
void endSectionDrag() { section_drag_active_ = false; }
bool sectionDragActive() const { return section_drag_active_; }
// ---- Render loop (#84-x) ----------------------------------------------
//
// Encode one frame: acquire the swapchain texture, run cull (parallel
@@ -608,6 +649,41 @@ public:
// async (pickObjectAtAsync) readbacks. Caller validates bounds/attachments.
void encodePickReadbackToStaging(int x_pixels, int y_pixels, bool want_normal);
// Encode the pick pass + copy the (x,y,w,h) object_id sub-rect into
// box_pick_staging_buffer_ and submit. Clamps the rect (x/y/w/h in-out) and
// reports the padded bytes-per-row + total mapped size. Shared by the sync
// picksInRect and async picksInRectAsync — they differ only in the map.
// False if nothing is pickable or the rect is empty.
bool encodeBoxPickToStaging(int& x, int& y, int& w, int& h,
std::uint64_t& padded_bpr_out,
std::uint64_t& needed_bytes_out);
// Read the (already-mapped) box-pick staging buffer → unique non-zero ids in
// the w×h rect (rows padded to padded_bpr). Unmaps before returning.
std::vector<std::uint32_t> collectMappedBoxPickIds(std::uint64_t padded_bpr,
int w, int h,
std::uint64_t needed_bytes);
// CPU half of pickSurfaceAt: cast the pixel's world ray against every
// instance carrying `object_id`, returning the closest hit's world pos,
// normal (mrt_normal if non-degenerate, else the AABB-face normal), and the
// instance bounding-sphere radius. Shared by the sync pickSurfaceAt and the
// async pickSurfaceAtAsync. False if no instance is hit.
bool raycastSurfaceForObject(std::uint32_t object_id, int x_pixels, int y_pixels,
const Eigen::Vector3f& mrt_normal,
Eigen::Vector3f& world_pos_out,
Eigen::Vector3f& world_normal_out,
float& aabb_radius_out);
// Decode the RGBA16F pick-normal from the (already-mapped) normal staging
// buffer into a unit world normal; unmaps. False if degenerate. Shared by
// the sync pickObjectAt and the async pickSurfaceAtAsync.
bool decodeMappedPickNormal(Eigen::Vector3f& out);
// Logical (CSS-px) viewport size from the host framebuffer / DPR, for the
// section-gizmo hit-test + drag (which work in logical pixels).
void sectionLogicalViewport(int& w, int& h) const;
// Decode the RGBA32F exact world position from the (already-mapped) position
// staging buffer; unmaps. False if the texel was a miss (w == 0).
bool decodeMappedPickPosition(Eigen::Vector3f& out);
// Tear down every pick-owned wgpu resource (pipeline + MRTs +
// staging buffers). Called from shutdown() before device_ dies.
void releasePickResources();
@@ -624,6 +700,11 @@ public:
// Marks selection_ dirty for the next render's flush.
void applyPickToSelection(std::uint32_t object_id, bool add, bool remove);
// Apply a marquee box-pick result to the selection: plain = replace with
// `ids`, add = union, remove = subtract. Schedules a frame.
void applyMarqueeToSelection(const std::vector<std::uint32_t>& ids,
bool add, bool remove);
// Visibility + X-ray, shared by desktop (H / Shift+H / Alt+H / Alt+X) and
// web. Hidden objects are skipped by the cull and xray_alpha_cap_ is read
// by the frame uniform, both per frame — so each call just mutates state and
@@ -649,9 +730,18 @@ public:
// Marquee box select: encode the pick pass, copy the (x, y, w, h)
// sub-rect of the object_id MRT back, return the set of unique
// non-zero ids. Synchronous (rare interaction).
// non-zero ids. Synchronous (rare interaction) — desktop only path.
std::vector<std::uint32_t> picksInRect(int x, int y, int w, int h);
#if defined(__EMSCRIPTEN__)
// Async marquee box select for web (the sync spin-map would hang the JS
// loop). Same pick pass + rect copy as picksInRect, mapped via a spontaneous
// callback that delivers the unique non-zero ids to `cb`. One in flight at a
// time (a box-pick issued while another is mapping is dropped → cb({})).
void picksInRectAsync(int x, int y, int w, int h,
std::function<void(std::vector<std::uint32_t>)> cb);
#endif
// Run pickObjectAt + raycast against every instance carrying the
// hit object_id, then return the closest hit's world position,
// world normal, and (optionally) the bounding-sphere radius. The
@@ -663,8 +753,24 @@ public:
Eigen::Vector3f& world_normal_out,
float* aabb_radius_out = nullptr);
#if defined(__EMSCRIPTEN__)
// Async surface pick for web (drives the section tool). Reuses the async
// object pick (no new GPU readback), then runs the same CPU ray-AABB cast as
// pickSurfaceAt. On web the normal is the AABB-face normal (the precise MRT
// normal would need a second async map — a later refinement).
struct SurfaceHit {
bool found = false;
std::uint32_t object_id = 0;
Eigen::Vector3f world_pos = Eigen::Vector3f::Zero();
Eigen::Vector3f world_normal = Eigen::Vector3f::UnitZ();
float aabb_radius = 0.0f;
};
void pickSurfaceAtAsync(int x_pixels, int y_pixels,
std::function<void(SurfaceHit)> cb);
#endif
// Per-pick result for the Area / Length / Volume tools. The
// composed_transform mirrors InstanceCpu::transform so callers can
// composed_transform mirrors InstanceInfo::transform so callers can
// round-trip from mesh-local back to world without re-deriving it.
struct MeshLocalPick {
std::uint32_t object_id = 0;
@@ -793,6 +899,10 @@ private:
WGPUPipelineLayout pipeline_layout_ = nullptr;
WGPURenderPipeline main_pipeline_ = nullptr;
WGPURenderPipeline main_pipeline_transparent_ = nullptr;
// Section-plane gizmo, shared by desktop + web (both render via render()).
// Lifted out of the Qt-coupled OverlayRenderer so one identical gizmo draws
// everywhere; the desktop's OverlayRenderer no longer draws it.
SectionGizmoRenderer section_gizmo_;
// HiZ occlusion-cull pipeline group. Downsamples MSAA depth into a
// mip pyramid; consumed by next-frame cull.
@@ -882,10 +992,13 @@ private:
WGPUTextureView pick_color_view_ = nullptr;
WGPUTexture pick_normal_texture_ = nullptr;
WGPUTextureView pick_normal_view_ = nullptr;
WGPUTexture pick_position_texture_ = nullptr; // RGBA32F exact world pos
WGPUTextureView pick_position_view_ = nullptr;
WGPUTexture pick_depth_texture_ = nullptr;
WGPUTextureView pick_depth_view_ = nullptr;
WGPUBuffer pick_staging_buffer_ = nullptr;
WGPUBuffer pick_normal_staging_buffer_ = nullptr;
WGPUBuffer pick_position_staging_buffer_ = nullptr;
int pick_w_ = 0;
int pick_h_ = 0;
WGPUBuffer box_pick_staging_buffer_ = nullptr;
@@ -895,6 +1008,22 @@ private:
// pick_async_cb_ fires with object_id when the spontaneous map resolves.
bool pick_async_in_flight_ = false;
std::function<void(std::uint32_t)> pick_async_cb_;
// Async box-pick (marquee) state (web). Rect dims are stashed so the
// spontaneous map callback knows how to walk the padded staging rows.
bool box_pick_async_in_flight_ = false;
std::function<void(std::vector<std::uint32_t>)> box_pick_async_cb_;
int box_pick_async_w_ = 0;
int box_pick_async_h_ = 0;
std::uint64_t box_pick_async_padded_bpr_ = 0;
std::uint64_t box_pick_async_bytes_ = 0;
// Async surface pick (section tool): chained id→normal staging maps. Reuses
// pick_async_in_flight_ (same staging buffers as the single object pick).
std::function<void(SurfaceHit)> surface_async_cb_;
int surface_async_x_ = 0;
int surface_async_y_ = 0;
std::uint32_t surface_async_id_ = 0;
Eigen::Vector3f surface_async_normal_ = Eigen::Vector3f::Zero();
void finishSurfaceAsync(SurfaceHit hit);
#endif
// ---- Frame uniforms + selection bind ----------------------------------
@@ -918,6 +1047,13 @@ private:
// removeSectionPlane (still Qt-bound — they wire into the input
// path). Reading happens here.
std::vector<SectionPlane> section_planes_;
// Section-gizmo drag state (shared): which plane, its press-time origin, and
// the press point (logical px) so update can slide it along the normal.
bool section_drag_active_ = false;
int section_drag_index_ = -1;
Eigen::Vector3f section_drag_start_origin_ = Eigen::Vector3f::Zero();
int section_drag_start_mx_ = 0;
int section_drag_start_my_ = 0;
// X-ray mode alpha clamp: when < 1.0 every instance routes through
// the transparent pass with fragment.a clamped to min(in.color.a, cap).
@@ -1026,8 +1162,8 @@ private:
// completes.
std::string pending_screenshot_path_;
// Bonsai direct-load staging map. uploadMeshChunk +
// uploadInstanceChunk append into entries keyed by model_id; the
// Bonsai direct-load staging map. uploadStreamedMesh +
// uploadStreamedInstance append into entries keyed by model_id; the
// finalizeModel call moves the entry out, hands it to
// applyCachedModel, and uploads the chunk slices synchronously.
std::unordered_map<std::uint32_t, std::unique_ptr<SidecarData>>
@@ -1151,6 +1287,10 @@ private:
// Fly-camera move speed (m/s), wheel-adjustable via flyAdjustSpeed. Shared
// by desktop + web fly mode; the mode flag itself lives in each host.
float fly_move_speed_ = 5.0f;
// Nav mouse bindings; default matches the historical "blender" preset.
NavBindings nav_bindings_ = { MouseBtn::Middle, NavMod::Plain,
MouseBtn::Middle, NavMod::Shift,
MouseBtn::Left, NavMod::Plain };
// Perspective by default; toggleProjection (P key) flips this. When
// true, buildViewProj uses an orthographic matrix sized by
// camera_distance_ × tan(fov/2) so toggling looks like a smooth
+57 -129
View File
@@ -436,7 +436,8 @@ void ViewportWindow::encodeOverlaysInMainPass(WGPURenderPassEncoder pass,
// — drawn inside the MSAA pass so depth-test correctly hides them
// behind closer geometry. (Corner axis / marquee / labels run on the
// resolved surface; see encodeOverlaysPostMain.)
overlays_.encodeSectionGizmos(pass, frame, section_planes_);
// NB: section-plane gizmos now draw from ViewportCore::render via the shared
// SectionGizmoRenderer (desktop + web), so they are NOT drawn here.
overlays_.encodeHighlightTriangles(pass, frame);
overlays_.encodePivot(pass, frame, pivot_indicator_visible_);
overlays_.encodeOverlayLines(pass, frame);
@@ -558,17 +559,19 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, StreamingSidecar metada
}
// -----------------------------------------------------------------------------
// Direct-IFC ingestion (mirrors GL ViewportWindow::uploadMeshChunk /
// uploadInstanceChunk / finalizeModel). Streamer pushes chunks; we stage
// Direct-IFC ingestion (mirrors GL ViewportWindow::uploadStreamedMesh /
// uploadStreamedInstance / finalizeModel). Streamer pushes transfer records; we stage
// them into a SidecarData-shaped buffer and commit at finalize via the
// same chunk planner the sidecar load uses.
// -----------------------------------------------------------------------------
// getOrCreateDirectStaging moved to ViewportCore (anon namespace) (#84-q).
void ViewportWindow::uploadMeshChunk(const MeshChunk& chunk) { core_.uploadMeshChunk(chunk); }
void ViewportWindow::uploadStreamedMesh(const StreamedMesh& mesh) { core_.uploadStreamedMesh(mesh); }
void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) { core_.uploadInstanceChunk(chunk); }
void ViewportWindow::uploadStreamedInstance(const StreamedInstance& instance_record) {
core_.uploadStreamedInstance(instance_record);
}
void ViewportWindow::finalizeModel(uint32_t model_id) { core_.finalizeModel(model_id); }
@@ -1047,11 +1050,11 @@ bool ViewportWindow::meshLocalToGlobal(uint32_t object_id,
double global_out[3]) const {
// Find the instance via the per-model object_id_to_instance map.
// Use the live map key (`mid`) — see pickMeshLocalAt comment about
// stale InstanceCpu::model_id from sidecar writes.
// stale InstanceInfo::model_id from sidecar writes.
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(object_id);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
// CoordinateOperation · placement · local — gives the IFC's own
// georeferenced world frame (ENH). Excludes FederatedFalseOrigin
// and ModelTransformation, matching the GL meshLocalToGlobal
@@ -1147,7 +1150,7 @@ void ViewportWindow::invertElementVisibility() {
to_hide.reserve(1024);
for (const auto& [mid, m] : models_gpu_) {
if (m.hidden) continue;
for (const InstanceCpu& inst : m.instances) {
for (const InstanceInfo& inst : m.instances) {
if (inst.object_id == 0) continue;
if (!visibility_.isHidden(inst.object_id)) {
to_hide.push_back(inst.object_id);
@@ -1243,7 +1246,7 @@ void ViewportWindow::updateVolumeReadout() {
total += v;
if (!show_labels) continue;
// O(1) instance lookup via object_id_to_instance, then read the
// world AABB from the cached InstanceCpu directly — same data
// world AABB from the cached InstanceInfo directly — same data
// computeObjectAabb's linear scan would have produced for the
// first matching instance. For label placement at the AABB
// centre this is identical-looking; only the rare multi-
@@ -1251,7 +1254,7 @@ void ViewportWindow::updateVolumeReadout() {
for (const auto& [mid, m] : models_gpu_) {
auto it = m.object_id_to_instance.find(oid);
if (it == m.object_id_to_instance.end()) continue;
const InstanceCpu& inst = m.instances[it->second];
const InstanceInfo& inst = m.instances[it->second];
OverlayRenderer::Label lbl;
lbl.world_pos[0] = (inst.world_aabb_min[0] + inst.world_aabb_max[0]) * 0.5f;
lbl.world_pos[1] = (inst.world_aabb_min[1] + inst.world_aabb_max[1]) * 0.5f;
@@ -1274,93 +1277,12 @@ void ViewportWindow::updateVolumeReadout() {
overlays_.setOverlayLabels(labels);
}
// Project a world point to LOGICAL pixel coords (Qt's mouse-event units).
// Returns false if behind the camera.
static bool projectWorldToLogicalScreen(const Eigen::Matrix4f& vp,
const Eigen::Vector3f& world,
int win_w, int win_h,
Eigen::Vector2f& out) {
const Eigen::Vector4f clip = vp * Eigen::Vector4f(world.x(), world.y(), world.z(), 1.0f);
if (clip.w() <= 0.0f) return false;
const float invw = 1.0f / clip.w();
out = Eigen::Vector2f(
(clip.x() * invw * 0.5f + 0.5f) * float(win_w),
(1.0f - (clip.y() * invw * 0.5f + 0.5f)) * float(win_h));
return true;
}
// projectWorldToLogicalScreen moved to SectionGizmoRenderer (its only users,
// the section hit-test + drag, now live in ViewportCore).
int ViewportWindow::hitTestSectionGizmo(int x, int y) const {
if (section_planes_.empty()) return -1;
const int w = width();
const int h = height();
if (w <= 0 || h <= 0) return -1;
Eigen::Matrix4f view, proj;
core_.buildViewProj(view, proj);
const Eigen::Matrix4f vp = proj * view;
const float grab_px = 12.0f;
int best = -1;
float best_d2 = grab_px * grab_px;
for (int i = 0; i < int(section_planes_.size()); ++i) {
const SectionPlane& p = section_planes_[i];
Eigen::Vector2f s_origin, s_tip;
if (!projectWorldToLogicalScreen(vp, p.origin,
w, h, s_origin)) continue;
// The gizmo's arrow extends along +n by exactly 1 m in world
// space — OverlayRenderer::encodeSectionGizmos uses
// half_size = 1.0 to scale a plane-local arrow tip at z = 1.
// Mirror that here.
if (!projectWorldToLogicalScreen(vp, p.origin + p.n * 1.0f,
w, h, s_tip)) continue;
const Eigen::Vector2f q{float(x), float(y)};
const Eigen::Vector2f ab = s_tip - s_origin;
const float ab_len2 = ab.squaredNorm();
if (ab_len2 < 1e-3f) continue;
float t = (q - s_origin).dot(ab) / ab_len2;
t = std::clamp(t, 0.0f, 1.0f);
const Eigen::Vector2f proj_pt = s_origin + ab * t;
const float d2 = (q - proj_pt).squaredNorm();
if (d2 < best_d2) { best_d2 = d2; best = i; }
}
return best;
}
void ViewportWindow::updateSectionDrag(int x, int y) {
if (!section_drag_active_) return;
if (section_drag_index_ < 0
|| section_drag_index_ >= int(section_planes_.size())) return;
SectionPlane& p = section_planes_[section_drag_index_];
const int w = width();
const int h = height();
if (w <= 0 || h <= 0) return;
Eigen::Matrix4f view, proj;
core_.buildViewProj(view, proj);
const Eigen::Matrix4f vp = proj * view;
// Re-project the press-time origin and origin + n to screen space.
// The press-time origin is what `start` should be relative to — so the
// plane slides smoothly even as the camera moves (we re-project every
// frame to handle mid-drag camera rotation cleanly).
Eigen::Vector2f s_origin, s_n;
if (!projectWorldToLogicalScreen(vp, section_drag_start_origin_,
w, h, s_origin)) return;
if (!projectWorldToLogicalScreen(vp, section_drag_start_origin_ + p.n,
w, h, s_n)) return;
const Eigen::Vector2f screen_axis = s_n - s_origin;
const float screen_axis_len2 = screen_axis.squaredNorm();
if (screen_axis_len2 < 1e-3f) return; // arrow is edge-on
// Project pixel delta onto the screen-space axis; convert to metres
// via (delta · axis) / |axis|² (axis is 1 m long in world space).
const Eigen::Vector2f delta_px(float(x - section_drag_start_mouse_.x()),
float(y - section_drag_start_mouse_.y()));
const float meters = delta_px.dot(screen_axis)
/ screen_axis_len2;
p.origin = section_drag_start_origin_ + p.n * meters;
p.d = -p.n.dot(p.origin);
requestUpdate();
}
// Section-gizmo hit-test + drag-to-move now live in ViewportCore (shared with
// web, using SectionGizmoRenderer::hitTest). The mouse handlers call
// core_.hitTestSectionGizmo / beginSectionDrag / updateSectionDrag / endSectionDrag.
// buildHizPipeline moved to ViewportCore (#84-r).
@@ -1542,21 +1464,32 @@ void ViewportWindow::fpsIntegrate() {
// chunkScreenAreaPx moved to ViewportCore (#84-h).
void ViewportWindow::applyNavPreset(const char* name) {
// Matches GL AppSettings::NavPreset semantics exactly.
// blender — Orbit MMB, Pan Shift+MMB (default)
// rhino — Orbit RMB, Pan Shift+RMB
// revit — Orbit Shift+MMB, Pan MMB
if (name && std::strcmp(name, "rhino") == 0) {
orbit_button_ = Qt::RightButton; orbit_mods_ = Qt::NoModifier;
pan_button_ = Qt::RightButton; pan_mods_ = Qt::ShiftModifier;
} else if (name && std::strcmp(name, "revit") == 0) {
orbit_button_ = Qt::MiddleButton; orbit_mods_ = Qt::ShiftModifier;
pan_button_ = Qt::MiddleButton; pan_mods_ = Qt::NoModifier;
} else {
orbit_button_ = Qt::MiddleButton; orbit_mods_ = Qt::NoModifier;
pan_button_ = Qt::MiddleButton; pan_mods_ = Qt::ShiftModifier;
static Qt::MouseButton toQtBtn(ViewportCore::MouseBtn b) {
switch (b) {
case ViewportCore::MouseBtn::Left: return Qt::LeftButton;
case ViewportCore::MouseBtn::Middle: return Qt::MiddleButton;
case ViewportCore::MouseBtn::Right: return Qt::RightButton;
}
return Qt::LeftButton;
}
static Qt::KeyboardModifiers toQtMod(ViewportCore::NavMod m) {
switch (m) {
case ViewportCore::NavMod::Plain: return Qt::NoModifier;
case ViewportCore::NavMod::Shift: return Qt::ShiftModifier;
case ViewportCore::NavMod::Ctrl: return Qt::ControlModifier;
case ViewportCore::NavMod::Alt: return Qt::AltModifier;
}
return Qt::NoModifier;
}
void ViewportWindow::applyNavPreset(const char* name) {
// The preset table lives in ViewportCore (shared with web). Map its bindings
// to the Qt types the mouse handlers compare against.
core_.setNavPreset(name);
const auto& b = core_.navBindings();
orbit_button_ = toQtBtn(b.orbit); orbit_mods_ = toQtMod(b.orbit_mod);
pan_button_ = toQtBtn(b.pan); pan_mods_ = toQtMod(b.pan_mod);
select_button_ = toQtBtn(b.select); select_mods_ = toQtMod(b.select_mod);
}
// -----------------------------------------------------------------------------
@@ -1615,13 +1548,9 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
&& event->button() == Qt::LeftButton
&& event->modifiers() == Qt::NoModifier) {
const Eigen::Vector2i lp = toV2i(event->position().toPoint());
const int hit = hitTestSectionGizmo(lp.x(), lp.y());
if (hit >= 0) {
section_drag_active_ = true;
section_drag_index_ = hit;
section_drag_start_mouse_ = lp;
section_drag_start_origin_ = section_planes_[hit].origin;
nav_drag_kind_ = NavDrag::Inactive;
const int hit = core_.hitTestSectionGizmo(lp.x(), lp.y());
if (hit >= 0 && core_.beginSectionDrag(hit, lp.x(), lp.y())) {
nav_drag_kind_ = NavDrag::Inactive;
Log::info().noquote().nospace()
<< "[wgpu section] drag start: plane=" << hit;
return;
@@ -1642,15 +1571,15 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
&& (mods & Qt::KeyboardModifierMask) == pan_mods_) {
nav_drag_kind_ = NavDrag::Pan;
setPivotIndicatorVisible(true);
} else if (event->button() == Qt::LeftButton
} else if (event->button() == select_button_
&& !section_tool_active_
&& tool_mode_ != ToolMode::Area
&& tool_mode_ != ToolMode::Length
&& nav_drag_kind_ == NavDrag::Inactive) {
// Arm marquee box-select. Plain / Shift / Ctrl LMB without a tool
// intercepting the click; if the cursor never moves past the
// threshold this stays armed-only and the release falls through
// to single-pick.
// Arm marquee box-select. Plain / Shift / Ctrl on the select button
// (Shift/Ctrl = add/remove) without a tool intercepting the click; if
// the cursor never moves past the threshold this stays armed-only and
// the release falls through to single-pick.
box_select_armed_ = true;
box_select_active_ = false;
box_select_start_pos_ = nav_press_pos_;
@@ -1660,16 +1589,15 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) {
}
void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
if (section_drag_active_ && event->button() == Qt::LeftButton) {
section_drag_active_ = false;
section_drag_index_ = -1;
if (core_.sectionDragActive() && event->button() == Qt::LeftButton) {
core_.endSectionDrag();
nav_active_button_ = Qt::NoButton;
return;
}
// Marquee finalisation: only commit when the drag actually became
// active (cursor moved past threshold). Press-time mods decide the
// set op so a mid-drag Shift release doesn't flip the behaviour.
if (box_select_armed_ && event->button() == Qt::LeftButton) {
if (box_select_armed_ && event->button() == select_button_) {
const bool was_active = box_select_active_;
box_select_armed_ = false;
box_select_active_ = false;
@@ -1711,7 +1639,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
// LMB-click without drag → pick the object under the cursor and
// route through the selection state. Shift = add, Ctrl = remove,
// no modifier = replace. Empty-space click clears.
if (event->button() == Qt::LeftButton && !nav_dragged_) {
if (event->button() == select_button_ && !nav_dragged_) {
const Eigen::Vector2i pos = toV2i(event->position().toPoint());
const int px = int(pos.x() * devicePixelRatio());
const int py = int(pos.y() * devicePixelRatio());
@@ -1861,9 +1789,9 @@ void ViewportWindow::mouseMoveEvent(QMouseEvent* event) {
// Section drag intercepts the move handler entirely: the orbit/pan
// classification already declined this drag in mousePressEvent, so all
// we have to do is slide the plane along its normal.
if (section_drag_active_) {
if (core_.sectionDragActive()) {
const Eigen::Vector2i pos = toV2i(event->position().toPoint());
updateSectionDrag(pos.x(), pos.y());
core_.updateSectionDrag(pos.x(), pos.y());
return;
}
+22 -25
View File
@@ -122,8 +122,8 @@ public:
struct StreamingSidecar metadata);
// Direct-IFC ingestion (mirrors GL ViewportWindow). The host (typically
// a GeometryStreamer running on a worker) calls uploadMeshChunk +
// uploadInstanceChunk once per representation / placement as the IFC
// a GeometryStreamer running on a worker) calls uploadStreamedMesh +
// uploadStreamedInstance once per representation / placement as the IFC
// triangulates; finalizeModel commits when the iterator finishes.
// Staged in CPU memory; finalizeModel runs the chunk planner over the
// staged data, allocates pool slices, and uploads — same render path
@@ -131,8 +131,8 @@ public:
// every chunk lands `is_resident=true` immediately. The streamer's
// model_id is passed through unchanged; the viewport's globally-unique
// object_id rebasing happens at finalize time.
void uploadMeshChunk(const struct MeshChunk& chunk);
void uploadInstanceChunk(const struct InstanceChunk& chunk);
void uploadStreamedMesh(const struct StreamedMesh& mesh);
void uploadStreamedInstance(const struct StreamedInstance& instance_record);
void finalizeModel(uint32_t model_id);
void removeModel(uint32_t model_id);
@@ -239,12 +239,15 @@ public:
private:
// Re-aim the orbit camera so the bounding sphere of [mn, mx] fits.
void frameAabb(const float mn[3], const float mx[3], float padding);
// Resolve nav_preset_ env var to orbit/pan bindings.
void applyNavPreset(const char* name);
// chunkScreenAreaPx moved to ViewportCore (#84-h).
public:
// Apply a nav mouse preset by name ("blender"|"rhino"|"revit"|"web").
// Sources the shared binding table from ViewportCore; called from init
// (env / persisted setting) and live from the Settings dialog.
void applyNavPreset(const char* name);
// Queue a one-shot framebuffer capture: the next rendered frame is
// copied back to host memory and saved to `path` as PNG. If
@@ -393,7 +396,7 @@ public:
// A point that actually lies on the model's first instance — the
// first instance's mesh AABB centre transformed by that instance's
// placement, in metres, pre-CoordinateOperation. Lookup only — the
// viewport already keeps the CPU-side MeshInfo + InstanceCpu around
// viewport already keeps the CPU-side MeshInfo + InstanceInfo around
// for picking / measurement; the federation false-origin guess
// (ViewportView::guessFederatedFalseOriginFromFirstModel) consumes
// this lazily on modelGeometryReady. Returns false when the model
@@ -734,20 +737,9 @@ private:
Qt::KeyboardModifiers box_select_press_mods_ = Qt::NoModifier;
static constexpr int kBoxSelectThresholdPx = 5;
// box_pick_staging_buffer_/_capacity_ moved to ViewportCore (#84-t).
// Drag-to-move state for the arrow gizmo. While `section_drag_active_`
// is true, mouseMoveEvent calls updateSectionDrag instead of letting
// the press fall through to the orbit/pan handlers.
bool section_drag_active_ = false;
int section_drag_index_ = -1;
Eigen::Vector2i section_drag_start_mouse_;
Eigen::Vector3f section_drag_start_origin_;
// Mirrors GL ViewportWindow::hitTestSectionGizmo: returns the index of
// the plane whose arrow gizmo is within grab_px of (x, y), or -1.
int hitTestSectionGizmo(int x, int y) const;
// Mirrors GL ViewportWindow::updateSectionDrag: projects the cursor
// delta onto the plane's normal in screen space and slides the plane
// along that direction.
void updateSectionDrag(int x, int y);
// Section-gizmo drag state + hit-test/drag math moved to ViewportCore
// (shared with web; the mouse handlers call core_.begin/update/endSectionDrag
// and core_.hitTestSectionGizmo).
// HiZ slot + pyramid aliases (storage in core_, #84-r). VW's render
// loop reads hiz_valid_ / hiz_vp_ to gate the HizOccludedFn, and
@@ -800,10 +792,15 @@ private:
// so the click-vs-drag distinction at mouseReleaseEvent's pick path keeps
// working. Set at init from WGPU_NAV_PRESET=blender|rhino|revit (default
// blender, matching GL's AppSettings::NavPreset::Blender default).
Qt::MouseButton orbit_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier;
Qt::MouseButton pan_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier;
// Mirror of ViewportCore's preset bindings, mapped to Qt types by
// applyNavPreset (the core owns the preset table; these are the Qt-side
// cache the mouse handlers compare against).
Qt::MouseButton orbit_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier;
Qt::MouseButton pan_button_ = Qt::MiddleButton;
Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier;
Qt::MouseButton select_button_ = Qt::LeftButton;
Qt::KeyboardModifiers select_mods_ = Qt::NoModifier;
// Set by mousePressEvent based on which binding matched; consumed by
// mouseMoveEvent so mid-drag modifier changes don't switch axes.
enum class NavDrag : uint8_t { Inactive, Orbit, Pan };
@@ -283,7 +283,7 @@ namespace {
ModelGpuData make_model_with_one_instance(uint32_t object_id, uint32_t mesh_id,
double placement_tx) {
ModelGpuData m;
InstanceCpu inst{};
InstanceInfo inst{};
inst.mesh_id = mesh_id;
inst.object_id = object_id;
// Column-major identity with a tx for verification.
@@ -21,7 +21,7 @@
// vertex quantization used to fill it.
//
// quantizeVertex / octEncodeNormal (VertexQuantization.h) are the shared
// production helpers: ViewportWindow::uploadMeshChunk and SidecarBuilder both
// production helpers: ViewportWindow::uploadStreamedMesh and SidecarBuilder both
// route through them so the rendered VBO and the on-disk .ifcview record are
// byte-identical. The tests exercise that real implementation directly:
// - runtime size/alignment assertions (defense in depth for the static_asserts)
@@ -245,14 +245,14 @@ TEST_CASE("quantizeVertex passes the packed color through unchanged", "[instgeom
REQUIRE(std::memcmp(dst + INSTANCED_VERTEX_COLOR_OFFSET, rgba, 4) == 0);
}
TEST_CASE("MeshChunk and InstanceChunk default-init to zeroed metadata", "[instgeom]") {
MeshChunk mc;
TEST_CASE("StreamedMesh and StreamedInstance default-init to zeroed metadata", "[instgeom]") {
StreamedMesh mc;
REQUIRE(mc.model_id == 0);
REQUIRE(mc.local_mesh_id == 0);
REQUIRE(mc.vertices.empty());
REQUIRE(mc.indices.empty());
InstanceChunk ic;
StreamedInstance ic;
REQUIRE(ic.model_id == 0);
REQUIRE(ic.local_mesh_id == 0);
REQUIRE(ic.object_id == 0);
+8 -7
View File
@@ -83,7 +83,7 @@ SidecarData buildFixture() {
sd.instances.resize(5);
for (size_t i = 0; i < sd.instances.size(); ++i) {
InstanceCpu& inst = sd.instances[i];
InstanceInfo& inst = sd.instances[i];
inst.mesh_id = (i < 3) ? 0u : 1u;
inst.object_id = uint32_t(100 + i);
inst.color_override_rgba8 = uint32_t(0xAA000000u | (i * 0x010203u));
@@ -109,11 +109,10 @@ SidecarData buildFixture() {
sd.string_table = std::string("\0Wall\0Slab\0", 11); // includes embedded NULs
sd.elements.resize(3);
for (size_t i = 0; i < sd.elements.size(); ++i) {
PackedElementInfo& e = sd.elements[i];
ElementTableRecord& e = sd.elements[i];
e.object_id = uint32_t(100 + i);
e.model_id = 1;
e.ifc_id = int32_t(1000 + i);
e.parent_id = (i == 0) ? -1 : int32_t(100);
e.guid_offset = 0; e.guid_length = 0;
e.name_offset = 1; e.name_length = 4; // "Wall"
e.type_offset = 6; e.type_length = 4; // "Slab"
@@ -136,10 +135,10 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
if (std::memcmp(&a.meshes[i], &b.meshes[i], sizeof(MeshInfo)) != 0) return false;
}
for (size_t i = 0; i < a.instances.size(); ++i) {
if (std::memcmp(&a.instances[i], &b.instances[i], sizeof(InstanceCpu)) != 0) return false;
if (std::memcmp(&a.instances[i], &b.instances[i], sizeof(InstanceInfo)) != 0) return false;
}
for (size_t i = 0; i < a.elements.size(); ++i) {
if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(PackedElementInfo)) != 0) return false;
if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(ElementTableRecord)) != 0) return false;
}
// v11 georef block.
@@ -155,10 +154,12 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
} // namespace
TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") {
TEST_CASE("MeshInfo and InstanceInfo have stable layouts (sidecar wire format)", "[sidecar]") {
REQUIRE(sizeof(MeshInfo) == 56);
REQUIRE(sizeof(InstanceInfo) == 232);
REQUIRE(sizeof(InstanceGpu) == 80);
REQUIRE(SIDECAR_VERSION == 16);
REQUIRE(sizeof(ElementTableRecord) == 36);
REQUIRE(SIDECAR_VERSION == 17);
REQUIRE(sizeof(SidecarChunk) == 56);
REQUIRE(SIDECAR_MAGIC == 0x49465657u);
}
+2 -2
View File
@@ -81,7 +81,7 @@ SidecarData buildFixture() {
for (uint32_t k = 0; k < 3; ++k) { // outer loop = interleave
for (int i = 0; i < N; ++i) {
if (k >= ninst(i)) continue;
InstanceCpu ic;
InstanceInfo ic;
ic.mesh_id = uint32_t(i); // authoritative
ic.object_id = obj++;
ic.model_id = 1;
@@ -114,7 +114,7 @@ struct InstSig {
}
};
InstSig sigFor(const SidecarData& sd, const InstanceCpu& inst) {
InstSig sigFor(const SidecarData& sd, const InstanceInfo& inst) {
const MeshInfo& m = sd.meshes.at(inst.mesh_id);
InstSig s{};
s.verts.assign(sd.vertices.begin() + m.vbo_byte_offset,
+13 -14
View File
@@ -84,7 +84,6 @@ SidecarData buildFixture() {
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);
}
// v16 stores geometry per-chunk (compressed); a fixture with geometry needs
// a chunk TOC covering its meshes (one chunk per mesh here).
@@ -113,8 +112,8 @@ TEST_CASE("readSidecarMetadataOnly returns metadata, skips bulk geometry",
// 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);
// The element metadata block locator is recorded for on-demand fetch.
REQUIRE(meta->element_metadata_comp_size > 0);
// Metadata round-trips.
REQUIRE(meta->meta.meshes.size() == sd.meshes.size());
@@ -197,37 +196,37 @@ TEST_CASE("parseSidecarHead validates magic / version, reads geom length", "[str
REQUIRE_FALSE(parseSidecarHead(bad, sizeof(bad), got));
}
TEST_CASE("v16 deferred block: fetch via locator, decompress, parse", "[streaming]") {
fs::path dir = makeScratchDir("v16def");
TEST_CASE("v16 element metadata block: fetch via locator, decompress, parse", "[streaming]") {
fs::path dir = makeScratchDir("v16element");
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.meshes.size() == sd.meshes.size()); // critical
REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); // geometry metadata
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);
REQUIRE(meta->meta.elements.size() == sd.elements.size()); // desktop reads element metadata too
REQUIRE(meta->element_metadata_comp_size > 0);
// The on-demand path (web) fetches the compressed deferred frame via the
// The on-demand path (web) fetches the compressed element metadata 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> cz(size_t(meta->deferred_comp_size));
std::fseek(f, long(meta->deferred_comp_offset), SEEK_SET);
std::vector<uint8_t> cz(size_t(meta->element_metadata_comp_size));
std::fseek(f, long(meta->element_metadata_comp_offset), SEEK_SET);
REQUIRE(std::fread(cz.data(), 1, cz.size(), f) == cz.size());
std::fclose(f);
std::vector<uint8_t> raw(size_t(meta->deferred_raw_size));
std::vector<uint8_t> raw(size_t(meta->element_metadata_raw_size));
REQUIRE(SidecarCompress::decompress(cz.data(), cz.size(), raw.data(), raw.size()));
SidecarData d;
REQUIRE(parseSidecarDeferred(raw.data(), raw.size(), d));
REQUIRE(parseSidecarElementMetadata(raw.data(), raw.size(), d));
REQUIRE(d.elements.size() == sd.elements.size());
REQUIRE(d.string_table == sd.string_table);
SidecarData chopped;
REQUIRE_FALSE(parseSidecarDeferred(raw.data(), raw.size() - 1, chopped));
REQUIRE_FALSE(parseSidecarElementMetadata(raw.data(), raw.size() - 1, chopped));
}
TEST_CASE("planSidecarReadRanges coalesces adjacent ranges, keeps far ones split",
@@ -143,6 +143,63 @@ TEST_CASE("toggleXray flips the active state", "[camera][xray]") {
REQUIRE_FALSE(core.xrayActive());
}
TEST_CASE("section planes: add appends, clear drops all, capped at the max",
"[camera][section]") {
MockHost host; ViewportCore core(&host);
REQUIRE(core.sectionPlaneCount() == 0);
const Eigen::Vector3f pt(1, 2, 3), n(0, 0, 1);
REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.sectionPlaneCount() == 2);
// Fill to the cap (kMaxSectionPlanes == 6); further adds are rejected.
while (core.sectionPlaneCount() < 6) REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE_FALSE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.sectionPlaneCount() == 6);
core.clearSectionPlanes();
REQUIRE(core.sectionPlaneCount() == 0);
}
TEST_CASE("setNavPreset maps names to the shared button bindings", "[camera][nav]") {
MockHost host; ViewportCore core(&host);
using B = ViewportCore::MouseBtn; using M = ViewportCore::NavMod;
// Default is blender: orbit MMB, pan Shift+MMB, select LMB.
{
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Middle); REQUIRE(b.orbit_mod == M::Plain);
REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Shift);
REQUIRE(b.select == B::Left); REQUIRE(b.select_mod == M::Plain);
}
SECTION("web: orbit LMB, pan MMB, select RMB") {
core.setNavPreset("web");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Left); REQUIRE(b.orbit_mod == M::Plain);
REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Plain);
REQUIRE(b.select == B::Right); REQUIRE(b.select_mod == M::Plain);
}
SECTION("rhino: orbit RMB, pan Shift+RMB") {
core.setNavPreset("rhino");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Right); REQUIRE(b.pan == B::Right);
REQUIRE(b.pan_mod == M::Shift); REQUIRE(b.select == B::Left);
}
SECTION("revit: orbit Shift+MMB, pan MMB") {
core.setNavPreset("revit");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Middle); REQUIRE(b.orbit_mod == M::Shift);
REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Plain);
}
SECTION("unknown name falls back to blender") {
core.setNavPreset("web");
core.setNavPreset("nonsense");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Middle); REQUIRE(b.select == B::Left);
}
}
TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibility]") {
MockHost host; ViewportCore core(&host);
REQUIRE(core.hiddenCount() == 0);
@@ -158,3 +215,25 @@ TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibi
core.showAll();
REQUIRE(core.hiddenCount() == 0);
}
TEST_CASE("applyMarqueeToSelection: replace / add / remove", "[camera][selection]") {
MockHost host; ViewportCore core(&host);
// No public selection accessor, so verify via hideSelected → hiddenCount.
SECTION("plain marquee replaces the selection") {
core.applyMarqueeToSelection({1, 2, 3}, /*add*/false, /*remove*/false);
core.hideSelected();
REQUIRE(core.hiddenCount() == 3);
}
SECTION("add unions, remove subtracts") {
core.applyMarqueeToSelection({5}, false, false); // replace → {5}
core.applyMarqueeToSelection({6, 7}, true, false); // add → {5,6,7}
core.applyMarqueeToSelection({6}, false, true); // remove → {5,7}
core.hideSelected();
REQUIRE(core.hiddenCount() == 2);
}
SECTION("id 0 is ignored") {
core.applyMarqueeToSelection({0, 9, 0}, false, false);
core.hideSelected();
REQUIRE(core.hiddenCount() == 1);
}
}