From 1ca2e12f9251693f8084197c3cfbcafa7242cbcc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 30 Apr 2026 10:21:23 +1000 Subject: [PATCH] ifcviewer: draw 3D pivot indicator during navigation A small RGB axis cross is rendered at camera_target_ while the user is orbiting, panning, or has just zoomed. Visibility toggles on middle-mouse press/release; the wheel arms a single-shot QTimer that hides it 750 ms after the last notch. Drawn in two passes: GL_GREATER at 30% alpha for the occluded portion (X-ray cue) and GL_LEQUAL at full alpha for the visible portion. Arm length is computed from camera_distance_, fovy, and viewport height so the cross stays ~30 px on screen across zoom levels. Co-Authored-By: Claude Opus 4.7 --- src/ifcviewer/ViewportWindow.cpp | 138 +++++++++++++++++++++++++++++++ src/ifcviewer/ViewportWindow.h | 24 ++++++ 2 files changed, 162 insertions(+) diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index e928525f03..df6bbe96a4 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -236,6 +237,35 @@ out vec4 frag_color; void main() { frag_color = vec4(v_color, 1.0); } )"; +// Pivot indicator: a small 3D axis cross drawn at u_pivot in world space. +// a_local is the unit endpoint of one of the six arm vertices (±X,±Y,±Z), +// scaled by u_arm_world so the cross keeps a roughly constant on-screen size +// across zoom levels. Drawing in world space (not screen-aligned) is the +// whole point: it tumbles visibly when the camera orbits, which makes it +// unambiguous that the marker is anchored to a 3D point and not glued to +// the screen. Per-vertex color gives RGB axes matching the corner gizmo. +static const char* PIVOT_VERTEX_SHADER = R"( +#version 450 core +layout(location = 0) in vec3 a_local; +layout(location = 1) in vec3 a_color; +uniform mat4 u_mvp; +uniform vec3 u_pivot; +uniform float u_arm_world; +out vec3 v_color; +void main() { + gl_Position = u_mvp * vec4(u_pivot + a_local * u_arm_world, 1.0); + v_color = a_color; +} +)"; + +static const char* PIVOT_FRAGMENT_SHADER = R"( +#version 450 core +in vec3 v_color; +uniform float u_alpha; +out vec4 frag_color; +void main() { frag_color = vec4(v_color, u_alpha); } +)"; + static GLuint compileShader(QOpenGLFunctions_4_5_Core* gl, GLenum type, const char* source) { GLuint shader = gl->glCreateShader(type); gl->glShaderSource(shader, 1, &source, nullptr); @@ -434,9 +464,12 @@ ViewportWindow::~ViewportWindow() { } if (axis_vao_) gl_->glDeleteVertexArrays(1, &axis_vao_); if (axis_vbo_) gl_->glDeleteBuffers(1, &axis_vbo_); + if (pivot_vao_) gl_->glDeleteVertexArrays(1, &pivot_vao_); + if (pivot_vbo_) gl_->glDeleteBuffers(1, &pivot_vbo_); if (main_program_) gl_->glDeleteProgram(main_program_); if (pick_program_) gl_->glDeleteProgram(pick_program_); if (axis_program_) gl_->glDeleteProgram(axis_program_); + if (pivot_program_) gl_->glDeleteProgram(pivot_program_); if (pick_fbo_) gl_->glDeleteFramebuffers(1, &pick_fbo_); if (pick_color_tex_) gl_->glDeleteTextures(1, &pick_color_tex_); if (pick_depth_rbo_) gl_->glDeleteRenderbuffers(1, &pick_depth_rbo_); @@ -464,6 +497,7 @@ void ViewportWindow::initGL() { buildShaders(); buildAxisGizmo(); + buildPivotIndicator(); gl_->glEnable(GL_DEPTH_TEST); gl_->glEnable(GL_MULTISAMPLE); @@ -570,6 +604,11 @@ void ViewportWindow::buildShaders() { GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, AXIS_FRAGMENT_SHADER); axis_program_ = linkProgram(gl_, vs, fs); } + { + GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, PIVOT_VERTEX_SHADER); + GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, PIVOT_FRAGMENT_SHADER); + pivot_program_ = linkProgram(gl_, vs, fs); + } { GLuint vs = compileShader(gl_, GL_VERTEX_SHADER, HIZ_DOWNSAMPLE_VS); GLuint fs = compileShader(gl_, GL_FRAGMENT_SHADER, HIZ_DOWNSAMPLE_FS); @@ -599,6 +638,31 @@ void ViewportWindow::buildAxisGizmo() { gl_->glVertexArrayAttribBinding(axis_vao_, 1, 0); } +void ViewportWindow::buildPivotIndicator() { + // 6 verts = 3 line segments along world ±X, ±Y, ±Z, RGB-coded. + static const float verts[] = { + // local x,y,z r,g,b + -1, 0, 0, 1.0f, 0.30f, 0.30f, + 1, 0, 0, 1.0f, 0.30f, 0.30f, + 0,-1, 0, 0.35f, 0.95f, 0.35f, + 0, 1, 0, 0.35f, 0.95f, 0.35f, + 0, 0,-1, 0.35f, 0.55f, 1.0f, + 0, 0, 1, 0.35f, 0.55f, 1.0f, + }; + pivot_rim_count_ = 6; // total vertex count drawn as GL_LINES + + gl_->glCreateVertexArrays(1, &pivot_vao_); + gl_->glCreateBuffers(1, &pivot_vbo_); + gl_->glNamedBufferStorage(pivot_vbo_, sizeof(verts), verts, 0); + gl_->glVertexArrayVertexBuffer(pivot_vao_, 0, pivot_vbo_, 0, 6 * sizeof(float)); + gl_->glEnableVertexArrayAttrib(pivot_vao_, 0); + gl_->glVertexArrayAttribFormat(pivot_vao_, 0, 3, GL_FLOAT, GL_FALSE, 0); + gl_->glVertexArrayAttribBinding(pivot_vao_, 0, 0); + gl_->glEnableVertexArrayAttrib(pivot_vao_, 1); + gl_->glVertexArrayAttribFormat(pivot_vao_, 1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float)); + gl_->glVertexArrayAttribBinding(pivot_vao_, 1, 0); +} + bool ViewportWindow::growModelVbo(ModelGpuData& m, size_t needed_total) { size_t new_capacity = m.vbo_capacity; while (new_capacity < needed_total) new_capacity *= 2; @@ -2082,6 +2146,7 @@ void ViewportWindow::render() { hiz_reject_count_.load()); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); + renderPivotIndicator(); renderAxisGizmo(); // Build HiZ from this frame's resolved depth for next frame's cull. @@ -2418,6 +2483,69 @@ void ViewportWindow::renderPickPass() { gl_->glBindFramebuffer(GL_FRAMEBUFFER, 0); } +void ViewportWindow::renderPivotIndicator() { + if (!pivot_indicator_visible_ || !pivot_program_ || !pivot_vao_) return; + + const int h = height() * devicePixelRatio(); + if (h <= 0) return; + + // Pick a world-space arm length that projects to ~30 pixels at the pivot's + // distance. At fovy=45°, half the visible world height at distance d is + // d * tan(22.5°) ≈ 0.4142 * d. pixels_per_world = (h/2) / (0.4142 * d). + // Inverting: world_per_pixel = 0.8284 * d / h. + const float fovy_rad = qDegreesToRadians(camera_fov_y_deg_); + const float world_per_pixel = camera_distance_ * tanf(fovy_rad * 0.5f) * 2.0f / float(h); + const float arm_pixels = 30.0f * float(devicePixelRatio()); + const float arm_world = arm_pixels * world_per_pixel; + + const QMatrix4x4 mvp = proj_matrix_ * view_matrix_; + + gl_->glUseProgram(pivot_program_); + gl_->glUniformMatrix4fv(gl_->glGetUniformLocation(pivot_program_, "u_mvp"), + 1, GL_FALSE, mvp.constData()); + gl_->glUniform3f(gl_->glGetUniformLocation(pivot_program_, "u_pivot"), + camera_target_.x(), camera_target_.y(), camera_target_.z()); + gl_->glUniform1f(gl_->glGetUniformLocation(pivot_program_, "u_arm_world"), + arm_world); + + gl_->glEnable(GL_BLEND); + gl_->glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + gl_->glLineWidth(2.0f); + gl_->glBindVertexArray(pivot_vao_); + + // Pass 1: occluded portions, depth test reversed, dim — gives an X-ray + // hint that the pivot lives behind geometry. + gl_->glDepthFunc(GL_GREATER); + gl_->glUniform1f(gl_->glGetUniformLocation(pivot_program_, "u_alpha"), 0.30f); + gl_->glDrawArrays(GL_LINES, 0, pivot_rim_count_); + + // Pass 2: visible portions, normal depth, full alpha. + gl_->glDepthFunc(GL_LEQUAL); + gl_->glUniform1f(gl_->glGetUniformLocation(pivot_program_, "u_alpha"), 1.0f); + gl_->glDrawArrays(GL_LINES, 0, pivot_rim_count_); + + gl_->glBindVertexArray(0); + gl_->glDisable(GL_BLEND); + gl_->glDepthFunc(GL_LESS); +} + +void ViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_ms) { + if (!pivot_indicator_hide_timer_) { + pivot_indicator_hide_timer_ = new QTimer(this); + pivot_indicator_hide_timer_->setSingleShot(true); + connect(pivot_indicator_hide_timer_, &QTimer::timeout, this, [this]() { + pivot_indicator_visible_ = false; + requestUpdate(); + }); + } + pivot_indicator_visible_ = visible; + if (visible && hide_after_ms > 0) { + pivot_indicator_hide_timer_->start(hide_after_ms); + } else { + pivot_indicator_hide_timer_->stop(); + } +} + void ViewportWindow::renderAxisGizmo() { if (!axis_program_ || !axis_vao_) return; const int dpr = devicePixelRatio(); @@ -2475,6 +2603,10 @@ void ViewportWindow::handleMousePress(QMouseEvent* e) { } active_button_ = e->button(); last_mouse_pos_ = e->pos(); + if (e->button() == Qt::MiddleButton) { + setPivotIndicatorVisible(true); + requestUpdate(); + } } void ViewportWindow::handleMouseRelease(QMouseEvent* e) { if (camera_mode_ == CameraMode::Fps) return; @@ -2484,7 +2616,12 @@ void ViewportWindow::handleMouseRelease(QMouseEvent* e) { emit objectPicked(id); requestUpdate(); // selection highlight changed } + const bool was_navigating = (active_button_ == Qt::MiddleButton); active_button_ = Qt::NoButton; + if (was_navigating && pivot_indicator_visible_) { + setPivotIndicatorVisible(false); + requestUpdate(); + } } void ViewportWindow::handleMouseMove(QMouseEvent* e) { if (camera_mode_ == CameraMode::Fps) { @@ -2550,5 +2687,6 @@ void ViewportWindow::handleWheel(QWheelEvent* e) { float factor = e->angleDelta().y() > 0 ? 0.9f : 1.1f; camera_distance_ *= factor; camera_distance_ = qMax(0.1f, camera_distance_); + setPivotIndicatorVisible(true, 750); requestUpdate(); } diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index ec223c559f..5e2e7ad92b 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -28,6 +28,10 @@ #include #include +QT_BEGIN_NAMESPACE +class QTimer; +QT_END_NAMESPACE + #include #include #include @@ -231,9 +235,16 @@ private: void render(); void renderPickPass(); void renderAxisGizmo(); + void renderPivotIndicator(); void updateCamera(); 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 @@ -315,6 +326,14 @@ private: 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 models_gpu_; @@ -411,6 +430,11 @@ private: 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