mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user