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
+19 -5
View File
@@ -911,20 +911,32 @@ void MainWindow::onAllLoadsFinished() {
}
void MainWindow::onObjectPicked(uint32_t object_id) {
viewport_->setSelectedObjectId(object_id);
// Signal originates from the viewport's SelectionState — viewport
// selection is already authoritative. Pushing setSelectedObjectId
// back here would clobber a multi-select set with {object_id}.
auto it = tree_items_.find(object_id);
if (it != tree_items_.end()) {
element_tree_->blockSignals(true);
element_tree_->setCurrentItem(it->second);
element_tree_->blockSignals(false);
} else {
// No active (e.g. selection cleared, or active id is in a model
// whose tree node hasn't materialised) — drop the tree highlight
// so the panel doesn't lie about what's active.
element_tree_->blockSignals(true);
element_tree_->setCurrentItem(nullptr);
element_tree_->blockSignals(false);
}
populateProperties(object_id);
if (object_id != 0) {
const double v = volumeOfObjects(*viewport_, {object_id});
qInfo("Volume of object %u: %.6f m^3", object_id, v);
// Volume readout: report for the full selection so multi-select
// matches the highlighted set.
const auto& selection = viewport_->selection().selectionIds();
if (!selection.empty()) {
std::vector<uint32_t> ids(selection.begin(), selection.end());
const double v = volumeOfObjects(*viewport_, ids);
qInfo("Volume of %zu selected object(s): %.6f m^3", ids.size(), v);
}
}
@@ -933,6 +945,8 @@ void MainWindow::onTreeSelectionChanged() {
if (items.isEmpty()) return;
uint32_t object_id = items.first()->data(0, Qt::UserRole).toUInt();
// Tree stays single-select per UX choice — clicking a tree item
// replaces the viewport's multi-set with just that item.
viewport_->setSelectedObjectId(object_id);
populateProperties(object_id);
}
+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));
+10
View File
@@ -20,6 +20,7 @@
#ifndef IFCVIEWER_OVERLAYRENDERER_H
#define IFCVIEWER_OVERLAYRENDERER_H
#include <QRect>
#include <QString>
#include <QtOpenGL/QOpenGLFunctions_4_5_Core>
@@ -86,6 +87,12 @@ public:
// 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)
@@ -182,6 +189,9 @@ private:
std::vector<Label> labels_;
QString hud_text_;
// Box-select rectangle in logical pixels. Null/empty = hidden.
QRect selection_rect_;
};
#endif // IFCVIEWER_OVERLAYRENDERER_H
+184
View File
@@ -0,0 +1,184 @@
/********************************************************************************
* *
* 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
@@ -0,0 +1,126 @@
/********************************************************************************
* *
* 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
+199 -26
View File
@@ -88,15 +88,21 @@ struct MeshQuant { vec4 aabb_min; vec4 aabb_max; };
layout(std430, binding = 2) readonly buffer Meshes {
MeshQuant meshes[];
};
// Per-object_id selection flag (bit 0 = in selection). Sized so that
// any object_id present in the scene is a valid index — see SelectionState.
layout(std430, binding = 3) readonly buffer SelectionFlags {
uint sel_flags[];
};
uniform mat4 u_view_projection;
uniform uint u_selected_id;
uniform uint u_active_id; // last single-clicked id (0 = none)
out vec3 v_normal;
out vec4 v_color;
out vec3 v_world_pos;
flat out uint v_object_id;
flat out uint v_selected;
flat out uint v_active;
// Meyer et al. octahedral normal decode. Input is in [-1,1]^2.
vec3 octDecode(vec2 e) {
@@ -143,7 +149,10 @@ void main() {
v_color = baked;
v_object_id = inst.object_id;
v_selected = (v_object_id == u_selected_id) ? 1u : 0u;
// Index by object_id directly; SelectionState guarantees the buffer
// is sized to cover every id allocated to the scene.
v_selected = ((sel_flags[v_object_id] & 1u) != 0u) ? 1u : 0u;
v_active = (v_object_id == u_active_id) ? 1u : 0u;
}
)";
@@ -154,6 +163,7 @@ in vec4 v_color;
in vec3 v_world_pos;
flat in uint v_object_id;
flat in uint v_selected;
flat in uint v_active;
uniform vec3 u_light_dir; // primary key direction (world-space)
uniform vec3 u_fill_dir; // secondary fill direction
@@ -202,7 +212,11 @@ void main() {
float cavity = clamp(length(fwidth(n)) * 1.5, 0.0, 0.35);
color *= (1.0 - cavity);
if (v_selected == 1u) color = mix(color, vec3(0.2, 0.6, 1.0), 0.5);
// Selected (member of the multi-set) gets a blue tint; the active
// (last single-clicked) gets a stronger mix so the user can always
// tell which one drives the properties panel.
if (v_selected == 1u) color = mix(color, vec3(0.2, 0.6, 1.0), 0.45);
if (v_active == 1u) color = mix(color, vec3(0.4, 0.8, 1.0), 0.40);
frag_color = vec4(color, v_color.a);
}
)";
@@ -671,6 +685,7 @@ ViewportWindow::~ViewportWindow() {
if (hiz_downsample_program_) gl_->glDeleteProgram(hiz_downsample_program_);
if (hiz_downsample_vao_) gl_->glDeleteVertexArrays(1, &hiz_downsample_vao_);
overlay_renderer_.release();
selection_.releaseGl();
}
context_->doneCurrent();
}
@@ -692,6 +707,13 @@ void ViewportWindow::initGL() {
buildPivotIndicator();
buildSectionPlaneGizmo();
overlay_renderer_.initialize(gl_);
selection_.initializeGl(gl_);
// Drive viewport repaints + tree/properties sync from selection state.
connect(&selection_, &SelectionState::changed, this,
[this](uint32_t active_id) {
requestUpdate();
emit objectPicked(active_id);
});
gl_->glEnable(GL_DEPTH_TEST);
gl_->glEnable(GL_MULTISAMPLE);
@@ -1074,6 +1096,8 @@ void ViewportWindow::uploadInstanceChunk(const InstanceChunk& chunk) {
ModelGpuData& m = getOrCreateModel(chunk.model_id);
selection_.noteObjectId(chunk.object_id);
InstanceCpu inst;
inst.mesh_id = chunk.local_mesh_id;
inst.object_id = chunk.object_id;
@@ -1235,6 +1259,14 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, SidecarData data) {
m.meshes = std::move(data.meshes);
m.instances = std::move(data.instances);
// Sidecar path bypasses uploadInstanceChunk, so register every
// instance's object_id with the selection state up front — otherwise
// the per-object_id flags SSBO would be too small for these ids and
// their selection bit reads would silently fall outside the buffer.
for (const auto& inst : m.instances) {
selection_.noteObjectId(inst.object_id);
}
uint32_t total_tri = 0;
for (const auto& mesh : m.meshes) {
total_tri += (mesh.index_count / 3) * mesh.instance_count;
@@ -1374,7 +1406,7 @@ void ViewportWindow::resetScene() {
if (m.indirect_buffer) gl_->glDeleteBuffers(1, &m.indirect_buffer);
}
models_gpu_.clear();
selected_object_id_ = 0;
selection_.reset();
have_cached_cull_ = false;
requestUpdate();
}
@@ -1436,8 +1468,10 @@ void ViewportWindow::removeModel(uint32_t model_id) {
}
void ViewportWindow::setSelectedObjectId(uint32_t id) {
selected_object_id_ = id;
requestUpdate();
// Convenience pass-through. SelectionState fires `changed` which
// re-emits objectPicked + requestUpdate via the lambda hooked up in
// initGL, so we don't need to do those manually here.
selection_.setSelectedObjectId(id);
}
void ViewportWindow::setCamera(float tx, float ty, float tz,
@@ -1534,8 +1568,12 @@ void ViewportWindow::frameAabb(const QVector3D& mn, const QVector3D& mx,
void ViewportWindow::focusOnSelectedObject() {
if (camera_mode_ == CameraMode::Fps) return;
// F frames the *active* object so it's predictable across multi-select
// states. Framing the union of every selected member would be more
// expansive but ambiguous in a large set.
const uint32_t target = selection_.activeObjectId();
QVector3D mn, mx;
if (!computeObjectAabb(selected_object_id_, mn, mx)) {
if (!computeObjectAabb(target, mn, mx)) {
qDebug("Focus: no object selected or no AABB available");
return;
}
@@ -2079,6 +2117,59 @@ uint32_t ViewportWindow::pickObjectAt(int x, int y) {
return pixel;
}
std::unordered_set<uint32_t> ViewportWindow::picksInRect(const QRect& rect_logical) {
std::unordered_set<uint32_t> out;
if (!gl_initialized_) return out;
if (rect_logical.isEmpty() || rect_logical.width() < 1 || rect_logical.height() < 1) {
return out;
}
context_->makeCurrent(this);
const int w = width() * devicePixelRatio();
const int h = height() * devicePixelRatio();
if (w <= 0 || h <= 0) return out;
// Reuse the same allocation path as pickObjectAt — call it once at
// (0,0) just to ensure the framebuffer matches the current size and
// the pick pass has been rendered for this state.
pickObjectAt(rect_logical.left(), rect_logical.top());
// Convert the logical-coords rect to physical pick-tex coords with
// GL's bottom-left origin. Clamp to surface to avoid OOB reads.
const qreal dpr = devicePixelRatio();
QRect r = rect_logical
.intersected(QRect(0, 0, width(), height()));
if (r.isEmpty()) return out;
const int rx = int(r.left() * dpr);
const int ry_top = int((height() - (r.top() + r.height())) * dpr);
const int rw = std::max(1, int(r.width() * dpr));
const int rh = std::max(1, int(r.height() * dpr));
if (rx < 0 || ry_top < 0 || rx + rw > w || ry_top + rh > h) {
// Final defensive clamp — devicePixelRatio + integer rounding can
// push the rect a pixel off the texture; readback would silently
// return zeros for those rows.
const int cx = std::max(0, std::min(rx, w - 1));
const int cy = std::max(0, std::min(ry_top, h - 1));
const int cw2 = std::max(1, std::min(rw, w - cx));
const int ch2 = std::max(1, std::min(rh, h - cy));
std::vector<uint32_t> pixels(size_t(cw2) * size_t(ch2), 0);
gl_->glGetTextureSubImage(pick_color_tex_, 0, cx, cy, 0, cw2, ch2, 1,
GL_RED_INTEGER, GL_UNSIGNED_INT,
GLsizei(pixels.size() * sizeof(uint32_t)),
pixels.data());
for (uint32_t id : pixels) if (id != 0) out.insert(id);
return out;
}
std::vector<uint32_t> pixels(size_t(rw) * size_t(rh), 0);
gl_->glGetTextureSubImage(pick_color_tex_, 0, rx, ry_top, 0, rw, rh, 1,
GL_RED_INTEGER, GL_UNSIGNED_INT,
GLsizei(pixels.size() * sizeof(uint32_t)),
pixels.data());
for (uint32_t id : pixels) if (id != 0) out.insert(id);
return out;
}
bool ViewportWindow::pickSurfaceAt(int x, int y,
uint32_t& object_id_out,
QVector3D& world_pos_out,
@@ -2565,7 +2656,7 @@ void ViewportWindow::render() {
GLint u_fill = gl_->glGetUniformLocation(main_program_, "u_fill_dir");
GLint u_sky = gl_->glGetUniformLocation(main_program_, "u_sky_color");
GLint u_ground = gl_->glGetUniformLocation(main_program_, "u_ground_color");
GLint u_sel = gl_->glGetUniformLocation(main_program_, "u_selected_id");
GLint u_active = gl_->glGetUniformLocation(main_program_, "u_active_id");
gl_->glUniformMatrix4fv(u_vp, 1, GL_FALSE, vp.constData());
// Key light: high noon-ish from off-camera; fill ~120° away so back-of-
// object surfaces still get some direct contribution. Sky/ground tints
@@ -2577,7 +2668,8 @@ void ViewportWindow::render() {
gl_->glUniform3f(u_fill, -0.3f, -0.5f, 0.8f);
gl_->glUniform3f(u_sky, 0.55f, 0.60f, 0.70f);
gl_->glUniform3f(u_ground, 0.35f, 0.32f, 0.28f);
gl_->glUniform1ui(u_sel, selected_object_id_);
gl_->glUniform1ui(u_active, selection_.activeObjectId());
selection_.bindForRender(/*binding_index=*/3);
uploadClipPlaneUniforms(main_program_);
visible_triangles_ = 0;
@@ -2740,6 +2832,14 @@ void ViewportWindow::render() {
// does not restore.
{
const qreal dpr = devicePixelRatio();
// Push the live box-select rect (or empty when not dragging) so
// the overlay draws/clears it as part of the per-frame pass.
QRect sel_rect;
if (box_select_active_) {
sel_rect = QRect(box_select_start_pos_,
box_select_current_pos_).normalized();
}
overlay_renderer_.setSelectionRect(sel_rect);
overlay_renderer_.render(vp.constData(),
int(width() * dpr),
int(height() * dpr),
@@ -3455,6 +3555,9 @@ void ViewportWindow::handleMousePress(QMouseEvent* e) {
}
active_button_ = e->button();
last_mouse_pos_ = e->pos();
box_select_armed_ = false;
box_select_active_ = false;
press_pick_id_ = 0;
if (e->button() == Qt::MiddleButton) {
setPivotIndicatorVisible(true);
requestUpdate();
@@ -3483,6 +3586,23 @@ void ViewportWindow::handleMousePress(QMouseEvent* e) {
section_plane_selected_ = -1;
requestUpdate();
}
return;
}
// Plain LMB: pick now so release can apply click semantics without
// a second pick pass, and arm a potential box-select drag — promoted
// to box_select_active_ at the first move past the click threshold.
// Arming on every LMB press (not just empty-space presses) means a
// drag that happens to start on an object still box-selects, which
// matches user intuition: the start point shouldn't disqualify the
// gesture. Tool-mode LMB defers pick handling to surfacePickedInTool
// on release.
if (e->button() == Qt::LeftButton && tool_mode_ == ToolMode::None) {
press_pick_id_ = pickObjectAt(e->pos().x(), e->pos().y());
box_select_start_pos_ = e->pos();
box_select_current_pos_ = e->pos();
box_select_press_mods_ = e->modifiers();
box_select_armed_ = true;
}
}
void ViewportWindow::handleMouseRelease(QMouseEvent* e) {
@@ -3493,22 +3613,57 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) {
active_button_ = Qt::NoButton;
return;
}
// LMB pick is suppressed in section-tool mode — LMB there creates or
// selects planes (handled in handleMousePress) and the release should
// not also trigger object selection.
if (active_button_ == Qt::LeftButton
&& !section_tool_active_
&& (e->pos() - last_mouse_pos_).manhattanLength() < 5) {
if (tool_mode_ != ToolMode::None) {
emit surfacePickedInTool(e->pos().x(), e->pos().y(),
int(e->modifiers()));
} else {
uint32_t id = pickObjectAt(e->pos().x(), e->pos().y());
selected_object_id_ = id;
emit objectPicked(id);
requestUpdate(); // selection highlight changed
if (active_button_ == Qt::LeftButton && !section_tool_active_) {
const bool was_drag =
(e->pos() - box_select_start_pos_).manhattanLength() >= 5;
if (box_select_active_) {
// Finalize a box-select. Modifier captured at press-time
// decides commit semantics: plain → replace, Shift → add,
// Ctrl → remove. Box-select never sets the active id; the
// last single-clicked object remains the active.
const QRect rect = QRect(box_select_start_pos_,
e->pos()).normalized();
const auto picks = picksInRect(rect);
const auto mods = box_select_press_mods_;
if (mods & Qt::ShiftModifier) {
selection_.addToSelection(picks);
} else if (mods & Qt::ControlModifier) {
selection_.removeFromSelection(picks);
} else {
// Replace. Active stays only if the new set still
// contains it; SelectionState handles the coercion.
selection_.setSelection(picks, selection_.activeObjectId());
}
} else if (!was_drag) {
// Click — apply press-time pick + modifiers.
if (tool_mode_ != ToolMode::None) {
emit surfacePickedInTool(e->pos().x(), e->pos().y(),
int(e->modifiers()));
} else {
const auto mods = e->modifiers();
if (mods & (Qt::ShiftModifier | Qt::ControlModifier)) {
if (press_pick_id_ != 0) {
// Toggle. No-op on empty space so a stray
// modifier-click doesn't blow away the set.
selection_.toggleInSelection(press_pick_id_);
}
} else {
// Plain click — replace (or clear, for empty hit).
selection_.setSelectedObjectId(press_pick_id_);
}
}
}
// Drag from an object (no box select armed, movement > threshold)
// is intentionally a no-op — keeps stray drags from rewriting
// the selection.
box_select_armed_ = false;
box_select_active_ = false;
press_pick_id_ = 0;
}
const bool was_navigating = (active_button_ == Qt::MiddleButton);
active_button_ = Qt::NoButton;
if (was_navigating && pivot_indicator_visible_) {
@@ -3555,6 +3710,23 @@ void ViewportWindow::handleMouseMove(QMouseEvent* e) {
last_mouse_pos_ = e->pos();
return;
}
// Promote an armed box-select to active once the cursor crosses the
// 5-px click threshold, then keep updating the rect for the overlay.
// last_mouse_pos_ stays at press position while armed so a fast tiny
// drag isn't classified as a click on release.
if (box_select_armed_ && active_button_ == Qt::LeftButton) {
if (!box_select_active_
&& (e->pos() - box_select_start_pos_).manhattanLength() >= 5) {
box_select_active_ = true;
}
if (box_select_active_) {
box_select_current_pos_ = e->pos();
requestUpdate();
}
return;
}
QPoint delta = e->pos() - last_mouse_pos_;
last_mouse_pos_ = e->pos();
if (active_button_ == Qt::MiddleButton) {
@@ -3697,7 +3869,8 @@ void ViewportWindow::setModelTransformation(uint32_t model_id,
}
void ViewportWindow::printSelectedObjectCoords() {
if (selected_object_id_ == 0) {
const uint32_t target = selection_.activeObjectId();
if (target == 0) {
qInfo("printSelectedObjectCoords: no object selected");
return;
}
@@ -3710,7 +3883,7 @@ void ViewportWindow::printSelectedObjectCoords() {
const ModelGpuData& m = kv.second;
for (size_t i = 0; i < m.instances.size(); ++i) {
const InstanceCpu& inst = m.instances[i];
if (inst.object_id != selected_object_id_) continue;
if (inst.object_id != target) continue;
qInfo("Selected object %u (model %u, mesh %u, instance %zu):",
inst.object_id, inst.model_id, inst.mesh_id, i);
@@ -3776,7 +3949,7 @@ void ViewportWindow::printSelectedObjectCoords() {
}
}
qInfo("printSelectedObjectCoords: object_id %u not found in any model",
selected_object_id_);
target);
}
bool ViewportWindow::readbackMeshTriangles(uint32_t model_id, uint32_t mesh_id,
+32 -2
View File
@@ -34,6 +34,7 @@ QT_END_NAMESPACE
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <cstdint>
#include <mutex>
#include <memory>
@@ -46,6 +47,7 @@ QT_END_NAMESPACE
#include "BvhAccel.h"
#include "InstancedGeometry.h"
#include "OverlayRenderer.h"
#include "Selection.h"
#include "SidecarCache.h"
// Matches GL_DRAW_INDIRECT_BUFFER layout for glMultiDrawElementsIndirect.
@@ -306,8 +308,21 @@ public:
void setModelCoordinateOperation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters);
void setModelTransformation(uint32_t model_id, const Eigen::Matrix4d& matrix_meters);
// 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);
// Extended pick: returns the object id, world-space hit point, and
// world-space surface normal at (x, y). Renders the same pick pass as
@@ -695,8 +710,23 @@ private:
QElapsedTimer fps_last_tick_;
bool fps_ignore_next_mouse_move_ = false;
// Selection
uint32_t selected_object_id_ = 0;
// Selection — set + active id + per-object_id flags SSBO (binding=3).
SelectionState selection_;
// 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;