Route bonsai through wgpu; delete the GL backend

Bonsai now drives the wgpu viewport for both sidecar and direct-IFC
loads. The GL viewer and its supporting state classes are gone.

SceneLoader rewire:
- Takes WgpuViewportWindow* instead of ViewportWindow*.
- Sidecar path reads metadata only (readSidecarMetadataOnly) and hands
  the StreamingSidecar off to the new applyCachedModel. Field accesses
  inside applySidecarData go through .meta.
- Direct-IFC path uses the wgpu A-path (upload{Mesh,Instance}Chunk +
  finalizeModel). The applyLodExtension call is dropped — wgpu has no
  live LOD1 splice; LOD1 still lands in the on-disk sidecar for the
  next open.

Bonsai migration:
- ViewportWindow → WgpuViewportWindow across MainWindow, Measurement,
  SessionState, and every modules/*/{Commands,Panel,View}.{h,cpp} —
  116 sites total. Same s/OverlayRenderer::/WgpuOverlayRenderer::/
  rename, 12 sites.
- Includes flipped from ../ifcviewer/ViewportWindow.h to
  ../ifcviewer-wgpu/WgpuViewportWindow.h. OverlayRenderer.h include
  dropped (transitively reached via the viewport header).
- BonsaiViewer links IfcViewerWgpu in addition to IfcViewer for the
  duration of the migration; the GL-side IfcViewer also publicly links
  IfcViewerWgpu so SceneLoader can resolve WgpuViewportWindow.

GL backend deletion:
- src/ifcviewer/ViewportWindow.{cpp,h}, BvhAccel.*, OverlayRenderer.*,
  Selection.*, Visibility.* all gone.
- src/ifcviewer-minimal/ removed entirely (MinimalWindow drove the GL
  viewport).
- src/ifcviewer/tests: test_bvh_accel, test_selection, test_visibility
  removed. The first has no replacement (wgpu doesn't use a per-instance
  BVH); the latter two are ported separately. test_lod_builder,
  test_sidecar_cache, test_instanced_geometry, test_federation remain
  (backend-agnostic).
- IfcViewer's CMakeLists drops OpenGL, Qt::OpenGL, Qt::Widgets — none
  of the surviving translation units reach for them.

Build flag plumbing:
- BUILD_BONSAIVIEWER now auto-enables BUILD_BONSAIVIEWER_WGPU since
  SceneLoader requires the wgpu lib for its WgpuViewportWindow* arg.
- The wgpu subprojects add_subdirectory ahead of the GL one so
  IfcViewerWgpu exists when IfcViewer's link evaluates.
- src/ifcviewer-minimal subdir reference removed from cmake/CMakeLists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-01 17:38:33 +10:00
parent 9c067d1d0e
commit 2981500b3b
40 changed files with 208 additions and 8008 deletions
-145
View File
@@ -1,145 +0,0 @@
/********************************************************************************
* *
* 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 "BvhAccel.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <limits>
namespace {
struct Centroid {
float x, y, z;
};
Centroid computeCentroid(const BvhItem& it) {
return {
(it.aabb_min[0] + it.aabb_max[0]) * 0.5f,
(it.aabb_min[1] + it.aabb_max[1]) * 0.5f,
(it.aabb_min[2] + it.aabb_max[2]) * 0.5f
};
}
void computeAABB(const std::vector<BvhItem>& items,
const uint32_t* indices, uint32_t count,
float out_min[3], float out_max[3]) {
out_min[0] = out_min[1] = out_min[2] = std::numeric_limits<float>::max();
out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits<float>::max();
for (uint32_t i = 0; i < count; ++i) {
const auto& it = items[indices[i]];
for (int a = 0; a < 3; ++a) {
if (it.aabb_min[a] < out_min[a]) out_min[a] = it.aabb_min[a];
if (it.aabb_max[a] > out_max[a]) out_max[a] = it.aabb_max[a];
}
}
}
void buildRecursive(ModelBvh& mbvh,
const std::vector<BvhItem>& items,
uint32_t start, uint32_t count) {
uint32_t node_idx = static_cast<uint32_t>(mbvh.nodes.size());
mbvh.nodes.emplace_back();
BvhNode& node = mbvh.nodes[node_idx];
computeAABB(items, &mbvh.item_indices[start], count,
node.aabb_min, node.aabb_max);
if (count <= BVH_MAX_LEAF_SIZE) {
node.right_or_first = start;
node.count = static_cast<uint16_t>(count);
node.axis = 0;
return;
}
float extent[3] = {
node.aabb_max[0] - node.aabb_min[0],
node.aabb_max[1] - node.aabb_min[1],
node.aabb_max[2] - node.aabb_min[2]
};
int axis = 0;
if (extent[1] > extent[axis]) axis = 1;
if (extent[2] > extent[axis]) axis = 2;
uint32_t mid = count / 2;
std::nth_element(
mbvh.item_indices.begin() + start,
mbvh.item_indices.begin() + start + mid,
mbvh.item_indices.begin() + start + count,
[&](uint32_t a, uint32_t b) {
Centroid ca = computeCentroid(items[a]);
Centroid cb = computeCentroid(items[b]);
return (&ca.x)[axis] < (&cb.x)[axis];
});
node.count = 0;
node.axis = static_cast<uint16_t>(axis);
buildRecursive(mbvh, items, start, mid);
uint32_t right_child_idx = static_cast<uint32_t>(mbvh.nodes.size());
buildRecursive(mbvh, items, start + mid, count - mid);
mbvh.nodes[node_idx].right_or_first = right_child_idx;
}
ModelBvh buildModelBvh(const std::vector<BvhItem>& items,
const std::vector<uint32_t>& model_item_indices,
uint32_t model_id) {
ModelBvh mbvh;
mbvh.model_id = model_id;
mbvh.item_indices = model_item_indices;
uint32_t count = static_cast<uint32_t>(model_item_indices.size());
if (count == 0) return mbvh;
mbvh.nodes.reserve(count * 2);
buildRecursive(mbvh, items, 0, count);
assert(!mbvh.nodes.empty());
return mbvh;
}
} // anonymous namespace
ModelBvh buildModelBvhOne(const std::vector<BvhItem>& items, uint32_t model_id) {
std::vector<uint32_t> idxs(items.size());
for (uint32_t i = 0; i < items.size(); ++i) idxs[i] = i;
return buildModelBvh(items, idxs, model_id);
}
std::shared_ptr<BvhSet> buildBvhSet(const std::vector<BvhItem>& items) {
auto bvh_set = std::make_shared<BvhSet>();
std::unordered_map<uint32_t, std::vector<uint32_t>> model_items;
for (uint32_t i = 0; i < static_cast<uint32_t>(items.size()); ++i) {
model_items[items[i].model_id].push_back(i);
}
for (auto& [model_id, idxs] : model_items) {
if (idxs.size() < BVH_MIN_OBJECTS) continue;
ModelBvh mbvh = buildModelBvh(items, idxs, model_id);
bvh_set->bvh_model_ids.insert(model_id);
bvh_set->models[model_id] = std::move(mbvh);
}
return bvh_set;
}
-70
View File
@@ -1,70 +0,0 @@
/********************************************************************************
* *
* 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 BVHACCEL_H
#define BVHACCEL_H
#include <cstdint>
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <memory>
// Generic BVH item — anything with a world AABB and a model_id.
// For the instanced renderer each item represents one InstanceCpu.
struct BvhItem {
float aabb_min[3];
float aabb_max[3];
uint32_t model_id;
};
static constexpr uint32_t BVH_MAX_LEAF_SIZE = 8;
static constexpr uint32_t BVH_MIN_OBJECTS = 32;
struct BvhNode {
float aabb_min[3];
float aabb_max[3];
uint32_t right_or_first; // interior: right child index (left is always this_index+1); leaf: first item index
uint16_t count; // 0 = interior; >0 = leaf with this many items
uint16_t axis; // split axis (0/1/2) for interior; unused for leaf
};
static_assert(sizeof(BvhNode) == 32, "BvhNode must be 32 bytes for cache alignment and sidecar format");
struct ModelBvh {
uint32_t model_id = 0;
std::vector<BvhNode> nodes;
std::vector<uint32_t> item_indices; // indices into the model's InstanceCpu array
};
struct BvhSet {
std::unordered_map<uint32_t, ModelBvh> models;
std::unordered_set<uint32_t> bvh_model_ids;
};
// Build BVH trees for all models in the given item snapshot.
// Items are expected to already be grouped/filtered by caller if needed.
// item_indices in the result reference positions within the full `items`
// vector — callers providing a single model's items will see 0..N-1.
std::shared_ptr<BvhSet> buildBvhSet(const std::vector<BvhItem>& items);
// Build a single-model BVH over `items`. model_id is stored on the result
// for identification; item_indices will be 0..items.size()-1.
ModelBvh buildModelBvhOne(const std::vector<BvhItem>& items, uint32_t model_id);
#endif // BVHACCEL_H
+4 -6
View File
@@ -23,9 +23,7 @@ set(QT_VERSION 6 CACHE STRING "Qt version")
# IfcViewerLib always needs OpenGL in addition to Core/Gui/Widgets. We don't
# use the CACHE'd QT_COMPONENTS here because it may have been set by another
# target (e.g. qtviewer) without the OpenGL component.
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Widgets OpenGL REQUIRED PATHS ${QT_DIR})
find_package(OpenGL REQUIRED)
find_package(Qt${QT_VERSION} COMPONENTS Core Gui REQUIRED PATHS ${QT_DIR})
file(GLOB IFCVIEWER_CPP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
file(GLOB IFCVIEWER_H_FILES ${CMAKE_CURRENT_SOURCE_DIR}/*.h)
@@ -59,10 +57,10 @@ target_link_libraries(IfcViewer PUBLIC
${CGAL_LIBRARIES}
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Gui
Qt${QT_VERSION}::Widgets
Qt${QT_VERSION}::OpenGL
OpenGL::GL
${MESH_OPTIMIZER_LIB}
# SceneLoader drives WgpuViewportWindow; the wgpu lib also provides
# the include path for WgpuViewportWindow.h that SceneLoader.h pulls in.
IfcViewerWgpu
)
if(UNIX AND NOT APPLE)
-725
View File
@@ -1,725 +0,0 @@
/********************************************************************************
* *
* 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 "OverlayRenderer.h"
#include <QFont>
#include <QFontDatabase>
#include <QFontMetrics>
#include <QPainter>
#include <QtGlobal>
#include <QtOpenGL/QOpenGLPaintDevice>
namespace {
GLuint compile(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* src) {
GLuint s = gl->glCreateShader(type);
gl->glShaderSource(s, 1, &src, nullptr);
gl->glCompileShader(s);
GLint ok = 0;
gl->glGetShaderiv(s, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[2048];
gl->glGetShaderInfoLog(s, sizeof(log), nullptr, log);
qWarning("OverlayRenderer shader compile error: %s", log);
}
return s;
}
GLuint link(QOpenGLFunctions_4_5_Core* gl, GLuint vs, GLuint fs) {
GLuint p = gl->glCreateProgram();
gl->glAttachShader(p, vs);
gl->glAttachShader(p, fs);
gl->glLinkProgram(p);
GLint ok = 0;
gl->glGetProgramiv(p, GL_LINK_STATUS, &ok);
if (!ok) {
char log[2048];
gl->glGetProgramInfoLog(p, sizeof(log), nullptr, log);
qWarning("OverlayRenderer program link error: %s", log);
}
gl->glDeleteShader(vs);
gl->glDeleteShader(fs);
return p;
}
QFont overlayTextFont(int point_size) {
QFont font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
font.setPointSize(point_size);
font.setStyleHint(QFont::TypeWriter);
return font;
}
// ---- Triangle program (flat color) ----
const char* TRI_VS = R"(
#version 450 core
layout(location = 0) in vec3 in_pos;
uniform mat4 u_view_proj;
void main() {
gl_Position = u_view_proj * vec4(in_pos, 1.0);
}
)";
const char* TRI_FS = R"(
#version 450 core
uniform vec4 u_color;
out vec4 frag_color;
void main() {
frag_color = u_color;
}
)";
// ---- Point sprite program (outlined disc via gl_PointCoord) ----
const char* POINT_VS = R"(
#version 450 core
layout(location = 0) in vec3 in_pos;
uniform mat4 u_view_proj;
uniform float u_point_size;
void main() {
gl_Position = u_view_proj * vec4(in_pos, 1.0);
gl_PointSize = u_point_size;
}
)";
// inner_radius_norm is the inner-disc radius as a fraction of the
// half-sprite (so 1.0 = no stroke, smaller = thicker stroke). The
// fragment shader reads gl_PointCoord (range [0,1] across the sprite),
// computes the distance from the centre normalised against the half-
// sprite, picks inner vs stroke with a sharp `step()` (no soft band),
// then anti-aliases the *outer* edge only.
const char* POINT_FS = R"(
#version 450 core
uniform vec4 u_inner_color;
uniform vec4 u_stroke_color;
uniform float u_inner_radius_norm;
out vec4 frag_color;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c) * 2.0; // 0 at centre, 1 at sprite edge
if (d > 1.0) discard;
float t_inner = step(u_inner_radius_norm, d);
vec4 col = mix(u_inner_color, u_stroke_color, t_inner);
float aa = fwidth(d);
float outer_alpha = smoothstep(1.0, 1.0 - aa, d);
frag_color = vec4(col.rgb, col.a * outer_alpha);
}
)";
// ---- Line program (screen-space-expanded quads with outline) ----
//
// Per-vertex layout: (in_a, in_b, in_side, in_along), 8 floats total.
// The vertex shader projects both endpoints to screen pixels, computes
// the screen-space perpendicular, and offsets *this* corner accordingly.
// Output v_dist_px is the signed perpendicular distance from the line
// axis at this corner; linear interpolation across the quad gives the
// per-fragment distance the FS uses to discard / pick inner vs stroke.
const char* LINE_VS = R"(
#version 450 core
layout(location = 0) in vec3 in_a;
layout(location = 1) in vec3 in_b;
layout(location = 2) in float in_side; // -1 or +1
layout(location = 3) in float in_along; // 0 (at a) or 1 (at b)
uniform mat4 u_view_proj;
uniform vec2 u_screen_size; // physical pixels
uniform float u_half_width; // inner half-width (px)
uniform float u_stroke_extra; // halo per side (px)
out float v_dist_px;
out float v_along_px; // distance from segment start (px)
void main() {
vec4 clip_a = u_view_proj * vec4(in_a, 1.0);
vec4 clip_b = u_view_proj * vec4(in_b, 1.0);
// Project to screen pixels.
vec2 screen_a = (clip_a.xy / clip_a.w) * 0.5 * u_screen_size;
vec2 screen_b = (clip_b.xy / clip_b.w) * 0.5 * u_screen_size;
vec2 delta = screen_b - screen_a;
float len = length(delta);
vec2 dir = (len > 1e-6) ? (delta / len) : vec2(1.0, 0.0);
vec2 perp = vec2(-dir.y, dir.x);
// Offset this corner perpendicular to the line.
vec4 clip_self = mix(clip_a, clip_b, in_along);
vec2 screen_self = (clip_self.xy / clip_self.w) * 0.5 * u_screen_size;
float total_half = u_half_width + u_stroke_extra;
screen_self += perp * in_side * total_half;
// Back to NDC, then to clip space (multiply by w to undo the w-divide
// GL is about to apply). Depth is preserved from the picked endpoint.
vec2 ndc_out = screen_self / (u_screen_size * 0.5);
gl_Position = vec4(ndc_out * clip_self.w, clip_self.z, clip_self.w);
v_dist_px = in_side * total_half;
v_along_px = in_along * len;
}
)";
// ---- Screen-space rect program (label + HUD backgrounds) ----
//
// Skip QPainter::fillRect entirely — on QOpenGLPaintDevice it's
// unreliable across drivers. Backgrounds are drawn as raw GL quads
// using NDC-space coordinates; QPainter only renders the text on top.
const char* RECT_VS = R"(
#version 450 core
layout(location = 0) in vec2 in_ndc;
void main() {
gl_Position = vec4(in_ndc, 0.0, 1.0);
}
)";
const char* RECT_FS = R"(
#version 450 core
uniform vec4 u_color;
out vec4 frag_color;
void main() {
frag_color = u_color;
}
)";
const char* LINE_FS = R"(
#version 450 core
in float v_dist_px;
in float v_along_px;
uniform vec4 u_inner_color;
uniform vec4 u_stroke_color;
uniform float u_half_width;
uniform float u_stroke_extra;
uniform float u_dash_period; // 0 = solid
uniform float u_dash_on_ratio;
out vec4 frag_color;
void main() {
if (u_dash_period > 0.0) {
float t = mod(v_along_px, u_dash_period);
if (t > u_dash_period * u_dash_on_ratio) discard;
}
float ad = abs(v_dist_px);
float total = u_half_width + u_stroke_extra;
if (ad > total) discard;
// Sharp inner-to-stroke transition; AA only the outer halo edge so
// the line reads crisp instead of mushy.
float t_stroke = step(u_half_width, ad);
vec4 col = mix(u_inner_color, u_stroke_color, t_stroke);
float outer_a = smoothstep(total, total - 1.0, ad);
frag_color = vec4(col.rgb, col.a * outer_a);
}
)";
void uploadFloats(QOpenGLFunctions_4_5_Core* gl,
GLuint vbo, size_t& capacity_bytes,
const std::vector<float>& data) {
const size_t bytes = data.size() * sizeof(float);
if (bytes == 0) return;
if (bytes > capacity_bytes) {
const size_t new_cap = bytes + bytes / 2;
gl->glBindBuffer(GL_ARRAY_BUFFER, vbo);
gl->glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(new_cap), nullptr, GL_DYNAMIC_DRAW);
capacity_bytes = new_cap;
}
gl->glBindBuffer(GL_ARRAY_BUFFER, vbo);
gl->glBufferSubData(GL_ARRAY_BUFFER, 0, GLsizeiptr(bytes), data.data());
gl->glBindBuffer(GL_ARRAY_BUFFER, 0);
}
void uploadFloatBytes(QOpenGLFunctions_4_5_Core* gl,
GLuint vbo, size_t& capacity_bytes,
const float* data, size_t float_count) {
const size_t bytes = float_count * sizeof(float);
if (bytes == 0) return;
if (bytes > capacity_bytes) {
const size_t new_cap = bytes + bytes / 2;
gl->glBindBuffer(GL_ARRAY_BUFFER, vbo);
gl->glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(new_cap), nullptr, GL_DYNAMIC_DRAW);
capacity_bytes = new_cap;
}
gl->glBindBuffer(GL_ARRAY_BUFFER, vbo);
gl->glBufferSubData(GL_ARRAY_BUFFER, 0, GLsizeiptr(bytes), data);
gl->glBindBuffer(GL_ARRAY_BUFFER, 0);
}
// CPU expansion of N segments (3 floats * 2 verts per segment, packed) into
// 6 vertices per segment, each carrying (a, b, side, along) = 8 floats.
void expandLineSegments(const std::vector<float>& endpoints,
std::vector<float>& out) {
out.clear();
if (endpoints.size() < 6) return;
const size_t n_segs = endpoints.size() / 6;
out.reserve(n_segs * 6 * 8);
static const float CORNERS[6][2] = {
{-1.0f, 0.0f}, {+1.0f, 0.0f}, {-1.0f, 1.0f},
{-1.0f, 1.0f}, {+1.0f, 0.0f}, {+1.0f, 1.0f},
};
for (size_t s = 0; s < n_segs; ++s) {
const float* a = &endpoints[s * 6 + 0];
const float* b = &endpoints[s * 6 + 3];
for (int c = 0; c < 6; ++c) {
out.push_back(a[0]); out.push_back(a[1]); out.push_back(a[2]);
out.push_back(b[0]); out.push_back(b[1]); out.push_back(b[2]);
out.push_back(CORNERS[c][0]);
out.push_back(CORNERS[c][1]);
}
}
}
} // namespace
void OverlayRenderer::initialize(QOpenGLFunctions_4_5_Core* gl) {
if (gl_) return;
gl_ = gl;
// Triangle program.
{
GLuint vs = compile(gl_, GL_VERTEX_SHADER, TRI_VS);
GLuint fs = compile(gl_, GL_FRAGMENT_SHADER, TRI_FS);
program_tri_ = link(gl_, vs, fs);
u_tri_view_proj_ = gl_->glGetUniformLocation(program_tri_, "u_view_proj");
u_tri_color_ = gl_->glGetUniformLocation(program_tri_, "u_color");
}
// Point program.
{
GLuint vs = compile(gl_, GL_VERTEX_SHADER, POINT_VS);
GLuint fs = compile(gl_, GL_FRAGMENT_SHADER, POINT_FS);
program_pt_ = link(gl_, vs, fs);
u_pt_view_proj_ = gl_->glGetUniformLocation(program_pt_, "u_view_proj");
u_pt_point_size_ = gl_->glGetUniformLocation(program_pt_, "u_point_size");
u_pt_inner_color_ = gl_->glGetUniformLocation(program_pt_, "u_inner_color");
u_pt_stroke_color_ = gl_->glGetUniformLocation(program_pt_, "u_stroke_color");
u_pt_inner_radius_ = gl_->glGetUniformLocation(program_pt_, "u_inner_radius_norm");
}
// Line program.
{
GLuint vs = compile(gl_, GL_VERTEX_SHADER, LINE_VS);
GLuint fs = compile(gl_, GL_FRAGMENT_SHADER, LINE_FS);
program_ln_ = link(gl_, vs, fs);
u_ln_view_proj_ = gl_->glGetUniformLocation(program_ln_, "u_view_proj");
u_ln_screen_size_ = gl_->glGetUniformLocation(program_ln_, "u_screen_size");
u_ln_half_width_ = gl_->glGetUniformLocation(program_ln_, "u_half_width");
u_ln_stroke_extra_ = gl_->glGetUniformLocation(program_ln_, "u_stroke_extra");
u_ln_inner_color_ = gl_->glGetUniformLocation(program_ln_, "u_inner_color");
u_ln_stroke_color_ = gl_->glGetUniformLocation(program_ln_, "u_stroke_color");
u_ln_dash_period_ = gl_->glGetUniformLocation(program_ln_, "u_dash_period");
u_ln_dash_on_ratio_ = gl_->glGetUniformLocation(program_ln_, "u_dash_on_ratio");
}
// Screen-space rect program.
{
GLuint vs = compile(gl_, GL_VERTEX_SHADER, RECT_VS);
GLuint fs = compile(gl_, GL_FRAGMENT_SHADER, RECT_FS);
program_rect_ = link(gl_, vs, fs);
u_rect_color_ = gl_->glGetUniformLocation(program_rect_, "u_color");
}
// Triangle VAO/VBO: one vec3 attribute.
gl_->glCreateVertexArrays(1, &triangles_.vao);
gl_->glCreateBuffers(1, &triangles_.vbo);
gl_->glEnableVertexArrayAttrib(triangles_.vao, 0);
gl_->glVertexArrayAttribFormat(triangles_.vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
gl_->glVertexArrayAttribBinding(triangles_.vao, 0, 0);
gl_->glVertexArrayVertexBuffer(triangles_.vao, 0, triangles_.vbo,
0, 3 * sizeof(float));
// Point VAO/VBO: one vec3 attribute.
gl_->glCreateVertexArrays(1, &points_.vao);
gl_->glCreateBuffers(1, &points_.vbo);
gl_->glEnableVertexArrayAttrib(points_.vao, 0);
gl_->glVertexArrayAttribFormat(points_.vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
gl_->glVertexArrayAttribBinding(points_.vao, 0, 0);
gl_->glVertexArrayVertexBuffer(points_.vao, 0, points_.vbo,
0, 3 * sizeof(float));
// Line VAO/VBO: 8 floats per vertex (a:vec3, b:vec3, side, along).
// Shared across every group; line_draws_ records the (first, count)
// slice for each.
gl_->glCreateVertexArrays(1, &vao_lines_);
gl_->glCreateBuffers(1, &vbo_lines_);
const GLsizei stride = 8 * sizeof(float);
gl_->glEnableVertexArrayAttrib(vao_lines_, 0);
gl_->glVertexArrayAttribFormat(vao_lines_, 0, 3, GL_FLOAT, GL_FALSE, 0);
gl_->glVertexArrayAttribBinding(vao_lines_, 0, 0);
gl_->glEnableVertexArrayAttrib(vao_lines_, 1);
gl_->glVertexArrayAttribFormat(vao_lines_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao_lines_, 1, 0);
gl_->glEnableVertexArrayAttrib(vao_lines_, 2);
gl_->glVertexArrayAttribFormat(vao_lines_, 2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao_lines_, 2, 0);
gl_->glEnableVertexArrayAttrib(vao_lines_, 3);
gl_->glVertexArrayAttribFormat(vao_lines_, 3, 1, GL_FLOAT, GL_FALSE, 7 * sizeof(float));
gl_->glVertexArrayAttribBinding(vao_lines_, 3, 0);
gl_->glVertexArrayVertexBuffer(vao_lines_, 0, vbo_lines_, 0, stride);
// Screen-rect VAO/VBO: 2 floats per vertex (vec2 NDC).
gl_->glCreateVertexArrays(1, &vao_rect_);
gl_->glCreateBuffers(1, &vbo_rect_);
gl_->glEnableVertexArrayAttrib(vao_rect_, 0);
gl_->glVertexArrayAttribFormat(vao_rect_, 0, 2, GL_FLOAT, GL_FALSE, 0);
gl_->glVertexArrayAttribBinding(vao_rect_, 0, 0);
gl_->glVertexArrayVertexBuffer(vao_rect_, 0, vbo_rect_, 0, 2 * sizeof(float));
}
void OverlayRenderer::release() {
if (!gl_) return;
if (triangles_.vbo) gl_->glDeleteBuffers(1, &triangles_.vbo);
if (triangles_.vao) gl_->glDeleteVertexArrays(1, &triangles_.vao);
if (points_.vbo) gl_->glDeleteBuffers(1, &points_.vbo);
if (points_.vao) gl_->glDeleteVertexArrays(1, &points_.vao);
if (vbo_lines_) gl_->glDeleteBuffers(1, &vbo_lines_);
if (vao_lines_) gl_->glDeleteVertexArrays(1, &vao_lines_);
if (vbo_rect_) gl_->glDeleteBuffers(1, &vbo_rect_);
if (vao_rect_) gl_->glDeleteVertexArrays(1, &vao_rect_);
if (program_tri_) gl_->glDeleteProgram(program_tri_);
if (program_pt_) gl_->glDeleteProgram(program_pt_);
if (program_ln_) gl_->glDeleteProgram(program_ln_);
if (program_rect_) gl_->glDeleteProgram(program_rect_);
triangles_ = {};
points_ = {};
line_draws_.clear();
vao_lines_ = vbo_lines_ = 0;
vbo_lines_capacity_ = 0;
vao_rect_ = vbo_rect_ = 0;
vbo_rect_capacity_ = 0;
program_tri_ = program_pt_ = program_ln_ = program_rect_ = 0;
gl_ = nullptr;
}
void OverlayRenderer::setHudText(const QString& text) {
hud_text_ = text;
}
void OverlayRenderer::setOverlayLabels(const std::vector<Label>& labels) {
labels_ = labels;
}
void OverlayRenderer::setSelectionRect(const QRect& rect_logical) {
selection_rect_ = rect_logical;
}
void OverlayRenderer::setHighlightTriangles(const std::vector<float>& world_xyz,
float r, float g, float b, float a) {
if (!gl_) return;
triangles_.color[0] = r; triangles_.color[1] = g;
triangles_.color[2] = b; triangles_.color[3] = a;
triangles_.vertex_count = GLsizei(world_xyz.size() / 3);
uploadFloats(gl_, triangles_.vbo, triangles_.vbo_capacity, world_xyz);
}
void OverlayRenderer::setOverlayPoints(const std::vector<float>& world_xyz,
float r, float g, float b, float a,
float pixel_size,
float sr, float sg, float sb, float sa,
float stroke_extra) {
if (!gl_) return;
points_.inner_color[0] = r; points_.inner_color[1] = g;
points_.inner_color[2] = b; points_.inner_color[3] = a;
points_.stroke_color[0] = sr; points_.stroke_color[1] = sg;
points_.stroke_color[2] = sb; points_.stroke_color[3] = sa;
points_.pixel_size = pixel_size;
points_.stroke_extra = stroke_extra;
points_.vertex_count = GLsizei(world_xyz.size() / 3);
uploadFloats(gl_, points_.vbo, points_.vbo_capacity, world_xyz);
}
void OverlayRenderer::setOverlayLines(const std::vector<LineGroup>& groups) {
if (!gl_) return;
line_draws_.clear();
// Concatenate every group's CPU-expanded vertices into one big buffer
// and remember each group's (first, count) slice + style so render()
// can iterate without re-expanding.
std::vector<float> combined;
for (const auto& g : groups) {
std::vector<float> exp;
expandLineSegments(g.world_xyz, exp);
if (exp.empty()) continue;
LineDrawCall dc;
std::memcpy(dc.color, g.color, sizeof(dc.color));
std::memcpy(dc.stroke_color, g.stroke_color, sizeof(dc.stroke_color));
dc.line_width = g.line_width;
dc.stroke_extra = g.stroke_extra;
dc.dash_period_px = g.dash_period_px;
dc.dash_on_ratio = g.dash_on_ratio;
dc.first = GLint(combined.size() / 8);
dc.count = GLsizei(exp.size() / 8);
line_draws_.push_back(dc);
combined.insert(combined.end(), exp.begin(), exp.end());
}
uploadFloats(gl_, vbo_lines_, vbo_lines_capacity_, combined);
}
void OverlayRenderer::render(const float view_proj[16],
int pixel_w, int pixel_h, qreal dpr) {
if (!gl_) return;
// Save GL state we touch.
GLboolean prev_blend = gl_->glIsEnabled(GL_BLEND);
GLboolean prev_cull = gl_->glIsEnabled(GL_CULL_FACE);
GLboolean prev_pt_size = gl_->glIsEnabled(GL_PROGRAM_POINT_SIZE);
GLboolean prev_depth_msk = GL_TRUE;
gl_->glGetBooleanv(GL_DEPTH_WRITEMASK, &prev_depth_msk);
GLint prev_depth_func = GL_LESS;
gl_->glGetIntegerv(GL_DEPTH_FUNC, &prev_depth_func);
GLint prev_blend_src = GL_ONE, prev_blend_dst = GL_ZERO;
gl_->glGetIntegerv(GL_BLEND_SRC_ALPHA, &prev_blend_src);
gl_->glGetIntegerv(GL_BLEND_DST_ALPHA, &prev_blend_dst);
gl_->glEnable(GL_BLEND);
gl_->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
gl_->glDisable(GL_CULL_FACE);
gl_->glDepthMask(GL_FALSE);
gl_->glDepthFunc(GL_LEQUAL);
gl_->glEnable(GL_PROGRAM_POINT_SIZE);
if (triangles_.vertex_count > 0 && triangles_.color[3] > 0.0f) {
// Highlight triangles stay depth-aware (GL_LEQUAL) so they tint
// the surface in place rather than poking through walls.
gl_->glUseProgram(program_tri_);
gl_->glUniformMatrix4fv(u_tri_view_proj_, 1, GL_FALSE, view_proj);
gl_->glUniform4fv(u_tri_color_, 1, triangles_.color);
gl_->glBindVertexArray(triangles_.vao);
gl_->glDrawArrays(GL_TRIANGLES, 0, triangles_.vertex_count);
}
// Measurement annotations (lines + points) draw on top of every other
// pass — the standard CAD convention. GL_ALWAYS wins every depth
// compare; GL_LEQUAL is restored at the end of the function.
gl_->glDepthFunc(GL_ALWAYS);
if (!line_draws_.empty()) {
gl_->glUseProgram(program_ln_);
gl_->glUniformMatrix4fv(u_ln_view_proj_, 1, GL_FALSE, view_proj);
gl_->glUniform2f(u_ln_screen_size_, float(pixel_w), float(pixel_h));
gl_->glBindVertexArray(vao_lines_);
for (const auto& dc : line_draws_) {
if (dc.count == 0 || dc.color[3] <= 0.0f) continue;
gl_->glUniform1f(u_ln_half_width_, dc.line_width * 0.5f);
gl_->glUniform1f(u_ln_stroke_extra_, dc.stroke_extra);
gl_->glUniform4fv(u_ln_inner_color_, 1, dc.color);
gl_->glUniform4fv(u_ln_stroke_color_, 1, dc.stroke_color);
gl_->glUniform1f(u_ln_dash_period_, dc.dash_period_px);
gl_->glUniform1f(u_ln_dash_on_ratio_, dc.dash_on_ratio);
gl_->glDrawArrays(GL_TRIANGLES, dc.first, dc.count);
}
}
if (points_.vertex_count > 0 && points_.inner_color[3] > 0.0f) {
// Inner-radius ratio in [0, 1]: how much of the sprite is the
// inner colour vs the stroke band. pixel_size is the inner-disc
// diameter; the sprite (and gl_PointSize) is enlarged by
// 2*stroke_extra so the halo has somewhere to draw.
const float total = points_.pixel_size + 2.0f * points_.stroke_extra;
const float inner_ratio = (total > 0.0f)
? (points_.pixel_size / total) : 1.0f;
gl_->glUseProgram(program_pt_);
gl_->glUniformMatrix4fv(u_pt_view_proj_, 1, GL_FALSE, view_proj);
gl_->glUniform1f(u_pt_point_size_, total);
gl_->glUniform1f(u_pt_inner_radius_, inner_ratio);
gl_->glUniform4fv(u_pt_inner_color_, 1, points_.inner_color);
gl_->glUniform4fv(u_pt_stroke_color_, 1, points_.stroke_color);
gl_->glBindVertexArray(points_.vao);
gl_->glDrawArrays(GL_POINTS, 0, points_.vertex_count);
}
gl_->glBindVertexArray(0);
if (!prev_blend) gl_->glDisable(GL_BLEND);
gl_->glBlendFunc(prev_blend_src, prev_blend_dst);
if (prev_cull) gl_->glEnable(GL_CULL_FACE);
if (!prev_pt_size) gl_->glDisable(GL_PROGRAM_POINT_SIZE);
gl_->glDepthMask(prev_depth_msk);
gl_->glDepthFunc(prev_depth_func);
if (pixel_w <= 0 || pixel_h <= 0) return;
const float logical_w = float(pixel_w) / float(dpr ? dpr : 1.0);
const float logical_h = float(pixel_h) / float(dpr ? dpr : 1.0);
auto px_to_ndc_x = [logical_w](float px) {
return (px / logical_w) * 2.0f - 1.0f;
};
auto px_to_ndc_y = [logical_h](float px) {
return 1.0f - (px / logical_h) * 2.0f;
};
// Box-select rectangle: translucent fill + 1-px outline drawn as
// four thin rects. Comes before the HUD/label pass so the HUD
// backgrounds still render on top of the rectangle if they overlap.
if (selection_rect_.isValid()
&& selection_rect_.width() > 0
&& selection_rect_.height() > 0) {
struct RectPx { float x0, y0, x1, y1; };
const QRect& sr = selection_rect_;
const float sx0 = float(sr.left());
const float sy0 = float(sr.top());
const float sx1 = float(sr.right() + 1);
const float sy1 = float(sr.bottom() + 1);
const RectPx pieces[5] = {
// Fill
{sx0, sy0, sx1, sy1},
// Top edge
{sx0, sy0, sx1, sy0 + 1.0f},
// Bottom edge
{sx0, sy1 - 1.0f, sx1, sy1},
// Left edge
{sx0, sy0, sx0 + 1.0f, sy1},
// Right edge
{sx1 - 1.0f, sy0, sx1, sy1},
};
const float colors[5][4] = {
{0.30f, 0.65f, 1.0f, 0.18f}, // fill
{0.30f, 0.65f, 1.0f, 0.9f}, // outline (each edge)
{0.30f, 0.65f, 1.0f, 0.9f},
{0.30f, 0.65f, 1.0f, 0.9f},
{0.30f, 0.65f, 1.0f, 0.9f},
};
GLboolean prev_dt2 = gl_->glIsEnabled(GL_DEPTH_TEST);
GLboolean prev_cf2 = gl_->glIsEnabled(GL_CULL_FACE);
GLboolean prev_bl2 = gl_->glIsEnabled(GL_BLEND);
gl_->glDisable(GL_DEPTH_TEST);
gl_->glDisable(GL_CULL_FACE);
gl_->glEnable(GL_BLEND);
gl_->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
gl_->glUseProgram(program_rect_);
gl_->glBindVertexArray(vao_rect_);
// Each piece is its own draw so we can switch alpha between
// fill and outline. All five share the same VBO slot — we
// stream-overwrite per draw.
for (int i = 0; i < 5; ++i) {
const float x0 = px_to_ndc_x(pieces[i].x0);
const float x1 = px_to_ndc_x(pieces[i].x1);
const float y0 = px_to_ndc_y(pieces[i].y0);
const float y1 = px_to_ndc_y(pieces[i].y1);
const float ndc[12] = {
x0, y0, x1, y0, x0, y1,
x0, y1, x1, y0, x1, y1
};
uploadFloatBytes(gl_, vbo_rect_, vbo_rect_capacity_, ndc, 12);
gl_->glUniform4f(u_rect_color_,
colors[i][0], colors[i][1],
colors[i][2], colors[i][3]);
gl_->glDrawArrays(GL_TRIANGLES, 0, 6);
}
gl_->glBindVertexArray(0);
if (prev_dt2) gl_->glEnable(GL_DEPTH_TEST);
if (prev_cf2) gl_->glEnable(GL_CULL_FACE);
if (!prev_bl2) gl_->glDisable(GL_BLEND);
}
// Two-stage HUD/label pass: collect rect bounds (in logical pixels) +
// text strings, draw all rect backgrounds via GL (screen-space NDC
// quads, depth test off), then run a QPainter pass that *only* draws
// text on top. Side-stepping QPainter::fillRect entirely avoids the
// QOpenGLPaintDevice quirk where solid fills silently drop while
// text continues to render.
const bool any_painter = !hud_text_.isEmpty() || !labels_.empty();
if (!any_painter) return;
QFont label_font = overlayTextFont(9);
QFont hud_font = overlayTextFont(11);
const QFontMetrics lfm(label_font);
const QFontMetrics hfm(hud_font);
const int label_pad_x = 4, label_pad_y = 2;
const int hud_pad_x = 10, hud_pad_y = 6;
const int hud_margin = 12;
struct PaintItem { QRect bg; QString text; const QFont* font; int align; };
std::vector<PaintItem> items;
items.reserve(labels_.size() + 1);
// World-anchored label rects.
for (const auto& lbl : labels_) {
const float* p = lbl.world_pos;
// Column-major: M[col*4 + row].
const float wx = view_proj[0]*p[0] + view_proj[4]*p[1] + view_proj[8]*p[2] + view_proj[12];
const float wy = view_proj[1]*p[0] + view_proj[5]*p[1] + view_proj[9]*p[2] + view_proj[13];
const float ww = view_proj[3]*p[0] + view_proj[7]*p[1] + view_proj[11]*p[2] + view_proj[15];
if (ww <= 0.0f) continue; // behind camera
const float ndc_x = wx / ww;
const float ndc_y = wy / ww;
if (ndc_x < -1.0f || ndc_x > 1.0f
|| ndc_y < -1.0f || ndc_y > 1.0f) continue;
const float sx = (ndc_x * 0.5f + 0.5f) * logical_w;
const float sy = (1.0f - (ndc_y * 0.5f + 0.5f)) * logical_h;
const int tw = lfm.horizontalAdvance(lbl.text);
const int th = lfm.height();
QRect bg(int(sx) - tw / 2 - label_pad_x,
int(sy) - th / 2 - label_pad_y,
tw + 2 * label_pad_x,
th + 2 * label_pad_y);
items.push_back({bg, lbl.text, &label_font, Qt::AlignCenter});
}
// HUD rect (always top-left if any text).
if (!hud_text_.isEmpty()) {
const QStringList lines = hud_text_.split('\n');
int tw = 0;
for (const auto& ln : lines) tw = qMax(tw, hfm.horizontalAdvance(ln));
const int th = hfm.height() * lines.size();
QRect bg(hud_margin, hud_margin,
tw + 2 * hud_pad_x,
th + 2 * hud_pad_y);
items.push_back({bg, hud_text_, &hud_font, int(Qt::AlignLeft | Qt::AlignTop)});
}
// GL pass: draw all background rects as NDC-space triangles.
if (!items.empty()) {
std::vector<float> ndc;
ndc.reserve(items.size() * 12); // 6 verts * 2 floats per rect
for (const auto& it : items) {
const float x0 = px_to_ndc_x(float(it.bg.left()));
const float x1 = px_to_ndc_x(float(it.bg.right() + 1));
const float y0 = px_to_ndc_y(float(it.bg.top()));
const float y1 = px_to_ndc_y(float(it.bg.bottom() + 1));
ndc.insert(ndc.end(), {
x0, y0, x1, y0, x0, y1,
x0, y1, x1, y0, x1, y1
});
}
uploadFloats(gl_, vbo_rect_, vbo_rect_capacity_, ndc);
// GL_TRIANGLES respects GL_CULL_FACE; the NDC→window y-flip turns
// our CCW NDC quads into window-CW which get back-culled if cull
// is on (which it is by default in this app). Disable cull for
// the rect pass — lines+points above were unaffected since
// GL_LINES / GL_POINTS skip face culling entirely.
GLboolean prev_depth_test = gl_->glIsEnabled(GL_DEPTH_TEST);
GLboolean prev_cull_face = gl_->glIsEnabled(GL_CULL_FACE);
gl_->glDisable(GL_DEPTH_TEST);
gl_->glDisable(GL_CULL_FACE);
gl_->glDisable(GL_BLEND);
gl_->glUseProgram(program_rect_);
gl_->glUniform4f(u_rect_color_, 0.08f, 0.08f, 0.08f, 1.0f);
gl_->glBindVertexArray(vao_rect_);
gl_->glDrawArrays(GL_TRIANGLES, 0, GLsizei(items.size() * 6));
gl_->glBindVertexArray(0);
if (prev_depth_test) gl_->glEnable(GL_DEPTH_TEST);
if (prev_cull_face) gl_->glEnable(GL_CULL_FACE);
}
// QPainter pass: text only, on top of the GL-drawn backgrounds.
QOpenGLPaintDevice device(QSize(pixel_w, pixel_h));
device.setDevicePixelRatio(dpr);
QPainter painter(&device);
painter.setRenderHint(QPainter::TextAntialiasing);
painter.setPen(Qt::white);
for (const auto& it : items) {
painter.setFont(*it.font);
const int px = it.font == &hud_font ? hud_pad_x : label_pad_x;
const int py = it.font == &hud_font ? hud_pad_y : label_pad_y;
painter.drawText(it.bg.adjusted(px, py, -px, -py), it.align, it.text);
}
}
-197
View File
@@ -1,197 +0,0 @@
/********************************************************************************
* *
* 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 IFCVIEWER_OVERLAYRENDERER_H
#define IFCVIEWER_OVERLAYRENDERER_H
#include <QRect>
#include <QString>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
#include <vector>
// Neutral overlay-primitive renderer attached to ViewportWindow. Today it
// draws a single tinted, translucent triangle list in world space — used by
// the area-measurement tool to shade selected coplanar patches. More
// primitives (lines, points, world-anchored labels) will land here as
// future tools require them.
//
// Lifetime: owned by ViewportWindow, initialised in the same context. All
// public methods assume the caller has already made the GL context current.
class OverlayRenderer {
public:
void initialize(QOpenGLFunctions_4_5_Core* gl);
void release();
// Replace the highlight-triangle list. `world_xyz` is 3 floats per
// vertex, 3 verts per triangle, in world space (post-composed-transform).
// Empty disables the overlay. Color is RGBA in [0, 1].
void setHighlightTriangles(const std::vector<float>& world_xyz,
float r, float g, float b, float a);
// One stylistic group of line segments rendered through the
// outlined / optionally-dashed line shader. Multiple groups in a
// single setOverlayLines call let the caller mix solid + dashed +
// axis-coloured legs in one frame (e.g. the length tool's white
// total line + RGB XYZ stair-step + dashed perpendicular).
struct LineGroup {
std::vector<float> world_xyz; // 6 floats per segment (a, b)
float color[4] = {1, 1, 1, 1}; // inner color
float stroke_color[4] = {0, 0, 0, 1}; // outline (0 alpha = no outline)
float line_width = 1.5f; // pixels (inner)
float stroke_extra = 0.5f; // pixels per side outside inner
float dash_period_px = 0.0f; // 0 = solid; else screen-space dash period
float dash_on_ratio = 0.6f; // [0..1], used only when period > 0
};
void setOverlayLines(const std::vector<LineGroup>& groups);
// Replace the overlay-point list (3 floats per point, world space).
// `pixel_size` is the inner-dot diameter in physical pixels.
//
// When stroke_a > 0, every point is rendered twice: a wider
// (pixel_size + 2*stroke_extra) outer dot in stroke_color, then the
// pixel_size inner dot in the main color — giving a crisp halo that
// reads on any background.
void setOverlayPoints(const std::vector<float>& world_xyz,
float r, float g, float b, float a,
float pixel_size,
float stroke_r, float stroke_g, float stroke_b, float stroke_a,
float stroke_extra);
// World-anchored text labels: each one is projected to screen space
// and drawn via QPainter at that pixel. Used today for per-segment
// length readouts in the length tool.
struct Label {
float world_pos[3];
QString text;
};
void setOverlayLabels(const std::vector<Label>& labels);
// Top-left HUD text drawn via QPainter on the GL surface as part of
// render(). Empty hides the HUD.
void setHudText(const QString& text);
// Box-select rectangle (in logical pixel coords, top-left origin).
// Drawn as a translucent fill + 1-px outline using the same
// screen-space rect program that draws label/HUD backgrounds.
// Empty rect hides it.
void setSelectionRect(const QRect& rect_logical);
// Render every overlay primitive in order: GL highlight triangles
// (using `view_proj`, column-major float[16]), then HUD text via
// QPainter on a QOpenGLPaintDevice sized to (pixel_w × pixel_h)
// with the supplied device pixel ratio. Caller is responsible for
// ensuring glViewport covers the full surface — the QPainter pass
// after this call leaves GL state in an undefined shape, so treat
// this as the last GL operation per frame before swapBuffers (or
// sandwich it before any pass that re-binds its own programs).
void render(const float view_proj[16],
int pixel_w, int pixel_h, qreal device_pixel_ratio);
private:
// Triangle bundle: position-only VBO, single-color shader.
struct TriBundle {
GLuint vao = 0;
GLuint vbo = 0;
size_t vbo_capacity = 0;
GLsizei vertex_count = 0;
float color[4] = {0, 0, 0, 0};
};
// Point bundle: position-only VBO, sprite shader uses gl_PointCoord
// to draw an antialiased disc with an outlined halo.
struct PointBundle {
GLuint vao = 0;
GLuint vbo = 0;
size_t vbo_capacity = 0;
GLsizei vertex_count = 0;
float inner_color[4] = {0, 0, 0, 0};
float stroke_color[4] = {0, 0, 0, 0};
float pixel_size = 8.0f;
float stroke_extra = 0.0f;
};
// Per-group draw-call record. setOverlayLines populates one of these
// per LineGroup, with `first` indexing into a shared expanded-vertex
// VBO. At render time we iterate them, set per-group uniforms, and
// issue one glDrawArrays each.
struct LineDrawCall {
float color[4] = {1, 1, 1, 1};
float stroke_color[4] = {0, 0, 0, 0};
float line_width = 1.5f;
float stroke_extra = 0.5f;
float dash_period_px = 0.0f;
float dash_on_ratio = 0.6f;
GLint first = 0;
GLsizei count = 0;
};
QOpenGLFunctions_4_5_Core* gl_ = nullptr;
// Triangle program (highlight tris).
GLuint program_tri_ = 0;
GLint u_tri_view_proj_ = -1;
GLint u_tri_color_ = -1;
// Point program (sprite).
GLuint program_pt_ = 0;
GLint u_pt_view_proj_ = -1;
GLint u_pt_point_size_ = -1;
GLint u_pt_inner_color_ = -1;
GLint u_pt_stroke_color_ = -1;
GLint u_pt_inner_radius_ = -1;
// Line program (screen-space expanded quads).
GLuint program_ln_ = 0;
GLint u_ln_view_proj_ = -1;
GLint u_ln_screen_size_ = -1;
GLint u_ln_half_width_ = -1;
GLint u_ln_stroke_extra_ = -1;
GLint u_ln_inner_color_ = -1;
GLint u_ln_stroke_color_ = -1;
GLint u_ln_dash_period_ = -1;
GLint u_ln_dash_on_ratio_ = -1;
// Screen-space rect program (label + HUD backgrounds). Vertex
// attribute is vec2 NDC; fragment outputs a uniform color. Drawn
// with depth test off so rects stack on top of the entire scene.
GLuint program_rect_ = 0;
GLint u_rect_color_ = -1;
GLuint vao_rect_ = 0;
GLuint vbo_rect_ = 0;
size_t vbo_rect_capacity_ = 0;
TriBundle triangles_;
PointBundle points_;
// Lines: one shared VAO/VBO holding the concatenated expanded
// vertices of every group; line_draws_ records each group's slice.
GLuint vao_lines_ = 0;
GLuint vbo_lines_ = 0;
size_t vbo_lines_capacity_ = 0;
std::vector<LineDrawCall> line_draws_;
std::vector<Label> labels_;
QString hud_text_;
// Box-select rectangle in logical pixels. Null/empty = hidden.
QRect selection_rect_;
};
#endif // IFCVIEWER_OVERLAYRENDERER_H
+31 -28
View File
@@ -37,7 +37,7 @@ void SceneLoader::setShouldWriteSidecar(bool enabled) {
should_write_sidecar_ = enabled;
}
SceneLoader::SceneLoader(ViewportWindow* viewport, QObject* parent)
SceneLoader::SceneLoader(WgpuViewportWindow* viewport, QObject* parent)
: QObject(parent), viewport_(viewport)
{
connect(&element_poll_timer_, &QTimer::timeout,
@@ -189,11 +189,11 @@ void SceneLoader::startNextLoad() {
joinSidecarThread();
sidecar_read_thread_ = std::thread([this, ifc_path, mid, is_sidecar_source]() {
QElapsedTimer rt; rt.start();
auto cached = readSidecar(ifc_path);
qDebug(" Sidecar read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str());
auto result = std::make_shared<std::optional<SidecarData>>(std::move(cached));
auto cached = readSidecarMetadataOnly(ifc_path);
qDebug(" Sidecar metadata read: %lld ms (%s)", rt.elapsed(), ifc_path.c_str());
auto result = std::make_shared<std::optional<StreamingSidecar>>(std::move(cached));
QMetaObject::invokeMethod(this, [this, mid, result, is_sidecar_source]() {
if (*result && !(*result)->instances.empty()) {
if (*result && !(*result)->meta.instances.empty()) {
applySidecarData(mid, std::move(**result));
if (!is_sidecar_source) {
startDataSourceLoad(mid);
@@ -233,36 +233,37 @@ void SceneLoader::startStreamLoadFor(uint32_t mid) {
m.file_path.toStdString(), next_object_id_, loading_model_id_);
}
void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) {
void SceneLoader::applySidecarData(uint32_t mid, StreamingSidecar metadata) {
auto it = models_.find(mid);
if (it == models_.end()) return;
auto& model = it->second;
SidecarData& d = metadata.meta;
qDebug("Sidecar hit: %s (%zu verts, %zu indices, %zu meshes, %zu instances, %zu elements)",
qDebug("Sidecar hit: %s (%zu metadata bytes, %zu indices, %zu meshes, %zu instances, %zu elements)",
model.file_path.toStdString().c_str(),
data.vertices.size() / INSTANCED_VERTEX_STRIDE_BYTES,
data.indices.size(),
data.meshes.size(),
data.instances.size(),
data.elements.size());
size_t(metadata.vertex_total_bytes),
size_t(metadata.index_total_count),
d.meshes.size(),
d.instances.size(),
d.elements.size());
// Rebase object/model IDs onto the current session's ID space. Two
// cached models both starting at object_id=1 would collide otherwise.
uint32_t min_oid = UINT32_MAX;
for (const auto& pe : data.elements) {
for (const auto& pe : d.elements) {
if (pe.object_id < min_oid) min_oid = pe.object_id;
}
uint32_t oid_offset = 0;
if (!data.elements.empty() && min_oid < UINT32_MAX) {
if (!d.elements.empty() && min_oid < UINT32_MAX) {
oid_offset = next_object_id_ - min_oid;
}
for (auto& pe : data.elements) {
for (auto& pe : d.elements) {
pe.object_id += oid_offset;
pe.model_id = mid;
if (pe.object_id >= next_object_id_)
next_object_id_ = pe.object_id + 1;
}
for (auto& inst : data.instances) {
for (auto& inst : d.instances) {
inst.object_id += oid_offset;
inst.model_id = mid;
}
@@ -273,26 +274,26 @@ void SceneLoader::applySidecarData(uint32_t mid, SidecarData data) {
// .ifc/.rdb sibling is absent.
{
ModelGeoref& gr = model.georef;
gr.has_coordinate_operation = data.has_coordinate_operation != 0;
gr.has_coordinate_operation = d.has_coordinate_operation != 0;
Eigen::Map<const Eigen::Matrix<double, 4, 4, Eigen::ColMajor>> M(
data.coordinate_operation_meters);
d.coordinate_operation_meters);
gr.coordinate_operation_meters = M;
gr.units.project_length_to_meters = data.project_length_to_meters;
gr.units.map_unit_to_meters = data.map_unit_to_meters;
gr.units.project_length_to_meters = d.project_length_to_meters;
gr.units.map_unit_to_meters = d.map_unit_to_meters;
model.has_georef = true;
}
if (!data.instances.empty() && !model.has_first_placement) {
if (!d.instances.empty() && !model.has_first_placement) {
using Mat4dCol = Eigen::Matrix<double, 4, 4, Eigen::ColMajor>;
model.first_placement =
Eigen::Map<const Mat4dCol>(data.instances[0].placement_transformation);
Eigen::Map<const Mat4dCol>(d.instances[0].placement_transformation);
model.has_first_placement = true;
}
std::vector<PackedElementInfo> elements = std::move(data.elements);
std::string stbl = std::move(data.string_table);
std::vector<PackedElementInfo> elements = std::move(d.elements);
std::string stbl = std::move(d.string_table);
viewport_->applyCachedModel(mid, std::move(data));
viewport_->applyCachedModel(mid, std::move(metadata));
emit sidecarElementsReady(mid, std::move(elements), std::move(stbl));
@@ -397,8 +398,11 @@ void SceneLoader::onStreamerFinished() {
next_object_id_ = m.streamer->lastObjectId();
viewport_->finalizeModel(mid);
// Sidecar finalize + LOD application + disk write. Runs on the
// GUI thread so applyLodExtension's GL touches are safe.
// Sidecar finalize + disk write. Wgpu has no live LOD1 apply —
// LOD1 indices land in the on-disk sidecar and are picked up
// on the *next* open of this file; first-session view is
// LOD0-only. Acceptable trade-off vs reallocating chunk index
// slices live to splice LOD1 in.
if (m.sidecar_builder) {
ModelGeoref georef;
if (auto* file = m.streamer->ifcFile()) {
@@ -406,7 +410,6 @@ void SceneLoader::onStreamerFinished() {
}
QElapsedTimer wt; wt.start();
SidecarData data = m.sidecar_builder->finalize(georef, m.streamed_elements);
viewport_->applyLodExtension(mid, data);
const bool ok = writeSidecar(m.file_path.toStdString(), data);
qDebug(" Sidecar finalize + write: %lld ms (%s)",
wt.elapsed(), ok ? "ok" : "FAILED");
+6 -5
View File
@@ -35,12 +35,13 @@
#include <vector>
#include "Federation.h"
#include "ViewportWindow.h"
#include "../ifcviewer-wgpu/WgpuViewportWindow.h"
#include "../ifcviewer-wgpu/WgpuStreamingLoader.h"
#include "GeometryStreamer.h"
#include "SidecarBuilder.h"
#include "SidecarCache.h"
// Drives IFC file loading into a ViewportWindow. Owns the per-model
// Drives IFC file loading into a WgpuViewportWindow. Owns the per-model
// GeometryStreamer, the load queue, the sidecar read thread, and the
// next-free object_id counter used to rebase cached models onto the
// current session's ID space.
@@ -54,7 +55,7 @@
class SceneLoader : public QObject {
Q_OBJECT
public:
explicit SceneLoader(ViewportWindow* viewport, QObject* parent = nullptr);
explicit SceneLoader(WgpuViewportWindow* viewport, QObject* parent = nullptr);
~SceneLoader();
// Sidecar cache use is opt-in per direction. Embedders that don't care
@@ -175,10 +176,10 @@ private:
void connectStreamer(GeometryStreamer* streamer);
void joinSidecarThread();
void joinDataSourceThreads();
void applySidecarData(uint32_t mid, SidecarData data);
void applySidecarData(uint32_t mid, StreamingSidecar metadata);
void startDataSourceLoad(uint32_t mid);
ViewportWindow* viewport_ = nullptr;
WgpuViewportWindow* viewport_ = nullptr;
bool should_read_sidecar_ = false;
bool should_write_sidecar_ = false;
std::map<uint32_t, Model> models_;
-184
View File
@@ -1,184 +0,0 @@
/********************************************************************************
* *
* 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 "Selection.h"
#include <algorithm>
SelectionState::SelectionState(QObject* parent) : QObject(parent) {
// Reserve slot 0 — object_id 0 is the "no object" sentinel and the
// shader still indexes the buffer with v_object_id, so the slot must
// exist (and be zero) to avoid OOB reads.
cpu_flags_.assign(1, 0u);
}
SelectionState::~SelectionState() = default;
void SelectionState::initializeGl(QOpenGLFunctions_4_5_Core* gl) {
gl_ = gl;
if (ssbo_ == 0) {
gl_->glCreateBuffers(1, &ssbo_);
}
// Force a fresh upload against the new context.
dirty_ = true;
}
void SelectionState::releaseGl() {
if (gl_ && ssbo_) {
gl_->glDeleteBuffers(1, &ssbo_);
}
ssbo_ = 0;
ssbo_capacity_slots_ = 0;
gl_ = nullptr;
dirty_ = true;
}
void SelectionState::reset() {
const bool had_state = !selected_ids_.empty() || active_id_ != 0;
selected_ids_.clear();
active_id_ = 0;
std::fill(cpu_flags_.begin(), cpu_flags_.end(), 0u);
markDirty();
if (had_state) emit changed(active_id_);
}
void SelectionState::noteObjectId(uint32_t id) {
if (id == 0) return;
if (uint32_t(cpu_flags_.size()) <= id) {
// Grow CPU side; the SSBO is sized lazily inside uploadFlags so
// we don't realloc GL on every streamed instance.
cpu_flags_.resize(size_t(id) + 1, 0u);
markDirty();
}
}
void SelectionState::setSelectedObjectId(uint32_t id) {
if (id == 0) {
clearSelection();
return;
}
setSelection({id}, id);
}
void SelectionState::setSelection(const std::unordered_set<uint32_t>& ids,
uint32_t active) {
// Avoid emitting churn when the call is a no-op.
if (ids == selected_ids_ && active == active_id_) return;
selected_ids_ = ids;
selected_ids_.erase(0);
active_id_ = (active != 0 && selected_ids_.count(active)) ? active : 0u;
markDirty();
emit changed(active_id_);
}
void SelectionState::addToSelection(const std::unordered_set<uint32_t>& ids) {
bool any_added = false;
for (uint32_t id : ids) {
if (id == 0) continue;
if (selected_ids_.insert(id).second) any_added = true;
}
if (!any_added) return;
markDirty();
emit changed(active_id_);
}
void SelectionState::removeFromSelection(const std::unordered_set<uint32_t>& ids) {
bool any_removed = false;
bool active_removed = false;
for (uint32_t id : ids) {
if (selected_ids_.erase(id) > 0) {
any_removed = true;
if (id == active_id_) active_removed = true;
}
}
if (!any_removed) return;
if (active_removed) active_id_ = 0;
markDirty();
emit changed(active_id_);
}
void SelectionState::toggleInSelection(uint32_t id) {
if (id == 0) return;
if (selected_ids_.erase(id) > 0) {
// Removed. If it was active, drop active.
if (active_id_ == id) active_id_ = 0;
} else {
// Added. Last-toggled becomes active so the properties panel
// tracks the most recent click — matches the user's "last single
// clicked is active" expectation.
selected_ids_.insert(id);
active_id_ = id;
}
markDirty();
emit changed(active_id_);
}
void SelectionState::clearSelection() {
if (selected_ids_.empty() && active_id_ == 0) return;
selected_ids_.clear();
active_id_ = 0;
markDirty();
emit changed(active_id_);
}
void SelectionState::markDirty() {
dirty_ = true;
}
void SelectionState::growTo(uint32_t capacity_ids) {
if (!gl_ || ssbo_ == 0) return;
if (capacity_ids <= ssbo_capacity_slots_) return;
// Round up to a power-of-two-ish step so streamed scenes don't realloc
// every few instances. Floor at 1024 slots = 4 KB.
size_t new_cap = std::max<size_t>(1024, ssbo_capacity_slots_ * 2);
while (new_cap < capacity_ids) new_cap *= 2;
gl_->glNamedBufferData(ssbo_,
GLsizeiptr(new_cap * sizeof(uint32_t)),
nullptr, GL_DYNAMIC_DRAW);
ssbo_capacity_slots_ = new_cap;
}
void SelectionState::uploadFlags() {
if (!gl_ || ssbo_ == 0) return;
// Rebuild the CPU flag vector from the canonical selected_ids_. The
// overhead is O(|cpu_flags_|), which scales with max object_id rather
// than with selection size — acceptable: object_ids are dense so this
// is just a memset + a handful of writes for the selected set.
std::fill(cpu_flags_.begin(), cpu_flags_.end(), 0u);
for (uint32_t id : selected_ids_) {
if (id < cpu_flags_.size()) cpu_flags_[id] = 1u;
}
growTo(static_cast<uint32_t>(cpu_flags_.size()));
if (ssbo_capacity_slots_ == 0) return;
const GLsizeiptr bytes =
GLsizeiptr(cpu_flags_.size() * sizeof(uint32_t));
if (bytes > 0) {
gl_->glNamedBufferSubData(ssbo_, 0, bytes, cpu_flags_.data());
}
dirty_ = false;
}
void SelectionState::bindForRender(GLuint binding_index) {
if (!gl_ || ssbo_ == 0) return;
if (dirty_) uploadFlags();
gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, binding_index, ssbo_);
}
-126
View File
@@ -1,126 +0,0 @@
/********************************************************************************
* *
* 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 IFCVIEWER_SELECTION_H
#define IFCVIEWER_SELECTION_H
#include <QObject>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
#include <cstdint>
#include <unordered_set>
#include <vector>
// Multi-selection state for the viewport, owned by ViewportWindow.
//
// Tracks the *set* of currently selected object_ids plus a single "active"
// id — the last single-clicked one. The active id is what the properties
// panel and tree mirror; the full set is what the viewport highlights.
//
// Selection state is published to the main shader through a per-object_id
// flags SSBO bound at a caller-chosen index (binding=3 today). The buffer
// is sized to max(object_id) + 1 and grown on demand via noteObjectId,
// which the viewport calls for every appended instance.
//
// On every mutation the selection emits `changed(active_id)`. Consumers
// (MainWindow, the viewport itself for requestUpdate) connect to it.
class SelectionState : public QObject {
Q_OBJECT
public:
explicit SelectionState(QObject* parent = nullptr);
~SelectionState() override;
// Wire up the GL context. Must be called once the viewport's GL
// context is current. release() drops GL resources before context
// teardown.
void initializeGl(QOpenGLFunctions_4_5_Core* gl);
void releaseGl();
// Tell the manager about a newly added object_id so the flag buffer
// can grow ahead of the next render. Cheap when the id fits in the
// already-allocated CPU vector; otherwise resizes (and marks dirty).
void noteObjectId(uint32_t id);
// Clear everything — both the selection set and the per-object flags.
// Called from clearScene.
void reset();
// ---- Mutation API ----
//
// Plain LMB click → setSelectedObjectId(id) (or clearSelection() for 0).
// Modifier+click → toggleInSelection(id).
// Box-select on release → setSelection / addToSelection / removeFromSelection
// depending on the modifier held when the drag started.
//
// setSelection's `active` should be in `ids` or 0; if it isn't, the
// active is silently coerced to 0.
void setSelectedObjectId(uint32_t id);
void setSelection(const std::unordered_set<uint32_t>& ids, uint32_t active);
void addToSelection(const std::unordered_set<uint32_t>& ids);
void removeFromSelection(const std::unordered_set<uint32_t>& ids);
void toggleInSelection(uint32_t id);
void clearSelection();
// ---- Accessors ----
bool isSelected(uint32_t id) const { return selected_ids_.count(id) > 0; }
bool empty() const { return selected_ids_.empty(); }
size_t size() const { return selected_ids_.size(); }
const std::unordered_set<uint32_t>& selectionIds() const { return selected_ids_; }
uint32_t activeObjectId() const { return active_id_; }
// ---- GL binding ----
//
// Bind the selection-flags SSBO at the given binding index for the
// upcoming draw. Lazily uploads any pending flag changes. Caller
// must have GL context current.
void bindForRender(GLuint binding_index);
signals:
// Emitted on any mutation that changes either the set or the active.
// Carries the new active id for convenience (consumers usually only
// care about the active for properties/tree sync).
void changed(uint32_t active_id);
private:
// Mark the SSBO dirty so the next bindForRender() re-uploads it.
void markDirty();
// Resize the CPU flag vector + GL buffer to hold up to capacity_ids
// entries. Called when noteObjectId outgrows the current capacity.
void growTo(uint32_t capacity_ids);
// Fully overwrite cpu_flags_ from selected_ids_, then upload to GL.
void uploadFlags();
QOpenGLFunctions_4_5_Core* gl_ = nullptr;
std::unordered_set<uint32_t> selected_ids_;
uint32_t active_id_ = 0;
// Per-object_id flag, indexed by id directly (slot 0 unused — object_id 0
// means "no object"). Bit 0 = selected. Stored as uint32 per slot for
// std430 alignment simplicity; the byte cost (~4 MB at 1M objects) is
// negligible compared to the instance SSBO.
std::vector<uint32_t> cpu_flags_;
GLuint ssbo_ = 0;
size_t ssbo_capacity_slots_ = 0;
bool dirty_ = true;
};
#endif // IFCVIEWER_SELECTION_H
File diff suppressed because it is too large Load Diff
-834
View File
@@ -1,834 +0,0 @@
/********************************************************************************
* *
* 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 VIEWPORTWINDOW_H
#define VIEWPORTWINDOW_H
#include <QWindow>
#include <QOpenGLContext>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
#include <QColor>
#include <QElapsedTimer>
#include <QMatrix4x4>
#include <QVector3D>
#include <QSet>
QT_BEGIN_NAMESPACE
class QTimer;
QT_END_NAMESPACE
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <cstdint>
#include <mutex>
#include <memory>
#include <atomic>
#include <future>
#include <deque>
#include <Eigen/Dense>
#include "BvhAccel.h"
#include "InstancedGeometry.h"
#include "OverlayRenderer.h"
#include "Selection.h"
#include "SidecarCache.h"
#include "Visibility.h"
// Matches GL_DRAW_INDIRECT_BUFFER layout for glMultiDrawElementsIndirect.
struct DrawElementsIndirectCommand {
uint32_t count;
uint32_t instanceCount;
uint32_t firstIndex;
uint32_t baseVertex;
uint32_t baseInstance;
};
// Per-model GPU state for the instanced render path.
//
// VBO: local-coord interleaved verts (pos3 + normal3 + color1_packed) — 28 B.
// EBO: mesh-local indices (uint32).
// meshes[]: per-unique-representation metadata; indexed by local_mesh_id.
// instances[]: CPU-side per-instance records; sorted by mesh_id at finalize.
// ssbo: InstanceGpu[]; populated at finalize.
//
// A model is drawable once `finalized == true`.
struct ModelGpuData {
GLuint vao = 0;
GLuint vbo = 0;
GLuint ebo = 0;
GLuint ssbo = 0;
GLuint mesh_info_ssbo = 0; // MeshGpu[] — per-mesh quantization basis
size_t mesh_info_capacity = 0; // bytes
size_t vbo_capacity = 0;
size_t ebo_capacity = 0;
size_t ssbo_capacity = 0; // bytes
size_t vbo_used = 0;
size_t ebo_used = 0;
uint32_t vertex_count = 0; // total (across all meshes)
uint32_t total_triangles = 0;
std::vector<MeshInfo> meshes;
std::vector<InstanceCpu> instances; // unsorted
// 1:1 with instances[] — true when the instance transform has
// det < 0 (a reflection). Reflected instances need their
// triangle winding treated as reversed so GL_CULL_FACE culls
// the correct side.
std::vector<uint8_t> instance_reflected;
uint32_t ssbo_instance_count = 0;
// Stats snapshot from the last cullAndUploadVisible call. Cached so we
// can report the same numbers on skipped-cull frames (see
// have_cached_cull_ on ViewportWindow) without iterating the per-model
// scratch array again.
uint32_t cached_visible_objects = 0;
uint32_t cached_visible_triangles = 0;
// Per-instance world AABB + BVH (built at finalize). The BVH is the
// same ordering as `instances`; bvh_items[i] corresponds to instances[i].
std::vector<BvhItem> bvh_items;
ModelBvh bvh;
// Dynamic visible-instance index buffer (std430, binding = 1).
// Re-uploaded each frame from visible_flat_.
GLuint visible_ssbo = 0;
size_t visible_ssbo_capacity = 0; // bytes
// GL_DRAW_INDIRECT_BUFFER of DrawElementsIndirectCommand[], one per
// non-empty mesh. Re-uploaded each frame.
GLuint indirect_buffer = 0;
size_t indirect_capacity = 0; // bytes
uint32_t indirect_command_count = 0; // total valid commands this frame
uint32_t indirect_forward_count = 0; // first N are CCW-winding draws
// Per-model cull scratch — owned by the model so each cull job runs
// without sharing mutable state. Four buckets = {fwd, rev} × {LOD0, LOD1}.
std::vector<std::vector<uint32_t>> vis_fwd_lod0;
std::vector<std::vector<uint32_t>> vis_fwd_lod1;
std::vector<std::vector<uint32_t>> vis_rev_lod0;
std::vector<std::vector<uint32_t>> vis_rev_lod1;
std::vector<uint32_t> visible_flat;
std::vector<DrawElementsIndirectCommand> indirect_scratch;
std::vector<uint32_t> dirty_meshes;
bool finalized = false;
bool hidden = false;
// Per-model federation-pipeline matrices in metres. Default identity
// → no per-model contribution to the composed transform. See
// Federation.h for the full pipeline composition order.
Eigen::Matrix4d coordinate_operation_meters = Eigen::Matrix4d::Identity();
Eigen::Matrix4d model_transformation_meters = Eigen::Matrix4d::Identity();
};
// Rendering is event-driven: render() runs only when QEvent::UpdateRequest
// is delivered, posted via requestUpdate(). An idle scene costs zero CPU.
// INVARIANT: every public mutator that changes what should be on screen
// (camera, selection, model lifecycle, visibility) MUST call requestUpdate()
// before returning, or the viewport will go silently stale.
class ViewportWindow : public QWindow {
Q_OBJECT
public:
explicit ViewportWindow(QWindow* parent = nullptr);
~ViewportWindow();
// Streaming ingress.
void uploadMeshChunk(const MeshChunk& chunk);
void uploadInstanceChunk(const InstanceChunk& chunk);
// Called once all chunks for a model have arrived: sorts instances by
// mesh_id, assigns each mesh its contiguous range, and uploads the
// instance SSBO. The model becomes drawable.
void finalizeModel(uint32_t model_id);
void resetScene();
// Restore a finalised model from a cached SidecarData struct. Replaces
// any existing state for model_id and marks it drawable.
void applyCachedModel(uint32_t model_id, SidecarData data);
// After buildLods() has extended sd.indices + populated lod1_* fields,
// push just the appended index slice + the refreshed mesh metadata onto
// the live GPU state for model_id. VBO / SSBO / instance array are left
// alone; only the EBO grows and m.meshes is replaced. No-op if the
// model isn't finalised on the viewport.
void applyLodExtension(uint32_t model_id, const SidecarData& sd);
void hideModel(uint32_t model_id);
void showModel(uint32_t model_id);
void removeModel(uint32_t model_id);
// Debug helper: walk to the currently selected object's instance and
// qInfo a sample vertex (decoded from the VBO), the placement
// transformation matrix, and the global matrix
// (CoordinateOperation · placement_transformation), in metres.
void printSelectedObjectCoords();
// Mesh-local CPU triangles read back from the VBO/EBO. Positions are
// dequantised against the mesh's local AABB; units match the streamer
// (metres). Indices are mesh-local (0..vertex_count-1).
struct MeshTriangles {
std::vector<float> positions; // 3 * vertex_count
std::vector<uint32_t> indices; // 3 * triangle_count
};
// Lazy GPU readback of one mesh's triangles. Fails if model_id /
// mesh_id aren't live, the model isn't finalised, or GL isn't ready.
// Stalls the GL pipeline for the readback — call from the main thread
// and not from inside render().
bool readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
MeshTriangles& out);
// Pure CPU lookup: object_id → owning model + mesh + raw streamer
// placement matrix (column-major, pre-CoordinateOperation /
// FederatedFalseOrigin / ModelTransformation).
struct InstanceLookup {
uint32_t model_id = 0;
uint32_t mesh_id = 0;
double placement_transformation[16]{};
};
bool findInstance(uint32_t object_id, InstanceLookup& out) const;
// Pick + resolve to mesh-local space. Runs pickSurfaceAt to get the
// world-space hit, then inverts the instance's composed transform
// (FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation) to express the hit in the mesh's own
// coordinates — what readbackMeshTriangles returns. Returns false if
// the click missed geometry or its object_id has no live instance.
struct MeshLocalPick {
uint32_t object_id = 0;
uint32_t model_id = 0;
uint32_t mesh_id = 0;
float mesh_local[3] = {0, 0, 0};
float world_pos[3] = {0, 0, 0};
float world_normal[3]= {0, 0, 0};
// The instance's composed (FederatedFalseOrigin · ModelTransformation
// · CoordinateOperation · placement_transformation) matrix in
// column-major form. Mesh-local positions × this = world. Caching
// by callers becomes stale if any federation matrix is later edited.
float composed_transform[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
};
bool pickMeshLocalAt(int x, int y, MeshLocalPick& out);
// Resolve a mesh-local point to the IFC's own georeferenced global
// frame: CoordinateOperation · placement_transformation · mesh_local.
// Excludes FederatedFalseOrigin and ModelTransformation.
bool meshLocalToGlobal(uint32_t object_id, const float mesh_local[3],
double global_out[3]) const;
// CPU raycast against the per-model BVHs. Walks the BVH for each
// finalised model, transforms the world ray into mesh-local space
// for each candidate instance, reads back its triangles, and runs
// Möller-Trumbore against them. Returns the closest hit overall.
//
// `dir` MUST be a unit vector — distance is reported as the t value
// along the ray, which equals world distance only when |dir|=1.
// Stalls the GL pipeline once per unique (model, mesh) candidate
// because triangle data is read back lazily; budget ~1ms for
// typical BIM scenes.
struct RaycastHit {
uint32_t object_id = 0;
float distance = 0.0f;
float world_pos[3] = {0, 0, 0};
float world_normal[3]= {0, 0, 0};
};
bool raycast(const float origin[3], const float dir[3], RaycastHit& out);
// Measurement tool modes. While any tool is active, LMB clicks emit
// surfacePickedInTool with the click coordinates (instead of swapping
// object selection); the app interprets them per-tool. Esc exits the
// active tool. Backspace/Delete in length mode emits
// toolBackspacePressed for "remove last point" semantics.
// Area / Length consume LMB clicks via surfacePickedInTool; Volume
// is passive — selection behaves as in None and the host just gates
// its volume HUD/labels on this mode.
enum class ToolMode { None, Area, Length, Volume };
Q_ENUM(ToolMode)
void toggleAreaTool();
void toggleLengthTool();
void toggleVolumeTool();
void setToolMode(ToolMode mode);
ToolMode toolMode() const { return tool_mode_; }
// Replace the overlay highlight-triangle list rendered after the main
// pass. `world_xyz` is 3 floats per vertex, 3 verts per triangle, in
// world space. Empty disables the overlay. Triggers a viewport
// update so the change becomes visible immediately.
void setHighlightTriangles(const std::vector<float>& world_xyz,
float r, float g, float b, float a);
// Replace the overlay line groups. Each group has its own segments
// + style (color/halo/width/dash) — see OverlayRenderer::LineGroup.
// Empty disables every line.
void setOverlayLines(const std::vector<OverlayRenderer::LineGroup>& groups);
// Replace the overlay-point list (3 floats per point, world space).
// pixel_size is the inner-disc diameter in physical pixels; when
// stroke_a > 0 the sprite is enlarged by 2*stroke_extra to draw a
// halo around the inner disc — proper outlined points via the
// sprite shader (no two-pass glPointSize trickery).
void setOverlayPoints(const std::vector<float>& world_xyz,
float r, float g, float b, float a,
float pixel_size,
float stroke_r, float stroke_g, float stroke_b, float stroke_a,
float stroke_extra);
// Replace the world-anchored label list — projected to screen space
// and drawn via QPainter inside the same overlay pass as the HUD.
// Used today for per-segment length readouts.
void setOverlayLabels(const std::vector<OverlayRenderer::Label>& labels);
// Multi-line HUD text drawn via QPainter on top of the GL surface at
// the end of each frame. Empty hides the HUD. Newlines split the
// string into separate rows in the same translucent box.
void setHudText(const QString& text);
// Federation pipeline: composed instance transform =
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// setFederatedFalseOrigin affects every model; the per-model setters
// affect a single model. Each setter rewrites the SSBO, recomputes
// world AABBs, rebuilds the BVH, and posts an update. Defaults are
// identity, so until a setter is called the composed transform equals
// placement_transformation.
void setFederatedFalseOrigin(const Eigen::Matrix4d& matrix_meters);
void setModelCoordinateOperation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters);
void setModelTransformation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters);
void setBackgroundColor(const QColor& color);
void enterFpsMode();
// Selection. The viewport owns a SelectionState that tracks both
// the multi-set and the "active" (last single-clicked) id. External
// callers (MainWindow / tree sync) drive selection through it.
SelectionState& selection() { return selection_; }
const SelectionState& selection() const { return selection_; }
// Convenience: replace the selection with {id} (or clear if id==0).
// Used by callers that have not yet been ported off the single-id API.
void setSelectedObjectId(uint32_t id);
uint32_t pickObjectAt(int x, int y);
// Render the pick pass and collect every distinct non-zero object_id
// covered by the pixels inside `rect` (logical coords, top-left origin).
// Used by the box-select drag. Returns an empty set if rect is empty
// or off-surface.
std::unordered_set<uint32_t> picksInRect(const QRect& rect);
// Per-element visibility (independent of model-level hidden flag).
// External callers go through the convenience verbs below; the
// VisibilityState getter is exposed for read access (e.g. the host
// mirroring its state into a tree's grey-out style).
VisibilityState& visibility() { return visibility_; }
const VisibilityState& visibility() const { return visibility_; }
// Hide every currently-selected element. Selection itself is
// preserved — toggling them back on with showAllElements() leaves
// the same items selected.
void hideSelectedElements();
// Hide every element NOT in the current selection (limited to
// visible models — model-hidden objects are left as-is so they
// stay model-hidden rather than picking up a redundant
// element-hidden flag too). No-op when nothing is selected.
void isolateSelectedElements();
// Clear element-level visibility overrides for every loaded model.
// Model-level `hidden` flags are not touched — a hidden model stays
// hidden, matching the user's expectation of "show all *elements*".
void showAllElements();
// Flip element-level visibility across every live element in visible
// models. Model-hidden objects are skipped and remain model-hidden.
void invertElementVisibility();
// Extended pick: returns the object id, world-space hit point, and
// world-space surface normal at (x, y). Renders the same pick pass as
// pickObjectAt but reads back from two extra color attachments
// (world_pos in RGB32F, world_normal in RGB16F). Returns false if the
// click missed all geometry; the out-params are then untouched.
bool pickSurfaceAt(int x, int y,
uint32_t& object_id_out,
QVector3D& world_pos_out,
QVector3D& world_normal_out);
// Section planes — fragment-shader clipping with up to MaxSectionPlanes
// active. Each plane clips the half-space dot(n, p) + d > 0. addSection-
// PlaneAtSurface auto-flips the normal toward the camera so the first
// click immediately cuts away the camera-facing side.
static constexpr int MaxSectionPlanes = 8;
struct SectionPlane {
QVector3D n; // unit world-space normal
QVector3D origin; // point on the plane — the gizmo's anchor
float d; // = -dot(n, origin); kept in sync with origin
};
int sectionPlaneCount() const { return int(section_planes_.size()); }
bool addSectionPlaneAtSurface(const QVector3D& point, const QVector3D& normal);
void removeSectionPlane(int index);
void clearSectionPlanes();
// Section tool: when active, LMB on geometry creates a new plane (using
// pickSurfaceAt for hit-point + normal); LMB on an existing plane's
// arrow gizmo selects + drags it; Delete removes the selected plane;
// Esc or another K-press exits the tool.
void toggleSectionTool();
bool sectionToolActive() const { return section_tool_active_; }
// Projection: orthographic vs perspective. In ortho mode the visible
// box is sized to match what the perspective camera would show at the
// pivot's distance, so toggling at any zoom level keeps the framing.
void toggleProjection();
bool projectionOrtho() const { return projection_ortho_; }
// Snap the camera to a canonical axis-aligned view. Yaw/pitch are
// clamped according to the orbit convention; target and distance are
// preserved (the user explicitly asked for a rotate-only behavior).
void setStandardView(float yaw_deg, float pitch_deg);
void setCamera(float tx, float ty, float tz, float dist, float yaw, float pitch);
void setBenchmarkFrames(int n);
// Queue a one-shot framebuffer capture: at the end of the next render()
// (just before swapBuffers), the default framebuffer is read back with
// glReadPixels, saved as PNG to `path`, and — if `quit_after` — the
// QCoreApplication is asked to quit. Used by parity-diff harnesses to
// produce a GL output PNG that's directly comparable to the wgpu
// backend's --screenshot.
void captureNextFrameToPng(const QString& path, bool quit_after = true);
QString cameraString() const;
// Move camera_target_ to the selected set's world-AABB centroid and
// dolly camera_distance_ so the union fits the current viewport.
// Yaw/pitch are preserved. No-op if no selected object has an AABB.
void focusOnSelectedObject();
// Frame the union of all finalized models. No-op if the scene is empty.
void viewAll();
// World-space AABB query. Returns false when the object has no live
// instance or no mesh AABB yet (caller should treat as "unknown").
bool computeObjectAabb(uint32_t object_id, QVector3D& mn, QVector3D& mx) const;
struct CameraState {
QVector3D target;
float distance;
float yaw; // degrees
float pitch; // degrees
};
CameraState cameraState() const;
struct FrameStats {
float fps;
float frame_time_ms;
uint32_t total_objects;
uint32_t visible_objects;
uint32_t total_triangles;
uint32_t visible_triangles;
uint32_t unique_meshes;
uint32_t gl_draw_calls; // actual glMultiDrawElementsIndirect issues per frame
uint32_t indirect_sub_draws; // total commands packed into those indirect buffers
};
signals:
void objectPicked(uint32_t object_id);
void initialized();
void frameStatsUpdated(const ViewportWindow::FrameStats& stats);
// Emitted instead of objectPicked when any measurement tool is active.
// The app branches on toolMode() to decide what to do, calls
// pickMeshLocalAt(x, y, ...) for hit details, and accumulates.
// modifiers carries Qt::KeyboardModifiers at click time (Alt etc.).
void surfacePickedInTool(int x, int y, int modifiers);
// Emitted whenever the active tool changes (incl. on→off transitions).
// The app uses this to reset accumulator state on entry/exit.
void toolModeChanged(ViewportWindow::ToolMode mode);
// Backspace/Delete pressed while a tool is active. Used by the length
// tool to remove the last point; other tools may ignore it.
void toolBackspacePressed();
protected:
void exposeEvent(QExposeEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
bool event(QEvent* event) override;
private:
// Resolved mouse-binding for the active AppSettings::NavPreset.
// Selection always stays on LMB; only orbit and pan move around
// (presets are picked so neither lands on plain LMB).
struct NavBindings {
Qt::MouseButton orbit_button;
Qt::KeyboardModifiers orbit_mods;
Qt::MouseButton pan_button;
Qt::KeyboardModifiers pan_mods;
};
NavBindings currentNavBindings() const;
enum class PendingOpType {
UploadMeshChunk,
UploadInstanceChunk,
FinalizeModel,
ApplyCachedModel,
ApplyLodExtension,
ResetScene,
HideModel,
ShowModel,
RemoveModel,
};
struct PendingOperation {
PendingOpType type;
MeshChunk mesh_chunk;
InstanceChunk instance_chunk;
SidecarData sidecar_data;
uint32_t model_id = 0;
};
void initGL();
void flushPendingOperations();
void enqueuePendingOperation(PendingOperation op);
void render();
void renderPickPass();
void renderAxisGizmo();
void renderPivotIndicator();
void renderSectionPlanes();
void buildSectionPlaneGizmo();
// Post-process edge enhancement: resolve MSAA depth into a single-
// sample texture, then run a fullscreen pass that detects sharp
// depth-laplacian peaks and darkens the colour buffer there. Catches
// silhouettes and overlapping-surface boundaries as faint dark lines.
void renderEdgePass();
// Returns the index of the section plane whose arrow gizmo is under
// (x, y), or -1 if none. Screen-space line-segment distance test.
int hitTestSectionGizmo(int x, int y) const;
// Update section_planes_[section_drag_index_] from the current cursor
// position by projecting the move onto the plane's normal axis in
// screen space.
void updateSectionDrag(int x, int y);
void updateCamera();
// Scene-wide AABB used by viewAll(). Returns false when the scene
// has no finalized geometry (caller should leave the camera alone).
bool computeSceneAabb(QVector3D& mn, QVector3D& mx) const;
// Re-aim the orbit camera so the bounding sphere of [mn, mx] just fits
// vertically and horizontally within the current FOV, with `padding`
// headroom (1.0 = tight). Yaw/pitch are preserved; only target and
// distance change.
void frameAabb(const QVector3D& mn, const QVector3D& mx, float padding);
void buildShaders();
void buildAxisGizmo();
void buildPivotIndicator();
// Show/hide the orbit-pivot marker. hide_after_ms > 0 starts a single-shot
// timer that auto-hides — used by the wheel handler to give the marker a
// short afterglow after zoom. Drag-based callers pass 0 and toggle
// visibility manually on press/release.
void setPivotIndicatorVisible(bool visible, int hide_after_ms = 0);
void setupVaoLayout(GLuint vao, GLuint vbo, GLuint ebo);
// Resolve the default framebuffer's MSAA depth into a single-sample
// texture, read it back, and max-reduce a mip pyramid on the CPU. The
// resulting pyramid is stored in hiz_pyramid_ along with the VP matrix
// used to draw it; next frame's cullAndUploadVisible can test AABBs
// against it. Synchronous readback — at 256×128 the cost is sub-ms
// and not a measured bottleneck; Phase 3D's compute-shader cull will
// eliminate the readback entirely.
void buildHizPyramid();
// True if the AABB is fully occluded by the previous frame's depth.
// Returns false when the HiZ is invalid, the AABB crosses the near
// plane, or the projection falls outside NDC.
bool aabbOccludedByHiz(const float mn[3], const float mx[3]) const;
bool growModelVbo(ModelGpuData& m, size_t needed_total);
bool growModelEbo(ModelGpuData& m, size_t needed_total);
bool growModelSsbo(ModelGpuData& m, size_t needed_total);
ModelGpuData& getOrCreateModel(uint32_t model_id);
// Frustum-cull m's instances (BVH if available, else linear scan),
// build the per-mesh DrawElementsIndirectCommand array + flat visible
// list, and upload both to m.indirect_buffer / m.visible_ssbo.
//
// `min_pixel_radius` controls contribution culling: instances (and BVH
// subtrees) whose projected bounding-sphere radius would be below this
// many pixels are dropped. 0 = disabled (all frustum-visible kept),
// which is what the pick pass uses so clickable targets aren't filtered.
void cullAndUploadVisible(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius);
// Thread-safe: CPU-only cull (frustum + contribution + HiZ + bucketing +
// emit). Writes survivors into m.vis_* / m.visible_flat / m.indirect_scratch
// and sets m.indirect_forward_count / m.indirect_command_count /
// m.cached_visible_*. Touches no GL state and no ViewportWindow mutable
// state other than the atomic counters below — safe to run on a worker.
void cullModelCpu(ModelGpuData& m, const float planes[6][4],
float focal_px, float min_pixel_radius);
// Main-thread only: uploads m.visible_flat / m.indirect_scratch into the
// model's SSBO + indirect buffer, growing them if needed.
void uploadCullResults(ModelGpuData& m);
// Compose
// FederatedFalseOrigin · ModelTransformation · CoordinateOperation
// · placement_transformation
// for one instance: writes inst.transform (composed, float) and
// recomputes inst.world_aabb_* from the composed transform + the
// mesh's local AABB. Maths runs in double; narrow to float at the end.
void composeInstanceFromPlacement(InstanceCpu& inst, const ModelGpuData& m) const;
// Walk every instance of `model_id`, recompose `transform` from
// `placement_transformation` + current stage matrices, recompute world
// AABBs, re-upload the InstanceGpu SSBO, refresh instance_reflected,
// and rebuild the BVH. Posts an update. No-op if model_id is unknown
// or GL isn't initialised yet.
void recomposeAndUploadModel(uint32_t model_id);
// Mouse interaction
void handleMousePress(QMouseEvent* event);
void handleMouseRelease(QMouseEvent* event);
void handleMouseMove(QMouseEvent* event);
void handleWheel(QWheelEvent* event);
// FPS/fly mode. Toggled with Shift+F; exits on any mouse click or Esc.
// While active, WASD translates the camera in view-space, Q/E moves
// world-down/up, mouse rotates the view (cursor hidden + recentered each
// move), Shift accelerates, and the wheel scales the base move speed
// instead of zooming. The underlying orbit state is preserved: movement
// translates camera_target_ and rotation re-pins it so camera_eye_ stays
// put, so exiting drops the user back into orbit at the same viewpoint.
//
// Movement is integrated inside render() using wall-clock dt and the
// next frame is requestUpdate()'d while any movement key is held — that
// way one long frame only produces a single catch-up step instead of
// also missing a QTimer tick.
enum class CameraMode { Orbit, Fps };
void exitFpsMode();
void fpsIntegrate(); // called from render()
void recenterFpsCursor();
QOpenGLContext* context_ = nullptr;
QOpenGLFunctions_4_5_Core* gl_ = nullptr;
bool gl_initialized_ = false;
QColor background_color_ = QColor("#202329");
std::deque<PendingOperation> pending_ops_;
// Shaders
GLuint main_program_ = 0;
GLuint pick_program_ = 0;
GLuint axis_program_ = 0;
// Axis gizmo
GLuint axis_vao_ = 0;
GLuint axis_vbo_ = 0;
// Orbit-pivot indicator: 1 center vertex + (N+1) rim vertices on the unit
// circle (last == first to close the triangle fan). Rendered as a screen-
// space disc at camera_target_, visible only while the user is navigating.
GLuint pivot_program_ = 0;
GLuint pivot_vao_ = 0;
GLuint pivot_vbo_ = 0;
int pivot_rim_count_ = 0;
// Per-model GPU data
std::unordered_map<uint32_t, ModelGpuData> models_gpu_;
// FederatedFalseOrigin matrix, in metres. Default identity → no
// contribution to the composed transform. See Federation.h.
Eigen::Matrix4d federated_false_origin_meters_ = Eigen::Matrix4d::Identity();
// Pick framebuffer. Three color attachments:
// 0: R32UI — object_id
// 1: RGB32F — world position at hit
// 2: RGB16F — world normal at hit (already flipped for reflections in
// the vertex shader)
GLuint pick_fbo_ = 0;
GLuint pick_color_tex_ = 0;
GLuint pick_pos_tex_ = 0;
GLuint pick_normal_tex_ = 0;
GLuint pick_depth_rbo_ = 0;
int pick_width_ = 0;
int pick_height_ = 0;
// HiZ occlusion culling (Phase 3C).
//
// Each frame after the main draw we blit the MSAA depth buffer down
// into a single-sample depth texture (hiz_fbo_ / hiz_depth_tex_), then
// glReadPixels it into hiz_depth_readback_. We max-reduce that into a
// mip pyramid (hiz_pyramid_) and remember the VP matrix used
// (hiz_vp_ + hiz_vp_valid_) so next frame's cull can test AABBs
// against a slightly-stale depth. Skipped for the pick pass and when
// IFC_NO_HIZ=1.
GLuint hiz_downsample_program_ = 0;
GLuint hiz_downsample_vao_ = 0;
GLuint hiz_fbo_ = 0;
GLuint hiz_depth_tex_ = 0;
GLuint hiz_resolve_fbo_ = 0; // full-size single-sample resolve
GLuint hiz_resolve_depth_tex_ = 0;
int hiz_resolve_w_ = 0;
int hiz_resolve_h_ = 0;
int hiz_base_w_ = 0;
int hiz_base_h_ = 0;
std::vector<float> hiz_depth_readback_; // hiz_base_w_ * hiz_base_h_ floats
std::vector<float> hiz_pyramid_; // concatenated mip levels
std::vector<uint32_t> hiz_mip_offset_; // into hiz_pyramid_
std::vector<uint32_t> hiz_mip_w_;
std::vector<uint32_t> hiz_mip_h_;
QMatrix4x4 hiz_vp_;
bool hiz_vp_valid_ = false;
std::atomic<uint32_t> hiz_reject_count_{0}; // per-frame stat
// Cull-phase timers. Accumulated across all frames in the current
// 1-second stats window; divided by frame_count_ at print time to
// give per-frame average ms. Reset each window. Lets us see where
// CPU time actually goes: bucket clears vs BVH traversal vs emit vs
// GPU upload.
// Atomic so parallel cull workers can fetch_add into them without
// contending on a lock. clr/trv/emt are SUMS across all worker threads
// for the frame — they describe total CPU work, not wall-clock. The
// wall counter is measured once around the dispatch block in render()
// and is what actually determines frame time.
std::atomic<uint64_t> cull_clear_ns_{0};
std::atomic<uint64_t> cull_traverse_ns_{0};
std::atomic<uint64_t> cull_emit_ns_{0};
std::atomic<uint64_t> cull_upload_ns_{0};
uint64_t cull_wall_ns_ = 0; // main-thread only
uint32_t cull_skipped_frames_ = 0;
// Skip cullAndUploadVisible + buildHizPyramid when the camera and scene
// haven't changed since the last cull. The existing per-model
// indirect_buffer / visible_ssbo are still correct and just get
// redrawn. Invalidated by any function that mutates models_gpu_.
QMatrix4x4 last_cull_view_;
QMatrix4x4 last_cull_proj_;
bool have_cached_cull_ = false;
// Motion-adaptive contribution culling. During camera motion, use a
// larger pixel-radius threshold to aggressively cull small objects.
// When the camera stops, re-cull once at the base threshold.
bool last_cull_was_motion_ = false;
// Benchmark mode: render N frames, collect stats, then exit.
int benchmark_total_ = 0;
int benchmark_count_ = 0;
int benchmark_warmup_ = 5;
float benchmark_yaw_start_ = 0.0f;
float benchmark_yaw_speed_ = 0.5f; // degrees per frame
std::vector<float> benchmark_frame_times_;
// Queued one-shot framebuffer capture (see captureNextFrameToPng).
QString pending_screenshot_path_;
bool pending_screenshot_quit_ = false;
// Per-frame stats
uint32_t visible_triangles_ = 0;
uint32_t visible_objects_ = 0;
uint32_t gl_draw_calls_ = 0;
uint32_t indirect_sub_draws_ = 0;
// Camera
QVector3D camera_target_{0, 0, 0};
QVector3D camera_eye_{0, 0, 0}; // world-space eye, set in updateCamera
float camera_distance_ = 50.0f;
float camera_yaw_ = 45.0f;
float camera_pitch_ = 30.0f;
float camera_fov_y_deg_ = 45.0f;
bool projection_ortho_ = false;
QMatrix4x4 view_matrix_;
QMatrix4x4 proj_matrix_;
// Mouse
Qt::MouseButton active_button_ = Qt::NoButton;
QPoint last_mouse_pos_;
// Pivot indicator visibility — true while drag-navigating, or briefly
// after a wheel notch (the timer auto-clears it).
bool pivot_indicator_visible_ = false;
QTimer* pivot_indicator_hide_timer_ = nullptr;
// FPS/fly mode state. fps_keys_held_ tracks WASD/QE/Shift between
// press+release; fps_last_tick_ gates dt inside render().
// fps_ignore_next_mouse_move_ swallows the synthetic MouseMove that
// QCursor::setPos() generates after we recenter.
CameraMode camera_mode_ = CameraMode::Orbit;
QSet<int> fps_keys_held_;
float fps_move_speed_ = 5.0f; // m/s at speed=1
QElapsedTimer fps_last_tick_;
bool fps_ignore_next_mouse_move_ = false;
// Selection — set + active id + per-object_id flags SSBO (binding=3).
SelectionState selection_;
// Per-element visibility — CPU-only flag vector consulted by the
// cull, plus a canonical hidden-id set for mutation/reporting.
VisibilityState visibility_;
// LMB-press state. press_pick_id_ caches the object hit at press
// time so the release path can apply click semantics without a
// second pick pass. box_select_armed_ is set only when the press
// landed on empty space — a subsequent drag past the click
// threshold then promotes to box_select_active_, so a small wobble
// on a click doesn't accidentally box-select. Modifiers captured
// at press-time decide commit semantics: plain replace, Shift add,
// Ctrl remove.
uint32_t press_pick_id_ = 0;
bool box_select_armed_ = false;
bool box_select_active_ = false;
QPoint box_select_start_pos_;
QPoint box_select_current_pos_;
Qt::KeyboardModifiers box_select_press_mods_ = Qt::NoModifier;
// Active measurement tool: see ToolMode / surfacePickedInTool.
ToolMode tool_mode_ = ToolMode::None;
// Renders any client-supplied overlay primitives (highlight triangles
// today; lines/points/labels later) in their own pass after the main
// geometry, with depth-test on / depth-write off.
OverlayRenderer overlay_renderer_;
// Active section planes. Uploaded as uniform array each frame to the
// main + pick programs; capped at MaxSectionPlanes.
std::vector<SectionPlane> section_planes_;
bool section_tool_active_ = false;
int section_plane_selected_ = -1;
bool section_drag_active_ = false;
int section_drag_index_ = -1;
QVector3D section_drag_start_origin_;
QPoint section_drag_start_mouse_;
// Push the current plane list into a freshly-bound program. No-op if
// the program does not declare u_clip_count / u_clip_planes.
void uploadClipPlaneUniforms(GLuint program);
// GL resources for the per-plane visualization (quad outline + arrow).
GLuint plane_program_ = 0;
GLuint plane_vao_ = 0;
GLuint plane_vbo_ = 0;
int plane_quad_offset_ = 0;
int plane_quad_count_ = 0;
int plane_arrow_offset_ = 0;
int plane_arrow_count_ = 0;
// Edge-enhancement pass resources. edge_depth_tex_ is a single-sample
// resolve target the size of the window; we blit the default FB depth
// into it each frame, then sample it from the fullscreen edge shader.
GLuint edge_program_ = 0;
GLuint edge_depth_fbo_ = 0;
GLuint edge_depth_tex_ = 0;
GLuint edge_vao_ = 0; // empty VAO for fullscreen-triangle draw
int edge_w_ = 0;
int edge_h_ = 0;
// FPS smoothing
int frame_count_ = 0;
float accumulated_time_ = 0.0f;
float last_fps_ = 0.0f;
};
#endif // VIEWPORTWINDOW_H
-93
View File
@@ -1,93 +0,0 @@
/********************************************************************************
* *
* 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 "Visibility.h"
#include <algorithm>
VisibilityState::VisibilityState(QObject* parent) : QObject(parent) {
// Slot 0 is the "no object" sentinel; keep it present so isHidden(0)
// is well-defined (returns false).
cpu_flags_.assign(1, 0u);
}
void VisibilityState::noteObjectId(uint32_t id) {
if (id == 0) return;
if (uint32_t(cpu_flags_.size()) <= id) {
cpu_flags_.resize(size_t(id) + 1, 0u);
}
}
void VisibilityState::reset() {
const bool had_state = !hidden_ids_.empty();
hidden_ids_.clear();
std::fill(cpu_flags_.begin(), cpu_flags_.end(), 0u);
if (had_state) emit changed();
}
void VisibilityState::hideObjects(const std::unordered_set<uint32_t>& ids) {
bool any = false;
for (uint32_t id : ids) {
if (id == 0) continue;
if (hidden_ids_.insert(id).second) {
if (uint32_t(cpu_flags_.size()) <= id) {
cpu_flags_.resize(size_t(id) + 1, 0u);
}
cpu_flags_[id] = 1u;
any = true;
}
}
if (any) emit changed();
}
void VisibilityState::showObjects(const std::unordered_set<uint32_t>& ids) {
bool any = false;
for (uint32_t id : ids) {
if (hidden_ids_.erase(id) > 0) {
if (id < cpu_flags_.size()) cpu_flags_[id] = 0u;
any = true;
}
}
if (any) emit changed();
}
void VisibilityState::setHidden(const std::unordered_set<uint32_t>& ids) {
if (ids == hidden_ids_) return;
hidden_ids_ = ids;
hidden_ids_.erase(0);
rebuildFlags();
emit changed();
}
void VisibilityState::showAll() {
if (hidden_ids_.empty()) return;
hidden_ids_.clear();
std::fill(cpu_flags_.begin(), cpu_flags_.end(), 0u);
emit changed();
}
void VisibilityState::rebuildFlags() {
std::fill(cpu_flags_.begin(), cpu_flags_.end(), 0u);
for (uint32_t id : hidden_ids_) {
if (uint32_t(cpu_flags_.size()) <= id) {
cpu_flags_.resize(size_t(id) + 1, 0u);
}
cpu_flags_[id] = 1u;
}
}
-89
View File
@@ -1,89 +0,0 @@
/********************************************************************************
* *
* 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 IFCVIEWER_VISIBILITY_H
#define IFCVIEWER_VISIBILITY_H
#include <QObject>
#include <cstdint>
#include <unordered_set>
#include <vector>
// Per-element visibility state, owned by ViewportWindow. Independent of
// the per-model `hidden` flag — model-level hiding still wins (a hidden
// model never draws regardless of its elements' visibility). This class
// only tracks element-level overrides on top.
//
// Lookup is hot — the CPU cull queries `isHidden(object_id)` for every
// surviving instance — so the canonical set is mirrored into a flat
// per-id byte vector indexed directly by object_id. Ownership is purely
// CPU: nothing on the GPU reads visibility, the cull just skips hidden
// instances before they reach the visible[] SSBO.
class VisibilityState : public QObject {
Q_OBJECT
public:
explicit VisibilityState(QObject* parent = nullptr);
// Tell the manager about a newly added object_id so the flag vector
// can grow ahead of the next cull. Cheap when the id fits already.
void noteObjectId(uint32_t id);
// Drop everything — clears the hidden set. Called from clearScene.
void reset();
// ---- Mutation ----
//
// hideObjects: union into the hidden set.
// showObjects: subtract from the hidden set.
// setHidden: replace the hidden set wholesale (used by isolate).
// showAll: equivalent to setHidden({}).
void hideObjects(const std::unordered_set<uint32_t>& ids);
void showObjects(const std::unordered_set<uint32_t>& ids);
void setHidden(const std::unordered_set<uint32_t>& ids);
void showAll();
// ---- Hot-path query ----
//
// Inline so the cull's `if (visibility_.isHidden(...)) return;`
// compiles to a bounds check + a byte load + a compare.
bool isHidden(uint32_t id) const {
return id < cpu_flags_.size() && cpu_flags_[id] != 0;
}
// ---- Accessors ----
bool empty() const { return hidden_ids_.empty(); }
size_t size() const { return hidden_ids_.size(); }
const std::unordered_set<uint32_t>& hiddenIds() const { return hidden_ids_; }
signals:
// Emitted on any mutation that changes the hidden set. Consumers
// (the viewport) connect to invalidate cached cull state and
// requestUpdate.
void changed();
private:
// Recompute cpu_flags_ from hidden_ids_. Cheap: O(|cpu_flags_|).
void rebuildFlags();
std::unordered_set<uint32_t> hidden_ids_;
std::vector<uint8_t> cpu_flags_;
};
#endif // IFCVIEWER_VISIBILITY_H
+3 -42
View File
@@ -32,10 +32,6 @@ function(add_ifcviewer_unit_test name)
catch_discover_tests(${name})
endfunction()
add_ifcviewer_unit_test(test_bvh_accel
SOURCES ${IFCVIEWER_SRC}/BvhAccel.cpp
)
if(WITH_MESH_OPTIMIZER)
add_ifcviewer_unit_test(test_lod_builder
SOURCES
@@ -50,11 +46,9 @@ add_ifcviewer_unit_test(test_sidecar_cache
add_ifcviewer_unit_test(test_instanced_geometry)
# Federation, Visibility and Selection are Qt-derived (QObject + signals).
# Unlike the other Tier-1 tests they have to pull Qt6 in directly and enable
# AUTOMOC for the Q_OBJECT moc-generation. OpenGL is needed only by the
# Selection test (Selection.cpp references QOpenGLFunctions_4_5_Core).
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test OpenGL REQUIRED PATHS ${QT_DIR})
# Federation is Qt-derived (QObject + signals). It has to pull Qt6 in
# directly and enable AUTOMOC for the Q_OBJECT moc-generation.
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR})
find_package(Eigen3 REQUIRED)
@@ -82,36 +76,3 @@ target_link_libraries(test_federation PRIVATE
IfcParse # Unit.cpp uses express::Base / file APIs
)
catch_discover_tests(test_federation)
# VisibilityState / SelectionState are QObjects (for their changed() signal)
# but their construction + mutation API touch no GL, so the tests exercise
# the pure CPU state machine without ever creating a context. Selection.cpp
# still references QOpenGLFunctions_4_5_Core, so test_selection has to link
# Qt6::OpenGL even though no GL call is reached at runtime.
add_executable(test_visibility
test_visibility.cpp
${IFCVIEWER_SRC}/Visibility.cpp
)
set_target_properties(test_visibility PROPERTIES AUTOMOC ON)
target_include_directories(test_visibility PRIVATE ${IFCVIEWER_SRC})
target_link_libraries(test_visibility PRIVATE
Catch2::Catch2WithMain
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Test # QSignalSpy
)
catch_discover_tests(test_visibility)
add_executable(test_selection
test_selection.cpp
${IFCVIEWER_SRC}/Selection.cpp
)
set_target_properties(test_selection PROPERTIES AUTOMOC ON)
target_include_directories(test_selection PRIVATE ${IFCVIEWER_SRC})
target_link_libraries(test_selection PRIVATE
Catch2::Catch2WithMain
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Gui # QtOpenGL depends on QtGui
Qt${QT_VERSION}::OpenGL # Selection.cpp: QOpenGLFunctions_4_5_Core
Qt${QT_VERSION}::Test # QSignalSpy
)
catch_discover_tests(test_selection)
-184
View File
@@ -1,184 +0,0 @@
/********************************************************************************
* *
* 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 "BvhAccel.h"
#include <catch2/catch_test_macros.hpp>
#include <random>
#include <vector>
namespace {
BvhItem makeItem(float x, float y, float z, float r, uint32_t model_id = 1) {
BvhItem it{};
it.aabb_min[0] = x - r;
it.aabb_min[1] = y - r;
it.aabb_min[2] = z - r;
it.aabb_max[0] = x + r;
it.aabb_max[1] = y + r;
it.aabb_max[2] = z + r;
it.model_id = model_id;
return it;
}
bool aabbContains(const float outer_min[3], const float outer_max[3],
const float inner_min[3], const float inner_max[3]) {
for (int a = 0; a < 3; ++a) {
if (inner_min[a] < outer_min[a]) return false;
if (inner_max[a] > outer_max[a]) return false;
}
return true;
}
void verifyNode(const ModelBvh& mbvh,
const std::vector<BvhItem>& items,
uint32_t node_idx) {
REQUIRE(node_idx < mbvh.nodes.size());
const BvhNode& node = mbvh.nodes[node_idx];
if (node.count > 0) {
// Leaf: every item's AABB must be inside the node AABB.
REQUIRE(node.count <= BVH_MAX_LEAF_SIZE);
for (uint32_t k = 0; k < node.count; ++k) {
uint32_t idx = mbvh.item_indices[node.right_or_first + k];
REQUIRE(idx < items.size());
REQUIRE(aabbContains(node.aabb_min, node.aabb_max,
items[idx].aabb_min, items[idx].aabb_max));
}
return;
}
// Interior: left child is at node_idx + 1, right at node.right_or_first.
uint32_t left_idx = node_idx + 1;
uint32_t right_idx = node.right_or_first;
REQUIRE(left_idx < mbvh.nodes.size());
REQUIRE(right_idx < mbvh.nodes.size());
REQUIRE(left_idx != right_idx);
const BvhNode& l = mbvh.nodes[left_idx];
const BvhNode& r = mbvh.nodes[right_idx];
REQUIRE(aabbContains(node.aabb_min, node.aabb_max, l.aabb_min, l.aabb_max));
REQUIRE(aabbContains(node.aabb_min, node.aabb_max, r.aabb_min, r.aabb_max));
REQUIRE(node.axis < 3);
verifyNode(mbvh, items, left_idx);
verifyNode(mbvh, items, right_idx);
}
} // namespace
TEST_CASE("BvhNode is 32 bytes (sidecar/cache layout invariant)", "[bvh]") {
REQUIRE(sizeof(BvhNode) == 32);
}
TEST_CASE("buildModelBvhOne on empty input produces no nodes", "[bvh]") {
std::vector<BvhItem> items;
ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/42);
REQUIRE(mbvh.model_id == 42);
REQUIRE(mbvh.nodes.empty());
REQUIRE(mbvh.item_indices.empty());
}
TEST_CASE("buildModelBvhOne with <= BVH_MAX_LEAF_SIZE items yields a single leaf", "[bvh]") {
std::vector<BvhItem> items;
for (int i = 0; i < 5; ++i) {
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f));
}
ModelBvh mbvh = buildModelBvhOne(items, 1);
REQUIRE(mbvh.nodes.size() == 1);
REQUIRE(mbvh.nodes[0].count == 5);
REQUIRE(mbvh.item_indices.size() == 5);
verifyNode(mbvh, items, 0);
}
TEST_CASE("buildModelBvhOne with many items splits and respects invariants", "[bvh]") {
std::mt19937 rng(0xC0FFEE);
std::uniform_real_distribution<float> coord(-100.0f, 100.0f);
std::uniform_real_distribution<float> radius(0.1f, 1.0f);
constexpr int N = 256;
std::vector<BvhItem> items;
items.reserve(N);
for (int i = 0; i < N; ++i) {
items.push_back(makeItem(coord(rng), coord(rng), coord(rng), radius(rng)));
}
ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/7);
REQUIRE(mbvh.model_id == 7);
REQUIRE(!mbvh.nodes.empty());
REQUIRE(mbvh.item_indices.size() == N);
// Permutation invariant: each item must appear exactly once.
std::vector<int> seen(N, 0);
for (uint32_t idx : mbvh.item_indices) {
REQUIRE(idx < uint32_t(N));
seen[idx]++;
}
for (int s : seen) REQUIRE(s == 1);
// Recursive structural invariants.
verifyNode(mbvh, items, 0);
// Sum of leaf counts must equal item count.
uint32_t leaf_total = 0;
for (const auto& n : mbvh.nodes) {
if (n.count > 0) leaf_total += n.count;
}
REQUIRE(leaf_total == N);
}
TEST_CASE("buildBvhSet partitions by model_id and gates on BVH_MIN_OBJECTS", "[bvh]") {
// Model 1: well above BVH_MIN_OBJECTS — should get a BVH.
// Model 2: a single item — below the gate, must be skipped.
std::vector<BvhItem> items;
for (uint32_t i = 0; i < BVH_MIN_OBJECTS + 4; ++i) {
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f, /*model_id=*/1));
}
items.push_back(makeItem(0.0f, 0.0f, 0.0f, 0.5f, /*model_id=*/2));
auto set = buildBvhSet(items);
REQUIRE(set);
REQUIRE(set->bvh_model_ids.count(1) == 1);
REQUIRE(set->bvh_model_ids.count(2) == 0);
REQUIRE(set->models.count(1) == 1);
REQUIRE(set->models.count(2) == 0);
const auto& mbvh = set->models.at(1);
REQUIRE(mbvh.model_id == 1);
REQUIRE(mbvh.item_indices.size() == BVH_MIN_OBJECTS + 4);
// item_indices reference positions in the *full* items array — the model-1
// items are at indices [0, BVH_MIN_OBJECTS + 4), so every entry must be
// less than that.
for (uint32_t idx : mbvh.item_indices) {
REQUIRE(idx < BVH_MIN_OBJECTS + 4);
}
verifyNode(mbvh, items, 0);
}
TEST_CASE("buildBvhSet returns empty set when nothing meets the gate", "[bvh]") {
std::vector<BvhItem> items;
for (uint32_t i = 0; i < BVH_MIN_OBJECTS - 1; ++i) {
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f));
}
auto set = buildBvhSet(items);
REQUIRE(set);
REQUIRE(set->bvh_model_ids.empty());
REQUIRE(set->models.empty());
}
-230
View File
@@ -1,230 +0,0 @@
/********************************************************************************
* *
* 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/>. *
* *
********************************************************************************/
// Tier-1 coverage of SelectionState — the viewport's multi-selection state
// machine. The class also owns a GL flags SSBO, but every GL path guards on
// a context that initializeGl() has wired up; the test never calls
// initializeGl(), so the CPU-side selection set / active-id logic runs in
// full isolation (gl_ stays null and bindForRender is simply not exercised).
#include "Selection.h"
#include <catch2/catch_test_macros.hpp>
#include <QCoreApplication>
#include <QSignalSpy>
namespace {
// Catch2 owns main(), so QCoreApplication can't live in a TU constructor.
// Lazily construct it (intentionally leaked) the first time any test asks.
void ensureQApp() {
if (QCoreApplication::instance()) return;
static int argc = 1;
static char arg0[] = "test_selection";
static char* argv[] = {arg0, nullptr};
new QCoreApplication(argc, argv);
}
// First arg of the most recent changed(active_id) signal.
uint32_t lastActive(QSignalSpy& spy) {
REQUIRE(spy.count() > 0);
return spy.takeLast().at(0).toUInt();
}
} // namespace
TEST_CASE("SelectionState starts empty with no active id", "[selection]") {
ensureQApp();
SelectionState sel;
REQUIRE(sel.empty());
REQUIRE(sel.size() == 0);
REQUIRE(sel.activeObjectId() == 0);
REQUIRE_FALSE(sel.isSelected(1));
}
TEST_CASE("setSelectedObjectId selects a single id and makes it active",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.setSelectedObjectId(5);
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 1);
REQUIRE(sel.isSelected(5));
REQUIRE(sel.activeObjectId() == 5);
REQUIRE(lastActive(spy) == 5);
}
TEST_CASE("setSelectedObjectId(0) clears the selection", "[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelectedObjectId(5);
QSignalSpy spy(&sel, &SelectionState::changed);
sel.setSelectedObjectId(0);
REQUIRE(spy.count() == 1);
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
REQUIRE(lastActive(spy) == 0);
}
TEST_CASE("setSelection coerces active to 0 when it is not in the set",
"[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelection({1, 2, 3}, /*active=*/9); // 9 not in the set
REQUIRE(sel.size() == 3);
REQUIRE(sel.activeObjectId() == 0);
sel.setSelection({1, 2, 3}, /*active=*/2); // 2 is in the set
REQUIRE(sel.activeObjectId() == 2);
}
TEST_CASE("setSelection drops object_id 0 and no-ops on identical state",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.setSelection({0, 1, 2}, /*active=*/1);
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 2); // id 0 stripped
REQUIRE_FALSE(sel.isSelected(0));
REQUIRE(sel.activeObjectId() == 1);
// Same set + same active — no churn.
sel.setSelection({1, 2}, /*active=*/1);
REQUIRE(spy.count() == 1);
}
TEST_CASE("addToSelection adds ids, keeps active, ignores 0 and no-ops",
"[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelectedObjectId(1);
QSignalSpy spy(&sel, &SelectionState::changed);
sel.addToSelection({2, 3});
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 3);
REQUIRE(sel.activeObjectId() == 1); // active unchanged by add
REQUIRE(lastActive(spy) == 1);
// Nothing new -> no signal.
sel.addToSelection({2});
REQUIRE(spy.count() == 0);
// id 0 is never added.
sel.addToSelection({0});
REQUIRE(spy.count() == 0);
REQUIRE_FALSE(sel.isSelected(0));
REQUIRE(sel.size() == 3);
}
TEST_CASE("removeFromSelection clears active when the active id is removed",
"[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelection({1, 2, 3}, /*active=*/2);
QSignalSpy spy(&sel, &SelectionState::changed);
// Removing a non-active id keeps the active id.
sel.removeFromSelection({1});
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 2);
REQUIRE(sel.activeObjectId() == 2);
// Removing the active id drops the active.
sel.removeFromSelection({2});
REQUIRE(spy.count() == 2);
REQUIRE(sel.activeObjectId() == 0);
REQUIRE(sel.isSelected(3));
// Removing something absent -> no signal.
sel.removeFromSelection({99});
REQUIRE(spy.count() == 2);
}
TEST_CASE("toggleInSelection adds-as-active then removes-and-clears-active",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.toggleInSelection(5); // add
REQUIRE(sel.isSelected(5));
REQUIRE(sel.activeObjectId() == 5); // last-toggled becomes active
sel.toggleInSelection(6); // add — active follows the click
REQUIRE(sel.isSelected(6));
REQUIRE(sel.activeObjectId() == 6);
sel.toggleInSelection(5); // remove non-active — active unchanged
REQUIRE_FALSE(sel.isSelected(5));
REQUIRE(sel.activeObjectId() == 6);
sel.toggleInSelection(6); // remove the active — active cleared
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
REQUIRE(spy.count() == 4);
// id 0 is never toggled.
spy.clear();
sel.toggleInSelection(0);
REQUIRE(spy.count() == 0);
REQUIRE(sel.empty());
}
TEST_CASE("clearSelection empties the set; no-op when already empty",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.clearSelection(); // already empty
REQUIRE(spy.count() == 0);
sel.setSelectedObjectId(1);
REQUIRE(spy.count() == 1);
sel.clearSelection();
REQUIRE(spy.count() == 2);
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
}
TEST_CASE("reset clears state and emits only when state existed", "[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.reset(); // nothing to clear
REQUIRE(spy.count() == 0);
sel.setSelectedObjectId(7);
REQUIRE(spy.count() == 1);
sel.reset();
REQUIRE(spy.count() == 2);
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
}
-180
View File
@@ -1,180 +0,0 @@
/********************************************************************************
* *
* 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/>. *
* *
********************************************************************************/
// Tier-1 coverage of VisibilityState — the per-element hidden-set tracker.
// It is a QObject (for the changed() signal) but touches no GL, so the test
// exercises the full state machine directly and asserts the hot-path
// isHidden() flag mirror stays consistent with the canonical hidden set.
#include "Visibility.h"
#include <catch2/catch_test_macros.hpp>
#include <QCoreApplication>
#include <QSignalSpy>
namespace {
// Catch2 owns main(), so QCoreApplication can't live in a TU constructor.
// Lazily construct it (intentionally leaked) the first time any test asks.
void ensureQApp() {
if (QCoreApplication::instance()) return;
static int argc = 1;
static char arg0[] = "test_visibility";
static char* argv[] = {arg0, nullptr};
new QCoreApplication(argc, argv);
}
} // namespace
TEST_CASE("VisibilityState starts empty", "[visibility]") {
ensureQApp();
VisibilityState vis;
REQUIRE(vis.empty());
REQUIRE(vis.size() == 0);
REQUIRE_FALSE(vis.isHidden(0)); // 0 is the "no object" sentinel
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE_FALSE(vis.isHidden(1u << 20));
}
TEST_CASE("hideObjects unions ids, emits changed, and isHidden reflects it",
"[visibility]") {
ensureQApp();
VisibilityState vis;
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.hideObjects({1, 2, 3});
REQUIRE(spy.count() == 1);
REQUIRE(vis.size() == 3);
REQUIRE(vis.isHidden(1));
REQUIRE(vis.isHidden(2));
REQUIRE(vis.isHidden(3));
REQUIRE_FALSE(vis.isHidden(4));
// Unioning in a fresh id signals once more and grows the set.
vis.hideObjects({2, 4}); // 2 already hidden, 4 is new
REQUIRE(spy.count() == 2);
REQUIRE(vis.size() == 4);
REQUIRE(vis.isHidden(4));
}
TEST_CASE("hideObjects ignores object_id 0 and is idempotent", "[visibility]") {
ensureQApp();
VisibilityState vis;
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.hideObjects({0});
REQUIRE(vis.empty());
REQUIRE(spy.count() == 0); // nothing changed
REQUIRE_FALSE(vis.isHidden(0));
vis.hideObjects({7});
REQUIRE(spy.count() == 1);
vis.hideObjects({7}); // already hidden — no-op
REQUIRE(spy.count() == 1);
}
TEST_CASE("showObjects subtracts and emits only when something changes",
"[visibility]") {
ensureQApp();
VisibilityState vis;
vis.hideObjects({1, 2, 3});
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.showObjects({2});
REQUIRE(spy.count() == 1);
REQUIRE_FALSE(vis.isHidden(2));
REQUIRE(vis.isHidden(1));
REQUIRE(vis.isHidden(3));
REQUIRE(vis.size() == 2);
// Showing an id that was never hidden changes nothing.
vis.showObjects({99});
REQUIRE(spy.count() == 1);
}
TEST_CASE("setHidden replaces the set wholesale and drops id 0", "[visibility]") {
ensureQApp();
VisibilityState vis;
vis.hideObjects({1, 2});
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.setHidden({3, 4, 0});
REQUIRE(spy.count() == 1);
REQUIRE(vis.size() == 2); // id 0 was stripped
REQUIRE_FALSE(vis.isHidden(0));
REQUIRE_FALSE(vis.isHidden(1)); // old members cleared
REQUIRE_FALSE(vis.isHidden(2));
REQUIRE(vis.isHidden(3));
REQUIRE(vis.isHidden(4));
// Replacing with an identical set is a no-op.
vis.setHidden({3, 4});
REQUIRE(spy.count() == 1);
}
TEST_CASE("showAll clears the set; no-op when already empty", "[visibility]") {
ensureQApp();
VisibilityState vis;
vis.hideObjects({1, 2});
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.showAll();
REQUIRE(spy.count() == 1);
REQUIRE(vis.empty());
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE_FALSE(vis.isHidden(2));
vis.showAll(); // already empty
REQUIRE(spy.count() == 1);
}
TEST_CASE("reset clears state and emits only when state existed", "[visibility]") {
ensureQApp();
VisibilityState vis;
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.reset(); // nothing to clear
REQUIRE(spy.count() == 0);
vis.hideObjects({5});
REQUIRE(spy.count() == 1);
vis.reset();
REQUIRE(spy.count() == 2);
REQUIRE(vis.empty());
REQUIRE_FALSE(vis.isHidden(5));
}
TEST_CASE("hideObjects grows the flag mirror for large object ids", "[visibility]") {
ensureQApp();
VisibilityState vis;
// A high id forces the cpu_flags_ vector (used by the hot-path isHidden)
// to resize. isHidden must report it correctly without going out of
// bounds, and neighbouring ids must stay visible.
const uint32_t big = 1'000'000;
vis.hideObjects({big});
REQUIRE(vis.isHidden(big));
REQUIRE_FALSE(vis.isHidden(big - 1));
REQUIRE_FALSE(vis.isHidden(big + 1));
REQUIRE(vis.hiddenIds().count(big) == 1);
vis.noteObjectId(big * 2); // pre-grow only — id stays visible
REQUIRE_FALSE(vis.isHidden(big * 2));
}