ifcviewer: multi-selection + box-select with active highlight

SelectionState (new) owns the multi-set, the "active" id (last single-
clicked), and a per-object_id flags SSBO bound at binding=3.  Main
shader reads sel_flags[v_object_id] for the in-set tint and a separate
u_active_id uniform for a stronger tint on the active.

Click semantics: plain replaces, Shift/Ctrl toggles.  LMB-drag past 5px
boxes the rect through a pick-pass readback — plain replaces, Shift
adds, Ctrl removes; box-select preserves the active.  Drag promotes
regardless of start point so a press on geometry doesn't disqualify it.

Sidecar fast-path bulk-loads instances, so noteObjectId is also called
from the apply path — without it the flags buffer was sized to 1 slot
while object_ids were in the 100k+ range and the in-set bit was lost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-09 22:04:00 +10:00
parent d0c8c84e7b
commit bba2f30816
7 changed files with 659 additions and 43 deletions
+89 -10
View File
@@ -382,6 +382,10 @@ 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;
@@ -512,6 +516,90 @@ void OverlayRenderer::render(const float view_proj[16],
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.
const size_t bytes = 12 * sizeof(float);
if (bytes > vbo_rect_capacity_) {
const size_t new_cap = bytes + bytes / 2;
gl_->glNamedBufferData(vbo_rect_, GLsizeiptr(new_cap),
nullptr, GL_DYNAMIC_DRAW);
vbo_rect_capacity_ = new_cap;
}
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
};
gl_->glNamedBufferSubData(vbo_rect_, 0, GLsizeiptr(bytes), ndc);
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
@@ -519,10 +607,7 @@ void OverlayRenderer::render(const float view_proj[16],
// QOpenGLPaintDevice quirk where solid fills silently drop while
// text continues to render.
const bool any_painter = !hud_text_.isEmpty() || !labels_.empty();
if (!any_painter || 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);
if (!any_painter) return;
QFont label_font("monospace", 9);
label_font.setStyleHint(QFont::TypeWriter);
@@ -577,12 +662,6 @@ void OverlayRenderer::render(const float view_proj[16],
if (!items.empty()) {
std::vector<float> ndc;
ndc.reserve(items.size() * 12); // 6 verts * 2 floats per rect
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;
};
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));